FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Add script to view diff size of conversation snapshots · cecli-dev/cecli@ee623ca · GitHub

Repository navigation

Commit ee623ca

Browse files
Your Name
committed
Add script to view diff size of conversation snapshots
1 parent 4fb5313 commit ee623ca

2 files changed

Lines changed: 267 additions & 2 deletions

File tree

‎cecli/models.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,7 +1503,9 @@ def _log_messages(self, messages, name="message"):
15031503
"""
15041504
os.makedirs(".cecli/logs/messages", exist_ok=True)
15051505
with open(f".cecli/logs/messages/{name}-{time.time()}.log", "w") as f:
1506-
json.dump(messages, f, indent=4, default=lambda o: "<not serializable>")
1506+
json.dump(
1507+
messages, f, indent=4, ensure_ascii=False, default=lambda o: "<not serializable>"
1508+
)
15071509

15081510
def _log_request(self, model_call_dict):
15091511
"""
@@ -1513,7 +1515,13 @@ def _log_request(self, model_call_dict):
15131515
log_file_path = f".cecli/logs/litellm/request-{time.time()}.log"
15141516

15151517
with open(log_file_path, "a", encoding="utf-8") as f:
1516-
json.dump(model_call_dict, f, indent=4, default=lambda o: "<not serializable>")
1518+
json.dump(
1519+
model_call_dict,
1520+
f,
1521+
indent=4,
1522+
ensure_ascii=False,
1523+
default=lambda o: "<not serializable>",
1524+
)
15171525
f.write(",\n")
15181526

15191527

