ccx-native 0.0.0-stage → 0.1.0

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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,1540 @@
1
+ "Normalized conversation conversion for both CCX engines.\n\nOne versioned document shape is produced from two very different native\nsources:\n\n* Claude — the raw JSONL transcript ccx already records for every managed\n worker. It is the authoritative source, so full mode reads it directly\n instead of the cleaned renderer (whose 4 KB per-tool-result cap stays exactly\n where it is, for ``logs()``/``peek``/dashboard previews).\n* Codex — the app-server ``thread/read(includeTurns=true)`` response. Rollout\n files are never parsed, and the artifact is never called a raw transcript.\n\nThe document keys are camelCase because that is the shape the feature brief\nfroze and the shape ``transcript.render_transcript_json`` already writes;\n:class:`ccx.workers.WorkerClient` operation results stay snake_case. Only this\ndocument carries a ``schemaVersion``.\n\nEvery item carries an ``ordinal``: its position in the historical source this\nconverter was handed, per turn, dense and zero-based. The ``input``/``output``\nsplit is a classification, not an order — a Codex steer is user input that\nbelongs in the middle of a turn — so the ordinal is the only thing that\nreconstructs the source's own sequence.\n\nIt is not a claim about live chronology. Codex ``thread/read`` reconstructs a\nthread rather than replaying it: on 0.150.1 it has been observed to omit items\nthat certainly ran and to place user inputs differently from when they were\nactually delivered. The authoritative record of what CCX observed, in the order\nit observed it, is the evidence journal's ``sequence`` (:mod:`ccx.codex.evidence`).\nThe two orders are separate and must stay that way.\n\nMemory tradeoff (accepted for v1): both converters parse the whole native\nsource and materialize the whole normalized document, and\n:func:`write_export` serializes it in one ``json.dumps``. Peak memory is\nroughly 2-3x the raw native size, so a 50 MB transcript costs ~100-150 MB.\nTool results dominate large transcripts, so ``max_tool_result_bytes=N`` is the\nmitigation; the *native* copy is streamed to disk regardless, so full evidence\nsurvives even under a bound. Streaming conversion is deliberately deferred.\n"
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import tempfile
11
+ import time
12
+ from datetime import datetime, timezone
13
+
14
+ from ccx.system.bounded import bound_value
15
+ from ccx.codex.provenance import origin_of, parse_client_message_id
16
+ from ccx.errors import InvalidRequest, SessionNotFound
17
+ from ccx.system.sanitize import safe_native
18
+ from ccx.claude.transcript import (
19
+ PinnedNativeSource,
20
+ _format_tool_result_content,
21
+ _iter_transcript,
22
+ _parse_event_ts_ms,
23
+ )
24
+
25
+ SCHEMA_VERSION = 5
26
+
27
+ # Beyond this many warnings the document records a single suppression note
28
+ # instead of an unbounded list (warnings are per-item on a pathological source).
29
+ _MAX_WARNINGS = 50
30
+
31
+ # Claude writes API-error replies under this pseudo-model. It is never an
32
+ # observed model identity.
33
+ _SYNTHETIC_MODEL = "<synthetic>"
34
+
35
+ # --- small shared helpers ----------------------------------------------------
36
+
37
+ def _iso_ms(ms) -> str | None:
38
+ if not isinstance(ms, (int, float)):
39
+ return None
40
+ return datetime.fromtimestamp(ms / 1000.0, timezone.utc).strftime(
41
+ "%Y-%m-%dT%H:%M:%SZ"
42
+ )
43
+
44
+
45
+ def _iso_unix_s(seconds) -> str | None:
46
+ if not isinstance(seconds, (int, float)):
47
+ return None
48
+ return _iso_ms(seconds * 1000)
49
+
50
+
51
+ def _now_iso() -> str:
52
+ return _iso_ms(int(time.time() * 1000))
53
+
54
+
55
+ class _Counters:
56
+ """Mutable tallies threaded through a single conversion."""
57
+
58
+ def __init__(self) -> None:
59
+ self.truncated = 0
60
+ self.omitted = 0
61
+ self.malformed = 0
62
+ self.reasoning_excluded = 0
63
+ self.bookkeeping_excluded = 0
64
+ self.source_limited = False
65
+ self.warnings: list[str] = []
66
+ self._suppressed = 0
67
+
68
+ def warn(self, message: str) -> None:
69
+ if message in self.warnings:
70
+ return
71
+ if len(self.warnings) < _MAX_WARNINGS:
72
+ self.warnings.append(message)
73
+ else:
74
+ self._suppressed += 1
75
+
76
+ def warning_list(self) -> list[str]:
77
+ if not self._suppressed:
78
+ return list(self.warnings)
79
+ return [*self.warnings, f"{self._suppressed} more warnings suppressed"]
80
+
81
+
82
+ def _result_payload(content, is_error: bool, limit: int | None,
83
+ counters: _Counters) -> dict:
84
+ """A tool result, byte-exact unless an explicit bound clips it.
85
+
86
+ ``contentSha256`` is the digest of the full stringified content, so a
87
+ consumer holding the truncated bytes can still verify a copy of the whole
88
+ thing. Truncation cuts on a UTF-8 codepoint boundary, so the returned text
89
+ is always valid UTF-8 and ``returnedByteCount <= limit``.
90
+ """
91
+ text = content if isinstance(content, str) else _format_tool_result_content(content)
92
+ payload = {
93
+ "content": text,
94
+ "isError": bool(is_error),
95
+ "truncated": False,
96
+ "originalByteCount": None,
97
+ "returnedByteCount": None,
98
+ "contentSha256": None,
99
+ }
100
+ if limit is None:
101
+ return payload
102
+ bounded = bound_value(text, limit)
103
+ if not bounded.truncated:
104
+ return payload
105
+ counters.truncated += 1
106
+ payload.update({
107
+ "content": bounded.value,
108
+ "truncated": True,
109
+ "originalByteCount": bounded.original_byte_count,
110
+ "returnedByteCount": bounded.returned_byte_count,
111
+ "contentSha256": bounded.sha256,
112
+ })
113
+ return payload
114
+
115
+
116
+ def _no_result() -> dict:
117
+ return {
118
+ "content": None,
119
+ "isError": False,
120
+ "truncated": False,
121
+ "originalByteCount": None,
122
+ "returnedByteCount": None,
123
+ "contentSha256": None,
124
+ }
125
+
126
+
127
+ _SUMMARY_TARGET_MAX_BYTES = 512
128
+
129
+
130
+ def _summary_target(target: str) -> tuple[str, bool]:
131
+ """Return a one-line, UTF-8-safe display target and whether it was cut."""
132
+ line_end = len(target)
133
+ for separator in ("\r", "\n"):
134
+ index = target.find(separator)
135
+ if index >= 0:
136
+ line_end = min(line_end, index)
137
+ first_line = target[:line_end]
138
+ bounded = bound_value(first_line, _SUMMARY_TARGET_MAX_BYTES)
139
+ return bounded.value, line_end != len(target) or bounded.truncated
140
+
141
+
142
+ def _summary_field(verb: str, target) -> dict:
143
+ """Optional display metadata derived only from known native semantics.
144
+
145
+ Summary targets are intentionally previews: first line only and at most 512
146
+ UTF-8 bytes. The exact provider value remains in ``arguments``/native fields.
147
+ """
148
+ if not isinstance(target, str) or not target:
149
+ return {}
150
+ preview, truncated = _summary_target(target)
151
+ if not preview:
152
+ return {}
153
+ summary = {"verb": verb, "target": preview}
154
+ if truncated:
155
+ summary["target_truncated"] = True
156
+ return {"summary": summary}
157
+
158
+
159
+ def _summary_with_description(value: dict, description) -> dict:
160
+ """Add one native model-written description to an existing summary."""
161
+ if not isinstance(description, str):
162
+ return value
163
+ description = description.strip()
164
+ if not description:
165
+ return value
166
+ summary = value.get("summary")
167
+ if not isinstance(summary, dict):
168
+ return value
169
+ preview, truncated = _summary_target(description)
170
+ preview = preview.strip()
171
+ if not preview:
172
+ return value
173
+ summary["description"] = preview
174
+ if truncated:
175
+ summary["description_truncated"] = True
176
+ return value
177
+
178
+
179
+ def _called_summary(name) -> dict:
180
+ return _summary_field("Called", name)
181
+
182
+
183
+ def _claude_tool_summary(name, arguments) -> dict:
184
+ """Conservative summary for one native Claude ``tool_use`` block.
185
+
186
+ Only first-party argument names whose meaning is fixed by the native tool
187
+ contract are inspected. Unknown/MCP tools never infer a display target from
188
+ arbitrary ``path``/``name``/``target``-looking arguments.
189
+ """
190
+ if not isinstance(name, str) or not name:
191
+ return {}
192
+ args = arguments if isinstance(arguments, dict) else {}
193
+
194
+ if name == "Bash":
195
+ summary = _summary_field("Ran", args.get("command")) or _called_summary(name)
196
+ elif name == "Read":
197
+ summary = _summary_field("Read", args.get("file_path")) or _called_summary(name)
198
+ elif name == "Edit":
199
+ summary = _summary_field("Edited", args.get("file_path")) or _called_summary(name)
200
+ elif name == "Write":
201
+ summary = _summary_field("Wrote", args.get("file_path")) or _called_summary(name)
202
+ elif name in ("Grep", "Glob"):
203
+ summary = _summary_field("Searched", args.get("pattern")) or _called_summary(name)
204
+ elif name == "WebSearch":
205
+ summary = _summary_field("Searched", args.get("query")) or _called_summary(name)
206
+ else:
207
+ summary = _called_summary(name)
208
+
209
+ if name in ("Bash", "Task", "Agent"):
210
+ return _summary_with_description(summary, args.get("description"))
211
+ return summary
212
+
213
+
214
+ def _codex_file_change_summary(native_changes, normalized_changes: list[dict]) -> dict:
215
+ if not isinstance(native_changes, list) or not native_changes:
216
+ return {}
217
+ distinct_paths: list[str] = []
218
+ seen: set[str] = set()
219
+ for change in normalized_changes:
220
+ path = change.get("path")
221
+ if not isinstance(path, str) or not path or path in seen:
222
+ continue
223
+ seen.add(path)
224
+ distinct_paths.append(path)
225
+ if not distinct_paths:
226
+ return {}
227
+ if len(distinct_paths) == 1:
228
+ return _summary_field("Changed", distinct_paths[0])
229
+ return _summary_field("Changed", f"{len(distinct_paths)} files")
230
+
231
+
232
+ def _is_claude_narration(block: dict) -> bool:
233
+ """Is this Claude ``thinking`` block user-visible narration?
234
+
235
+ Claude Code (2.1.3xx) stores interim status text the user saw as a
236
+ ``thinking`` block whose opaque signature carries a ``narration`` tag; real
237
+ reasoning is stored with empty text. Anything unrecognised stays excluded.
238
+ """
239
+ signature = block.get("signature")
240
+ if not isinstance(signature, str) or not signature:
241
+ return False
242
+ try:
243
+ head = base64.b64decode(signature[:96] + "=" * (-len(signature[:96]) % 4))
244
+ except (ValueError, binascii.Error):
245
+ return False
246
+ return b"narration" in head
247
+
248
+
249
+ def _item(kind: str, native_type: str | None, item_id=None, **fields) -> dict:
250
+ return {"kind": kind, "id": item_id, "ordinal": None,
251
+ "nativeType": native_type, **fields}
252
+
253
+
254
+ def _place(turn: dict, bucket: str, item: dict) -> dict:
255
+ """Append one normalized item, stamping its place in the source order.
256
+
257
+ ``ordinal`` is the order of the historical source being normalized: dense,
258
+ zero-based, per turn, and assigned as each item is emitted from that
259
+ ordered source — before it is split into ``input`` and ``output``. So
260
+
261
+ sorted(turn["input"] + turn["output"], key=lambda i: i["ordinal"])
262
+
263
+ reproduces the source's own sequence exactly, which the two buckets alone
264
+ cannot: a Codex steer belongs in the middle of a turn, and grouping alone
265
+ would file it beside the turn's opening prompt.
266
+
267
+ It is not a claim about when things happened live. What the source lists is
268
+ what this reproduces — including a source that omitted items or ordered
269
+ them differently from delivery. Live chronology lives in the evidence
270
+ journal's ``sequence``, and the two are never conflated.
271
+
272
+ Because every emitted item lands in exactly one bucket and neither bucket
273
+ is ever reordered or pruned, the count already placed is the next ordinal.
274
+ Updating an item that was already emitted (Claude pairing a tool result
275
+ with its call) mutates it in place and does not consume one.
276
+ """
277
+ item["ordinal"] = len(turn["input"]) + len(turn["output"])
278
+ turn[bucket].append(item)
279
+ return item
280
+
281
+
282
+ def _place_all(turn: dict, bucket: str, items) -> None:
283
+ """Place several children of one native item, in source-part order."""
284
+ for item in items:
285
+ _place(turn, bucket, item)
286
+
287
+
288
+ def _turn(sequence: int, turn_id=None) -> dict:
289
+ return {
290
+ "turnId": turn_id,
291
+ "sequence": sequence,
292
+ "status": "completed",
293
+ "startedAt": None,
294
+ "completedAt": None,
295
+ "input": [],
296
+ "output": [],
297
+ "usage": _empty_usage(),
298
+ }
299
+
300
+
301
+ def _empty_usage() -> dict:
302
+ return {
303
+ "inputTokens": None,
304
+ "outputTokens": None,
305
+ "cachedInputTokens": None,
306
+ "cacheCreationTokens": None,
307
+ "reasoningTokens": None,
308
+ }
309
+
310
+
311
+ def _completeness(requested: str, counters: _Counters) -> dict:
312
+ # partial > source-limited > bounded > complete. Anything we could not
313
+ # represent at all (a malformed record, structurally unusable data) is
314
+ # partial: an export that dropped content must never read as complete.
315
+ if counters.malformed or counters.omitted:
316
+ status = "partial"
317
+ elif counters.source_limited:
318
+ status = "source-limited"
319
+ elif counters.truncated:
320
+ status = "bounded"
321
+ else:
322
+ status = "complete"
323
+ return {
324
+ "requested": requested,
325
+ "status": status,
326
+ "sourceLimited": bool(counters.source_limited),
327
+ "truncatedItemCount": counters.truncated,
328
+ "omittedItemCount": counters.omitted,
329
+ "warnings": counters.warning_list(),
330
+ }
331
+
332
+
333
+ # What a document says when no live evidence journal backs it: a Claude
334
+ # document (Claude has no journal), or a Codex thread CCX observed before it
335
+ # began journaling. Deliberately a separate axis from ``completeness``: content
336
+ # read back from ``thread/read`` can be perfectly complete while CCX never saw
337
+ # the turn run.
338
+ _NO_EVIDENCE = {
339
+ "source": None,
340
+ "available": False,
341
+ "status": "absent",
342
+ "beganAtMs": None,
343
+ "eventCount": 0,
344
+ "enrichedItemCount": 0,
345
+ "warnings": [],
346
+ }
347
+
348
+
349
+ def _evidence_block(index: dict | None, enriched: int) -> dict:
350
+ if not isinstance(index, dict):
351
+ return dict(_NO_EVIDENCE)
352
+ status = index.get("status") or (
353
+ "complete" if index.get("complete") else "degraded"
354
+ )
355
+ if status == "absent":
356
+ return dict(_NO_EVIDENCE)
357
+ return {
358
+ "source": "ccx-codex-evidence-journal",
359
+ # A degraded journal whose file is gone entirely is still not
360
+ # ``absent``: CCX knows evidence was attempted and lost, and saying
361
+ # "none" would erase that.
362
+ "available": bool(index.get("available")),
363
+ "status": status,
364
+ "beganAtMs": index.get("beganAtMs"),
365
+ "eventCount": index.get("eventCount") or 0,
366
+ "enrichedItemCount": enriched,
367
+ "warnings": list(index.get("warnings") or []),
368
+ }
369
+
370
+
371
+ def _document(*, worker: dict, state: str | None, turns: list[dict],
372
+ requested: str, counters: _Counters,
373
+ artifacts: list[dict], evidence: dict | None = None) -> dict:
374
+ return {
375
+ "schemaVersion": SCHEMA_VERSION,
376
+ "generatedAt": _now_iso(),
377
+ "worker": worker,
378
+ "state": state,
379
+ "turns": turns,
380
+ "completeness": _completeness(requested, counters),
381
+ # Evidence health, never folded into `completeness`: a worker with no
382
+ # journal still has complete content.
383
+ "evidence": evidence if evidence is not None else dict(_NO_EVIDENCE),
384
+ "nativeArtifacts": artifacts,
385
+ "stats": {
386
+ "turnCount": len(turns),
387
+ "itemCount": sum(len(t["input"]) + len(t["output"]) for t in turns),
388
+ "malformedNativeRecords": counters.malformed,
389
+ "excludedReasoningBlocks": counters.reasoning_excluded,
390
+ "excludedBookkeepingRecords": counters.bookkeeping_excluded,
391
+ },
392
+ }
393
+
394
+
395
+ def _requested_label(max_tool_result_bytes: int | None) -> str:
396
+ if max_tool_result_bytes is None:
397
+ return "full"
398
+ # 0 is a legitimate bound ("metadata only"): it yields empty content with a
399
+ # truthful original byte count and the full-content digest, so nothing is
400
+ # claimed that was not delivered.
401
+ if isinstance(max_tool_result_bytes, bool) or not isinstance(
402
+ max_tool_result_bytes, int
403
+ ) or max_tool_result_bytes < 0:
404
+ raise InvalidRequest(
405
+ "max_tool_result_bytes must be a non-negative integer"
406
+ )
407
+ return "bounded"
408
+
409
+
410
+ # --- Claude ------------------------------------------------------------------
411
+
412
+ def _command_from_text(text: str) -> tuple[str, str | None] | None:
413
+ """``<command-name>/compact</command-name><command-args>x</command-args>``."""
414
+ if "<command-name>" not in text or "</command-name>" not in text:
415
+ return None
416
+ i = text.find("<command-name>") + len("<command-name>")
417
+ j = text.find("</command-name>")
418
+ if i >= j:
419
+ return None
420
+ name = text[i:j].strip()
421
+ args = None
422
+ if "<command-args>" in text and "</command-args>" in text:
423
+ ai = text.find("<command-args>") + len("<command-args>")
424
+ aj = text.find("</command-args>")
425
+ if ai < aj:
426
+ args = text[ai:aj].strip() or None
427
+ return name, args
428
+
429
+
430
+ def _plain_slash_command(text: str) -> tuple[str, str | None] | None:
431
+ """Parse one literal user-entered slash command without interpreting prose."""
432
+ value = text.strip()
433
+ if not value.startswith("/") or value.startswith("//") or "\n" in value or "\r" in value:
434
+ return None
435
+ if value.startswith("<") or "<command-name>" in value:
436
+ return None
437
+ name, separator, rest = value.partition(" ")
438
+ if len(name) <= 1:
439
+ return None
440
+ return name, (rest.strip() or None) if separator else None
441
+
442
+
443
+ def _claude_structural_links(
444
+ events: list[dict],
445
+ ) -> tuple[dict[int, str], set[int], set[int]]:
446
+ """Resolve optional Claude-only enrichment by exact native identity.
447
+
448
+ Returns ``(summary_by_boundary_index, consumed_summary_indexes,
449
+ duplicate_local_command_indexes)``. No timestamp, adjacency, or prose
450
+ heuristics participate. Ambiguous/unlinked compact summaries remain
451
+ unconsumed so the ordinary normalization path preserves their text.
452
+ """
453
+ boundaries: dict[str, list[int]] = {}
454
+ summaries: dict[str, list[tuple[int, str]]] = {}
455
+ seen_plain_commands: dict[str, set[tuple[str, str | None]]] = {}
456
+ duplicate_commands: set[int] = set()
457
+
458
+ for index, evt in enumerate(events):
459
+ if not isinstance(evt, dict):
460
+ continue
461
+ if evt.get("type") == "system" and evt.get("subtype") == "compact_boundary":
462
+ boundary_id = evt.get("uuid")
463
+ if isinstance(boundary_id, str) and boundary_id:
464
+ boundaries.setdefault(boundary_id, []).append(index)
465
+ continue
466
+
467
+ if evt.get("type") != "user" or evt.get("isMeta"):
468
+ continue
469
+ message = evt.get("message") if isinstance(evt.get("message"), dict) else {}
470
+ content = message.get("content")
471
+ if evt.get("isCompactSummary"):
472
+ parent = evt.get("parentUuid")
473
+ if (
474
+ isinstance(parent, str)
475
+ and parent
476
+ and isinstance(content, str)
477
+ and content.strip()
478
+ ):
479
+ summaries.setdefault(parent, []).append((index, content))
480
+ continue
481
+
482
+ if not isinstance(content, str):
483
+ continue
484
+ prompt_id = evt.get("promptId")
485
+ if not isinstance(prompt_id, str) or not prompt_id:
486
+ continue
487
+ wrapped = _command_from_text(content)
488
+ if wrapped is not None:
489
+ if wrapped in seen_plain_commands.get(prompt_id, set()):
490
+ duplicate_commands.add(index)
491
+ continue
492
+ plain = _plain_slash_command(content)
493
+ if plain is not None:
494
+ seen_plain_commands.setdefault(prompt_id, set()).add(plain)
495
+
496
+ summary_by_boundary_index: dict[int, str] = {}
497
+ consumed_summary_indexes: set[int] = set()
498
+ for boundary_id, summary_rows in summaries.items():
499
+ boundary_rows = boundaries.get(boundary_id) or []
500
+ if len(boundary_rows) != 1 or len(summary_rows) != 1:
501
+ continue
502
+ summary_index, summary_text = summary_rows[0]
503
+ summary_by_boundary_index[boundary_rows[0]] = summary_text
504
+ consumed_summary_indexes.add(summary_index)
505
+
506
+ return summary_by_boundary_index, consumed_summary_indexes, duplicate_commands
507
+
508
+
509
+ def _claude_attachment(block: dict) -> dict:
510
+ """Image/attachment metadata with its locator, never its inline bytes."""
511
+ source = block.get("source")
512
+ locator = None
513
+ media_type = None
514
+ native = dict(block)
515
+ if isinstance(source, dict):
516
+ locator = source.get("url") or source.get("file_id") or source.get("path")
517
+ media_type = source.get("media_type")
518
+ native["source"] = {
519
+ k: v for k, v in source.items() if k not in ("data", "bytes")
520
+ }
521
+ return _item(
522
+ "attachment", block.get("type"),
523
+ locator=locator, mediaType=media_type, native=native,
524
+ )
525
+
526
+
527
+ # Claude Code session bookkeeping: titles, modes, file-history and API-request
528
+ # snapshots, hook outcomes, and the context Claude injects into the model's
529
+ # prompt (system prompt, environment, tool and skill listings, account details).
530
+ # None of it is conversation. Only these known shapes are dropped; an unknown
531
+ # record type still surfaces as an `other` item so nothing new disappears.
532
+ _CLAUDE_BOOKKEEPING_TYPES = frozenset({
533
+ "last-prompt", "custom-title", "ai-title", "agent-name", "mode",
534
+ "permission-mode", "atis-latch", "file-history-snapshot",
535
+ "file-history-delta", "api-request", "api-request-shape",
536
+ "api-request-blob",
537
+ })
538
+ _CLAUDE_BOOKKEEPING_ATTACHMENTS = frozenset({
539
+ "hook_success", "hook_non_blocking_error", "hook_permission_decision",
540
+ "hook_additional_context", "environment", "model", "deferred_tools_delta",
541
+ "agent_listing_delta", "mcp_instructions_delta", "skill_listing",
542
+ "total_tokens_reminder", "session_context", "date", "credential_org",
543
+ "remote_session_change", "prompt_snapshot",
544
+ })
545
+ _CLAUDE_BOOKKEEPING_SYSTEM = frozenset({"stop_hook_summary", "turn_duration"})
546
+
547
+
548
+ def _claude_bookkeeping(evt: dict) -> bool:
549
+ """Whether a native Claude record is session bookkeeping, not conversation."""
550
+ etype = evt.get("type")
551
+ if etype in _CLAUDE_BOOKKEEPING_TYPES:
552
+ return True
553
+ if etype == "attachment":
554
+ return (evt.get("attachment") or {}).get("type") in _CLAUDE_BOOKKEEPING_ATTACHMENTS
555
+ if etype == "system":
556
+ return evt.get("subtype") in _CLAUDE_BOOKKEEPING_SYSTEM
557
+ return False
558
+
559
+
560
+ def _claude_page_bearing(evt: dict) -> bool:
561
+ """Whether one native Claude record belongs to bounded conversation history."""
562
+ etype = evt.get("type")
563
+ if etype == "assistant":
564
+ return True
565
+ if etype == "attachment":
566
+ return (evt.get("attachment") or {}).get("type") == "queued_command"
567
+ if etype == "system":
568
+ return evt.get("subtype") == "compact_boundary"
569
+ if etype != "user" or evt.get("isMeta"):
570
+ return False
571
+ if evt.get("isCompactSummary"):
572
+ # Contextual normalization suppresses only summaries that were consumed
573
+ # into an exactly identified compact boundary. Unlinked future/provider
574
+ # shapes stay page-bearing so their text cannot disappear silently.
575
+ return True
576
+ content = (evt.get("message") or {}).get("content")
577
+ if isinstance(content, str):
578
+ text = content.strip()
579
+ if not text:
580
+ return False
581
+ if text.startswith(("<local-command-stdout>", "<local-command-stderr>")):
582
+ return False
583
+ return True
584
+ if not isinstance(content, list):
585
+ return False
586
+ for block in content:
587
+ if not isinstance(block, dict):
588
+ continue
589
+ btype = block.get("type")
590
+ if btype == "text" and (block.get("text") or "").strip():
591
+ return True
592
+ if btype in {"tool_result", "image", "document"}:
593
+ return True
594
+ if btype not in {None, "text"}:
595
+ return True
596
+ return False
597
+
598
+
599
+ def _claude_usage(message: dict) -> dict | None:
600
+ usage = message.get("usage")
601
+ if not isinstance(usage, dict) or not usage:
602
+ return None
603
+ return {
604
+ "inputTokens": usage.get("input_tokens"),
605
+ "outputTokens": usage.get("output_tokens"),
606
+ "cachedInputTokens": usage.get("cache_read_input_tokens"),
607
+ "cacheCreationTokens": usage.get("cache_creation_input_tokens"),
608
+ "reasoningTokens": None,
609
+ }
610
+
611
+
612
+ def _normalize_claude_events(
613
+ events: list[dict],
614
+ counters: _Counters,
615
+ max_tool_result_bytes: int | None,
616
+ ) -> tuple[list[dict], str | None, list[dict]]:
617
+ """Normalize Claude events once for full documents and bounded pages."""
618
+ turns: list[dict] = []
619
+ source_records: list[dict] = []
620
+ observed_model: str | None = None
621
+ current: dict | None = None
622
+ awaiting: dict[str, dict] = {}
623
+ usage_record_by_message: dict[str, dict] = {}
624
+ (
625
+ compact_summary_by_boundary_index,
626
+ consumed_compact_summary_indexes,
627
+ duplicate_local_command_indexes,
628
+ ) = _claude_structural_links(events)
629
+
630
+ def open_turn() -> dict:
631
+ nonlocal current
632
+ current = _turn(len(turns) + 1)
633
+ turns.append(current)
634
+ return current
635
+
636
+ def stamp(turn: dict, ts_ms) -> None:
637
+ iso = _iso_ms(ts_ms)
638
+ if iso is None:
639
+ return
640
+ if turn["startedAt"] is None:
641
+ turn["startedAt"] = iso
642
+ turn["completedAt"] = iso
643
+
644
+ for event_index, evt in enumerate(events):
645
+ etype = evt.get("type")
646
+ ts = _parse_event_ts_ms(evt)
647
+ message = evt.get("message") or {}
648
+ page_bearing = (
649
+ _claude_page_bearing(evt)
650
+ and event_index not in consumed_compact_summary_indexes
651
+ and event_index not in duplicate_local_command_indexes
652
+ )
653
+ record = {
654
+ "turn_id": None,
655
+ "source_record_id": evt.get("uuid"),
656
+ "timestamp_ms": ts,
657
+ "duration_ms": None,
658
+ "usage": None,
659
+ "items": [],
660
+ "_ccx_start_offset": evt.get("_ccx_start_offset"),
661
+ "_ccx_end_offset": evt.get("_ccx_end_offset"),
662
+ "_ccx_page_bearing": page_bearing,
663
+ "_ccx_type": etype,
664
+ "_ccx_fingerprint": hashlib.sha256(
665
+ json.dumps(
666
+ safe_native(evt),
667
+ sort_keys=True,
668
+ separators=(",", ":"),
669
+ ensure_ascii=False,
670
+ default=str,
671
+ ).encode("utf-8")
672
+ ).hexdigest(),
673
+ }
674
+ source_records.append(record)
675
+
676
+ def place(target: dict, bucket: str, item: dict) -> dict:
677
+ placed = _place(target, bucket, item)
678
+ if page_bearing:
679
+ record["items"].append(placed)
680
+ return placed
681
+
682
+ if _claude_bookkeeping(evt):
683
+ # Never page-bearing, so bounded pages are unaffected; the source
684
+ # record above still counts toward paging positions.
685
+ counters.bookkeeping_excluded += 1
686
+ continue
687
+
688
+ if etype == "user":
689
+ if (
690
+ event_index in consumed_compact_summary_indexes
691
+ or event_index in duplicate_local_command_indexes
692
+ ):
693
+ continue
694
+ content = message.get("content")
695
+ blocks = (
696
+ [{"type": "text", "text": content}]
697
+ if isinstance(content, str)
698
+ else (content if isinstance(content, list) else [])
699
+ )
700
+ opened: dict | None = None
701
+ for block in blocks:
702
+ if not isinstance(block, dict):
703
+ counters.omitted += 1
704
+ continue
705
+ btype = block.get("type")
706
+ if btype == "text":
707
+ text = block.get("text") or ""
708
+ command = _command_from_text(text)
709
+ if command is None and not text.strip():
710
+ continue
711
+ # A hidden meta row (e.g. Claude's image-source note)
712
+ # stays in the turn it follows instead of opening one.
713
+ if opened is None and evt.get("isMeta") and current is not None:
714
+ opened = current
715
+ opened = opened or open_turn()
716
+ if command is not None:
717
+ name, args = command
718
+ place(opened, "input", _item(
719
+ "command", "user", evt.get("uuid"),
720
+ name=name, arguments={"args": args} if args else {},
721
+ ))
722
+ else:
723
+ place(opened, "input", _item(
724
+ "user-text", "user", evt.get("uuid"), text=text,
725
+ ))
726
+ stamp(opened, ts)
727
+ elif btype == "tool_result":
728
+ tid = block.get("tool_use_id")
729
+ payload = _result_payload(
730
+ block.get("content"), block.get("is_error"),
731
+ max_tool_result_bytes, counters,
732
+ )
733
+ call = awaiting.pop(tid, None) if tid else None
734
+ if call is not None:
735
+ call["result"] = payload
736
+ call["status"] = "failed" if payload["isError"] else "completed"
737
+ continue
738
+ counters.warn(
739
+ f"tool_result {tid or '<no id>'} has no unpaired "
740
+ "tool_use (unmatched or duplicate); kept standalone"
741
+ )
742
+ target = current or open_turn()
743
+ place(target, "output", _item(
744
+ "tool-result", "tool_result", tid,
745
+ callId=tid, unpaired=True, result=payload,
746
+ ))
747
+ stamp(target, ts)
748
+ elif btype in ("image", "document"):
749
+ opened = opened or open_turn()
750
+ place(opened, "input", _claude_attachment(block))
751
+ stamp(opened, ts)
752
+ else:
753
+ target = current or open_turn()
754
+ place(target, "output", _item(
755
+ "other", btype, None, native=safe_native(block),
756
+ ))
757
+ stamp(target, ts)
758
+ continue
759
+
760
+ if etype == "assistant":
761
+ target = current or open_turn()
762
+ stamp(target, ts)
763
+ if evt.get("isApiErrorMessage"):
764
+ text = message.get("content")
765
+ if not isinstance(text, str):
766
+ text = _format_tool_result_content(text)
767
+ place(target, "output", _item(
768
+ "error", "assistant", message.get("id"),
769
+ message=text,
770
+ data={"status": evt.get("status"),
771
+ "requestId": evt.get("requestId")},
772
+ ))
773
+ target["status"] = "failed"
774
+ continue
775
+ model = message.get("model")
776
+ if isinstance(model, str) and model and model != _SYNTHETIC_MODEL:
777
+ observed_model = model
778
+ content = message.get("content")
779
+ for block in (content if isinstance(content, list) else []):
780
+ if not isinstance(block, dict):
781
+ counters.omitted += 1
782
+ continue
783
+ btype = block.get("type")
784
+ if btype == "text":
785
+ text = block.get("text") or ""
786
+ if not text:
787
+ continue
788
+ place(target, "output", _item(
789
+ "assistant-text", "assistant", message.get("id"),
790
+ text=text, model=model,
791
+ ))
792
+ elif btype == "thinking" and _is_claude_narration(block):
793
+ # Claude Code persists some user-visible interim text as a
794
+ # narration-signed thinking block; it is not hidden reasoning.
795
+ text = block.get("thinking") or ""
796
+ if not text.strip():
797
+ continue
798
+ place(target, "output", _item(
799
+ "assistant-text", "assistant", message.get("id"),
800
+ text=text, model=model, narration=True,
801
+ ))
802
+ elif btype in ("thinking", "redacted_thinking"):
803
+ counters.reasoning_excluded += 1
804
+ elif btype == "tool_use":
805
+ tid = block.get("id")
806
+ tool_name = block.get("name")
807
+ tool_arguments = block.get("input") or {}
808
+ call = place(target, "output", _item(
809
+ "tool-call", "tool_use", tid,
810
+ callId=tid, name=tool_name,
811
+ arguments=tool_arguments,
812
+ status="in-progress", result=_no_result(),
813
+ **_claude_tool_summary(tool_name, tool_arguments),
814
+ ))
815
+ if tid:
816
+ awaiting[tid] = call
817
+ else:
818
+ place(target, "output", _item(
819
+ "other", btype, None, native=safe_native(block),
820
+ ))
821
+ usage = _claude_usage(message)
822
+ if usage is not None:
823
+ target["usage"] = usage
824
+ mid = message.get("id")
825
+ if isinstance(mid, str) and mid:
826
+ previous = usage_record_by_message.get(mid)
827
+ if previous is not None:
828
+ previous["usage"] = None
829
+ usage_record_by_message[mid] = record
830
+ record["usage"] = dict(usage)
831
+ continue
832
+
833
+ if etype == "attachment":
834
+ att = evt.get("attachment") or {}
835
+ if att.get("type") == "queued_command":
836
+ text = (att.get("prompt") or "").strip()
837
+ if not text:
838
+ continue
839
+ target = open_turn()
840
+ place(target, "input", _item(
841
+ "user-text", "queued_command", evt.get("uuid"), text=text,
842
+ ))
843
+ stamp(target, ts)
844
+ else:
845
+ target = current or open_turn()
846
+ place(target, "output", _item(
847
+ "other", "attachment", evt.get("uuid"),
848
+ native=safe_native(evt),
849
+ ))
850
+ stamp(target, ts)
851
+ continue
852
+
853
+ if etype == "system" and evt.get("subtype") == "compact_boundary":
854
+ meta = evt.get("compactMetadata") or {}
855
+ compact_data = {
856
+ "preTokens": meta.get("preTokens"),
857
+ "postTokens": meta.get("postTokens"),
858
+ "durationMs": meta.get("durationMs"),
859
+ "trigger": meta.get("trigger"),
860
+ }
861
+ compact_summary = compact_summary_by_boundary_index.get(event_index)
862
+ if compact_summary is not None:
863
+ compact_data["summary"] = compact_summary
864
+ target = current or open_turn()
865
+ place(target, "output", _item(
866
+ "system-text", "compact_boundary", evt.get("uuid"),
867
+ text=evt.get("content") or "",
868
+ data=compact_data,
869
+ ))
870
+ stamp(target, ts)
871
+ continue
872
+
873
+ target = current or open_turn()
874
+ place(target, "output", _item(
875
+ "other", etype, evt.get("uuid"), native=safe_native(evt),
876
+ ))
877
+ stamp(target, ts)
878
+
879
+ if turns:
880
+ last = turns[-1]
881
+ if last["status"] == "completed" and any(
882
+ i["kind"] == "tool-call" and i["status"] == "in-progress"
883
+ for i in last["output"]
884
+ ):
885
+ last["status"] = "in-progress"
886
+
887
+ return turns, observed_model, source_records
888
+
889
+
890
+ def build_claude(rec: dict, *, state: str | None, worker: str,
891
+ max_tool_result_bytes: int | None = None,
892
+ before_offset: int | None = None,
893
+ after_offset: int = 0) -> dict:
894
+ """Normalize a managed Claude worker's raw JSONL transcript.
895
+
896
+ ``after_offset`` starts at a turn boundary to read only the newest turns.
897
+ """
898
+ requested = _requested_label(max_tool_result_bytes)
899
+ short = rec.get("short")
900
+ src = rec.get("transcript")
901
+ pinned = isinstance(src, PinnedNativeSource)
902
+ if not src or (not pinned and not os.path.exists(src)):
903
+ raise SessionNotFound(
904
+ f"no native transcript available for {short}", short=short
905
+ )
906
+
907
+ counters = _Counters()
908
+ raw_stats: dict = {"malformedLines": 0}
909
+ try:
910
+ events = list(_iter_transcript(src, raw_stats, after_offset=after_offset,
911
+ before_offset=before_offset))
912
+ except OSError as error:
913
+ raise SessionNotFound(
914
+ f"transcript not readable for {short}: {error.__class__.__name__}",
915
+ short=short,
916
+ ) from error
917
+ counters.malformed = raw_stats.get("malformedLines", 0)
918
+ if counters.malformed:
919
+ counters.warn(
920
+ f"{counters.malformed} malformed native JSONL line(s) skipped"
921
+ )
922
+
923
+ turns, observed_model, _records = _normalize_claude_events(
924
+ events, counters, max_tool_result_bytes,
925
+ )
926
+
927
+ artifact = {
928
+ "kind": "claude-transcript-jsonl",
929
+ "path": "<pinned-original>" if pinned else src,
930
+ "byteCount": None,
931
+ "sha256": None,
932
+ "availability": "referenced",
933
+ }
934
+ try:
935
+ artifact["byteCount"] = src.size() if pinned else os.path.getsize(src)
936
+ except OSError:
937
+ pass
938
+
939
+ return _document(
940
+ worker={
941
+ "handle": worker,
942
+ "engine": "claude",
943
+ "short": short,
944
+ "nativeSessionId": rec.get("sessionId"),
945
+ "cwd": rec.get("cwd"),
946
+ "requestedModel": (rec.get("createArgs") or {}).get("model"),
947
+ "observedModel": observed_model,
948
+ },
949
+ state=state,
950
+ turns=turns,
951
+ requested=requested,
952
+ counters=counters,
953
+ artifacts=[artifact],
954
+ )
955
+
956
+
957
+ # --- Codex -------------------------------------------------------------------
958
+
959
+ def _codex_user_input(entry: dict, item_id, client_id, counters: _Counters) -> dict:
960
+ """One part of a native ``userMessage``, carrying its provenance.
961
+
962
+ ``clientId`` is exactly what the native item holds — CCX's own id, another
963
+ client's, or ``None`` — and every part of one multipart message shares it.
964
+ ``origin`` is only ever set when that id parses as an id CCX itself minted;
965
+ it is never inferred from the message's position, text, or turn shape, so
966
+ ``None`` truthfully means "not provably ours".
967
+ """
968
+ provenance = {"clientId": client_id, "origin": origin_of(client_id)}
969
+ itype = entry.get("type")
970
+ if itype == "text":
971
+ return _item("user-text", "userMessage", item_id,
972
+ text=entry.get("text") or "", **provenance)
973
+ locator = entry.get("url") or entry.get("path")
974
+ if itype not in ("image", "localImage", "skill", "mention"):
975
+ counters.warn(f"unrecognized Codex user input type {itype!r}")
976
+ return _item("attachment", itype, item_id,
977
+ locator=locator, mediaType=None, native=safe_native(entry),
978
+ **provenance)
979
+
980
+
981
+ def _codex_data(entry: dict, *fields: str) -> dict:
982
+ """Keep selected native metadata without copying the whole item."""
983
+ data = {
984
+ field: safe_native(entry[field])
985
+ for field in fields
986
+ if field in entry
987
+ }
988
+ return {"data": data} if data else {}
989
+
990
+
991
+ def _codex_tool_name(namespace, tool) -> str | None:
992
+ if namespace:
993
+ return f"{namespace}/{tool}"
994
+ return tool
995
+
996
+
997
+ def _codex_item(entry: dict, counters: _Counters,
998
+ limit: int | None, observed_model: str | None) -> list[dict]:
999
+ """One native Codex thread item -> zero or more normalized items."""
1000
+ itype = entry.get("type")
1001
+ item_id = entry.get("id")
1002
+
1003
+ if itype == "userMessage":
1004
+ content = entry.get("content")
1005
+ client_id = entry.get("clientId")
1006
+ client_id = client_id if isinstance(client_id, str) else None
1007
+ return [
1008
+ _codex_user_input(part, item_id, client_id, counters)
1009
+ for part in (content if isinstance(content, list) else [])
1010
+ if isinstance(part, dict)
1011
+ ]
1012
+
1013
+ if itype == "hookPrompt":
1014
+ fragments = entry.get("fragments")
1015
+ fragments = fragments if isinstance(fragments, list) else []
1016
+ text = "\n".join(
1017
+ fragment.get("text") or ""
1018
+ for fragment in fragments
1019
+ if isinstance(fragment, dict)
1020
+ )
1021
+ # Hook input is deliberately not user-authored input. Keep it in the
1022
+ # input bucket, but make its source and native fragments explicit.
1023
+ return [_item(
1024
+ "system-text", itype, item_id, text=text,
1025
+ **_codex_data(entry, "fragments"),
1026
+ )]
1027
+
1028
+ if itype == "agentMessage":
1029
+ return [_item("assistant-text", itype, item_id,
1030
+ text=entry.get("text") or "", model=observed_model,
1031
+ phase=entry.get("phase"),
1032
+ memoryCitation=safe_native(entry.get("memoryCitation")))]
1033
+
1034
+ if itype == "plan":
1035
+ return [_item("assistant-plan", itype, item_id,
1036
+ text=entry.get("text") or "")]
1037
+
1038
+ if itype == "reasoning":
1039
+ # Only the engine's own surfaced summary; ``content`` is raw reasoning
1040
+ # and is never emitted.
1041
+ counters.reasoning_excluded += 1
1042
+ summary = entry.get("summary")
1043
+ parts = [str(s) for s in summary] if isinstance(summary, list) else []
1044
+ return [_item("reasoning-summary", itype, item_id,
1045
+ text="\n".join(parts))]
1046
+
1047
+ if itype == "commandExecution":
1048
+ status = _CODEX_STATUS.get(entry.get("status"))
1049
+ exit_code = entry.get("exitCode")
1050
+ output = entry.get("aggregatedOutput")
1051
+ result = _result_payload(
1052
+ output if output is not None else "",
1053
+ exit_code not in (0, None) or status in ("failed", "declined"),
1054
+ limit, counters,
1055
+ )
1056
+ # Named "shell" so Codex command execution and Claude's Bash tool line
1057
+ # up in a cross-engine comparison.
1058
+ return [_item("tool-call", itype, item_id,
1059
+ callId=item_id, name="shell",
1060
+ arguments={"command": entry.get("command"),
1061
+ "cwd": entry.get("cwd")},
1062
+ status=status, result=result,
1063
+ **(_summary_field("Ran", entry.get("command"))
1064
+ or _called_summary("shell")),
1065
+ **_codex_data(
1066
+ entry, "exitCode", "durationMs", "source",
1067
+ "processId", "pluginId", "scriptPath",
1068
+ "commandActions",
1069
+ ))]
1070
+
1071
+ if itype == "fileChange":
1072
+ changes = entry.get("changes")
1073
+ normalized_changes = [
1074
+ {"path": c.get("path"), "kind": c.get("kind"), "diff": c.get("diff")}
1075
+ for c in (changes if isinstance(changes, list) else [])
1076
+ if isinstance(c, dict)
1077
+ ]
1078
+ return [_item(
1079
+ "file-change", itype, item_id,
1080
+ status=_CODEX_STATUS.get(entry.get("status")),
1081
+ changes=normalized_changes,
1082
+ **_codex_file_change_summary(changes, normalized_changes),
1083
+ )]
1084
+
1085
+ if itype == "mcpToolCall":
1086
+ error = entry.get("error")
1087
+ native_error = safe_native(error)
1088
+ native_result = safe_native(entry.get("result"))
1089
+ payload = _result_payload(
1090
+ native_error if native_error is not None else native_result,
1091
+ error is not None, limit, counters,
1092
+ ) if (error is not None or native_result is not None) else _no_result()
1093
+ server = entry.get("server")
1094
+ tool = entry.get("tool")
1095
+ tool_name = (
1096
+ f"{server}/{tool}"
1097
+ if isinstance(server, str) and server
1098
+ and isinstance(tool, str) and tool
1099
+ else None
1100
+ )
1101
+ return [_item("tool-call", itype, item_id,
1102
+ callId=item_id,
1103
+ name=tool_name,
1104
+ arguments=entry.get("arguments")
1105
+ if entry.get("arguments") is not None else {},
1106
+ status=_CODEX_STATUS.get(entry.get("status")),
1107
+ result=payload,
1108
+ **_called_summary(tool_name),
1109
+ **_codex_data(
1110
+ entry, "appContext", "mcpAppResourceUri", "pluginId",
1111
+ "readOnlyHint", "durationMs",
1112
+ ))]
1113
+
1114
+ if itype == "dynamicToolCall":
1115
+ status = _CODEX_STATUS.get(entry.get("status"))
1116
+ content_items = entry.get("contentItems")
1117
+ success = entry.get("success")
1118
+ if content_items is None:
1119
+ result = _no_result()
1120
+ else:
1121
+ result = _result_payload(
1122
+ safe_native(content_items),
1123
+ success is False or status == "failed",
1124
+ limit, counters,
1125
+ )
1126
+ tool_name = _codex_tool_name(entry.get("namespace"), entry.get("tool"))
1127
+ return [_item(
1128
+ "tool-call", itype, item_id, callId=item_id,
1129
+ name=tool_name,
1130
+ arguments=entry.get("arguments")
1131
+ if entry.get("arguments") is not None else {},
1132
+ status=status, result=result,
1133
+ **_called_summary(tool_name),
1134
+ **_codex_data(
1135
+ entry, "namespace", "success", "durationMs",
1136
+ ),
1137
+ )]
1138
+
1139
+ if itype == "collabAgentToolCall":
1140
+ return [_item(
1141
+ "subagent-action", itype, item_id,
1142
+ callId=item_id, name=entry.get("tool"),
1143
+ status=_CODEX_STATUS.get(entry.get("status")),
1144
+ **_codex_data(
1145
+ entry, "senderThreadId", "receiverThreadIds", "prompt",
1146
+ "model", "reasoningEffort", "agentsStates",
1147
+ ),
1148
+ )]
1149
+
1150
+ if itype == "subAgentActivity":
1151
+ return [_item(
1152
+ "subagent-activity", itype, item_id,
1153
+ **_codex_data(entry, "kind", "agentThreadId", "agentPath"),
1154
+ )]
1155
+
1156
+ if itype == "webSearch":
1157
+ results = entry.get("results")
1158
+ result = (_result_payload(
1159
+ safe_native(results), False, limit, counters,
1160
+ ) if results is not None else _no_result())
1161
+ return [_item("tool-call", itype, item_id,
1162
+ callId=item_id, name="web_search",
1163
+ arguments={"query": entry.get("query"),
1164
+ "action": safe_native(entry.get("action"))},
1165
+ status=None, result=result,
1166
+ **(_summary_field("Searched", entry.get("query"))
1167
+ or _called_summary("web_search")))]
1168
+
1169
+ if itype == "imageView":
1170
+ return [_item(
1171
+ "attachment", itype, item_id,
1172
+ locator=entry.get("path"), mediaType=None,
1173
+ )]
1174
+
1175
+ if itype == "sleep":
1176
+ return [_item(
1177
+ "action", itype, item_id, name="sleep",
1178
+ **_codex_data(entry, "durationMs"),
1179
+ )]
1180
+
1181
+ if itype == "imageGeneration":
1182
+ return [_item(
1183
+ "action", itype, item_id, name="image_generation",
1184
+ status=_CODEX_STATUS.get(entry.get("status")),
1185
+ **_codex_data(
1186
+ entry, "revisedPrompt", "savedPath",
1187
+ "transparentBackground",
1188
+ ),
1189
+ )]
1190
+
1191
+ if itype in ("enteredReviewMode", "exitedReviewMode"):
1192
+ return [_item(
1193
+ "system-text", itype, item_id, text=entry.get("review"),
1194
+ data={"transition": "entered" if itype == "enteredReviewMode"
1195
+ else "exited"},
1196
+ )]
1197
+
1198
+ if itype == "contextCompaction":
1199
+ return [_item("system-text", itype, item_id, text=None, data=None)]
1200
+
1201
+ if itype == "functionCallOutput":
1202
+ return [_item(
1203
+ "tool-result", itype, item_id,
1204
+ callId=entry.get("callId") or item_id,
1205
+ name=entry.get("name"),
1206
+ result=_result_payload(entry.get("output"), False, limit, counters),
1207
+ )]
1208
+
1209
+ counters.warn(f"unmapped Codex thread item type {itype!r} preserved as other")
1210
+ return [_item("other", itype, item_id, native=safe_native(entry))]
1211
+
1212
+
1213
+ def _evidence_entry(
1214
+ entry: dict, turn_evidence: dict, turn_clients: dict
1215
+ ) -> dict | None:
1216
+ """The live evidence for one native item, by exact identity only.
1217
+
1218
+ Two identities qualify, in this order:
1219
+
1220
+ 1. the native item id the notification itself carried; and
1221
+ 2. for a ``userMessage`` only, the CCX client id — which the native
1222
+ protocol puts on both surfaces, so it is a real shared identity for the
1223
+ semantic message even when the two item ids differ.
1224
+
1225
+ Nothing else is ever used: not text, not position, not ordinal, not
1226
+ timestamps, not similarity. Tool, reasoning, and agent-message ids stay
1227
+ disjoint until a protocol gives them a legitimate key of their own.
1228
+ """
1229
+ direct = turn_evidence.get(entry.get("id"))
1230
+ if isinstance(direct, dict):
1231
+ return direct
1232
+ if entry.get("type") != "userMessage":
1233
+ return None
1234
+ client_id = entry.get("clientId")
1235
+ if not parse_client_message_id(client_id):
1236
+ # Only an id CCX can prove it minted. A foreign or absent one is not an
1237
+ # identity, and neither is one that merely looks similar.
1238
+ return None
1239
+ # A falsy value is the index's ambiguity marker: the same CCX id was seen
1240
+ # on two different live items, so it names neither.
1241
+ item_id = turn_clients.get(client_id)
1242
+ return turn_evidence.get(item_id) if item_id else None
1243
+
1244
+
1245
+ def _apply_timing(children: list[dict], entry: dict | None) -> int:
1246
+ """Stamp live item timing onto the children of one native item.
1247
+
1248
+ Only the native millisecond timestamps the notifications actually carried
1249
+ are used, and only for the item they named. Turn-level times are never
1250
+ spread over the items inside the turn.
1251
+ """
1252
+ if not isinstance(entry, dict):
1253
+ return 0
1254
+ started = entry.get("startedAtMs")
1255
+ completed = entry.get("completedAtMs")
1256
+ if not isinstance(started, int) and not isinstance(completed, int):
1257
+ return 0
1258
+ for child in children:
1259
+ child["startedAtMs"] = started if isinstance(started, int) else None
1260
+ child["completedAtMs"] = (
1261
+ completed if isinstance(completed, int) else None
1262
+ )
1263
+ child["timingSource"] = "ccx-live-item-notification"
1264
+ return len(children)
1265
+
1266
+
1267
+ _CODEX_STATUS = {
1268
+ "inProgress": "in-progress",
1269
+ "completed": "completed",
1270
+ "failed": "failed",
1271
+ "declined": "declined",
1272
+ }
1273
+
1274
+ _CODEX_TURN_STATUS = {
1275
+ "completed": "completed",
1276
+ "interrupted": "interrupted",
1277
+ "failed": "failed",
1278
+ "inProgress": "in-progress",
1279
+ }
1280
+
1281
+ # Items that are turn *input* rather than model output.
1282
+ _CODEX_INPUT_TYPES = frozenset({"userMessage", "hookPrompt"})
1283
+
1284
+
1285
+ def build_codex(rec: dict, thread: dict | None, *, state: str | None,
1286
+ worker: str,
1287
+ max_tool_result_bytes: int | None = None,
1288
+ evidence: dict | None = None) -> dict:
1289
+ "Normalize a Codex ``thread/read(includeTurns=true)`` response.\n\n ``evidence`` is an optional index built from this worker's live evidence\n journal (:func:`ccx.codex.evidence.read_index`). It only ever adds what\n ``thread/read`` cannot carry — the exact native millisecond timestamps CCX\n observed on ``item/started``/``item/completed``. Matching is by the exact\n ``(turnId, itemId)`` the event stream itself recorded; an item with no such\n evidence keeps no timing at all rather than borrowing its turn's.\n "
1290
+ requested = _requested_label(max_tool_result_bytes)
1291
+ short = rec.get("short")
1292
+ if not isinstance(thread, dict):
1293
+ raise SessionNotFound(
1294
+ f"no Codex thread available for {short}", short=short
1295
+ )
1296
+
1297
+ counters = _Counters()
1298
+ if thread.get("historySource") == "thread/items/list":
1299
+ # Native item pages are complete as items, but do not expose the
1300
+ # per-turn status/timestamps that thread/read(includeTurns) supplied.
1301
+ counters.source_limited = True
1302
+ counters.warn(
1303
+ "native turn listing is unsupported; item pages preserve original "
1304
+ "item history, but turn status and timestamps are unavailable"
1305
+ )
1306
+ observed_model = rec.get("resolvedModel")
1307
+ native_turns = thread.get("turns")
1308
+ native_turns = native_turns if isinstance(native_turns, list) else []
1309
+ if not native_turns and (rec.get("stats") or {}).get("turns"):
1310
+ counters.source_limited = True
1311
+ counters.warn(
1312
+ "app-server returned no turns although the worker has recorded "
1313
+ "turns; native history is unavailable"
1314
+ )
1315
+
1316
+ evidence_items = (evidence or {}).get("items") or {}
1317
+ evidence_clients = (evidence or {}).get("clients") or {}
1318
+ evidence_turns = (evidence or {}).get("turns") or {}
1319
+ # Only evidence CCX can vouch for may be used to contradict the historical
1320
+ # source. A missing, partial, or degraded journal proves nothing about what
1321
+ # `thread/read` left out.
1322
+ evidence_proves = (evidence or {}).get("status") == "complete"
1323
+ enriched = 0
1324
+
1325
+ turns: list[dict] = []
1326
+ for index, native in enumerate(native_turns):
1327
+ if not isinstance(native, dict):
1328
+ counters.omitted += 1
1329
+ continue
1330
+ turn = _turn(len(turns) + 1, native.get("id"))
1331
+ turn_evidence = evidence_items.get(native.get("id")) or {}
1332
+ turn_clients = evidence_clients.get(native.get("id")) or {}
1333
+ turn["status"] = _CODEX_TURN_STATUS.get(native.get("status"))
1334
+ turn["startedAt"] = _iso_unix_s(native.get("startedAt"))
1335
+ turn["completedAt"] = _iso_unix_s(native.get("completedAt"))
1336
+ view = native.get("itemsView", "full")
1337
+ if view != "full":
1338
+ counters.source_limited = True
1339
+ counters.warn(
1340
+ f"turn {native.get('id') or index} itemsView={view!r}: "
1341
+ "app-server did not return its full item list "
1342
+ "(omitted item count unknown)"
1343
+ )
1344
+ items = native.get("items")
1345
+ native_items = 0
1346
+ for entry in (items if isinstance(items, list) else []):
1347
+ if not isinstance(entry, dict):
1348
+ counters.omitted += 1
1349
+ continue
1350
+ native_items += 1
1351
+ bucket = ("input" if entry.get("type") in _CODEX_INPUT_TYPES
1352
+ else "output")
1353
+ children = _codex_item(
1354
+ entry, counters, max_tool_result_bytes, observed_model,
1355
+ )
1356
+ enriched += _apply_timing(
1357
+ children, _evidence_entry(entry, turn_evidence, turn_clients)
1358
+ )
1359
+ _place_all(turn, bucket, children)
1360
+ coverage = evidence_turns.get(native.get("id")) or {}
1361
+ observed_items = coverage.get("distinctItemCount") or 0
1362
+ if (
1363
+ evidence_proves
1364
+ and coverage.get("sawTurnStarted")
1365
+ and coverage.get("sawTurnCompleted")
1366
+ and observed_items > native_items
1367
+ ):
1368
+ # CCX watched this turn from start to settlement and counted more
1369
+ # item lifecycles than the historical source returned. That is
1370
+ # positive proof the source is limited — the one direction evidence
1371
+ # can argue in. The missing items are not invented into the
1372
+ # document; they stay where they were actually observed.
1373
+ counters.source_limited = True
1374
+ counters.warn(
1375
+ f"live evidence observed {observed_items} item lifecycles for "
1376
+ f"turn {native.get('id')} but thread/read returned "
1377
+ f"{native_items}; the historical source omits live turn items; "
1378
+ f"the evidence journal retains them"
1379
+ )
1380
+ error = native.get("error")
1381
+ if error:
1382
+ _place(turn, "output", _item(
1383
+ "error", "turnError", None,
1384
+ message=(error.get("message") if isinstance(error, dict)
1385
+ else str(error)),
1386
+ data=error if isinstance(error, dict) else None,
1387
+ ))
1388
+ turns.append(turn)
1389
+
1390
+ return _document(
1391
+ worker={
1392
+ "handle": worker,
1393
+ "engine": "codex",
1394
+ "short": short,
1395
+ "nativeSessionId": rec.get("threadId"),
1396
+ "cwd": rec.get("cwd"),
1397
+ "requestedModel": (rec.get("createArgs") or {}).get("model"),
1398
+ "observedModel": observed_model,
1399
+ },
1400
+ state=state,
1401
+ turns=turns,
1402
+ requested=requested,
1403
+ # thread/read carries no per-turn usage, and tokenUsage.last is a
1404
+ # per-request figure, so inferring turn usage would be a fabrication.
1405
+ counters=counters,
1406
+ artifacts=[],
1407
+ evidence=_evidence_block(evidence, enriched),
1408
+ )
1409
+
1410
+
1411
+ # --- export ------------------------------------------------------------------
1412
+
1413
+ def _atomic_write_bytes(path: str, payload: bytes) -> None:
1414
+ directory = os.path.dirname(path) or "."
1415
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".conversation.", suffix=".tmp")
1416
+ try:
1417
+ with os.fdopen(fd, "wb") as handle:
1418
+ handle.write(payload)
1419
+ os.replace(tmp, path)
1420
+ except BaseException:
1421
+ try:
1422
+ os.unlink(tmp)
1423
+ except FileNotFoundError:
1424
+ pass
1425
+ raise
1426
+
1427
+
1428
+ def _copy_hashed(src, dst: str) -> tuple[int, str]:
1429
+ """Stream-copy a native file, hashing as it goes."""
1430
+ digest = hashlib.sha256()
1431
+ size = 0
1432
+ reader = src.open_reader() if hasattr(src, "open_reader") else open(src, "rb")
1433
+ with reader as source, open(dst, "wb") as target:
1434
+ while True:
1435
+ chunk = source.read(1 << 20)
1436
+ if not chunk:
1437
+ break
1438
+ digest.update(chunk)
1439
+ size += len(chunk)
1440
+ target.write(chunk)
1441
+ return size, digest.hexdigest()
1442
+
1443
+
1444
+ def _exported(destination: str, kind: str, name: str, size: int,
1445
+ digest: str) -> dict:
1446
+ return {
1447
+ "kind": kind,
1448
+ "path": os.path.join("native", name),
1449
+ "byteCount": size,
1450
+ "sha256": digest,
1451
+ "availability": "exported",
1452
+ }
1453
+
1454
+
1455
+ def _native_dir(destination: str) -> str:
1456
+ path = os.path.join(destination, "native")
1457
+ os.makedirs(path, exist_ok=True)
1458
+ return path
1459
+
1460
+
1461
+ def write_export(doc: dict, destination, *, engine: str,
1462
+ native_source=None, include_native: bool = True,
1463
+ native_files=()) -> dict:
1464
+ """Write ``conversation.json`` (+ the native artifacts) under ``destination``.
1465
+
1466
+ ``native_source`` is the primary native evidence: a path (Claude) or an
1467
+ already-materialized object (Codex ``thread/read`` response).
1468
+ ``native_files`` is any further on-disk native evidence the engine owns, as
1469
+ ``(kind, filename, path)`` triples — the Codex evidence journal is one. The
1470
+ document's ``nativeArtifacts`` is rewritten to describe exactly what was
1471
+ written, each with a real byte count and SHA-256.
1472
+ """
1473
+ destination = str(destination)
1474
+ os.makedirs(destination, exist_ok=True)
1475
+ artifacts: list[dict] = []
1476
+
1477
+ if include_native and native_source is not None:
1478
+ native_dir = _native_dir(destination)
1479
+ if engine == "claude":
1480
+ name, kind = "claude-transcript.jsonl", "claude-transcript-jsonl"
1481
+ target = os.path.join(native_dir, name)
1482
+ try:
1483
+ size, digest = _copy_hashed(native_source, target)
1484
+ except OSError as error:
1485
+ raise SessionNotFound(
1486
+ f"native transcript not readable: {error.__class__.__name__}",
1487
+ short=doc.get("worker", {}).get("short"),
1488
+ ) from error
1489
+ else:
1490
+ name = "codex-thread.json"
1491
+ source = (native_source.get("historySource")
1492
+ if isinstance(native_source, dict) else None)
1493
+ kind = ("codex-thread-turns-paged" if source == "thread/turns/list"
1494
+ else "codex-thread-items-paged" if source == "thread/items/list"
1495
+ else "codex-thread-read")
1496
+ target = os.path.join(native_dir, name)
1497
+ payload = json.dumps(native_source, indent=2).encode("utf-8")
1498
+ _atomic_write_bytes(target, payload)
1499
+ size, digest = len(payload), hashlib.sha256(payload).hexdigest()
1500
+ artifacts.append(_exported(destination, kind, name, size, digest))
1501
+ elif engine == "claude" and native_source is not None:
1502
+ artifacts = list(doc.get("nativeArtifacts") or [])
1503
+
1504
+ if include_native:
1505
+ for kind, name, source in native_files:
1506
+ target = os.path.join(_native_dir(destination), name)
1507
+ try:
1508
+ size, digest = _copy_hashed(source, target)
1509
+ except FileNotFoundError:
1510
+ # Evidence that disappeared between listing and copying is
1511
+ # simply not claimed. An artifact entry is a promise that the
1512
+ # bytes are there.
1513
+ continue
1514
+ except OSError as error:
1515
+ raise SessionNotFound(
1516
+ f"native {kind} not readable: {error.__class__.__name__}",
1517
+ short=doc.get("worker", {}).get("short"),
1518
+ ) from error
1519
+ artifacts.append(_exported(destination, kind, name, size, digest))
1520
+
1521
+ doc["nativeArtifacts"] = artifacts
1522
+ conversation_path = os.path.join(destination, "conversation.json")
1523
+ _atomic_write_bytes(
1524
+ conversation_path, json.dumps(doc, indent=2).encode("utf-8")
1525
+ )
1526
+ exported = [
1527
+ os.path.join(destination, artifact["path"])
1528
+ for artifact in artifacts if artifact["availability"] == "exported"
1529
+ ]
1530
+ return {
1531
+ "destination": destination,
1532
+ "conversation_path": conversation_path,
1533
+ # The primary native artifact, unchanged; `native_paths` is everything
1534
+ # that was written, in the order `nativeArtifacts` lists it.
1535
+ "native_path": exported[0] if exported else None,
1536
+ "native_paths": exported,
1537
+ }
1538
+
1539
+
1540
+ __all__ = ["SCHEMA_VERSION", "build_claude", "build_codex", "write_export"]