opencode-chat-exporter 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chat_exporter/__init__.py +2 -0
- chat_exporter/__main__.py +16 -0
- chat_exporter/antigravity.py +599 -0
- chat_exporter/cli.py +194 -0
- chat_exporter/db.py +149 -0
- chat_exporter/gui.py +546 -0
- chat_exporter/render.py +272 -0
- opencode_chat_exporter-1.0.0.dist-info/METADATA +109 -0
- opencode_chat_exporter-1.0.0.dist-info/RECORD +13 -0
- opencode_chat_exporter-1.0.0.dist-info/WHEEL +5 -0
- opencode_chat_exporter-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_chat_exporter-1.0.0.dist-info/licenses/LICENSE +21 -0
- opencode_chat_exporter-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Entry point: `python -m chat_exporter` launches the GUI, `--cli` runs the console tool."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from .cli import main as cli_main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("--cli", "export", "list", "export-all", "watch"):
|
|
9
|
+
sys.argv = sys.argv[:1] + [a for a in sys.argv[1:] if a != "--cli"]
|
|
10
|
+
sys.exit(cli_main())
|
|
11
|
+
from .gui import main as gui_main
|
|
12
|
+
gui_main()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""AntiGravity conversation reader.
|
|
2
|
+
|
|
3
|
+
Conversation data for Google's AntiGravity (the Gemini coding agent) lives under
|
|
4
|
+
`%USERPROFILE%\\.gemini\\antigravity`:
|
|
5
|
+
|
|
6
|
+
- `conversations\\<cascade-uuid>.db` SQLite database with protobuf-encoded steps
|
|
7
|
+
- `brain\\<cascade-uuid>\\.system_generated\\logs\\transcript.jsonl` readable JSON-Lines transcript
|
|
8
|
+
|
|
9
|
+
The transcript file is the primary source (plain JSON, one event per line). When it is
|
|
10
|
+
missing, this module falls back to decoding the protobuf blobs inside the SQLite database
|
|
11
|
+
(steps / task_details / render_info / metadata / step_payload columns).
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
DEFAULT_AG_DIR = Path(os.path.expandvars(r"%USERPROFILE%\.gemini\antigravity"))
|
|
22
|
+
|
|
23
|
+
# transcript.jsonl entry kinds that represent tool execution results
|
|
24
|
+
TOOL_KIND_NAMES = {
|
|
25
|
+
"LIST_DIRECTORY": "list_dir",
|
|
26
|
+
"VIEW_FILE": "view_file",
|
|
27
|
+
"RUN_COMMAND": "run_command",
|
|
28
|
+
"CODE_ACTION": "code",
|
|
29
|
+
"SEARCH_WEB": "search_web",
|
|
30
|
+
"GENERIC": "tool",
|
|
31
|
+
}
|
|
32
|
+
SKIP_KINDS = {"CONVERSATION_HISTORY"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------- small helpers
|
|
36
|
+
def _parse_iso(value: str) -> int | None:
|
|
37
|
+
"""ISO timestamp string -> epoch milliseconds."""
|
|
38
|
+
try:
|
|
39
|
+
s = value.replace("Z", "+00:00")
|
|
40
|
+
t = datetime.fromisoformat(s)
|
|
41
|
+
if t.tzinfo is None:
|
|
42
|
+
t = t.replace(tzinfo=timezone.utc)
|
|
43
|
+
return int(t.timestamp() * 1000)
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _strip_meta_lines(text: str) -> str:
|
|
49
|
+
"""Drop the leading 'Created At: ... / Completed At: ...' header."""
|
|
50
|
+
lines = text.splitlines()
|
|
51
|
+
while lines and (lines[0].startswith("Created At:") or lines[0].startswith("Completed At:")):
|
|
52
|
+
lines.pop(0)
|
|
53
|
+
return "\n".join(lines).strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clean_tool_content(kind: str, content: str | None, exit_code: Any) -> tuple[str | None, dict]:
|
|
57
|
+
"""Normalize a tool result into (output_text, metadata)."""
|
|
58
|
+
if not content:
|
|
59
|
+
return None, {}
|
|
60
|
+
text = _strip_meta_lines(content)
|
|
61
|
+
metadata: dict = {}
|
|
62
|
+
if kind == "RUN_COMMAND":
|
|
63
|
+
if exit_code is not None:
|
|
64
|
+
metadata["exit"] = exit_code
|
|
65
|
+
m = re.search(r"The command exited with code (\d+)\.\s*\n*\s*Output:\s*\n*", text,
|
|
66
|
+
re.IGNORECASE)
|
|
67
|
+
if m:
|
|
68
|
+
text = text[m.end():].strip()
|
|
69
|
+
return (text or None), metadata
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unquote(value: Any) -> Any:
|
|
73
|
+
"""AntiGravity sometimes stores JSON argument values as extra-quoted strings."""
|
|
74
|
+
if isinstance(value, str) and value.startswith('"') and value.endswith('"'):
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(value)
|
|
77
|
+
except Exception:
|
|
78
|
+
return value
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return [_unquote(v) for v in value]
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
return {k: _unquote(v) for k, v in value.items()}
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------- transcript.jsonl
|
|
87
|
+
def load_transcript(path: Path) -> list[dict]:
|
|
88
|
+
"""Read a transcript.jsonl file into a list of event dicts."""
|
|
89
|
+
out: list[dict] = []
|
|
90
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
91
|
+
for line in f:
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
out.append(json.loads(line))
|
|
97
|
+
except Exception:
|
|
98
|
+
continue
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------ SQLite fallback dir
|
|
103
|
+
def _iter_fields(buf: bytes):
|
|
104
|
+
"""Minimal protobuf wire-format reader: yields (field, wire_type, value).
|
|
105
|
+
|
|
106
|
+
value is an int for varints and bytes for length-delimited fields.
|
|
107
|
+
"""
|
|
108
|
+
i, n = 0, len(buf)
|
|
109
|
+
while i < n:
|
|
110
|
+
tag = 0
|
|
111
|
+
shift = 0
|
|
112
|
+
while i < n:
|
|
113
|
+
b = buf[i]
|
|
114
|
+
i += 1
|
|
115
|
+
tag |= (b & 0x7F) << shift
|
|
116
|
+
if not (b & 0x80):
|
|
117
|
+
break
|
|
118
|
+
shift += 7
|
|
119
|
+
field = tag >> 3
|
|
120
|
+
wt = tag & 7
|
|
121
|
+
if wt == 0:
|
|
122
|
+
val = 0
|
|
123
|
+
shift = 0
|
|
124
|
+
while i < n:
|
|
125
|
+
b = buf[i]
|
|
126
|
+
i += 1
|
|
127
|
+
val |= (b & 0x7F) << shift
|
|
128
|
+
if not (b & 0x80):
|
|
129
|
+
break
|
|
130
|
+
shift += 7
|
|
131
|
+
yield field, wt, val
|
|
132
|
+
elif wt == 1:
|
|
133
|
+
i += 8
|
|
134
|
+
elif wt == 2:
|
|
135
|
+
ln = 0
|
|
136
|
+
shift = 0
|
|
137
|
+
while i < n:
|
|
138
|
+
b = buf[i]
|
|
139
|
+
i += 1
|
|
140
|
+
ln |= (b & 0x7F) << shift
|
|
141
|
+
if not (b & 0x80):
|
|
142
|
+
break
|
|
143
|
+
shift += 7
|
|
144
|
+
end = i + ln
|
|
145
|
+
if end > n:
|
|
146
|
+
return
|
|
147
|
+
yield field, wt, buf[i:end]
|
|
148
|
+
i = end
|
|
149
|
+
elif wt == 5:
|
|
150
|
+
i += 4
|
|
151
|
+
else:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _field(buf: bytes, wanted: int) -> list[Any]:
|
|
156
|
+
"""Collect field values matching a field number."""
|
|
157
|
+
found: list[Any] = []
|
|
158
|
+
try:
|
|
159
|
+
for f, wt, v in _iter_fields(bytes(buf)):
|
|
160
|
+
if f == wanted:
|
|
161
|
+
found.append(v)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _str_field(buf: bytes, wanted: int) -> str | None:
|
|
168
|
+
for v in _field(buf, wanted):
|
|
169
|
+
if isinstance(v, bytes):
|
|
170
|
+
try:
|
|
171
|
+
s = v.decode("utf-8")
|
|
172
|
+
if s:
|
|
173
|
+
return s
|
|
174
|
+
except Exception:
|
|
175
|
+
continue
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _varint_field(buf: bytes, wanted: int) -> int | None:
|
|
180
|
+
for v in _field(buf, wanted):
|
|
181
|
+
if isinstance(v, int):
|
|
182
|
+
return v
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decode_timestamp_ms(buf: bytes) -> int | None:
|
|
187
|
+
"""Protobuf google.protobuf.Timestamp {seconds, nanos} -> epoch milliseconds."""
|
|
188
|
+
secs = _varint_field(buf, 1)
|
|
189
|
+
if secs is None:
|
|
190
|
+
return None
|
|
191
|
+
nanos = _varint_field(buf, 2) or 0
|
|
192
|
+
return int(secs * 1000 + nanos / 1_000_000)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _db_entry_to_json(r: sqlite3.Row, title_out: list) -> dict | None:
|
|
196
|
+
"""Convert one `steps` row into the same shape as a transcript.jsonl entry.
|
|
197
|
+
|
|
198
|
+
Returns None for internal bookkeeping steps that should be skipped.
|
|
199
|
+
"""
|
|
200
|
+
payload = r["step_payload"] or b""
|
|
201
|
+
meta = r["metadata"] or b""
|
|
202
|
+
step_type = r["step_type"]
|
|
203
|
+
status = r["status"]
|
|
204
|
+
e: dict = {
|
|
205
|
+
"step_index": r["idx"],
|
|
206
|
+
"source": "MODEL",
|
|
207
|
+
"type": "GENERIC",
|
|
208
|
+
"status": "DONE",
|
|
209
|
+
"created_at": "",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# --- tool call info from metadata.f4 { id, name, argsJson } ---
|
|
213
|
+
tool_name: str | None = None
|
|
214
|
+
for f in _field(meta, 4):
|
|
215
|
+
name = _str_field(f, 2)
|
|
216
|
+
if name:
|
|
217
|
+
tool_name = name
|
|
218
|
+
summary = _str_field(meta, 30)
|
|
219
|
+
action = _str_field(meta, 31)
|
|
220
|
+
|
|
221
|
+
if step_type == 14: # user message
|
|
222
|
+
text = None
|
|
223
|
+
for f19 in _field(payload, 19):
|
|
224
|
+
text = _str_field(f19, 2)
|
|
225
|
+
if text:
|
|
226
|
+
break
|
|
227
|
+
if not text:
|
|
228
|
+
return None
|
|
229
|
+
e.update(type="USER_INPUT", source="USER_EXPLICIT", status="DONE", content=text)
|
|
230
|
+
return e
|
|
231
|
+
|
|
232
|
+
if step_type == 15: # assistant turn
|
|
233
|
+
content = None
|
|
234
|
+
tool_calls: list[dict] = []
|
|
235
|
+
for f20 in _field(payload, 20):
|
|
236
|
+
if content is None:
|
|
237
|
+
content = _str_field(f20, 3)
|
|
238
|
+
for f7 in _field(f20, 7):
|
|
239
|
+
tc = {"name": _str_field(f7, 2) or "tool"}
|
|
240
|
+
aj = _str_field(f7, 3)
|
|
241
|
+
if aj:
|
|
242
|
+
try:
|
|
243
|
+
tc["args"] = json.loads(aj)
|
|
244
|
+
except Exception:
|
|
245
|
+
tc["args"] = {"raw": aj}
|
|
246
|
+
tool_calls.append(tc)
|
|
247
|
+
e.update(type="PLANNER_RESPONSE", status="DONE")
|
|
248
|
+
if content:
|
|
249
|
+
e["content"] = content
|
|
250
|
+
if tool_calls:
|
|
251
|
+
e["tool_calls"] = tool_calls
|
|
252
|
+
return e
|
|
253
|
+
|
|
254
|
+
if step_type in (5, 9, 8): # tool calls / results
|
|
255
|
+
kind = {5: "CODE_ACTION", 8: "VIEW_FILE", 9: "LIST_DIRECTORY"}[step_type]
|
|
256
|
+
e.update(type=kind, status="DONE")
|
|
257
|
+
parts: list[str] = []
|
|
258
|
+
if tool_name:
|
|
259
|
+
parts.append(f"Tool: {tool_name}")
|
|
260
|
+
if action:
|
|
261
|
+
parts.append(action)
|
|
262
|
+
if summary:
|
|
263
|
+
parts.append(summary)
|
|
264
|
+
# file content result
|
|
265
|
+
if step_type == 8:
|
|
266
|
+
for f14 in _field(payload, 14):
|
|
267
|
+
content = _str_field(f14, 4)
|
|
268
|
+
if content:
|
|
269
|
+
parts.append(content)
|
|
270
|
+
elif step_type == 5:
|
|
271
|
+
for f10 in _field(payload, 10):
|
|
272
|
+
inner = _field(f10, 1)
|
|
273
|
+
if inner:
|
|
274
|
+
content = _str_field(inner[0], 2)
|
|
275
|
+
if content:
|
|
276
|
+
parts.append(content)
|
|
277
|
+
e["content"] = "\n".join(parts).strip()
|
|
278
|
+
return e
|
|
279
|
+
|
|
280
|
+
if step_type == 21: # run_command
|
|
281
|
+
e.update(type="RUN_COMMAND")
|
|
282
|
+
stdout: str | None = None
|
|
283
|
+
exit_code: int | None = None
|
|
284
|
+
for f28 in _field(payload, 28):
|
|
285
|
+
for f21 in _field(f28, 21):
|
|
286
|
+
stdout = _str_field(f21, 1)
|
|
287
|
+
exit_code = _varint_field(f28, 6)
|
|
288
|
+
if status in (6, 7): # failed / denied
|
|
289
|
+
e["status"] = "ERROR" if status == 6 else "DENIED"
|
|
290
|
+
err = None
|
|
291
|
+
for f31 in _field(payload, 31):
|
|
292
|
+
err = _str_field(f31, 1) or _str_field(f31, 2) or _str_field(f31, 3)
|
|
293
|
+
if err:
|
|
294
|
+
break
|
|
295
|
+
e["content"] = err or stdout
|
|
296
|
+
if exit_code is not None:
|
|
297
|
+
e["exit_code"] = exit_code
|
|
298
|
+
return e
|
|
299
|
+
e["status"] = "DONE"
|
|
300
|
+
e["exit_code"] = exit_code if exit_code is not None else 0
|
|
301
|
+
e["content"] = stdout or f"{tool_name or 'run_command'} returned {summary or 'ok'}"
|
|
302
|
+
return e
|
|
303
|
+
|
|
304
|
+
if step_type == 17: # invalid tool call
|
|
305
|
+
e.update(type="ERROR_MESSAGE", source="SYSTEM", status="DONE")
|
|
306
|
+
err = None
|
|
307
|
+
for f24 in _field(payload, 24):
|
|
308
|
+
err = _str_field(f24, 9) or _str_field(f24, 3)
|
|
309
|
+
if err:
|
|
310
|
+
break
|
|
311
|
+
e["error"] = err or "invalid tool call"
|
|
312
|
+
e["content"] = "Error invalid tool call: " + (err or "")
|
|
313
|
+
return e
|
|
314
|
+
|
|
315
|
+
if step_type == 33: # search_web
|
|
316
|
+
e.update(type="SEARCH_WEB", status="DONE")
|
|
317
|
+
text = None
|
|
318
|
+
for f42 in _field(payload, 42):
|
|
319
|
+
text = _str_field(f42, 5) or _str_field(f42, 1)
|
|
320
|
+
if text:
|
|
321
|
+
break
|
|
322
|
+
e["content"] = text or summary or "search completed"
|
|
323
|
+
return e
|
|
324
|
+
|
|
325
|
+
if step_type == 132: # manage_task -> generic result
|
|
326
|
+
e.update(type="GENERIC", status="DONE")
|
|
327
|
+
text = None
|
|
328
|
+
for f140 in _field(payload, 140):
|
|
329
|
+
for f2 in _field(f140, 2):
|
|
330
|
+
text = _str_field(f2, 1)
|
|
331
|
+
if text:
|
|
332
|
+
break
|
|
333
|
+
e["content"] = text or summary or "task updated"
|
|
334
|
+
return e
|
|
335
|
+
|
|
336
|
+
if step_type == 101: # task notification
|
|
337
|
+
e.update(type="SYSTEM_MESSAGE", source="SYSTEM", status="DONE")
|
|
338
|
+
text: str | None = None
|
|
339
|
+
for f114 in _field(payload, 114):
|
|
340
|
+
for f2 in _field(f114, 2):
|
|
341
|
+
text = _str_field(f2, 1)
|
|
342
|
+
if text:
|
|
343
|
+
break
|
|
344
|
+
if text:
|
|
345
|
+
break
|
|
346
|
+
e["content"] = text or "task notification"
|
|
347
|
+
return e
|
|
348
|
+
|
|
349
|
+
if step_type == 23: # conversation header: carries the title
|
|
350
|
+
for f30 in _field(payload, 30):
|
|
351
|
+
title = _str_field(f30, 4)
|
|
352
|
+
if title:
|
|
353
|
+
title_out.append(title)
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def load_steps_from_db(db: Path) -> list[dict]:
|
|
360
|
+
"""Decode every meaningful step from the conversations SQLite database."""
|
|
361
|
+
entries: list[dict] = []
|
|
362
|
+
title_holder: list = []
|
|
363
|
+
with sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=30) as con:
|
|
364
|
+
con.row_factory = sqlite3.Row
|
|
365
|
+
rows = con.execute(
|
|
366
|
+
"SELECT idx, step_type, status, metadata, step_payload FROM steps ORDER BY idx"
|
|
367
|
+
).fetchall()
|
|
368
|
+
for r in rows:
|
|
369
|
+
try:
|
|
370
|
+
e = _db_entry_to_json(r, title_holder)
|
|
371
|
+
except Exception:
|
|
372
|
+
e = None
|
|
373
|
+
if e:
|
|
374
|
+
entries.append(e)
|
|
375
|
+
return entries
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------- messages model
|
|
379
|
+
def _msg(role: str, time_ms: int | None, parts: list[dict], mid: str) -> dict:
|
|
380
|
+
return {
|
|
381
|
+
"id": mid,
|
|
382
|
+
"time_created": time_ms,
|
|
383
|
+
"time_updated": time_ms,
|
|
384
|
+
"data": {"role": role},
|
|
385
|
+
"parts": parts,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _text_part(text: str) -> dict:
|
|
390
|
+
return {"data": {"type": "text", "text": text}}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _reasoning_part(text: str) -> dict:
|
|
394
|
+
return {"data": {"type": "reasoning", "text": text}}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _tool_part(tool: str, status: str, inp: Any, output: Any = None,
|
|
398
|
+
metadata: dict | None = None) -> dict:
|
|
399
|
+
state: dict = {"status": status, "input": inp or {}}
|
|
400
|
+
if output is not None:
|
|
401
|
+
state["output"] = output
|
|
402
|
+
if metadata:
|
|
403
|
+
state["metadata"] = metadata
|
|
404
|
+
return {"data": {"type": "tool", "tool": tool, "state": state}}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def entries_to_messages(entries: list[dict]) -> list[dict]:
|
|
408
|
+
"""Convert transcript-style entries into the OpenCode message model used by the GUI."""
|
|
409
|
+
messages: list[dict] = []
|
|
410
|
+
for n, e in enumerate(entries):
|
|
411
|
+
kind = e.get("type", "")
|
|
412
|
+
time_ms = _parse_iso(e.get("created_at") or "")
|
|
413
|
+
mid = f"ag-{n}"
|
|
414
|
+
if kind == "USER_INPUT":
|
|
415
|
+
text = (e.get("content") or "").strip()
|
|
416
|
+
if text.startswith("<USER_REQUEST>"):
|
|
417
|
+
text = text[len("<USER_REQUEST>"):]
|
|
418
|
+
end = text.find("</USER_REQUEST>")
|
|
419
|
+
if end != -1:
|
|
420
|
+
text = text[:end]
|
|
421
|
+
text = text.strip()
|
|
422
|
+
messages.append(_msg("user", time_ms, [_text_part(text)], mid))
|
|
423
|
+
elif kind == "PLANNER_RESPONSE":
|
|
424
|
+
parts: list[dict] = []
|
|
425
|
+
content = (e.get("content") or "").strip()
|
|
426
|
+
if content:
|
|
427
|
+
parts.append(_text_part(content))
|
|
428
|
+
thinking = (e.get("thinking") or "").strip()
|
|
429
|
+
if thinking:
|
|
430
|
+
parts.append(_reasoning_part(thinking))
|
|
431
|
+
for tc in e.get("tool_calls") or []:
|
|
432
|
+
parts.append(_tool_part(tc.get("name", "tool"), "completed",
|
|
433
|
+
_unquote(tc.get("args", {}))))
|
|
434
|
+
if not parts:
|
|
435
|
+
parts.append(_text_part("*(no visible response)*"))
|
|
436
|
+
messages.append(_msg("assistant", time_ms, parts, mid))
|
|
437
|
+
elif kind in TOOL_KIND_NAMES:
|
|
438
|
+
tool = TOOL_KIND_NAMES[kind]
|
|
439
|
+
output, metadata = _clean_tool_content(kind, e.get("content"), e.get("exit_code"))
|
|
440
|
+
status = "error" if e.get("status") == "ERROR" else (
|
|
441
|
+
"cancelled" if e.get("status") == "DENIED" else "completed")
|
|
442
|
+
messages.append(_msg("assistant", time_ms,
|
|
443
|
+
[_tool_part(tool, status, {}, output, metadata or None)], mid))
|
|
444
|
+
elif kind == "ERROR_MESSAGE":
|
|
445
|
+
messages.append(_msg("assistant", time_ms,
|
|
446
|
+
[_tool_part("error", "error", {},
|
|
447
|
+
e.get("content") or e.get("error") or "tool error")], mid))
|
|
448
|
+
elif kind == "SYSTEM_MESSAGE":
|
|
449
|
+
text = (e.get("content") or "").strip()
|
|
450
|
+
if text:
|
|
451
|
+
messages.append(_msg("system", time_ms, [_text_part(text)], mid))
|
|
452
|
+
elif kind == "CHECKPOINT":
|
|
453
|
+
text = (e.get("content") or "").strip()
|
|
454
|
+
if text:
|
|
455
|
+
text = text[:2000] + "\n…[checkpoint truncated]" if len(text) > 2000 else text
|
|
456
|
+
messages.append(_msg("system", time_ms, [_text_part("🧹 " + text)], mid))
|
|
457
|
+
# CONVERSATION_HISTORY and unknown kinds are skipped
|
|
458
|
+
return messages
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------- public class
|
|
462
|
+
class AntiGravityDB:
|
|
463
|
+
"""Read-only access to AntiGravity conversations, mirroring OpenCodeDB's interface."""
|
|
464
|
+
|
|
465
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
466
|
+
p = Path(root) if root else DEFAULT_AG_DIR
|
|
467
|
+
self.brain_dir: Path | None = None
|
|
468
|
+
self.single_db: Path | None = None
|
|
469
|
+
if p.is_file() and p.suffix.lower() == ".db":
|
|
470
|
+
# a single conversations *.db file was given directly
|
|
471
|
+
self.single_db = Path(p)
|
|
472
|
+
self.conv_dir = self.single_db.parent
|
|
473
|
+
self.brain_dir = self.single_db.parent.parent / "brain"
|
|
474
|
+
elif p.is_dir():
|
|
475
|
+
if (p / "conversations").is_dir():
|
|
476
|
+
self.conv_dir = p / "conversations"
|
|
477
|
+
self.brain_dir = p / "brain"
|
|
478
|
+
elif any(p.glob("*.db")):
|
|
479
|
+
# the conversations folder itself was given
|
|
480
|
+
self.conv_dir = p
|
|
481
|
+
self.brain_dir = p.parent / "brain"
|
|
482
|
+
else:
|
|
483
|
+
self.conv_dir = p / "conversations"
|
|
484
|
+
self.brain_dir = p / "brain"
|
|
485
|
+
else:
|
|
486
|
+
raise FileNotFoundError(f"AntiGravity folder not found: {p}")
|
|
487
|
+
if self.single_db is None and not self.conv_dir.is_dir():
|
|
488
|
+
raise FileNotFoundError(f"No AntiGravity conversations folder at: {self.conv_dir}")
|
|
489
|
+
self._cache: dict[str, dict] = {}
|
|
490
|
+
self._info_cache: dict[str, dict] = {}
|
|
491
|
+
|
|
492
|
+
# ----------------------------------------------------------------- sessions
|
|
493
|
+
def list_cascades(self) -> list[str]:
|
|
494
|
+
if self.single_db is not None:
|
|
495
|
+
return [self.single_db.stem]
|
|
496
|
+
if not self.conv_dir.is_dir():
|
|
497
|
+
return []
|
|
498
|
+
return sorted(p.stem for p in self.conv_dir.glob("*.db"))
|
|
499
|
+
|
|
500
|
+
def _could_load(self, cascade_id: str) -> bool:
|
|
501
|
+
return self._transcript(cascade_id).exists() or self._db(cascade_id).exists()
|
|
502
|
+
|
|
503
|
+
def _transcript(self, cascade_id: str) -> Path:
|
|
504
|
+
if self.brain_dir is not None:
|
|
505
|
+
return self.brain_dir / cascade_id / ".system_generated" / "logs" / "transcript.jsonl"
|
|
506
|
+
return _transcript_path(Path.cwd(), cascade_id)
|
|
507
|
+
|
|
508
|
+
def _db(self, cascade_id: str) -> Path:
|
|
509
|
+
if self.single_db is not None:
|
|
510
|
+
return self.single_db
|
|
511
|
+
return self.conv_dir / f"{cascade_id}.db"
|
|
512
|
+
|
|
513
|
+
def _session_info(self, cascade_id: str, entries: list[dict],
|
|
514
|
+
messages: list[dict]) -> dict:
|
|
515
|
+
title = ""
|
|
516
|
+
created = updated = None
|
|
517
|
+
for e in entries:
|
|
518
|
+
t = _parse_iso(e.get("created_at") or "")
|
|
519
|
+
if t:
|
|
520
|
+
created = t if created is None else min(created, t)
|
|
521
|
+
updated = t if updated is None else max(updated, t)
|
|
522
|
+
if not title:
|
|
523
|
+
for e in entries:
|
|
524
|
+
if e.get("type") == "USER_INPUT":
|
|
525
|
+
text = (e.get("content") or "").strip()
|
|
526
|
+
text = re.sub(r"^<USER_REQUEST>\s*", "", text)
|
|
527
|
+
title = text.replace("\r", " ").replace("\n", " ")[:64].strip()
|
|
528
|
+
break
|
|
529
|
+
if not title:
|
|
530
|
+
title = f"Conversation {cascade_id[:8]}"
|
|
531
|
+
brain = self.brain_dir / cascade_id if self.brain_dir is not None else None
|
|
532
|
+
directory = str(brain) if brain is not None and brain.is_dir() else str(
|
|
533
|
+
self.conv_dir if self.single_db is None else self.single_db.parent.parent)
|
|
534
|
+
return {
|
|
535
|
+
"id": cascade_id,
|
|
536
|
+
"title": title,
|
|
537
|
+
"directory": directory,
|
|
538
|
+
"agent": "antigravity",
|
|
539
|
+
"model": None,
|
|
540
|
+
"time_created": created,
|
|
541
|
+
"time_updated": updated,
|
|
542
|
+
"time_archived": None,
|
|
543
|
+
"message_count": len(messages),
|
|
544
|
+
"part_count": sum(len(m.get("parts", [])) for m in messages),
|
|
545
|
+
"steps": len(entries),
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
def _load_cascade(self, cascade_id: str) -> tuple[list[dict], list[dict]]:
|
|
549
|
+
"""Return (entries, messages) for one cascade."""
|
|
550
|
+
tr = load_transcript(self._transcript(cascade_id))
|
|
551
|
+
if tr:
|
|
552
|
+
entries = [e for e in tr if e.get("type") not in SKIP_KINDS]
|
|
553
|
+
return entries, entries_to_messages(entries)
|
|
554
|
+
db = self._db(cascade_id)
|
|
555
|
+
if db.exists():
|
|
556
|
+
entries = load_steps_from_db(db)
|
|
557
|
+
return entries, entries_to_messages(entries)
|
|
558
|
+
return [], []
|
|
559
|
+
|
|
560
|
+
def list_sessions(self, include_archived: bool = False) -> list[dict]:
|
|
561
|
+
sessions = []
|
|
562
|
+
for cid in self.list_cascades():
|
|
563
|
+
try:
|
|
564
|
+
entries, messages = self._load_cascade(cid)
|
|
565
|
+
except Exception:
|
|
566
|
+
continue
|
|
567
|
+
if not messages and not entries:
|
|
568
|
+
continue
|
|
569
|
+
info = self._session_info(cid, entries, messages)
|
|
570
|
+
sessions.append(info)
|
|
571
|
+
self._cache[cid] = entries
|
|
572
|
+
self._info_cache[cid] = info
|
|
573
|
+
sessions.sort(key=lambda s: (s["time_updated"] or 0), reverse=True)
|
|
574
|
+
return sessions
|
|
575
|
+
|
|
576
|
+
# ------------------------------------------------------------- conversation
|
|
577
|
+
def get_session(self, cascade_id: str) -> dict | None:
|
|
578
|
+
if cascade_id in self._info_cache:
|
|
579
|
+
return self._info_cache[cascade_id]
|
|
580
|
+
entries, messages = self._load_cascade(cascade_id)
|
|
581
|
+
if not entries and not messages:
|
|
582
|
+
return None
|
|
583
|
+
info = self._session_info(cascade_id, entries, messages)
|
|
584
|
+
self._info_cache[cascade_id] = info
|
|
585
|
+
return info
|
|
586
|
+
|
|
587
|
+
def get_session_messages(self, cascade_id: str) -> list[dict]:
|
|
588
|
+
if cascade_id in self._cache:
|
|
589
|
+
entries = self._cache[cascade_id]
|
|
590
|
+
else:
|
|
591
|
+
entries, _ = self._load_cascade(cascade_id)
|
|
592
|
+
return entries_to_messages(entries)
|
|
593
|
+
|
|
594
|
+
# ----------------------------------------------------------------- helpers
|
|
595
|
+
@staticmethod
|
|
596
|
+
def ts(ms: int | None) -> datetime | None:
|
|
597
|
+
if not isinstance(ms, int) or ms <= 0:
|
|
598
|
+
return None
|
|
599
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).astimezone()
|