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,753 @@
|
|
|
1
|
+
"""Parse OpenAI Codex (CLI / VS Code / Desktop) session logs, "rollouts", into the same model as Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Layout (Codex 0.12x+):
|
|
4
|
+
~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl one file per thread
|
|
5
|
+
~/.codex/session_index.jsonl thread titles
|
|
6
|
+
~/.codex/external_agent_session_imports.json Claude Code sessions imported by Codex Desktop
|
|
7
|
+
~/.codex/memories/*.md, memories/rollout_summaries/*.md Codex's own memory notes
|
|
8
|
+
Every line is {"timestamp", "type", "payload", "ordinal"}: session_meta, turn_context, response_item,
|
|
9
|
+
event_msg, token_usage_record, compacted, world_state, ... Subagent threads are separate rollouts whose
|
|
10
|
+
session_meta.source names the parent thread.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Sequence
|
|
20
|
+
|
|
21
|
+
from .parser import (
|
|
22
|
+
IDLE_GAP_CAP_S,
|
|
23
|
+
TEXT_LIMITS,
|
|
24
|
+
ApiCall,
|
|
25
|
+
Event,
|
|
26
|
+
FileStat,
|
|
27
|
+
ParsedSession,
|
|
28
|
+
Subagent,
|
|
29
|
+
ToolCall,
|
|
30
|
+
_TAG_RE,
|
|
31
|
+
clean_prompt,
|
|
32
|
+
parse_command,
|
|
33
|
+
)
|
|
34
|
+
from . import artifacts
|
|
35
|
+
from .pricing import normalize_model, openai_cost
|
|
36
|
+
from .util import iter_jsonl, one_line, parse_ts, safe_text, truncate
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger("chronicle.codex")
|
|
39
|
+
|
|
40
|
+
CODEX_PARSER_VERSION = 4 # 4: artifacts (files created, PRs, commits, generated images)
|
|
41
|
+
ROLLOUT_RE = re.compile(r"^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-([0-9a-f-]{36})\.jsonl(\.gz)?$")
|
|
42
|
+
_UUID_RE = re.compile(r"^[0-9a-f-]{36}$")
|
|
43
|
+
_IDE_REQUEST_RE = re.compile(r"^## My request( for Codex)?:[ \t]*$", re.M)
|
|
44
|
+
_PATCH_FILE_RE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$", re.M)
|
|
45
|
+
_EXIT_CODE_RE = re.compile(r"(?:^|\n)\s*(?:Exit code|exit code|Process exited with code|exited with code)[: ]+(\d+)")
|
|
46
|
+
|
|
47
|
+
# Code mode (Codex 0.142+): the exec tool runs a JS script that calls the real tools as functions,
|
|
48
|
+
# const r = await tools.exec_command({cmd: "npm test"}); text(r);
|
|
49
|
+
# and returns "Script completed|failed" with one JSON chunk ({"chunk_id", "exit_code", "output"}) per result.
|
|
50
|
+
# A long script answers "Script running with cell ID N"; a later wait({cell_id: N}) returns its result.
|
|
51
|
+
_JS_SCAN_RE = re.compile(r"[\"'`]|//|/\*|(?<![\w$.])tools\.([A-Za-z_]\w*)\s*\(")
|
|
52
|
+
_JS_STOP = {q: re.compile(r"[\\%s]" % q) for q in "\"'`"}
|
|
53
|
+
_JS_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0", "\n": ""}
|
|
54
|
+
_JS_FIELDS = {k: re.compile(r"(?<![\w$])[\"']?%s[\"']?\s*:\s*([\"'`])" % k)
|
|
55
|
+
for k in ("cmd", "path", "url", "q", "query", "message", "prompt", "chars")}
|
|
56
|
+
_JS_PATCH_RE = re.compile(r"([\"'`])\*\*\* Begin Patch")
|
|
57
|
+
_HEX = re.compile(r"[0-9a-fA-F]+")
|
|
58
|
+
_CELL_RE = re.compile(r"Script running with cell ID (\S+)")
|
|
59
|
+
_CHUNK_RE = re.compile(r'\{"chunk_id"')
|
|
60
|
+
_DECODER = json.JSONDecoder()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ discovery
|
|
64
|
+
def rollout_files(codex_dir: Path) -> list[Path]:
|
|
65
|
+
root = codex_dir / "sessions"
|
|
66
|
+
if not root.is_dir():
|
|
67
|
+
return []
|
|
68
|
+
return sorted(p for p in root.rglob("rollout-*.jsonl*") if ROLLOUT_RE.match(p.name))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def rollout_id(path: Path) -> str | None:
|
|
72
|
+
m = ROLLOUT_RE.match(path.name)
|
|
73
|
+
return m.group(2) if m else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_meta(path: Path) -> dict:
|
|
77
|
+
"""The session_meta payload (first line) of a rollout."""
|
|
78
|
+
for d in iter_jsonl(path):
|
|
79
|
+
return d.get("payload") or {} if d.get("type") == "session_meta" else {}
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parent_thread_id(meta: dict) -> str | None:
|
|
84
|
+
src = meta.get("source")
|
|
85
|
+
if isinstance(src, dict):
|
|
86
|
+
spawn = (src.get("subagent") or {}).get("thread_spawn") or {}
|
|
87
|
+
return spawn.get("parent_thread_id")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_titles(codex_dir: Path) -> dict[str, str]:
|
|
92
|
+
titles: dict[str, str] = {}
|
|
93
|
+
path = codex_dir / "session_index.jsonl"
|
|
94
|
+
if path.exists():
|
|
95
|
+
for d in iter_jsonl(path):
|
|
96
|
+
if d.get("id") and d.get("thread_name"):
|
|
97
|
+
titles[d["id"]] = one_line(safe_text(d["thread_name"]), 160)
|
|
98
|
+
return titles
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_imports(codex_dir: Path) -> dict[str, dict]:
|
|
102
|
+
"""imported_thread_id -> {claude_id, title, source_path} for Claude Code sessions that Codex Desktop imported."""
|
|
103
|
+
out: dict[str, dict] = {}
|
|
104
|
+
path = codex_dir / "external_agent_session_imports.json"
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(path.read_text())
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
return out
|
|
109
|
+
for r in data.get("records") or [] if isinstance(data, dict) else []:
|
|
110
|
+
if not isinstance(r, dict) or not r.get("imported_thread_id"):
|
|
111
|
+
continue
|
|
112
|
+
stem = Path(r.get("source_path") or "").stem
|
|
113
|
+
out[r["imported_thread_id"]] = {
|
|
114
|
+
"claude_id": stem if _UUID_RE.match(stem) else None,
|
|
115
|
+
"title": safe_text(r.get("title") or ""),
|
|
116
|
+
"source_path": r.get("source_path") or "",
|
|
117
|
+
"imported_at": r.get("imported_at"),
|
|
118
|
+
}
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------ classification
|
|
123
|
+
def classify_codex_user(text: str) -> tuple[str, str]:
|
|
124
|
+
"""-> (kind, text). Unwraps Codex's IDE-context envelope so only the developer's request counts as the prompt."""
|
|
125
|
+
t = text.lstrip()
|
|
126
|
+
if t.startswith("# Context from my IDE"):
|
|
127
|
+
m = _IDE_REQUEST_RE.search(t)
|
|
128
|
+
return ("prompt", t[m.end():].strip()) if m else ("meta", "[IDE context]")
|
|
129
|
+
if t.startswith(("<environment_context", "<recommended_plugins", "# AGENTS.md", "<skill", "<user_instructions",
|
|
130
|
+
"<permissions", "<app_instructions", "<collaboration_mode")):
|
|
131
|
+
return "meta", one_line(t, 120)
|
|
132
|
+
if t.startswith("<turn_aborted"):
|
|
133
|
+
return "meta", "[turn aborted]" # the event_msg carries the interrupt
|
|
134
|
+
if t.startswith(("<task-notification", "[SYSTEM NOTIFICATION")):
|
|
135
|
+
return "notification", t
|
|
136
|
+
if t.startswith(("<command-name>", "<command-message")):
|
|
137
|
+
return "command", t
|
|
138
|
+
if t.startswith(("<local-command-stdout>", "<local-command-stderr>", "<local-command-caveat")):
|
|
139
|
+
return "command_output", _TAG_RE.sub("", t).strip()
|
|
140
|
+
if t.startswith("[Request interrupted by user"):
|
|
141
|
+
return "interrupt", t
|
|
142
|
+
if t.startswith("This session is being continued from a previous conversation"):
|
|
143
|
+
return "compact_summary", t
|
|
144
|
+
cleaned = clean_prompt(t)
|
|
145
|
+
return ("prompt", cleaned) if cleaned else ("meta", "[IDE context]")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _output_text(output) -> tuple[str, int, bool | None]:
|
|
149
|
+
"""Flatten a tool output -> (text, n_images, is_error or None when the output does not say)."""
|
|
150
|
+
images = 0
|
|
151
|
+
if isinstance(output, list):
|
|
152
|
+
parts = []
|
|
153
|
+
for c in output:
|
|
154
|
+
if not isinstance(c, dict):
|
|
155
|
+
continue
|
|
156
|
+
if c.get("type") in ("input_text", "output_text", "text"):
|
|
157
|
+
parts.append(c.get("text") or "")
|
|
158
|
+
elif c.get("type") == "input_image":
|
|
159
|
+
images += 1
|
|
160
|
+
parts.append("[image]")
|
|
161
|
+
text = "\n".join(parts)
|
|
162
|
+
elif isinstance(output, str):
|
|
163
|
+
text = output
|
|
164
|
+
elif output is None:
|
|
165
|
+
text = ""
|
|
166
|
+
else:
|
|
167
|
+
text = json.dumps(output, ensure_ascii=False)
|
|
168
|
+
err: bool | None = None
|
|
169
|
+
codes: list[int] = []
|
|
170
|
+
if '{"chunk_id"' in text:
|
|
171
|
+
text, codes = _render_chunks(text)
|
|
172
|
+
stripped = text.lstrip()
|
|
173
|
+
if stripped.startswith("{"):
|
|
174
|
+
try:
|
|
175
|
+
obj = json.loads(stripped)
|
|
176
|
+
except ValueError:
|
|
177
|
+
obj = None
|
|
178
|
+
if isinstance(obj, dict):
|
|
179
|
+
code = (obj.get("metadata") or {}).get("exit_code") if isinstance(obj.get("metadata"), dict) else None
|
|
180
|
+
if isinstance(code, int):
|
|
181
|
+
err = code != 0
|
|
182
|
+
if isinstance(obj.get("output"), str):
|
|
183
|
+
text = obj["output"]
|
|
184
|
+
m = _EXIT_CODE_RE.search(text[:600])
|
|
185
|
+
if m:
|
|
186
|
+
err = int(m.group(1)) != 0
|
|
187
|
+
if codes:
|
|
188
|
+
err = any(codes)
|
|
189
|
+
low = text[:80].lower()
|
|
190
|
+
if low.startswith(("apply_patch verification failed", "error:", "failed to", "script failed", "aborted by user")):
|
|
191
|
+
err = True
|
|
192
|
+
return text, images, err
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _js_string(src: str, start: int) -> tuple[str | None, int]:
|
|
196
|
+
"""Decode the JS string literal opening at src[start] -> (value or None if it never closes, index after it)."""
|
|
197
|
+
stop = _JS_STOP.get(src[start])
|
|
198
|
+
if stop is None:
|
|
199
|
+
return None, start + 1
|
|
200
|
+
out, i = [], start + 1
|
|
201
|
+
while True:
|
|
202
|
+
m = stop.search(src, i)
|
|
203
|
+
if not m:
|
|
204
|
+
return None, len(src)
|
|
205
|
+
out.append(src[i:m.start()])
|
|
206
|
+
i = m.start()
|
|
207
|
+
if src[i] != "\\":
|
|
208
|
+
break
|
|
209
|
+
nxt = src[i + 1:i + 2]
|
|
210
|
+
width = 4 if nxt == "u" else 2 if nxt == "x" else 0
|
|
211
|
+
if width and _HEX.fullmatch(src, i + 2, i + 2 + width):
|
|
212
|
+
out.append(chr(int(src[i + 2:i + 2 + width], 16)))
|
|
213
|
+
i += 2 + width
|
|
214
|
+
else:
|
|
215
|
+
out.append(_JS_ESCAPES.get(nxt, nxt))
|
|
216
|
+
i += 2
|
|
217
|
+
text = "".join(out)
|
|
218
|
+
try: # 😀 escapes arrive as two halves of a surrogate pair
|
|
219
|
+
text = text.encode("utf-16", "surrogatepass").decode("utf-16")
|
|
220
|
+
except UnicodeError:
|
|
221
|
+
pass
|
|
222
|
+
return text, i + 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _code_mode_calls(src: str) -> list[tuple[str, dict]]:
|
|
226
|
+
"""The tools a code-mode exec script calls, in order -> [(tool, {string argument: value})].
|
|
227
|
+
String literals and comments are skipped, so a command that merely mentions tools.x( is not a call."""
|
|
228
|
+
found, i = [], 0
|
|
229
|
+
while (m := _JS_SCAN_RE.search(src, i)) is not None:
|
|
230
|
+
tok = m.group(0)
|
|
231
|
+
if m.group(1):
|
|
232
|
+
found.append((m.group(1), m.end()))
|
|
233
|
+
i = m.end()
|
|
234
|
+
elif tok == "//":
|
|
235
|
+
j = src.find("\n", m.end())
|
|
236
|
+
i = len(src) if j < 0 else j
|
|
237
|
+
elif tok == "/*":
|
|
238
|
+
j = src.find("*/", m.end())
|
|
239
|
+
i = len(src) if j < 0 else j + 2
|
|
240
|
+
else:
|
|
241
|
+
i = _js_string(src, m.start())[1]
|
|
242
|
+
calls = []
|
|
243
|
+
for n, (name, pos) in enumerate(found):
|
|
244
|
+
seg = src[pos:found[n + 1][1] if n + 1 < len(found) else len(src)]
|
|
245
|
+
fields = {}
|
|
246
|
+
for key, rx in _JS_FIELDS.items():
|
|
247
|
+
fm = rx.search(seg)
|
|
248
|
+
value = _js_string(seg, fm.start(1))[0] if fm else None
|
|
249
|
+
if value:
|
|
250
|
+
fields[key] = value
|
|
251
|
+
calls.append((name, fields))
|
|
252
|
+
return calls
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _js_tool_name(raw: str) -> tuple[str, str | None]:
|
|
256
|
+
"""Code-mode function name -> (tool name as function calls spell it, MCP server)."""
|
|
257
|
+
if raw.startswith("mcp__"):
|
|
258
|
+
parts = raw.split("__")
|
|
259
|
+
return raw, parts[1] if len(parts) > 2 else None
|
|
260
|
+
namespace, sep, base = raw.partition("__")
|
|
261
|
+
return (f"{namespace}.{base}", None) if sep else (raw, None)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _render_chunks(text: str) -> tuple[str, list[int]]:
|
|
265
|
+
"""Replace code-mode JSON result chunks with their output -> (readable text, exit codes)."""
|
|
266
|
+
out, codes, pos = [], [], 0
|
|
267
|
+
for m in _CHUNK_RE.finditer(text):
|
|
268
|
+
if m.start() < pos:
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
obj, end = _DECODER.raw_decode(text, m.start())
|
|
272
|
+
except ValueError:
|
|
273
|
+
continue
|
|
274
|
+
if not isinstance(obj, dict):
|
|
275
|
+
continue
|
|
276
|
+
out.append(text[pos:m.start()])
|
|
277
|
+
code = obj.get("exit_code")
|
|
278
|
+
if isinstance(code, int):
|
|
279
|
+
codes.append(code)
|
|
280
|
+
out.append(f"Exit code: {code}\n")
|
|
281
|
+
out.append(obj["output"] if isinstance(obj.get("output"), str) else "")
|
|
282
|
+
pos = end
|
|
283
|
+
out.append(text[pos:])
|
|
284
|
+
return "".join(out), codes
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _patch_files(patch: str, cwd: str | None) -> dict[str, tuple[str, int, int]]:
|
|
288
|
+
"""apply_patch text -> {path: (op, lines_added, lines_removed)}."""
|
|
289
|
+
files: dict[str, tuple[str, int, int]] = {}
|
|
290
|
+
current = None
|
|
291
|
+
for line in patch.splitlines():
|
|
292
|
+
m = _PATCH_FILE_RE.match(line)
|
|
293
|
+
if m:
|
|
294
|
+
op, path = m.group(1), m.group(2).strip()
|
|
295
|
+
if cwd and not path.startswith("/"):
|
|
296
|
+
path = f"{cwd.rstrip('/')}/{path}"
|
|
297
|
+
current = path
|
|
298
|
+
files[current] = (op, 0, 0)
|
|
299
|
+
continue
|
|
300
|
+
if current is None or line.startswith("***"):
|
|
301
|
+
continue
|
|
302
|
+
op, added, removed = files[current]
|
|
303
|
+
if line.startswith("+"):
|
|
304
|
+
files[current] = (op, added + 1, removed)
|
|
305
|
+
elif line.startswith("-"):
|
|
306
|
+
files[current] = (op, added, removed + 1)
|
|
307
|
+
return files
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------ thread parser
|
|
311
|
+
class _CodexThread:
|
|
312
|
+
def __init__(self, ps: ParsedSession, agent_id: str = ""):
|
|
313
|
+
self.ps = ps
|
|
314
|
+
self.agent_id = agent_id
|
|
315
|
+
self.seq = 0
|
|
316
|
+
self.pending: dict[str, tuple[ToolCall, str | None]] = {}
|
|
317
|
+
self.model: str | None = None
|
|
318
|
+
self.cwd: str | None = None
|
|
319
|
+
self.first_ts: str | None = None
|
|
320
|
+
self.last_ts: str | None = None
|
|
321
|
+
self.n_tool_calls = 0
|
|
322
|
+
self.n_tool_errors = 0
|
|
323
|
+
self.n_usage = 0
|
|
324
|
+
self.task_prompt: str | None = None
|
|
325
|
+
self.usage_calls: list[ApiCall] = [] # from token_usage_record (newer Codex)
|
|
326
|
+
self.tc_calls: list[ApiCall] = [] # from event_msg/token_count (older Codex), used only as a fallback
|
|
327
|
+
self.last_total = 0
|
|
328
|
+
# file edits: newer Codex reports them as FileChange items, older as apply_patch calls; never count both
|
|
329
|
+
self.patch_ops: list[tuple[str, str, int, int, ToolCall]] = [] # + the call, to skip a patch that failed
|
|
330
|
+
self.change_ops: list[tuple[str, str, int, int, str | None, str | None]] = [] # + when, and a new file's content
|
|
331
|
+
self.cells: dict[str, tuple[ToolCall, str | None]] = {} # code-mode cell id -> the call whose script still runs
|
|
332
|
+
|
|
333
|
+
# ---------- helpers
|
|
334
|
+
def _fail(self, call: ToolCall) -> None:
|
|
335
|
+
if not call.is_error:
|
|
336
|
+
call.is_error = True
|
|
337
|
+
self.n_tool_errors += 1
|
|
338
|
+
|
|
339
|
+
def _event(self, ts, role, kind, text, *, limit_key=None, **kw) -> Event:
|
|
340
|
+
ev = Event(self.agent_id, self.seq, ts, role, kind, truncate(safe_text(text), TEXT_LIMITS.get(limit_key or kind, 2_000)), **kw)
|
|
341
|
+
self.seq += 1
|
|
342
|
+
self.ps.events.append(ev)
|
|
343
|
+
return ev
|
|
344
|
+
|
|
345
|
+
def _touch(self, ts: str | None) -> None:
|
|
346
|
+
if not ts:
|
|
347
|
+
return
|
|
348
|
+
if self.first_ts is None or ts < self.first_ts:
|
|
349
|
+
self.first_ts = ts
|
|
350
|
+
if self.last_ts is None or ts > self.last_ts:
|
|
351
|
+
self.last_ts = ts
|
|
352
|
+
self.ps._timestamps.append(ts)
|
|
353
|
+
|
|
354
|
+
# ---------- feed
|
|
355
|
+
def feed_file(self, path: Path) -> None:
|
|
356
|
+
for d in iter_jsonl(path):
|
|
357
|
+
self.feed(d)
|
|
358
|
+
self.finish()
|
|
359
|
+
|
|
360
|
+
def feed(self, d: dict) -> None:
|
|
361
|
+
try:
|
|
362
|
+
self._feed(d)
|
|
363
|
+
except Exception as exc: # unexpected shapes must never lose the session
|
|
364
|
+
self.ps.parse_errors += 1
|
|
365
|
+
if self.ps.parse_errors <= 3:
|
|
366
|
+
log.warning("skipped a malformed rollout line in %s: %r", self.ps.id, exc)
|
|
367
|
+
|
|
368
|
+
def _feed(self, d: dict) -> None:
|
|
369
|
+
t = d.get("type")
|
|
370
|
+
p = d.get("payload") if isinstance(d.get("payload"), dict) else {}
|
|
371
|
+
ts = d.get("timestamp")
|
|
372
|
+
if t in ("session_meta", "turn_context", "response_item", "event_msg", "token_usage_record", "compacted"):
|
|
373
|
+
self._touch(ts)
|
|
374
|
+
if t == "session_meta":
|
|
375
|
+
self._meta(p)
|
|
376
|
+
elif t == "turn_context":
|
|
377
|
+
self.model = normalize_model(p.get("model")) or self.model
|
|
378
|
+
if p.get("cwd"):
|
|
379
|
+
self.cwd = p["cwd"]
|
|
380
|
+
if not self.agent_id and not self.ps.project_path:
|
|
381
|
+
self.ps.project_path = p["cwd"]
|
|
382
|
+
policy = p.get("approval_policy")
|
|
383
|
+
if not self.agent_id and policy:
|
|
384
|
+
self.ps.permission_mode = policy if isinstance(policy, str) else one_line(json.dumps(policy), 60)
|
|
385
|
+
elif t == "response_item":
|
|
386
|
+
self._item(p, ts)
|
|
387
|
+
elif t == "event_msg":
|
|
388
|
+
self._event_msg(p, ts)
|
|
389
|
+
elif t == "token_usage_record":
|
|
390
|
+
self._usage(p, ts)
|
|
391
|
+
elif t == "compacted":
|
|
392
|
+
if not self.agent_id:
|
|
393
|
+
self.ps.n_compactions += 1
|
|
394
|
+
self._event(ts, "system", "compact", f"Context compacted (window {p.get('window_number', '?')})",
|
|
395
|
+
meta={"trigger": "auto", "window": p.get("window_number")})
|
|
396
|
+
|
|
397
|
+
def _meta(self, p: dict) -> None:
|
|
398
|
+
ps = self.ps
|
|
399
|
+
if self.agent_id:
|
|
400
|
+
return
|
|
401
|
+
ps.project_path = p.get("cwd") or ps.project_path
|
|
402
|
+
ps.cc_version = p.get("cli_version") or ps.cc_version
|
|
403
|
+
ps.entrypoint = f"codex:{p.get('originator') or 'codex'}"
|
|
404
|
+
git = p.get("git") if isinstance(p.get("git"), dict) else {}
|
|
405
|
+
if git.get("branch"):
|
|
406
|
+
ps.git_branch = git["branch"]
|
|
407
|
+
ps.branches[git["branch"]] += 1
|
|
408
|
+
if isinstance(git.get("repository_url"), str):
|
|
409
|
+
ps.git_remote = git["repository_url"]
|
|
410
|
+
if p.get("timestamp"):
|
|
411
|
+
self._touch(p["timestamp"])
|
|
412
|
+
|
|
413
|
+
# ---------- response items
|
|
414
|
+
def _item(self, p: dict, ts: str | None) -> None:
|
|
415
|
+
pt = p.get("type")
|
|
416
|
+
if pt == "message":
|
|
417
|
+
self._message(p, ts)
|
|
418
|
+
elif pt == "reasoning":
|
|
419
|
+
summary = p.get("summary") or []
|
|
420
|
+
text = "\n".join(s.get("text", "") for s in summary if isinstance(s, dict)).strip()
|
|
421
|
+
if text:
|
|
422
|
+
self._event(ts, "assistant", "thinking", text)
|
|
423
|
+
elif pt in ("function_call", "custom_tool_call", "web_search_call", "tool_search_call"):
|
|
424
|
+
self._tool_use(p, ts)
|
|
425
|
+
elif pt in ("function_call_output", "custom_tool_call_output", "tool_search_output"):
|
|
426
|
+
self._tool_result(p, ts)
|
|
427
|
+
elif pt == "agent_message":
|
|
428
|
+
text = "\n".join(c.get("text", "") for c in p.get("content") or [] if isinstance(c, dict)).strip()
|
|
429
|
+
self._event(ts, "user", "notification", f"[{p.get('author') or 'agent'} → {p.get('recipient') or 'agent'}] {text}")
|
|
430
|
+
|
|
431
|
+
def _message(self, p: dict, ts: str | None) -> None:
|
|
432
|
+
role = p.get("role")
|
|
433
|
+
content = p.get("content") or []
|
|
434
|
+
texts = [c.get("text") or "" for c in content if isinstance(c, dict) and c.get("type") in ("input_text", "output_text", "text")]
|
|
435
|
+
images = sum(1 for c in content if isinstance(c, dict) and c.get("type") == "input_image")
|
|
436
|
+
text = "\n".join(texts).strip()
|
|
437
|
+
if role == "assistant":
|
|
438
|
+
if text:
|
|
439
|
+
self._event(ts, "assistant", "text", text, meta={"model": self.model, "phase": p.get("phase")} if self.model or p.get("phase") else None)
|
|
440
|
+
return
|
|
441
|
+
if role != "user":
|
|
442
|
+
return # developer/system instructions
|
|
443
|
+
kind, body = classify_codex_user(text) if text else ("prompt", "")
|
|
444
|
+
if kind == "prompt":
|
|
445
|
+
self._prompt(body, ts, images=images)
|
|
446
|
+
elif kind == "command":
|
|
447
|
+
name, args = parse_command(body)
|
|
448
|
+
if name and not self.agent_id:
|
|
449
|
+
self.ps.commands[name] += 1
|
|
450
|
+
self._event(ts, "user", "command", f"{name} {args}".strip() or one_line(body, 200))
|
|
451
|
+
elif kind == "interrupt":
|
|
452
|
+
if not self.agent_id:
|
|
453
|
+
self.ps.n_interrupts += 1
|
|
454
|
+
self._event(ts, "user", "interrupt", body)
|
|
455
|
+
else:
|
|
456
|
+
self._event(ts, "user", kind, body)
|
|
457
|
+
|
|
458
|
+
def _prompt(self, text: str, ts: str | None, *, images: int = 0) -> None:
|
|
459
|
+
ps = self.ps
|
|
460
|
+
if images:
|
|
461
|
+
ps.n_images += images
|
|
462
|
+
text = (text + "\n" if text else "") + " ".join(["[image]"] * images)
|
|
463
|
+
if not text:
|
|
464
|
+
return
|
|
465
|
+
if self.agent_id:
|
|
466
|
+
if self.task_prompt is None:
|
|
467
|
+
self.task_prompt = text
|
|
468
|
+
self._event(ts, "user", "prompt", text, meta={"subagent": True})
|
|
469
|
+
return
|
|
470
|
+
ps.n_prompts += 1
|
|
471
|
+
if ps.first_prompt is None:
|
|
472
|
+
ps.first_prompt = text
|
|
473
|
+
ps.last_prompt = text
|
|
474
|
+
self._event(ts, "user", "prompt", text)
|
|
475
|
+
|
|
476
|
+
# ---------- tools
|
|
477
|
+
def _tool_use(self, p: dict, ts: str | None) -> None:
|
|
478
|
+
ps = self.ps
|
|
479
|
+
pt = p.get("type")
|
|
480
|
+
call_id = p.get("call_id") or p.get("id")
|
|
481
|
+
args: dict = {}
|
|
482
|
+
raw = p.get("arguments") if pt == "function_call" else p.get("input")
|
|
483
|
+
if isinstance(raw, str) and pt == "function_call":
|
|
484
|
+
try:
|
|
485
|
+
args = json.loads(raw)
|
|
486
|
+
except ValueError:
|
|
487
|
+
args = {"raw": raw}
|
|
488
|
+
elif isinstance(raw, dict):
|
|
489
|
+
args = raw
|
|
490
|
+
namespace, base = p.get("namespace"), p.get("name") or pt
|
|
491
|
+
if pt == "web_search_call":
|
|
492
|
+
base, args = "web_search", {"query": (p.get("action") or {}).get("query") if isinstance(p.get("action"), dict) else ""}
|
|
493
|
+
elif pt == "tool_search_call":
|
|
494
|
+
base, args = "tool_search", p.get("arguments") if isinstance(p.get("arguments"), dict) else {}
|
|
495
|
+
mcp = namespace[5:] if isinstance(namespace, str) and namespace.startswith("mcp__") else None
|
|
496
|
+
name = f"{namespace}__{base}" if mcp else (f"{namespace}.{base}" if namespace else base)
|
|
497
|
+
inner = _code_mode_calls(raw) if pt == "custom_tool_call" and base == "exec" and isinstance(raw, str) else []
|
|
498
|
+
if inner:
|
|
499
|
+
name, mcp, summary, fp, cmd, file_ops, reads = self._code_mode(raw, inner)
|
|
500
|
+
args = {"script": raw}
|
|
501
|
+
else:
|
|
502
|
+
summary, fp, cmd, file_ops = self._summarize(base, raw, args)
|
|
503
|
+
reads = [fp] if fp and base in ("view_image", "read_file") else []
|
|
504
|
+
call = ToolCall(self.agent_id, call_id, ts, safe_text(name), mcp, safe_text(summary), fp, cmd, input=args or None)
|
|
505
|
+
ps.tool_calls.append(call)
|
|
506
|
+
ps.tools[name] += 1
|
|
507
|
+
self.n_tool_calls += 1
|
|
508
|
+
if mcp:
|
|
509
|
+
ps.mcp_servers[mcp] += 1
|
|
510
|
+
for path, (op, added, removed) in file_ops.items():
|
|
511
|
+
self.patch_ops.append((path, op.lower(), added, removed, call))
|
|
512
|
+
for path in reads:
|
|
513
|
+
ps.files.setdefault(path, FileStat()).reads += 1
|
|
514
|
+
if call_id:
|
|
515
|
+
self.pending[call_id] = (call, ts)
|
|
516
|
+
shown = summary if summary and summary != name else ""
|
|
517
|
+
self._event(ts, "assistant", "tool_use", f"{name}: {shown}" if shown else name, tool_name=name, tool_use_id=call_id,
|
|
518
|
+
meta={"input": truncate(json.dumps(args, ensure_ascii=False, default=str), 1_500)} if args else None)
|
|
519
|
+
|
|
520
|
+
def _code_mode(self, src: str, inner: list[tuple[str, dict]]):
|
|
521
|
+
"""A code-mode exec script -> (name, mcp server, summary, file_path, command, patch file ops, files read).
|
|
522
|
+
It is named after the tool it calls; a script mixing several tools stays "exec"."""
|
|
523
|
+
cwd = self.cwd or self.ps.project_path
|
|
524
|
+
root = (cwd or "").rstrip("/") + "/"
|
|
525
|
+
ops: dict[str, tuple[str, int, int]] = {}
|
|
526
|
+
for m in _JS_PATCH_RE.finditer(src):
|
|
527
|
+
patch = _js_string(src, m.start(1))[0] or ""
|
|
528
|
+
for path, (op, added, removed) in _patch_files(safe_text(patch), cwd).items():
|
|
529
|
+
prev = ops.get(path)
|
|
530
|
+
ops[path] = (prev[0], prev[1] + added, prev[2] + removed) if prev else (op, added, removed)
|
|
531
|
+
names = [_js_tool_name(n) for n, _ in inner]
|
|
532
|
+
cmds = [f["cmd"] for _, f in inner if f.get("cmd")]
|
|
533
|
+
reads = [f["path"] for n, f in inner if n == "view_image" and f.get("path")]
|
|
534
|
+
detail = ", ".join(f"{p[len(root):] if p.startswith(root) else p} (+{a}/-{r})" for p, (_, a, r) in list(ops.items())[:6]) \
|
|
535
|
+
or next((f[k] for _, f in inner for k in _JS_FIELDS if f.get(k)), "")
|
|
536
|
+
distinct = list(dict.fromkeys(names))
|
|
537
|
+
if len(distinct) == 1:
|
|
538
|
+
(name, mcp), more = distinct[0], len(inner) - 1
|
|
539
|
+
summary = detail + (f" (+{more} more)" if more else "")
|
|
540
|
+
else:
|
|
541
|
+
name, mcp = "exec", None
|
|
542
|
+
counts = {n: sum(1 for m, _ in names if m == n) for n, _ in distinct}
|
|
543
|
+
summary = ", ".join(f"{n} ×{c}" if c > 1 else n for n, c in counts.items()) + (f": {detail}" if detail else "")
|
|
544
|
+
cmd = safe_text("\n".join(cmds)) if cmds else None
|
|
545
|
+
return name, mcp, one_line(safe_text(summary), 240), next(iter(ops), None) or (reads[0] if reads else None), cmd, ops, reads
|
|
546
|
+
|
|
547
|
+
def _summarize(self, base: str, raw, args: dict) -> tuple[str, str | None, str | None, dict]:
|
|
548
|
+
"""-> (summary, file_path, command, patch file ops)."""
|
|
549
|
+
if base == "wait" and "cell_id" in args:
|
|
550
|
+
return f"cell {args['cell_id']}", None, None, {}
|
|
551
|
+
if base in ("exec", "exec_command", "shell", "container.exec"):
|
|
552
|
+
# function calls carry JSON arguments ({"cmd": ...}); the custom exec tool carries the raw command text
|
|
553
|
+
cmd = args.get("cmd") or args.get("command") or (raw if isinstance(raw, str) and not args else "")
|
|
554
|
+
if isinstance(cmd, list):
|
|
555
|
+
cmd = " ".join(str(c) for c in cmd)
|
|
556
|
+
cmd = safe_text(cmd)
|
|
557
|
+
return one_line(cmd, 240), None, cmd, {}
|
|
558
|
+
if base == "apply_patch":
|
|
559
|
+
patch = raw if isinstance(raw, str) else args.get("patch") or args.get("input") or ""
|
|
560
|
+
ops = _patch_files(safe_text(patch), self.cwd or self.ps.project_path)
|
|
561
|
+
root = (self.cwd or self.ps.project_path or "").rstrip("/") + "/"
|
|
562
|
+
shown = ", ".join(f"{p[len(root):] if p.startswith(root) else p} (+{a}/-{r})" for p, (_, a, r) in list(ops.items())[:6])
|
|
563
|
+
return one_line(shown or "patch", 240), next(iter(ops), None), None, ops
|
|
564
|
+
if base == "write_stdin":
|
|
565
|
+
return one_line(safe_text(args.get("chars") or ""), 200), None, None, {}
|
|
566
|
+
if base == "view_image":
|
|
567
|
+
return safe_text(args.get("path") or ""), safe_text(args.get("path") or "") or None, None, {}
|
|
568
|
+
if base == "update_plan":
|
|
569
|
+
plan = args.get("plan") or []
|
|
570
|
+
done = sum(1 for s in plan if isinstance(s, dict) and s.get("status") == "completed")
|
|
571
|
+
return f"{len(plan)} steps ({done} done)", None, None, {}
|
|
572
|
+
if base == "spawn_agent":
|
|
573
|
+
return one_line(safe_text(args.get("message") or args.get("prompt") or args.get("task") or "agent"), 200), None, None, {}
|
|
574
|
+
if base in ("web_search", "run"):
|
|
575
|
+
q = args.get("query") or args.get("search_query") or args
|
|
576
|
+
return one_line(safe_text(q if isinstance(q, str) else json.dumps(q, ensure_ascii=False)), 200), None, None, {}
|
|
577
|
+
if not args:
|
|
578
|
+
return base, None, None, {}
|
|
579
|
+
compact = ", ".join(f"{k}={one_line(safe_text(v), 60)}" for k, v in list(args.items())[:3])
|
|
580
|
+
return one_line(compact, 200), None, None, {}
|
|
581
|
+
|
|
582
|
+
def _tool_result(self, p: dict, ts: str | None) -> None:
|
|
583
|
+
call_id = p.get("call_id") or p.get("id")
|
|
584
|
+
text, images, err = _output_text(p.get("output") if p.get("type") != "tool_search_output" else p.get("tools"))
|
|
585
|
+
self.ps.n_result_images += images
|
|
586
|
+
call, started = self.pending.pop(call_id, (None, None))
|
|
587
|
+
is_error = bool(err)
|
|
588
|
+
if call:
|
|
589
|
+
call.result_chars = len(text)
|
|
590
|
+
end, start = parse_ts(ts), parse_ts(started)
|
|
591
|
+
if start and end:
|
|
592
|
+
call.duration_ms = max(int((end - start).total_seconds() * 1000), 0)
|
|
593
|
+
owner, cell = call, _CELL_RE.match(text)
|
|
594
|
+
if cell and call.name != "wait":
|
|
595
|
+
self.cells[cell.group(1)] = (call, started)
|
|
596
|
+
elif not cell and call.name == "wait" and isinstance(call.input, dict):
|
|
597
|
+
# the script finished: its result belongs to the call that started it, not to the wait
|
|
598
|
+
origin = self.cells.pop(str(call.input.get("cell_id")), None)
|
|
599
|
+
if origin:
|
|
600
|
+
owner, origin_start = origin[0], parse_ts(origin[1])
|
|
601
|
+
if origin_start and end:
|
|
602
|
+
owner.duration_ms = max(int((end - origin_start).total_seconds() * 1000), 0)
|
|
603
|
+
if is_error:
|
|
604
|
+
self._fail(owner)
|
|
605
|
+
elif not cell:
|
|
606
|
+
artifacts.from_tool(self.ps, owner.name, owner.input, text, ts=ts, agent_id=self.agent_id,
|
|
607
|
+
tool_use_id=owner.tool_use_id, command=owner.command)
|
|
608
|
+
self._event(ts, "user", "tool_result", text, limit_key="tool_error" if is_error else "tool_result",
|
|
609
|
+
tool_name=call.name if call else None, tool_use_id=call_id, is_error=is_error)
|
|
610
|
+
|
|
611
|
+
# ---------- events & usage
|
|
612
|
+
def _event_msg(self, p: dict, ts: str | None) -> None:
|
|
613
|
+
pt = p.get("type")
|
|
614
|
+
if pt == "turn_aborted":
|
|
615
|
+
if not self.agent_id:
|
|
616
|
+
self.ps.n_interrupts += 1
|
|
617
|
+
self._event(ts, "user", "interrupt", f"[Turn aborted: {p.get('reason') or 'interrupted'}]")
|
|
618
|
+
elif pt == "task_complete" and p.get("error"):
|
|
619
|
+
self.ps.n_api_errors += 1
|
|
620
|
+
self._event(ts, "system", "api_error", one_line(safe_text(p["error"]), 300))
|
|
621
|
+
elif pt == "patch_apply_end" and p.get("call_id") in self.pending and p.get("success") is False:
|
|
622
|
+
self._fail(self.pending[p["call_id"]][0])
|
|
623
|
+
elif pt == "item_completed" and isinstance(p.get("item"), dict):
|
|
624
|
+
self._item_completed(p["item"], ts)
|
|
625
|
+
elif pt == "token_count" and isinstance(p.get("info"), dict):
|
|
626
|
+
info = p["info"]
|
|
627
|
+
total = info.get("total_token_usage") if isinstance(info.get("total_token_usage"), dict) else {}
|
|
628
|
+
last = info.get("last_token_usage") if isinstance(info.get("last_token_usage"), dict) else {}
|
|
629
|
+
tt = total.get("total_tokens") or 0
|
|
630
|
+
if tt > self.last_total and last: # the same totals are re-emitted; count each response once
|
|
631
|
+
self.last_total = tt
|
|
632
|
+
self.tc_calls.append(self._api_call(last, f"tc#{len(self.tc_calls) + 1}", ts))
|
|
633
|
+
|
|
634
|
+
def _item_completed(self, item: dict, ts: str | None = None) -> None:
|
|
635
|
+
"""Newer Codex: FileChange items carry the edits, CommandExecution items the real exit codes."""
|
|
636
|
+
kind = item.get("type")
|
|
637
|
+
if kind == "FileChange" and isinstance(item.get("changes"), dict):
|
|
638
|
+
root = (self.cwd or self.ps.project_path or "").rstrip("/")
|
|
639
|
+
for path, change in item["changes"].items():
|
|
640
|
+
if not isinstance(change, dict):
|
|
641
|
+
continue
|
|
642
|
+
path = safe_text(path)
|
|
643
|
+
if root and not path.startswith("/"):
|
|
644
|
+
path = f"{root}/{path}"
|
|
645
|
+
op = str(change.get("type") or "update").lower()
|
|
646
|
+
added = removed = 0
|
|
647
|
+
if isinstance(change.get("unified_diff"), str):
|
|
648
|
+
for line in change["unified_diff"].splitlines():
|
|
649
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
650
|
+
added += 1
|
|
651
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
652
|
+
removed += 1
|
|
653
|
+
elif isinstance(change.get("content"), str):
|
|
654
|
+
lines = change["content"].count("\n") + (0 if change["content"].endswith("\n") else 1)
|
|
655
|
+
added, removed = (lines, 0) if op == "add" else (0, lines if op == "delete" else 0)
|
|
656
|
+
self.change_ops.append((path, op, added, removed, ts, change.get("content") if op == "add" else None))
|
|
657
|
+
elif kind == "CommandExecution":
|
|
658
|
+
self._command_result(item)
|
|
659
|
+
|
|
660
|
+
def _command_result(self, item: dict) -> None:
|
|
661
|
+
"""Mark the tool call that ran this command as failed when Codex reports a failure."""
|
|
662
|
+
code = item.get("exit_code")
|
|
663
|
+
failed = item.get("status") == "failed" or (isinstance(code, int) and code != 0)
|
|
664
|
+
if not failed:
|
|
665
|
+
return
|
|
666
|
+
command = safe_text(item.get("command") if isinstance(item.get("command"), str) else " ".join(map(str, item.get("command") or [])))
|
|
667
|
+
recent = [c for c in self.ps.tool_calls[-40:] if c.agent_id == self.agent_id and c.command is not None]
|
|
668
|
+
match = next((c for c in reversed(recent) if command and c.command and (c.command in command or command in c.command)), None)
|
|
669
|
+
match = match or (recent[-1] if recent else None)
|
|
670
|
+
if match:
|
|
671
|
+
self._fail(match)
|
|
672
|
+
|
|
673
|
+
def _usage(self, p: dict, ts: str | None) -> None:
|
|
674
|
+
u = p.get("usage") if isinstance(p.get("usage"), dict) else {}
|
|
675
|
+
if not u:
|
|
676
|
+
return
|
|
677
|
+
self.n_usage += 1
|
|
678
|
+
self.usage_calls.append(self._api_call(u, p.get("response_id") or f"{p.get('turn_id') or 'turn'}#{self.n_usage}", ts))
|
|
679
|
+
|
|
680
|
+
def _api_call(self, u: dict, msg_id: str, ts: str | None) -> ApiCall:
|
|
681
|
+
total_in = u.get("input_tokens") or 0
|
|
682
|
+
cached = u.get("cached_input_tokens") or 0
|
|
683
|
+
uncached = max(total_in - cached, 0)
|
|
684
|
+
out = u.get("output_tokens") or 0
|
|
685
|
+
return ApiCall(self.agent_id, msg_id, ts, self.model, input_tokens=uncached, output_tokens=out,
|
|
686
|
+
cache_read_tokens=cached, cache_write_tokens=u.get("cache_write_input_tokens") or 0,
|
|
687
|
+
cost_usd=openai_cost(self.model, uncached, cached, out))
|
|
688
|
+
|
|
689
|
+
def finish(self) -> None:
|
|
690
|
+
ps = self.ps
|
|
691
|
+
for path, op, added, removed, *how in self.change_ops or self.patch_ops:
|
|
692
|
+
if op == "add" and len(how) == 2: # a FileChange: applied, with the new file's content
|
|
693
|
+
artifacts.file_written(ps, path, content=how[1], ts=how[0], agent_id=self.agent_id)
|
|
694
|
+
elif op == "add" and not how[0].is_error:
|
|
695
|
+
artifacts.file_written(ps, path, ts=how[0].ts, agent_id=self.agent_id, tool_use_id=how[0].tool_use_id)
|
|
696
|
+
fs = ps.files.setdefault(path, FileStat())
|
|
697
|
+
if op == "add":
|
|
698
|
+
fs.writes += 1
|
|
699
|
+
else:
|
|
700
|
+
fs.edits += 1
|
|
701
|
+
fs.lines_added += added
|
|
702
|
+
fs.lines_removed += removed
|
|
703
|
+
calls = self.usage_calls or self.tc_calls
|
|
704
|
+
seen = {c.msg_id for c in ps.api_calls if c.agent_id == self.agent_id}
|
|
705
|
+
for c in calls:
|
|
706
|
+
if c.msg_id in seen:
|
|
707
|
+
continue
|
|
708
|
+
ps.api_calls.append(c)
|
|
709
|
+
if c.model:
|
|
710
|
+
ps.models[c.model] += c.output_tokens
|
|
711
|
+
if not self.agent_id:
|
|
712
|
+
return
|
|
713
|
+
sub = ps.subagents.setdefault(self.agent_id, Subagent(self.agent_id))
|
|
714
|
+
sub.agent_type = sub.agent_type or "codex-agent"
|
|
715
|
+
sub.description = sub.description or one_line(self.task_prompt or "", 120) or None
|
|
716
|
+
sub.started_at, sub.ended_at = self.first_ts, self.last_ts
|
|
717
|
+
sub.n_tool_calls, sub.n_tool_errors = self.n_tool_calls, self.n_tool_errors
|
|
718
|
+
sub.model = sub.model or self.model
|
|
719
|
+
for c in ps.api_calls:
|
|
720
|
+
if c.agent_id == self.agent_id:
|
|
721
|
+
sub.input_tokens += c.input_tokens
|
|
722
|
+
sub.output_tokens += c.output_tokens
|
|
723
|
+
sub.cache_read_tokens += c.cache_read_tokens
|
|
724
|
+
sub.cache_write_tokens += c.cache_write_tokens
|
|
725
|
+
sub.est_cost_usd += c.cost_usd
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# ------------------------------------------------------------------ entry point
|
|
729
|
+
def parse_codex_session(main: Path, subagent_files: Sequence[Path] = (), *, title: str | None = None) -> ParsedSession:
|
|
730
|
+
ps = ParsedSession(id=rollout_id(main) or main.stem)
|
|
731
|
+
ps.entrypoint = "codex"
|
|
732
|
+
_CodexThread(ps).feed_file(main)
|
|
733
|
+
for f in subagent_files:
|
|
734
|
+
aid = rollout_id(f) or f.stem
|
|
735
|
+
ps.subagents.setdefault(aid, Subagent(aid, agent_type="codex-agent"))
|
|
736
|
+
_CodexThread(ps, aid).feed_file(f)
|
|
737
|
+
if title:
|
|
738
|
+
ps.ai_title = title
|
|
739
|
+
stamps = sorted(ps._timestamps)
|
|
740
|
+
if stamps:
|
|
741
|
+
ps.started_at, ps.ended_at = stamps[0], stamps[-1]
|
|
742
|
+
start, end = parse_ts(stamps[0]), parse_ts(stamps[-1])
|
|
743
|
+
if start and end:
|
|
744
|
+
ps.duration_s = (end - start).total_seconds()
|
|
745
|
+
active, prev = 0.0, None
|
|
746
|
+
for s in stamps:
|
|
747
|
+
cur = parse_ts(s)
|
|
748
|
+
if cur and prev:
|
|
749
|
+
active += min(max((cur - prev).total_seconds(), 0.0), IDLE_GAP_CAP_S)
|
|
750
|
+
prev = cur or prev
|
|
751
|
+
ps.active_s = active
|
|
752
|
+
ps.events.sort(key=lambda e: (e.agent_id != "", e.agent_id, e.seq))
|
|
753
|
+
return ps
|