‎scripts/compare-message-history.py‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compare agent trajectory JSON files from .cecli/logs/messages/
4+
5+
Sorts them by timestamp and finds where one trajectory diverges
6+
from the next by comparing parsed JSON messages message-by-message.
7+
8+
Usage:
9+
python scripts/compare-message-history.py
10+
python scripts/compare-message-history.py <directory>
11+
python scripts/compare-message-history.py file1.json file2.json
12+
"""
13+
14+
import glob
15+
import json
16+
import os
17+
import re
18+
import sys
19+
20+
DEFAULT_DIR = os.path.join(".cecli", "logs", "messages")
21+
CONTEXT_SIZE = 40 # chars on each side of divergence (80 total window)
22+
23+
24+
def _get_timestamp(filepath: str) -> float:
25+
"""Extract the timestamp from a message log filename."""
26+
basename = os.path.basename(filepath)
27+
match = re.match(r"message-(\d+(?:\.\d+)?)\.log", basename)
28+
if match:
29+
return float(match.group(1))
30+
return 0.0
31+
32+
33+
def _load_messages(filepath: str) -> list[dict]:
34+
"""Load and parse a JSON message file."""
35+
with open(filepath, "r", encoding="utf-8") as f:
36+
data = json.load(f)
37+
if not isinstance(data, list):
38+
print(f"Warning: {filepath} does not contain a JSON array, skipping", file=sys.stderr)
39+
return []
40+
return data
41+
42+
43+
def _serialize_message(msg: dict) -> str:
44+
"""Serialize a message dict to a consistent JSON string for comparison."""
45+
return json.dumps(msg, indent=2, ensure_ascii=False, sort_keys=True)
46+
47+
48+
def _compare_messages(msg_a: dict, msg_b: dict) -> tuple[int, int] | None:
49+
"""
50+
Compare two message dicts by serializing to JSON strings.
51+
Returns (divergence_offset, total_diff_bytes) where they differ, or None if identical.
52+
total_diff_bytes is the number of characters in B after the divergence point
53+
(i.e. how much content is different/new).
54+
"""
55+
str_a = _serialize_message(msg_a)
56+
str_b = _serialize_message(msg_b)
57+
for i, (ca, cb) in enumerate(zip(str_a, str_b)):
58+
if ca != cb:
59+
diff_bytes = len(str_b) - i
60+
return i, diff_bytes
61+
if len(str_a) != len(str_b):
62+
diff_bytes = len(str_b) - len(str_a)
63+
return min(len(str_a), len(str_b)), diff_bytes
64+
return None
65+
66+
67+
def _get_context_snippet(text: str, pos: int, size: int = CONTEXT_SIZE) -> str:
68+
"""Extract an ~80-char window (40 before, 40 after) around a position."""
69+
start = max(0, pos - size)
70+
end = min(len(text), pos + size)
71+
snippet = text[start:end]
72+
prefix = "" if start == 0 else "..."
73+
suffix = "" if end == len(text) else "..."
74+
return f"{prefix}{snippet}{suffix}"
75+
76+
"""Extract an ~80-char window (40 before, 40 after) around a position."""
77+
start = max(0, pos - size)
78+
end = min(len(text), pos + size)
79+
snippet = text[start:end]
80+
prefix = "" if start == 0 else "..."
81+
suffix = "" if end == len(text) else "..."
82+
return f"{prefix}{snippet}{suffix}"
83+
84+
85+
def compare_files(file_a: str, file_b: str) -> dict | None:
86+
"""
87+
Compare two message log files.
88+
Returns a dict with divergence info, or None if identical.
89+
"""
90+
msgs_a = _load_messages(file_a)
91+
msgs_b = _load_messages(file_b)
92+
93+
if msgs_a == msgs_b:
94+
return None
95+
96+
# Pre-compute serialized messages for size calculations
97+
serialized_b = [_serialize_message(m) for m in msgs_b]
98+
99+
# Compare message by message to find the first differing index
100+
for idx in range(max(len(msgs_a), len(msgs_b))):
101+
if idx >= len(msgs_a):
102+
# File A ran out of messages — divergence is at the start of B's extra message
103+
msg_b_str = serialized_b[idx]
104+
diff_total = len(msg_b_str) + sum(len(s) for s in serialized_b[idx + 1 :])
105+
return {
106+
"file_a": file_a,
107+
"file_b": file_b,
108+
"message_index": idx,
109+
"offset_in_message": 0,
110+
"diff_bytes_after": diff_total,
111+
"snippet": _get_context_snippet(msg_b_str, 0),
112+
"reason": (
113+
f"File A has {len(msgs_a)} messages, File B has {len(msgs_b)} (extra message at index {idx})"
114+
),
115+
"context_b": msg_b_str[:80],
116+
}
117+
if idx >= len(msgs_b):
118+
# File B ran out of messages — divergence is at start of A's extra message
119+
msg_a_str = _serialize_message(msgs_a[idx])
120+
return {
121+
"file_a": file_a,
122+
"file_b": file_b,
123+
"message_index": idx,
124+
"offset_in_message": 0,
125+
"diff_bytes_after": len(msg_a_str),
126+
"snippet": _get_context_snippet(msg_a_str, 0),
127+
"reason": (
128+
f"File B has {len(msgs_b)} messages, File A has {len(msgs_a)} (extra message at index {idx})"
129+
),
130+
"context_b": msg_a_str[:80],
131+
}
132+
133+
# Compare this message's content
134+
result = _compare_messages(msgs_a[idx], msgs_b[idx])
135+
if result is not None:
136+
offset, _diff_bytes = result
137+
msg_b_str = serialized_b[idx]
138+
# Total diff = remaining of this message + all subsequent messages
139+
diff_total = (len(msg_b_str) - offset) + sum(len(s) for s in serialized_b[idx + 1 :])
140+
return {
141+
"file_a": file_a,
142+
"file_b": file_b,
143+
"message_index": idx,
144+
"offset_in_message": offset,
145+
"diff_bytes_after": diff_total,
146+
"snippet": _get_context_snippet(msg_b_str, offset),
147+
"reason": f"Messages at index {idx} differ",
148+
"context_b": _get_context_snippet(msg_b_str, offset, 60),
149+
}
150+
151+
return None # Shouldn't reach here
152+
153+
154+
def _format_result(result: dict) -> str:
155+
"""Format a divergence result as a readable string."""
156+
lines = []
157+
lines.append("─" * 80)
158+
lines.append(f"Divergence at message index {result['message_index']}")
159+
lines.append(f" → Offset {result['offset_in_message']} chars into that message (serialized)")
160+
lines.append(f" → ~{result['diff_bytes_after'] / 4} diff tokens after divergence")
161+
lines.append(f" → {result['reason']}")
162+
lines.append(f" → File A: {result['file_a']}")
163+
lines.append(f" → File B: {result['file_b']}")
164+
lines.append("")
165+
lines.append("Context around divergence (from file B serialized):")
166+
lines.append(f" {result['snippet']}")
167+
return "\n".join(lines)
168+
169+
170+
def run_all_comparisons(directory: str | None = None) -> None:
171+
"""Find all message files, sort by time, and compare adjacent pairs."""
172+
if directory is None:
173+
directory = DEFAULT_DIR
174+
175+
pattern = os.path.join(directory, "message-*.log")
176+
files = sorted(glob.glob(pattern), key=_get_timestamp)
177+
178+
if not files:
179+
print(f"No message files found in {directory}/")
180+
return
181+
182+
print(f"Found {len(files)} message files in {directory}")
183+
184+
has_output = False
185+
for i in range(len(files) - 1):
186+
file_a = files[i]
187+
file_b = files[i + 1]
188+
189+
result = compare_files(file_a, file_b)
190+
if result is None:
191+
print(f"\n{file_a} vs {file_b}")
192+
print(" → Files are identical")
193+
continue
194+
195+
has_output = True
196+
print(_format_result(result))
197+
198+
if not has_output and len(files) >= 2:
199+
print("\nNo divergences found between any adjacent file pairs.")
200+
elif len(files) < 2:
201+
print(f"\nNeed at least 2 files to compare, found {len(files)}.")
202+
203+
204+
def run_specific_comparison(file_a: str, file_b: str) -> None:
205+
"""Compare two specific files."""
206+
for fp in (file_a, file_b):
207+
if not os.path.isfile(fp):
208+
print(f"Error: file not found: {fp}", file=sys.stderr)
209+
sys.exit(1)
210+
211+
result = compare_files(file_a, file_b)
212+
if result is None:
213+
print(f"{file_a} and {file_b} are identical.")
214+
else:
215+
print(_format_result(result))
216+
217+
218+
def main() -> None:
219+
args = sys.argv[1:]
220+
221+
if not args:
222+
run_all_comparisons()
223+
elif args == ["--all"]:
224+
run_all_comparisons()
225+
elif len(args) == 1:
226+
path = args[0]
227+
if os.path.isdir(path):
228+
run_all_comparisons(path)
229+
elif os.path.isfile(path):
230+
print(
231+
"Error: expected a directory, got a file. Use two arguments to compare specific files.",
232+
file=sys.stderr,
233+
)
234+
sys.exit(1)
235+
else:
236+
print(f"Error: path not found: {path}", file=sys.stderr)
237+
sys.exit(1)
238+
elif len(args) == 2:
239+
run_specific_comparison(args[0], args[1])
240+
else:
241+
print("Usage:", file=sys.stderr)
242+
print(
243+
f" {sys.argv[0]} # Compare all files in default directory ({DEFAULT_DIR})",
244+
file=sys.stderr,
245+
)
246+
print(
247+
f" {sys.argv[0]} <directory> # Compare all files in the given directory",
248+
file=sys.stderr,
249+
)
250+
print(
251+
f" {sys.argv[0]} file1.json file2.json # Compare two specific files", file=sys.stderr
252+
)
253+
sys.exit(1)
254+
255+
256+
if __name__ == "__main__":
257+
main()

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL