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.
Files changed (118) hide show
  1. chronicle/__init__.py +17 -0
  2. chronicle/__main__.py +3 -0
  3. chronicle/access.py +93 -0
  4. chronicle/agents.py +23 -0
  5. chronicle/analyze.py +498 -0
  6. chronicle/antigravity_parser.py +311 -0
  7. chronicle/artifacts.py +621 -0
  8. chronicle/bob_parser.py +259 -0
  9. chronicle/cases.py +75 -0
  10. chronicle/chat_import.py +325 -0
  11. chronicle/chatgpt_export.py +127 -0
  12. chronicle/claude_export.py +129 -0
  13. chronicle/cli.py +2980 -0
  14. chronicle/codex_cloud.py +138 -0
  15. chronicle/codex_parser.py +753 -0
  16. chronicle/config.py +627 -0
  17. chronicle/connectors.py +666 -0
  18. chronicle/container.py +202 -0
  19. chronicle/copilot_parser.py +494 -0
  20. chronicle/db.py +598 -0
  21. chronicle/desktop.py +543 -0
  22. chronicle/diagram.py +488 -0
  23. chronicle/digest.py +194 -0
  24. chronicle/export_md.py +210 -0
  25. chronicle/friction.py +841 -0
  26. chronicle/glossary.py +522 -0
  27. chronicle/groups.py +181 -0
  28. chronicle/hooks.py +169 -0
  29. chronicle/hub.py +2291 -0
  30. chronicle/i18n.py +37 -0
  31. chronicle/i18n_ja.py +421 -0
  32. chronicle/ingest.py +1190 -0
  33. chronicle/install.py +590 -0
  34. chronicle/instructions.py +699 -0
  35. chronicle/ladder.py +244 -0
  36. chronicle/learning.py +170 -0
  37. chronicle/llm.py +570 -0
  38. chronicle/mcp_server.py +403 -0
  39. chronicle/menubar.py +771 -0
  40. chronicle/migrate.py +580 -0
  41. chronicle/mirror.py +453 -0
  42. chronicle/news.py +110 -0
  43. chronicle/notify.py +92 -0
  44. chronicle/parser.py +908 -0
  45. chronicle/people.py +536 -0
  46. chronicle/pg.py +122 -0
  47. chronicle/pricing.py +168 -0
  48. chronicle/providers.py +591 -0
  49. chronicle/redact.py +50 -0
  50. chronicle/reviews.py +251 -0
  51. chronicle/screen.py +308 -0
  52. chronicle/search.py +238 -0
  53. chronicle/server.py +3009 -0
  54. chronicle/session_export.py +178 -0
  55. chronicle/statusline.py +258 -0
  56. chronicle/suggest.py +643 -0
  57. chronicle/synthesize.py +365 -0
  58. chronicle/systems.py +1265 -0
  59. chronicle/tailnet.py +97 -0
  60. chronicle/team_store.py +450 -0
  61. chronicle/update.py +404 -0
  62. chronicle/util.py +217 -0
  63. chronicle/views.py +249 -0
  64. chronicle/web/PLEX-OFL.txt +93 -0
  65. chronicle/web/app.css +2350 -0
  66. chronicle/web/app.js +7587 -0
  67. chronicle/web/art-book.webp +0 -0
  68. chronicle/web/art-camera.webp +0 -0
  69. chronicle/web/art-chip.webp +0 -0
  70. chronicle/web/art-clock.webp +0 -0
  71. chronicle/web/art-cloud.webp +0 -0
  72. chronicle/web/art-coins.webp +0 -0
  73. chronicle/web/art-container.webp +0 -0
  74. chronicle/web/art-dashboard.webp +0 -0
  75. chronicle/web/art-dex.webp +0 -0
  76. chronicle/web/art-document.webp +0 -0
  77. chronicle/web/art-flag.webp +0 -0
  78. chronicle/web/art-folder.webp +0 -0
  79. chronicle/web/art-gateway.webp +0 -0
  80. chronicle/web/art-gears.webp +0 -0
  81. chronicle/web/art-glitch.webp +0 -0
  82. chronicle/web/art-globe.webp +0 -0
  83. chronicle/web/art-laptop.webp +0 -0
  84. chronicle/web/art-log.webp +0 -0
  85. chronicle/web/art-lumi.webp +0 -0
  86. chronicle/web/art-magnifier.webp +0 -0
  87. chronicle/web/art-pip.webp +0 -0
  88. chronicle/web/art-plug.webp +0 -0
  89. chronicle/web/art-rack.webp +0 -0
  90. chronicle/web/art-tink.webp +0 -0
  91. chronicle/web/boot.js +25 -0
  92. chronicle/web/favicon.png +0 -0
  93. chronicle/web/i18n.js +50 -0
  94. chronicle/web/icon-180.png +0 -0
  95. chronicle/web/icon-192.png +0 -0
  96. chronicle/web/icon-512.png +0 -0
  97. chronicle/web/icon.png +0 -0
  98. chronicle/web/index.html +92 -0
  99. chronicle/web/ja.js +1880 -0
  100. chronicle/web/learning.js +438 -0
  101. chronicle/web/manifest.webmanifest +15 -0
  102. chronicle/web/panel.css +175 -0
  103. chronicle/web/panel.html +14 -0
  104. chronicle/web/panel.js +241 -0
  105. chronicle/web/plex-mono-400.woff2 +0 -0
  106. chronicle/web/plex-mono-500.woff2 +0 -0
  107. chronicle/web/plex-mono-600.woff2 +0 -0
  108. chronicle/web/plex-sans-400.woff2 +0 -0
  109. chronicle/web/plex-sans-500.woff2 +0 -0
  110. chronicle/web/plex-sans-600.woff2 +0 -0
  111. chronicle/web/plex-sans-700.woff2 +0 -0
  112. chronicle/web/rough.js +2 -0
  113. chronicle/worker.py +390 -0
  114. interlatch-0.22.0.dist-info/METADATA +221 -0
  115. interlatch-0.22.0.dist-info/RECORD +118 -0
  116. interlatch-0.22.0.dist-info/WHEEL +4 -0
  117. interlatch-0.22.0.dist-info/entry_points.txt +3 -0
  118. 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