interlatch 0.22.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.
- chronicle/__init__.py +17 -0
- chronicle/__main__.py +3 -0
- chronicle/access.py +93 -0
- chronicle/agents.py +23 -0
- chronicle/analyze.py +498 -0
- chronicle/antigravity_parser.py +311 -0
- chronicle/artifacts.py +621 -0
- chronicle/bob_parser.py +259 -0
- chronicle/cases.py +75 -0
- chronicle/chat_import.py +325 -0
- chronicle/chatgpt_export.py +127 -0
- chronicle/claude_export.py +129 -0
- chronicle/cli.py +2980 -0
- chronicle/codex_cloud.py +138 -0
- chronicle/codex_parser.py +753 -0
- chronicle/config.py +627 -0
- chronicle/connectors.py +666 -0
- chronicle/container.py +202 -0
- chronicle/copilot_parser.py +494 -0
- chronicle/db.py +598 -0
- chronicle/desktop.py +543 -0
- chronicle/diagram.py +488 -0
- chronicle/digest.py +194 -0
- chronicle/export_md.py +210 -0
- chronicle/friction.py +841 -0
- chronicle/glossary.py +522 -0
- chronicle/groups.py +181 -0
- chronicle/hooks.py +169 -0
- chronicle/hub.py +2291 -0
- chronicle/i18n.py +37 -0
- chronicle/i18n_ja.py +421 -0
- chronicle/ingest.py +1190 -0
- chronicle/install.py +590 -0
- chronicle/instructions.py +699 -0
- chronicle/ladder.py +244 -0
- chronicle/learning.py +170 -0
- chronicle/llm.py +570 -0
- chronicle/mcp_server.py +403 -0
- chronicle/menubar.py +771 -0
- chronicle/migrate.py +580 -0
- chronicle/mirror.py +453 -0
- chronicle/news.py +110 -0
- chronicle/notify.py +92 -0
- chronicle/parser.py +908 -0
- chronicle/people.py +536 -0
- chronicle/pg.py +122 -0
- chronicle/pricing.py +168 -0
- chronicle/providers.py +591 -0
- chronicle/redact.py +50 -0
- chronicle/reviews.py +251 -0
- chronicle/screen.py +308 -0
- chronicle/search.py +238 -0
- chronicle/server.py +3009 -0
- chronicle/session_export.py +178 -0
- chronicle/statusline.py +258 -0
- chronicle/suggest.py +643 -0
- chronicle/synthesize.py +365 -0
- chronicle/systems.py +1265 -0
- chronicle/tailnet.py +97 -0
- chronicle/team_store.py +450 -0
- chronicle/update.py +404 -0
- chronicle/util.py +217 -0
- chronicle/views.py +249 -0
- chronicle/web/PLEX-OFL.txt +93 -0
- chronicle/web/app.css +2350 -0
- chronicle/web/app.js +7587 -0
- chronicle/web/art-book.webp +0 -0
- chronicle/web/art-camera.webp +0 -0
- chronicle/web/art-chip.webp +0 -0
- chronicle/web/art-clock.webp +0 -0
- chronicle/web/art-cloud.webp +0 -0
- chronicle/web/art-coins.webp +0 -0
- chronicle/web/art-container.webp +0 -0
- chronicle/web/art-dashboard.webp +0 -0
- chronicle/web/art-dex.webp +0 -0
- chronicle/web/art-document.webp +0 -0
- chronicle/web/art-flag.webp +0 -0
- chronicle/web/art-folder.webp +0 -0
- chronicle/web/art-gateway.webp +0 -0
- chronicle/web/art-gears.webp +0 -0
- chronicle/web/art-glitch.webp +0 -0
- chronicle/web/art-globe.webp +0 -0
- chronicle/web/art-laptop.webp +0 -0
- chronicle/web/art-log.webp +0 -0
- chronicle/web/art-lumi.webp +0 -0
- chronicle/web/art-magnifier.webp +0 -0
- chronicle/web/art-pip.webp +0 -0
- chronicle/web/art-plug.webp +0 -0
- chronicle/web/art-rack.webp +0 -0
- chronicle/web/art-tink.webp +0 -0
- chronicle/web/boot.js +25 -0
- chronicle/web/favicon.png +0 -0
- chronicle/web/i18n.js +50 -0
- chronicle/web/icon-180.png +0 -0
- chronicle/web/icon-192.png +0 -0
- chronicle/web/icon-512.png +0 -0
- chronicle/web/icon.png +0 -0
- chronicle/web/index.html +92 -0
- chronicle/web/ja.js +1880 -0
- chronicle/web/learning.js +438 -0
- chronicle/web/manifest.webmanifest +15 -0
- chronicle/web/panel.css +175 -0
- chronicle/web/panel.html +14 -0
- chronicle/web/panel.js +241 -0
- chronicle/web/plex-mono-400.woff2 +0 -0
- chronicle/web/plex-mono-500.woff2 +0 -0
- chronicle/web/plex-mono-600.woff2 +0 -0
- chronicle/web/plex-sans-400.woff2 +0 -0
- chronicle/web/plex-sans-500.woff2 +0 -0
- chronicle/web/plex-sans-600.woff2 +0 -0
- chronicle/web/plex-sans-700.woff2 +0 -0
- chronicle/web/rough.js +2 -0
- chronicle/worker.py +390 -0
- interlatch-0.22.0.dist-info/METADATA +221 -0
- interlatch-0.22.0.dist-info/RECORD +118 -0
- interlatch-0.22.0.dist-info/WHEEL +4 -0
- interlatch-0.22.0.dist-info/entry_points.txt +3 -0
- interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Parse Google Antigravity conversations (~/.gemini/antigravity) into the same model as Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Antigravity keeps each conversation's own store in conversations/<id>.pb (encrypted; older versions) or
|
|
4
|
+
conversations/<id>.db (SQLite of protobuf blobs; newer versions). Both versions also write a plain step log next to
|
|
5
|
+
the agent's artifacts, which is what is read here:
|
|
6
|
+
|
|
7
|
+
brain/<id>/.system_generated/logs/transcript_full.jsonl one step per line, untruncated (newer versions)
|
|
8
|
+
brain/<id>/.system_generated/logs/transcript.jsonl the same with long fields cut (older versions: the only one)
|
|
9
|
+
brain/<id>/.system_generated/logs/chunks/<name>/*.jsonl a copy of each log in ~100 KB pieces, cut mid-line
|
|
10
|
+
annotations/<id>.pbtxt title:"..." (newer versions)
|
|
11
|
+
|
|
12
|
+
Steps are written as they finish, so they are sorted by step_index. A MODEL PLANNER_RESPONSE carries the reply, its
|
|
13
|
+
thinking, token usage and tool calls; the MODEL steps after it are those calls' results, in order. Where the .db
|
|
14
|
+
exists, its trajectory metadata adds the workspace folder, git remote and branch, and its generation metadata the
|
|
15
|
+
model id. Nothing in ~/.gemini is written to.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import codecs
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import sqlite3
|
|
25
|
+
from collections import Counter, deque
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .copilot_parser import _Builder, estimate_cost, finish_session, uri_path
|
|
29
|
+
from .parser import ApiCall, ParsedSession
|
|
30
|
+
from .pricing import normalize_model
|
|
31
|
+
from .util import one_line, parse_ts
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("chronicle.antigravity")
|
|
34
|
+
|
|
35
|
+
ANTIGRAVITY_PARSER_VERSION = 1
|
|
36
|
+
LOGS = Path(".system_generated") / "logs"
|
|
37
|
+
_REQUEST_RE = re.compile(r"<USER_REQUEST>(.*?)</USER_REQUEST>", re.S)
|
|
38
|
+
_BLOCK_RE = re.compile(r"<(USER_REQUEST|ADDITIONAL_METADATA|USER_SETTINGS_CHANGE)>.*?</\1>", re.S)
|
|
39
|
+
_ARTIFACT_URI_RE = re.compile(r"file://\S*?/brain/[^/\s]+/")
|
|
40
|
+
_MODEL_PICK_RE = re.compile(r"`Model Selection` from .*? to (.+?)\.(?:\s|$)", re.S)
|
|
41
|
+
_SYSTEM_RE = re.compile(r".*<SYSTEM_MESSAGE>\s*(?:\[Message\][^\n]*?content=)?(.*?)\s*</SYSTEM_MESSAGE>", re.S) # the last opening tag: the preamble names the tag too
|
|
42
|
+
_STAMPS_RE = re.compile(r"\s*Created At: (\S+)\s*(?:Completed At: (\S+))?\s*")
|
|
43
|
+
_TITLE_RE = re.compile(r'title:\s*"((?:[^"\\]|\\.)*)"')
|
|
44
|
+
_MODEL_ID_RE = re.compile(r"^[a-z][a-z0-9.-]*\d[a-z0-9.-]*$")
|
|
45
|
+
_PATH_KEYS = ("absolutePath", "targetFile", "file")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------ store layout
|
|
49
|
+
def conversation_dirs(home: Path) -> list[Path]:
|
|
50
|
+
"""brain/<id> folders that have a step log."""
|
|
51
|
+
brain = home / "brain"
|
|
52
|
+
if not brain.is_dir():
|
|
53
|
+
return []
|
|
54
|
+
return sorted(d for d in brain.iterdir() if d.is_dir() and transcript_files(d))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def transcript_files(conv: Path) -> list[Path]:
|
|
58
|
+
"""The step log (the untruncated one when there is one), or else its chunks, which read as one file joined in order."""
|
|
59
|
+
logs = conv / LOGS
|
|
60
|
+
for name in ("transcript_full", "transcript"):
|
|
61
|
+
if (logs / f"{name}.jsonl").exists():
|
|
62
|
+
return [logs / f"{name}.jsonl"]
|
|
63
|
+
chunks = sorted((logs / "chunks" / name).glob("*.jsonl"))
|
|
64
|
+
if chunks:
|
|
65
|
+
return chunks
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stored_conversations(home: Path) -> list[str]:
|
|
70
|
+
"""Ids of every conversation Antigravity keeps, readable or not."""
|
|
71
|
+
d = home / "conversations"
|
|
72
|
+
return sorted({p.stem for p in d.glob("*") if p.suffix in (".pb", ".db")}) if d.is_dir() else []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_title(home: Path, cid: str) -> str | None:
|
|
76
|
+
try:
|
|
77
|
+
text = (home / "annotations" / f"{cid}.pbtxt").read_text(errors="replace")
|
|
78
|
+
except OSError:
|
|
79
|
+
return None
|
|
80
|
+
m = _TITLE_RE.search(text)
|
|
81
|
+
if not m:
|
|
82
|
+
return None
|
|
83
|
+
try: # text-format protobuf: C escapes, octal for non-ASCII bytes
|
|
84
|
+
return codecs.escape_decode(m.group(1).encode())[0].decode("utf-8", "replace").strip() or None
|
|
85
|
+
except ValueError:
|
|
86
|
+
return m.group(1).strip() or None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------ conversation database (newer versions)
|
|
90
|
+
def _varint(b: bytes, i: int) -> tuple[int, int]:
|
|
91
|
+
out = shift = 0
|
|
92
|
+
while True:
|
|
93
|
+
byte = b[i]
|
|
94
|
+
i += 1
|
|
95
|
+
out |= (byte & 0x7F) << shift
|
|
96
|
+
shift += 7
|
|
97
|
+
if byte < 0x80:
|
|
98
|
+
return out, i
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fields(b: bytes) -> dict[int, list]:
|
|
102
|
+
"""One protobuf message's fields (number -> values; length-delimited ones stay bytes), or {} if it is not one."""
|
|
103
|
+
out: dict[int, list] = {}
|
|
104
|
+
i = 0
|
|
105
|
+
try:
|
|
106
|
+
while i < len(b):
|
|
107
|
+
key, i = _varint(b, i)
|
|
108
|
+
num, wire = key >> 3, key & 7
|
|
109
|
+
if wire == 0:
|
|
110
|
+
v, i = _varint(b, i)
|
|
111
|
+
elif wire == 2:
|
|
112
|
+
n, i = _varint(b, i)
|
|
113
|
+
v, i = b[i:i + n], i + n
|
|
114
|
+
elif wire in (1, 5):
|
|
115
|
+
n = 8 if wire == 1 else 4
|
|
116
|
+
v, i = b[i:i + n], i + n
|
|
117
|
+
else:
|
|
118
|
+
return {}
|
|
119
|
+
if num == 0 or i > len(b):
|
|
120
|
+
return {}
|
|
121
|
+
out.setdefault(num, []).append(v)
|
|
122
|
+
except IndexError:
|
|
123
|
+
return {}
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _pb_text(blob, *path: int) -> str | None:
|
|
128
|
+
"""The string at a field path (first value at each level), or None."""
|
|
129
|
+
for num in path:
|
|
130
|
+
vals = _fields(blob).get(num) if isinstance(blob, bytes) else None
|
|
131
|
+
if not vals or not isinstance(vals[0], bytes):
|
|
132
|
+
return None
|
|
133
|
+
blob = vals[0]
|
|
134
|
+
try:
|
|
135
|
+
return blob.decode() or None
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_db_meta(db: Path) -> dict:
|
|
141
|
+
"""Workspace folder, git remote and branch, and model id from a conversation database; {} if absent or unreadable."""
|
|
142
|
+
if not db.exists():
|
|
143
|
+
return {}
|
|
144
|
+
try:
|
|
145
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
|
|
146
|
+
try:
|
|
147
|
+
meta = conn.execute("SELECT data FROM trajectory_metadata_blob").fetchone()
|
|
148
|
+
gens = conn.execute("SELECT data FROM gen_metadata ORDER BY idx").fetchall()
|
|
149
|
+
finally:
|
|
150
|
+
conn.close()
|
|
151
|
+
except sqlite3.Error as exc:
|
|
152
|
+
log.warning("antigravity conversation db unreadable: %s: %s", db, exc)
|
|
153
|
+
return {}
|
|
154
|
+
out = {}
|
|
155
|
+
if meta and isinstance(meta[0], bytes):
|
|
156
|
+
out = {"workspace": uri_path(_pb_text(meta[0], 1, 1)), "remote": _pb_text(meta[0], 1, 3, 2),
|
|
157
|
+
"branch": _pb_text(meta[0], 1, 4)}
|
|
158
|
+
models = Counter(m for (g,) in gens if isinstance(g, bytes) and (m := _pb_text(g, 1, 19)) and _MODEL_ID_RE.match(m))
|
|
159
|
+
if models:
|
|
160
|
+
out["model"] = models.most_common(1)[0][0]
|
|
161
|
+
return {k: v for k, v in out.items() if v}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ steps
|
|
165
|
+
def load_steps(conv: Path) -> tuple[list[dict], int]:
|
|
166
|
+
"""The conversation's steps in order, and how many lines did not parse."""
|
|
167
|
+
data = b"".join(f.read_bytes() for f in transcript_files(conv)).decode("utf-8", "replace")
|
|
168
|
+
lines = data.split("\n")
|
|
169
|
+
if not data.endswith("\n"):
|
|
170
|
+
lines.pop() # a step still being written
|
|
171
|
+
steps: dict[int, dict] = {}
|
|
172
|
+
bad = 0
|
|
173
|
+
for line in lines:
|
|
174
|
+
if not line.strip():
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
row = json.loads(line)
|
|
178
|
+
except ValueError:
|
|
179
|
+
bad += 1
|
|
180
|
+
continue
|
|
181
|
+
if isinstance(row, dict) and isinstance(row.get("step_index"), int):
|
|
182
|
+
steps[row["step_index"]] = row
|
|
183
|
+
return [steps[k] for k in sorted(steps)], bad
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _prompt_text(content: str) -> str:
|
|
187
|
+
"""The user's words: the USER_REQUEST block, after any preamble (an artifact review: "Comments on artifact URI ...")."""
|
|
188
|
+
m = _REQUEST_RE.search(content)
|
|
189
|
+
request = m.group(1).strip() if m else ""
|
|
190
|
+
preamble = _ARTIFACT_URI_RE.sub("", _BLOCK_RE.sub("", content)).strip()
|
|
191
|
+
return "\n\n".join(p for p in (preamble, request) if p)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _model_from_label(label: str) -> str | None:
|
|
195
|
+
"""'Gemini 3.8 Flash (High)' -> 'gemini-3.8-flash'."""
|
|
196
|
+
return re.sub(r"\s+", "-", re.sub(r"\(.*?\)", "", label).strip().lower()) or None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _json_value(v):
|
|
200
|
+
try:
|
|
201
|
+
return json.loads(v)
|
|
202
|
+
except (TypeError, ValueError): # not JSON, or a long value the truncated log cut short
|
|
203
|
+
return v
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _args(raw) -> dict:
|
|
207
|
+
"""Tool arguments under the key names tool_summary reads. Older versions JSON-encode every value; those are decoded."""
|
|
208
|
+
if not isinstance(raw, dict):
|
|
209
|
+
return {}
|
|
210
|
+
args = dict(raw)
|
|
211
|
+
if any(isinstance(v, str) and len(v) > 1 and v[0] == v[-1] == '"' and isinstance(_json_value(v), str) for v in args.values()):
|
|
212
|
+
args = {k: _json_value(v) for k, v in args.items()}
|
|
213
|
+
out = {("description" if k == "toolSummary" else k[:1].lower() + k[1:]): v for k, v in args.items() if k != "toolAction"}
|
|
214
|
+
path = next((out[k] for k in _PATH_KEYS if isinstance(out.get(k), str) and out[k]), None)
|
|
215
|
+
if path and "filePath" not in out:
|
|
216
|
+
out["filePath"] = path
|
|
217
|
+
elif isinstance(out.get("directoryPath"), str) and "path" not in out:
|
|
218
|
+
out["path"] = out["directoryPath"] # shown as the call's summary; not counted as a file
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _result(step: dict) -> tuple[str, int | None]:
|
|
223
|
+
"""A result step's text without its Created At / Completed At header, and how long the call took (ms)."""
|
|
224
|
+
text = step.get("content") if isinstance(step.get("content"), str) else str(step.get("error") or "")
|
|
225
|
+
m = _STAMPS_RE.match(text)
|
|
226
|
+
if not m:
|
|
227
|
+
return text, None
|
|
228
|
+
start, end = parse_ts(m.group(1)), parse_ts(m.group(2))
|
|
229
|
+
return text[m.end():], int((end - start).total_seconds() * 1000) if start and end else None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _system_text(content: str) -> str:
|
|
233
|
+
m = _SYSTEM_RE.search(content)
|
|
234
|
+
return (m.group(1) if m else content).strip()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_antigravity_conversation(conv: Path, *, title: str | None = None, db_meta: dict | None = None) -> ParsedSession | None:
|
|
238
|
+
"""One brain/<id> folder as a session; None when it never got a prompt."""
|
|
239
|
+
steps, bad = load_steps(conv)
|
|
240
|
+
if not any(s.get("type") == "USER_INPUT" for s in steps):
|
|
241
|
+
return None
|
|
242
|
+
meta = db_meta or {}
|
|
243
|
+
ps = ParsedSession(id=conv.name)
|
|
244
|
+
ps.entrypoint = "antigravity"
|
|
245
|
+
ps.ai_title = title
|
|
246
|
+
ps.git_branch = meta.get("branch")
|
|
247
|
+
ps.git_remote = meta.get("remote")
|
|
248
|
+
ps.parse_errors = bad
|
|
249
|
+
b = _Builder(ps)
|
|
250
|
+
model = meta.get("model") # the database's model id; a model picked in the conversation takes over from there
|
|
251
|
+
pending: deque = deque() # tool calls waiting for their result steps
|
|
252
|
+
cwds: Counter = Counter()
|
|
253
|
+
|
|
254
|
+
def flush() -> None:
|
|
255
|
+
while pending:
|
|
256
|
+
ts, call_id, name, args = pending.popleft()
|
|
257
|
+
b.tool(ts, call_id, name, args)
|
|
258
|
+
|
|
259
|
+
for s in steps:
|
|
260
|
+
ts, kind, source = s.get("created_at"), s.get("type"), s.get("source")
|
|
261
|
+
content = s.get("content") if isinstance(s.get("content"), str) else ""
|
|
262
|
+
if kind == "USER_INPUT":
|
|
263
|
+
flush()
|
|
264
|
+
pick = _MODEL_PICK_RE.search(content)
|
|
265
|
+
if pick:
|
|
266
|
+
model = _model_from_label(pick.group(1)) or model
|
|
267
|
+
b.prompt(ts, _prompt_text(content))
|
|
268
|
+
elif kind == "PLANNER_RESPONSE":
|
|
269
|
+
flush()
|
|
270
|
+
if isinstance(s.get("thinking"), str) and s["thinking"].strip():
|
|
271
|
+
b.event(ts, "assistant", "thinking", s["thinking"])
|
|
272
|
+
if content.strip():
|
|
273
|
+
b.event(ts, "assistant", "text", content)
|
|
274
|
+
inp, cached, out = (int(s.get(k) or 0) for k in ("input_tokens", "cache_read_tokens", "output_tokens"))
|
|
275
|
+
if inp or cached or out:
|
|
276
|
+
m = normalize_model(model)
|
|
277
|
+
ps.api_calls.append(ApiCall("", f"step-{s['step_index']}", ts, m, input_tokens=inp, output_tokens=out,
|
|
278
|
+
cache_read_tokens=cached, cost_usd=estimate_cost(m, inp, cached, 0, out)))
|
|
279
|
+
for i, tc in enumerate(s.get("tool_calls") or []):
|
|
280
|
+
if isinstance(tc, dict):
|
|
281
|
+
args = _args(tc.get("args"))
|
|
282
|
+
if isinstance(args.get("cwd"), str) and args["cwd"]:
|
|
283
|
+
cwds[args["cwd"]] += 1
|
|
284
|
+
pending.append((ts, f"{s['step_index']}.{i}", tc.get("name") or "tool", args))
|
|
285
|
+
elif kind == "ERROR_MESSAGE": # the model's last tool call could not be run
|
|
286
|
+
text = one_line(s.get("error") or content, 300)
|
|
287
|
+
if pending:
|
|
288
|
+
call_ts, call_id, name, args = pending.popleft()
|
|
289
|
+
b.tool(call_ts, call_id, name, args, result=text, is_error=True)
|
|
290
|
+
else:
|
|
291
|
+
ps.n_api_errors += 1
|
|
292
|
+
b.event(ts, "system", "api_error", text)
|
|
293
|
+
elif source == "SYSTEM":
|
|
294
|
+
if kind == "SYSTEM_MESSAGE" and content.strip(): # e.g. a background command finished
|
|
295
|
+
b.event(ts, "system", "notification", _system_text(content))
|
|
296
|
+
elif source == "MODEL": # the result of the oldest call still waiting for one
|
|
297
|
+
text, ms = _result(s)
|
|
298
|
+
failed = s.get("status") == "ERROR" or bool(s.get("error"))
|
|
299
|
+
if pending:
|
|
300
|
+
call_ts, call_id, name, args = pending.popleft()
|
|
301
|
+
b.tool(call_ts, call_id, name, args, result=text, is_error=failed, duration_ms=ms)
|
|
302
|
+
elif text.strip():
|
|
303
|
+
b.event(ts, "user", "tool_result", text, is_error=failed, limit_key="tool_error" if failed else "tool_result")
|
|
304
|
+
flush()
|
|
305
|
+
ps.project_path = meta.get("workspace") or (cwds.most_common(1)[0][0] if cwds else None)
|
|
306
|
+
# keep files the agent read or changed, leaving out listed folders and its own task/plan/walkthrough artifacts
|
|
307
|
+
ps.files = {fp: st for fp, st in ps.files.items() if (st.reads or st.edits or st.writes) and not fp.startswith(f"{conv}/")}
|
|
308
|
+
ps = finish_session(ps, b.stamps)
|
|
309
|
+
if model and not ps.models: # older logs carry no token counts
|
|
310
|
+
ps.models[normalize_model(model)] += 1
|
|
311
|
+
return ps
|