ccx-native 0.0.1 → 0.2.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -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 +807 -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 +269 -0
  16. package/ccx/claude/host/session.py +5244 -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 +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,1593 @@
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, or the\n same thread rebuilt from Codex's local history database\n (``$CODEX_HOME/thread_history_1.sqlite``, read-only, schema-checked; see\n :mod:`ccx.codex.history_store`), which ``ccx history`` prefers and which\n falls back to app-server. Rollout files are never parsed, and the artifact is\n 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`` already returns;\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", "session-owner", "queue-operation", "tools-used",
537
+ "cost-state",
538
+ })
539
+ _CLAUDE_BOOKKEEPING_ATTACHMENTS = frozenset({
540
+ "hook_success", "hook_non_blocking_error", "hook_permission_decision",
541
+ "hook_additional_context", "environment", "model", "deferred_tools_delta",
542
+ "agent_listing_delta", "mcp_instructions_delta", "skill_listing",
543
+ "total_tokens_reminder", "session_context", "date", "credential_org",
544
+ "remote_session_change", "prompt_snapshot", "deferred_tools_record",
545
+ "silent_turn_reminder", "instructions", "nested_memory", "auto_mode",
546
+ "auto_mode_exit", "plan_mode", "plan_mode_exit", "edited_text_file",
547
+ "compact_file_reference", "thinking_drop", "invoked_skills",
548
+ "skill_mention", "command_permissions", "bash_output_audience_note",
549
+ "batching_reminder_sent", "session_cron_carry",
550
+ })
551
+ _CLAUDE_BOOKKEEPING_SYSTEM = frozenset({"stop_hook_summary", "turn_duration"})
552
+
553
+
554
+ def _claude_bookkeeping(evt: dict) -> bool:
555
+ """Whether a native Claude record is session bookkeeping, not conversation."""
556
+ etype = evt.get("type")
557
+ if etype in _CLAUDE_BOOKKEEPING_TYPES:
558
+ return True
559
+ if etype == "attachment":
560
+ return (evt.get("attachment") or {}).get("type") in _CLAUDE_BOOKKEEPING_ATTACHMENTS
561
+ if etype == "system":
562
+ return evt.get("subtype") in _CLAUDE_BOOKKEEPING_SYSTEM
563
+ return False
564
+
565
+
566
+ def _claude_page_bearing(evt: dict) -> bool:
567
+ """Whether one native Claude record belongs to bounded conversation history."""
568
+ etype = evt.get("type")
569
+ if etype == "assistant":
570
+ return True
571
+ if etype == "attachment":
572
+ return (evt.get("attachment") or {}).get("type") == "queued_command"
573
+ if etype == "system":
574
+ return evt.get("subtype") == "compact_boundary"
575
+ if etype != "user" or evt.get("isMeta"):
576
+ return False
577
+ if evt.get("isCompactSummary"):
578
+ # Contextual normalization suppresses only summaries that were consumed
579
+ # into an exactly identified compact boundary. Unlinked future/provider
580
+ # shapes stay page-bearing so their text cannot disappear silently.
581
+ return True
582
+ content = (evt.get("message") or {}).get("content")
583
+ if isinstance(content, str):
584
+ text = content.strip()
585
+ if not text:
586
+ return False
587
+ if text.startswith(("<local-command-stdout>", "<local-command-stderr>")):
588
+ return False
589
+ return True
590
+ if not isinstance(content, list):
591
+ return False
592
+ for block in content:
593
+ if not isinstance(block, dict):
594
+ continue
595
+ btype = block.get("type")
596
+ if btype == "text" and (block.get("text") or "").strip():
597
+ return True
598
+ if btype in {"tool_result", "image", "document"}:
599
+ return True
600
+ if btype not in {None, "text"}:
601
+ return True
602
+ return False
603
+
604
+
605
+ def _claude_usage(message: dict) -> dict | None:
606
+ usage = message.get("usage")
607
+ if not isinstance(usage, dict) or not usage:
608
+ return None
609
+ return {
610
+ "inputTokens": usage.get("input_tokens"),
611
+ "outputTokens": usage.get("output_tokens"),
612
+ "cachedInputTokens": usage.get("cache_read_input_tokens"),
613
+ "cacheCreationTokens": usage.get("cache_creation_input_tokens"),
614
+ "reasoningTokens": None,
615
+ }
616
+
617
+
618
+ def _normalize_claude_events(
619
+ events: list[dict],
620
+ counters: _Counters,
621
+ max_tool_result_bytes: int | None,
622
+ *,
623
+ fingerprints: bool = True,
624
+ ) -> tuple[list[dict], str | None, list[dict]]:
625
+ """Normalize Claude events once for full documents and bounded pages.
626
+
627
+ Only paging cursors need each source record's ``_ccx_fingerprint``, and it
628
+ costs most of a large transcript's parse, so full documents skip it.
629
+ """
630
+ turns: list[dict] = []
631
+ source_records: list[dict] = []
632
+ observed_model: str | None = None
633
+ current: dict | None = None
634
+ running: dict | None = None # current, until Claude records the turn's end
635
+ awaiting: dict[str, dict] = {}
636
+ usage_record_by_message: dict[str, dict] = {}
637
+ (
638
+ compact_summary_by_boundary_index,
639
+ consumed_compact_summary_indexes,
640
+ duplicate_local_command_indexes,
641
+ ) = _claude_structural_links(events)
642
+
643
+ def open_turn() -> dict:
644
+ nonlocal current, running
645
+ current = running = _turn(len(turns) + 1)
646
+ turns.append(current)
647
+ return current
648
+
649
+ def stamp(turn: dict, ts_ms) -> None:
650
+ iso = _iso_ms(ts_ms)
651
+ if iso is None:
652
+ return
653
+ if turn["startedAt"] is None:
654
+ turn["startedAt"] = iso
655
+ turn["completedAt"] = iso
656
+
657
+ for event_index, evt in enumerate(events):
658
+ etype = evt.get("type")
659
+ ts = _parse_event_ts_ms(evt)
660
+ message = evt.get("message") or {}
661
+ page_bearing = (
662
+ _claude_page_bearing(evt)
663
+ and event_index not in consumed_compact_summary_indexes
664
+ and event_index not in duplicate_local_command_indexes
665
+ )
666
+ record = {
667
+ "turn_id": None,
668
+ "source_record_id": evt.get("uuid"),
669
+ "timestamp_ms": ts,
670
+ "duration_ms": None,
671
+ "usage": None,
672
+ "items": [],
673
+ "_ccx_start_offset": evt.get("_ccx_start_offset"),
674
+ "_ccx_end_offset": evt.get("_ccx_end_offset"),
675
+ "_ccx_page_bearing": page_bearing,
676
+ "_ccx_type": etype,
677
+ "_ccx_fingerprint": hashlib.sha256(
678
+ json.dumps(
679
+ safe_native(evt),
680
+ sort_keys=True,
681
+ separators=(",", ":"),
682
+ ensure_ascii=False,
683
+ default=str,
684
+ ).encode("utf-8")
685
+ ).hexdigest() if fingerprints else None,
686
+ }
687
+ source_records.append(record)
688
+
689
+ def place(target: dict, bucket: str, item: dict) -> dict:
690
+ if isinstance(ts, int): # the native row's time, named as Codex's
691
+ item["startedAtMs"] = ts
692
+ item["timingSource"] = "claude-transcript"
693
+ placed = _place(target, bucket, item)
694
+ if page_bearing:
695
+ record["items"].append(placed)
696
+ return placed
697
+
698
+ if _claude_bookkeeping(evt):
699
+ # Never page-bearing, so bounded pages are unaffected; the source
700
+ # record above still counts toward paging positions.
701
+ counters.bookkeeping_excluded += 1
702
+ if etype == "system" and evt.get("subtype") == "turn_duration":
703
+ running = None # Claude closed the current turn
704
+ continue
705
+
706
+ if etype == "user":
707
+ if (
708
+ event_index in consumed_compact_summary_indexes
709
+ or event_index in duplicate_local_command_indexes
710
+ ):
711
+ continue
712
+ content = message.get("content")
713
+ blocks = (
714
+ [{"type": "text", "text": content}]
715
+ if isinstance(content, str)
716
+ else (content if isinstance(content, list) else [])
717
+ )
718
+ opened: dict | None = None
719
+ for block in blocks:
720
+ if not isinstance(block, dict):
721
+ counters.omitted += 1
722
+ continue
723
+ btype = block.get("type")
724
+ if btype == "text":
725
+ text = block.get("text") or ""
726
+ command = _command_from_text(text)
727
+ if command is None and not text.strip():
728
+ continue
729
+ # A hidden meta row (e.g. Claude's image-source note)
730
+ # stays in the turn it follows instead of opening one.
731
+ if opened is None and evt.get("isMeta") and current is not None:
732
+ opened = current
733
+ opened = opened or open_turn()
734
+ if command is not None:
735
+ name, args = command
736
+ place(opened, "input", _item(
737
+ "command", "user", evt.get("uuid"),
738
+ name=name, arguments={"args": args} if args else {},
739
+ ))
740
+ else:
741
+ place(opened, "input", _item(
742
+ "user-text", "user", evt.get("uuid"), text=text,
743
+ # Claude Code's own injected rows (command caveats,
744
+ # skill bodies, peer and scheduled prompts).
745
+ **({"meta": True} if evt.get("isMeta") else {}),
746
+ ))
747
+ stamp(opened, ts)
748
+ elif btype == "tool_result":
749
+ tid = block.get("tool_use_id")
750
+ payload = _result_payload(
751
+ block.get("content"), block.get("is_error"),
752
+ max_tool_result_bytes, counters,
753
+ )
754
+ call = awaiting.pop(tid, None) if tid else None
755
+ if call is not None:
756
+ call["result"] = payload
757
+ call["status"] = "failed" if payload["isError"] else "completed"
758
+ if isinstance(ts, int):
759
+ call["completedAtMs"] = ts
760
+ continue
761
+ counters.warn(
762
+ f"tool_result {tid or '<no id>'} has no unpaired "
763
+ "tool_use (unmatched or duplicate); kept standalone"
764
+ )
765
+ target = current or open_turn()
766
+ place(target, "output", _item(
767
+ "tool-result", "tool_result", tid,
768
+ callId=tid, unpaired=True, result=payload,
769
+ ))
770
+ stamp(target, ts)
771
+ elif btype in ("image", "document"):
772
+ opened = opened or open_turn()
773
+ place(opened, "input", _claude_attachment(block))
774
+ stamp(opened, ts)
775
+ else:
776
+ target = current or open_turn()
777
+ place(target, "output", _item(
778
+ "other", btype, None, native=safe_native(block),
779
+ ))
780
+ stamp(target, ts)
781
+ continue
782
+
783
+ if etype == "assistant":
784
+ target = current or open_turn()
785
+ stamp(target, ts)
786
+ if evt.get("isApiErrorMessage"):
787
+ text = message.get("content")
788
+ if not isinstance(text, str):
789
+ text = _format_tool_result_content(text)
790
+ place(target, "output", _item(
791
+ "error", "assistant", message.get("id"),
792
+ message=text,
793
+ data={"status": evt.get("status"),
794
+ "requestId": evt.get("requestId")},
795
+ ))
796
+ target["status"] = "failed"
797
+ continue
798
+ model = message.get("model")
799
+ if isinstance(model, str) and model and model != _SYNTHETIC_MODEL:
800
+ observed_model = model
801
+ content = message.get("content")
802
+ for block in (content if isinstance(content, list) else []):
803
+ if not isinstance(block, dict):
804
+ counters.omitted += 1
805
+ continue
806
+ btype = block.get("type")
807
+ if btype == "text":
808
+ text = block.get("text") or ""
809
+ if not text:
810
+ continue
811
+ place(target, "output", _item(
812
+ "assistant-text", "assistant", message.get("id"),
813
+ text=text, model=model,
814
+ ))
815
+ elif btype == "thinking" and _is_claude_narration(block):
816
+ # Claude Code persists some user-visible interim text as a
817
+ # narration-signed thinking block; it is not hidden reasoning.
818
+ text = block.get("thinking") or ""
819
+ if not text.strip():
820
+ continue
821
+ place(target, "output", _item(
822
+ "assistant-text", "assistant", message.get("id"),
823
+ text=text, model=model, narration=True,
824
+ ))
825
+ elif btype in ("thinking", "redacted_thinking"):
826
+ counters.reasoning_excluded += 1
827
+ elif btype == "tool_use":
828
+ tid = block.get("id")
829
+ tool_name = block.get("name")
830
+ tool_arguments = block.get("input") or {}
831
+ call = place(target, "output", _item(
832
+ "tool-call", "tool_use", tid,
833
+ callId=tid, name=tool_name,
834
+ arguments=tool_arguments,
835
+ status="in-progress", result=_no_result(),
836
+ **_claude_tool_summary(tool_name, tool_arguments),
837
+ ))
838
+ if tid:
839
+ awaiting[tid] = call
840
+ else:
841
+ place(target, "output", _item(
842
+ "other", btype, None, native=safe_native(block),
843
+ ))
844
+ usage = _claude_usage(message)
845
+ if usage is not None:
846
+ target["usage"] = usage
847
+ mid = message.get("id")
848
+ if isinstance(mid, str) and mid:
849
+ previous = usage_record_by_message.get(mid)
850
+ if previous is not None:
851
+ previous["usage"] = None
852
+ usage_record_by_message[mid] = record
853
+ record["usage"] = dict(usage)
854
+ continue
855
+
856
+ if etype == "attachment":
857
+ att = evt.get("attachment") or {}
858
+ if att.get("type") == "queued_command":
859
+ # A queued message is a string, or, when it carries an image
860
+ # or document, a content-block list like a user message's.
861
+ prompt = att.get("prompt")
862
+ media: list[dict] = []
863
+ others: list[dict] = []
864
+ if isinstance(prompt, list):
865
+ parts = []
866
+ for block in prompt:
867
+ if isinstance(block, str):
868
+ parts.append(block)
869
+ elif not isinstance(block, dict):
870
+ counters.omitted += 1
871
+ elif block.get("type") == "text":
872
+ parts.append(block.get("text") or "")
873
+ elif block.get("type") in ("image", "document"):
874
+ media.append(block)
875
+ else:
876
+ others.append(block)
877
+ text = "\n".join(p for p in parts if p.strip()).strip()
878
+ else:
879
+ text = prompt.strip() if isinstance(prompt, str) else ""
880
+ if not text and not media and not others:
881
+ continue
882
+ # Claude queues a steer or task notification into the running
883
+ # turn, so it stays there; it opens a turn only when none runs.
884
+ target = running or open_turn()
885
+ if text:
886
+ place(target, "input", _item(
887
+ "user-text", "queued_command", evt.get("uuid"), text=text,
888
+ ))
889
+ for block in media:
890
+ place(target, "input", _claude_attachment(block))
891
+ for block in others: # kept, as in a user message
892
+ place(target, "output", _item(
893
+ "other", block.get("type"), None, native=safe_native(block),
894
+ ))
895
+ stamp(target, ts)
896
+ else:
897
+ target = current or open_turn()
898
+ place(target, "output", _item(
899
+ "other", "attachment", evt.get("uuid"),
900
+ native=safe_native(evt),
901
+ ))
902
+ stamp(target, ts)
903
+ continue
904
+
905
+ if etype == "system" and evt.get("subtype") == "compact_boundary":
906
+ meta = evt.get("compactMetadata") or {}
907
+ compact_data = {
908
+ "preTokens": meta.get("preTokens"),
909
+ "postTokens": meta.get("postTokens"),
910
+ "durationMs": meta.get("durationMs"),
911
+ "trigger": meta.get("trigger"),
912
+ }
913
+ compact_summary = compact_summary_by_boundary_index.get(event_index)
914
+ if compact_summary is not None:
915
+ compact_data["summary"] = compact_summary
916
+ target = current or open_turn()
917
+ place(target, "output", _item(
918
+ "system-text", "compact_boundary", evt.get("uuid"),
919
+ text=evt.get("content") or "",
920
+ data=compact_data,
921
+ ))
922
+ stamp(target, ts)
923
+ continue
924
+
925
+ target = current or open_turn()
926
+ place(target, "output", _item(
927
+ "other", etype, evt.get("uuid"), native=safe_native(evt),
928
+ ))
929
+ stamp(target, ts)
930
+
931
+ if turns:
932
+ last = turns[-1]
933
+ if last["status"] == "completed" and any(
934
+ i["kind"] == "tool-call" and i["status"] == "in-progress"
935
+ for i in last["output"]
936
+ ):
937
+ last["status"] = "in-progress"
938
+
939
+ return turns, observed_model, source_records
940
+
941
+
942
+ def build_claude(rec: dict, *, state: str | None, worker: str,
943
+ max_tool_result_bytes: int | None = None,
944
+ before_offset: int | None = None,
945
+ after_offset: int = 0) -> dict:
946
+ """Normalize a managed Claude worker's raw JSONL transcript.
947
+
948
+ ``after_offset`` starts at a turn boundary to read only the newest turns.
949
+ """
950
+ requested = _requested_label(max_tool_result_bytes)
951
+ short = rec.get("short")
952
+ src = rec.get("transcript")
953
+ pinned = isinstance(src, PinnedNativeSource)
954
+ if not src or (not pinned and not os.path.exists(src)):
955
+ raise SessionNotFound(
956
+ f"no native transcript available for {short}", short=short
957
+ )
958
+
959
+ counters = _Counters()
960
+ raw_stats: dict = {"malformedLines": 0}
961
+ try:
962
+ events = list(_iter_transcript(src, raw_stats, after_offset=after_offset,
963
+ before_offset=before_offset))
964
+ except OSError as error:
965
+ raise SessionNotFound(
966
+ f"transcript not readable for {short}: {error.__class__.__name__}",
967
+ short=short,
968
+ ) from error
969
+ counters.malformed = raw_stats.get("malformedLines", 0)
970
+ if counters.malformed:
971
+ counters.warn(
972
+ f"{counters.malformed} malformed native JSONL line(s) skipped"
973
+ )
974
+
975
+ turns, observed_model, _records = _normalize_claude_events(
976
+ events, counters, max_tool_result_bytes, fingerprints=False,
977
+ )
978
+
979
+ artifact = {
980
+ "kind": "claude-transcript-jsonl",
981
+ "path": "<pinned-original>" if pinned else src,
982
+ "byteCount": None,
983
+ "sha256": None,
984
+ "availability": "referenced",
985
+ }
986
+ try:
987
+ artifact["byteCount"] = src.size() if pinned else os.path.getsize(src)
988
+ except OSError:
989
+ pass
990
+
991
+ return _document(
992
+ worker={
993
+ "handle": worker,
994
+ "engine": "claude",
995
+ "short": short,
996
+ "nativeSessionId": rec.get("sessionId"),
997
+ "cwd": rec.get("cwd"),
998
+ "requestedModel": (rec.get("createArgs") or {}).get("model"),
999
+ "observedModel": observed_model,
1000
+ },
1001
+ state=state,
1002
+ turns=turns,
1003
+ requested=requested,
1004
+ counters=counters,
1005
+ artifacts=[artifact],
1006
+ )
1007
+
1008
+
1009
+ # --- Codex -------------------------------------------------------------------
1010
+
1011
+ def _codex_user_input(entry: dict, item_id, client_id, counters: _Counters) -> dict:
1012
+ """One part of a native ``userMessage``, carrying its provenance.
1013
+
1014
+ ``clientId`` is exactly what the native item holds — CCX's own id, another
1015
+ client's, or ``None`` — and every part of one multipart message shares it.
1016
+ ``origin`` is only ever set when that id parses as an id CCX itself minted;
1017
+ it is never inferred from the message's position, text, or turn shape, so
1018
+ ``None`` truthfully means "not provably ours".
1019
+ """
1020
+ provenance = {"clientId": client_id, "origin": origin_of(client_id)}
1021
+ itype = entry.get("type")
1022
+ if itype == "text":
1023
+ return _item("user-text", "userMessage", item_id,
1024
+ text=entry.get("text") or "", **provenance)
1025
+ locator = entry.get("url") or entry.get("path")
1026
+ if itype not in ("image", "localImage", "skill", "mention"):
1027
+ counters.warn(f"unrecognized Codex user input type {itype!r}")
1028
+ return _item("attachment", itype, item_id,
1029
+ locator=locator, mediaType=None, native=safe_native(entry),
1030
+ **provenance)
1031
+
1032
+
1033
+ def _codex_data(entry: dict, *fields: str) -> dict:
1034
+ """Keep selected native metadata without copying the whole item."""
1035
+ data = {
1036
+ field: safe_native(entry[field])
1037
+ for field in fields
1038
+ if field in entry
1039
+ }
1040
+ return {"data": data} if data else {}
1041
+
1042
+
1043
+ def _codex_tool_name(namespace, tool) -> str | None:
1044
+ if namespace:
1045
+ return f"{namespace}/{tool}"
1046
+ return tool
1047
+
1048
+
1049
+ def _codex_item(entry: dict, counters: _Counters,
1050
+ limit: int | None, observed_model: str | None) -> list[dict]:
1051
+ """One native Codex thread item -> zero or more normalized items."""
1052
+ itype = entry.get("type")
1053
+ item_id = entry.get("id")
1054
+
1055
+ if itype == "userMessage":
1056
+ content = entry.get("content")
1057
+ client_id = entry.get("clientId")
1058
+ client_id = client_id if isinstance(client_id, str) else None
1059
+ return [
1060
+ _codex_user_input(part, item_id, client_id, counters)
1061
+ for part in (content if isinstance(content, list) else [])
1062
+ if isinstance(part, dict)
1063
+ ]
1064
+
1065
+ if itype == "hookPrompt":
1066
+ fragments = entry.get("fragments")
1067
+ fragments = fragments if isinstance(fragments, list) else []
1068
+ text = "\n".join(
1069
+ fragment.get("text") or ""
1070
+ for fragment in fragments
1071
+ if isinstance(fragment, dict)
1072
+ )
1073
+ # Hook input is deliberately not user-authored input. Keep it in the
1074
+ # input bucket, but make its source and native fragments explicit.
1075
+ return [_item(
1076
+ "system-text", itype, item_id, text=text,
1077
+ **_codex_data(entry, "fragments"),
1078
+ )]
1079
+
1080
+ if itype == "agentMessage":
1081
+ return [_item("assistant-text", itype, item_id,
1082
+ text=entry.get("text") or "", model=observed_model,
1083
+ phase=entry.get("phase"),
1084
+ memoryCitation=safe_native(entry.get("memoryCitation")))]
1085
+
1086
+ if itype == "plan":
1087
+ return [_item("assistant-plan", itype, item_id,
1088
+ text=entry.get("text") or "")]
1089
+
1090
+ if itype == "reasoning":
1091
+ # Only the engine's own surfaced summary; ``content`` is raw reasoning
1092
+ # and is never emitted.
1093
+ counters.reasoning_excluded += 1
1094
+ summary = entry.get("summary")
1095
+ parts = [str(s) for s in summary] if isinstance(summary, list) else []
1096
+ return [_item("reasoning-summary", itype, item_id,
1097
+ text="\n".join(parts))]
1098
+
1099
+ if itype == "commandExecution":
1100
+ status = _CODEX_STATUS.get(entry.get("status"))
1101
+ exit_code = entry.get("exitCode")
1102
+ output = entry.get("aggregatedOutput")
1103
+ result = _result_payload(
1104
+ output if output is not None else "",
1105
+ exit_code not in (0, None) or status in ("failed", "declined"),
1106
+ limit, counters,
1107
+ )
1108
+ # Named "shell" so Codex command execution and Claude's Bash tool line
1109
+ # up in a cross-engine comparison.
1110
+ return [_item("tool-call", itype, item_id,
1111
+ callId=item_id, name="shell",
1112
+ arguments={"command": entry.get("command"),
1113
+ "cwd": entry.get("cwd")},
1114
+ status=status, result=result,
1115
+ **(_summary_field("Ran", entry.get("command"))
1116
+ or _called_summary("shell")),
1117
+ **_codex_data(
1118
+ entry, "exitCode", "durationMs", "source",
1119
+ "processId", "pluginId", "scriptPath",
1120
+ "commandActions",
1121
+ ))]
1122
+
1123
+ if itype == "fileChange":
1124
+ changes = entry.get("changes")
1125
+ normalized_changes = [
1126
+ {"path": c.get("path"), "kind": c.get("kind"), "diff": c.get("diff")}
1127
+ for c in (changes if isinstance(changes, list) else [])
1128
+ if isinstance(c, dict)
1129
+ ]
1130
+ return [_item(
1131
+ "file-change", itype, item_id,
1132
+ status=_CODEX_STATUS.get(entry.get("status")),
1133
+ changes=normalized_changes,
1134
+ **_codex_file_change_summary(changes, normalized_changes),
1135
+ )]
1136
+
1137
+ if itype == "mcpToolCall":
1138
+ error = entry.get("error")
1139
+ native_error = safe_native(error)
1140
+ native_result = safe_native(entry.get("result"))
1141
+ payload = _result_payload(
1142
+ native_error if native_error is not None else native_result,
1143
+ error is not None, limit, counters,
1144
+ ) if (error is not None or native_result is not None) else _no_result()
1145
+ server = entry.get("server")
1146
+ tool = entry.get("tool")
1147
+ tool_name = (
1148
+ f"{server}/{tool}"
1149
+ if isinstance(server, str) and server
1150
+ and isinstance(tool, str) and tool
1151
+ else None
1152
+ )
1153
+ return [_item("tool-call", itype, item_id,
1154
+ callId=item_id,
1155
+ name=tool_name,
1156
+ arguments=entry.get("arguments")
1157
+ if entry.get("arguments") is not None else {},
1158
+ status=_CODEX_STATUS.get(entry.get("status")),
1159
+ result=payload,
1160
+ **_called_summary(tool_name),
1161
+ **_codex_data(
1162
+ entry, "appContext", "mcpAppResourceUri", "pluginId",
1163
+ "readOnlyHint", "durationMs",
1164
+ ))]
1165
+
1166
+ if itype == "dynamicToolCall":
1167
+ status = _CODEX_STATUS.get(entry.get("status"))
1168
+ content_items = entry.get("contentItems")
1169
+ success = entry.get("success")
1170
+ if content_items is None:
1171
+ result = _no_result()
1172
+ else:
1173
+ result = _result_payload(
1174
+ safe_native(content_items),
1175
+ success is False or status == "failed",
1176
+ limit, counters,
1177
+ )
1178
+ tool_name = _codex_tool_name(entry.get("namespace"), entry.get("tool"))
1179
+ return [_item(
1180
+ "tool-call", itype, item_id, callId=item_id,
1181
+ name=tool_name,
1182
+ arguments=entry.get("arguments")
1183
+ if entry.get("arguments") is not None else {},
1184
+ status=status, result=result,
1185
+ **_called_summary(tool_name),
1186
+ **_codex_data(
1187
+ entry, "namespace", "success", "durationMs",
1188
+ ),
1189
+ )]
1190
+
1191
+ if itype == "collabAgentToolCall":
1192
+ return [_item(
1193
+ "subagent-action", itype, item_id,
1194
+ callId=item_id, name=entry.get("tool"),
1195
+ status=_CODEX_STATUS.get(entry.get("status")),
1196
+ **_codex_data(
1197
+ entry, "senderThreadId", "receiverThreadIds", "prompt",
1198
+ "model", "reasoningEffort", "agentsStates",
1199
+ ),
1200
+ )]
1201
+
1202
+ if itype == "subAgentActivity":
1203
+ return [_item(
1204
+ "subagent-activity", itype, item_id,
1205
+ **_codex_data(entry, "kind", "agentThreadId", "agentPath"),
1206
+ )]
1207
+
1208
+ if itype == "webSearch":
1209
+ results = entry.get("results")
1210
+ result = (_result_payload(
1211
+ safe_native(results), False, limit, counters,
1212
+ ) if results is not None else _no_result())
1213
+ return [_item("tool-call", itype, item_id,
1214
+ callId=item_id, name="web_search",
1215
+ arguments={"query": entry.get("query"),
1216
+ "action": safe_native(entry.get("action"))},
1217
+ status=None, result=result,
1218
+ **(_summary_field("Searched", entry.get("query"))
1219
+ or _called_summary("web_search")))]
1220
+
1221
+ if itype == "imageView":
1222
+ return [_item(
1223
+ "attachment", itype, item_id,
1224
+ locator=entry.get("path"), mediaType=None,
1225
+ )]
1226
+
1227
+ if itype == "sleep":
1228
+ return [_item(
1229
+ "action", itype, item_id, name="sleep",
1230
+ **_codex_data(entry, "durationMs"),
1231
+ )]
1232
+
1233
+ if itype == "imageGeneration":
1234
+ return [_item(
1235
+ "action", itype, item_id, name="image_generation",
1236
+ status=_CODEX_STATUS.get(entry.get("status")),
1237
+ **_codex_data(
1238
+ entry, "revisedPrompt", "savedPath",
1239
+ "transparentBackground",
1240
+ ),
1241
+ )]
1242
+
1243
+ if itype in ("enteredReviewMode", "exitedReviewMode"):
1244
+ return [_item(
1245
+ "system-text", itype, item_id, text=entry.get("review"),
1246
+ data={"transition": "entered" if itype == "enteredReviewMode"
1247
+ else "exited"},
1248
+ )]
1249
+
1250
+ if itype == "contextCompaction":
1251
+ return [_item("system-text", itype, item_id, text=None, data=None)]
1252
+
1253
+ if itype == "functionCallOutput":
1254
+ return [_item(
1255
+ "tool-result", itype, item_id,
1256
+ callId=entry.get("callId") or item_id,
1257
+ name=entry.get("name"),
1258
+ namespace=entry.get("namespace"),
1259
+ result=_result_payload(entry.get("output"), False, limit, counters),
1260
+ )]
1261
+
1262
+ counters.warn(f"unmapped Codex thread item type {itype!r} preserved as other")
1263
+ return [_item("other", itype, item_id, native=safe_native(entry))]
1264
+
1265
+
1266
+ def _evidence_entry(
1267
+ entry: dict, turn_evidence: dict, turn_clients: dict
1268
+ ) -> dict | None:
1269
+ """The live evidence for one native item, by exact identity only.
1270
+
1271
+ Two identities qualify, in this order:
1272
+
1273
+ 1. the native item id the notification itself carried; and
1274
+ 2. for a ``userMessage`` only, the CCX client id — which the native
1275
+ protocol puts on both surfaces, so it is a real shared identity for the
1276
+ semantic message even when the two item ids differ.
1277
+
1278
+ Nothing else is ever used: not text, not position, not ordinal, not
1279
+ timestamps, not similarity. Tool, reasoning, and agent-message ids stay
1280
+ disjoint until a protocol gives them a legitimate key of their own.
1281
+ """
1282
+ direct = turn_evidence.get(entry.get("id"))
1283
+ if isinstance(direct, dict):
1284
+ return direct
1285
+ if entry.get("type") != "userMessage":
1286
+ return None
1287
+ client_id = entry.get("clientId")
1288
+ if not parse_client_message_id(client_id):
1289
+ # Only an id CCX can prove it minted. A foreign or absent one is not an
1290
+ # identity, and neither is one that merely looks similar.
1291
+ return None
1292
+ # A falsy value is the index's ambiguity marker: the same CCX id was seen
1293
+ # on two different live items, so it names neither.
1294
+ item_id = turn_clients.get(client_id)
1295
+ return turn_evidence.get(item_id) if item_id else None
1296
+
1297
+
1298
+ def _apply_timing(children: list[dict], entry: dict | None) -> int:
1299
+ """Stamp live item timing onto the children of one native item.
1300
+
1301
+ Only the native millisecond timestamps the notifications actually carried
1302
+ are used, and only for the item they named. Turn-level times are never
1303
+ spread over the items inside the turn.
1304
+ """
1305
+ if not isinstance(entry, dict):
1306
+ return 0
1307
+ started = entry.get("startedAtMs")
1308
+ completed = entry.get("completedAtMs")
1309
+ if not isinstance(started, int) and not isinstance(completed, int):
1310
+ return 0
1311
+ for child in children:
1312
+ child["startedAtMs"] = started if isinstance(started, int) else None
1313
+ child["completedAtMs"] = (
1314
+ completed if isinstance(completed, int) else None
1315
+ )
1316
+ child["timingSource"] = "ccx-live-item-notification"
1317
+ return len(children)
1318
+
1319
+
1320
+ _CODEX_STATUS = {
1321
+ "inProgress": "in-progress",
1322
+ "completed": "completed",
1323
+ "failed": "failed",
1324
+ "declined": "declined",
1325
+ }
1326
+
1327
+ _CODEX_TURN_STATUS = {
1328
+ "completed": "completed",
1329
+ "interrupted": "interrupted",
1330
+ "failed": "failed",
1331
+ "inProgress": "in-progress",
1332
+ }
1333
+
1334
+ # Items that are turn *input* rather than model output.
1335
+ _CODEX_INPUT_TYPES = frozenset({"userMessage", "hookPrompt"})
1336
+
1337
+
1338
+ def build_codex(rec: dict, thread: dict | None, *, state: str | None,
1339
+ worker: str,
1340
+ max_tool_result_bytes: int | None = None,
1341
+ evidence: dict | None = None) -> dict:
1342
+ "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 "
1343
+ requested = _requested_label(max_tool_result_bytes)
1344
+ short = rec.get("short")
1345
+ if not isinstance(thread, dict):
1346
+ raise SessionNotFound(
1347
+ f"no Codex thread available for {short}", short=short
1348
+ )
1349
+
1350
+ counters = _Counters()
1351
+ if thread.get("historySource") == "thread/items/list":
1352
+ # Native item pages are complete as items, but do not expose the
1353
+ # per-turn status/timestamps that thread/read(includeTurns) supplied.
1354
+ counters.source_limited = True
1355
+ counters.warn(
1356
+ "native turn listing is unsupported; item pages preserve original "
1357
+ "item history, but turn status and timestamps are unavailable"
1358
+ )
1359
+ observed_model = rec.get("resolvedModel")
1360
+ native_turns = thread.get("turns")
1361
+ native_turns = native_turns if isinstance(native_turns, list) else []
1362
+ if not native_turns and (rec.get("stats") or {}).get("turns"):
1363
+ counters.source_limited = True
1364
+ counters.warn(
1365
+ "app-server returned no turns although the worker has recorded "
1366
+ "turns; native history is unavailable"
1367
+ )
1368
+
1369
+ evidence_items = (evidence or {}).get("items") or {}
1370
+ evidence_clients = (evidence or {}).get("clients") or {}
1371
+ evidence_turns = (evidence or {}).get("turns") or {}
1372
+ # Only evidence CCX can vouch for may be used to contradict the historical
1373
+ # source. A missing, partial, or degraded journal proves nothing about what
1374
+ # `thread/read` left out.
1375
+ evidence_proves = (evidence or {}).get("status") == "complete"
1376
+ enriched = 0
1377
+
1378
+ turns: list[dict] = []
1379
+ for index, native in enumerate(native_turns):
1380
+ if not isinstance(native, dict):
1381
+ counters.omitted += 1
1382
+ continue
1383
+ turn = _turn(len(turns) + 1, native.get("id"))
1384
+ turn_evidence = evidence_items.get(native.get("id")) or {}
1385
+ turn_clients = evidence_clients.get(native.get("id")) or {}
1386
+ turn["status"] = _CODEX_TURN_STATUS.get(native.get("status"))
1387
+ turn["startedAt"] = _iso_unix_s(native.get("startedAt"))
1388
+ turn["completedAt"] = _iso_unix_s(native.get("completedAt"))
1389
+ view = native.get("itemsView", "full")
1390
+ if view != "full":
1391
+ counters.source_limited = True
1392
+ counters.warn(
1393
+ f"turn {native.get('id') or index} itemsView={view!r}: "
1394
+ "app-server did not return its full item list "
1395
+ "(omitted item count unknown)"
1396
+ )
1397
+ items = native.get("items")
1398
+ native_items = 0
1399
+ for entry in (items if isinstance(items, list) else []):
1400
+ if not isinstance(entry, dict):
1401
+ counters.omitted += 1
1402
+ continue
1403
+ native_items += 1
1404
+ bucket = ("input" if entry.get("type") in _CODEX_INPUT_TYPES
1405
+ else "output")
1406
+ children = _codex_item(
1407
+ entry, counters, max_tool_result_bytes, observed_model,
1408
+ )
1409
+ enriched += _apply_timing(
1410
+ children, _evidence_entry(entry, turn_evidence, turn_clients)
1411
+ )
1412
+ _place_all(turn, bucket, children)
1413
+ coverage = evidence_turns.get(native.get("id")) or {}
1414
+ observed_items = coverage.get("distinctItemCount") or 0
1415
+ if (
1416
+ evidence_proves
1417
+ and coverage.get("sawTurnStarted")
1418
+ and coverage.get("sawTurnCompleted")
1419
+ and observed_items > native_items
1420
+ ):
1421
+ # CCX watched this turn from start to settlement and counted more
1422
+ # item lifecycles than the historical source returned. That is
1423
+ # positive proof the source is limited — the one direction evidence
1424
+ # can argue in. The missing items are not invented into the
1425
+ # document; they stay where they were actually observed.
1426
+ counters.source_limited = True
1427
+ counters.warn(
1428
+ f"live evidence observed {observed_items} item lifecycles for "
1429
+ f"turn {native.get('id')} but thread/read returned "
1430
+ f"{native_items}; the historical source omits live turn items; "
1431
+ f"the evidence journal retains them"
1432
+ )
1433
+ error = native.get("error")
1434
+ if error:
1435
+ _place(turn, "output", _item(
1436
+ "error", "turnError", None,
1437
+ message=(error.get("message") if isinstance(error, dict)
1438
+ else str(error)),
1439
+ data=error if isinstance(error, dict) else None,
1440
+ ))
1441
+ turns.append(turn)
1442
+
1443
+ return _document(
1444
+ worker={
1445
+ "handle": worker,
1446
+ "engine": "codex",
1447
+ "short": short,
1448
+ "nativeSessionId": rec.get("threadId"),
1449
+ "cwd": rec.get("cwd"),
1450
+ "requestedModel": (rec.get("createArgs") or {}).get("model"),
1451
+ "observedModel": observed_model,
1452
+ },
1453
+ state=state,
1454
+ turns=turns,
1455
+ requested=requested,
1456
+ # thread/read carries no per-turn usage, and tokenUsage.last is a
1457
+ # per-request figure, so inferring turn usage would be a fabrication.
1458
+ counters=counters,
1459
+ artifacts=[],
1460
+ evidence=_evidence_block(evidence, enriched),
1461
+ )
1462
+
1463
+
1464
+ # --- export ------------------------------------------------------------------
1465
+
1466
+ def _atomic_write_bytes(path: str, payload: bytes) -> None:
1467
+ directory = os.path.dirname(path) or "."
1468
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".conversation.", suffix=".tmp")
1469
+ try:
1470
+ with os.fdopen(fd, "wb") as handle:
1471
+ handle.write(payload)
1472
+ os.replace(tmp, path)
1473
+ except BaseException:
1474
+ try:
1475
+ os.unlink(tmp)
1476
+ except FileNotFoundError:
1477
+ pass
1478
+ raise
1479
+
1480
+
1481
+ def _copy_hashed(src, dst: str) -> tuple[int, str]:
1482
+ """Stream-copy a native file, hashing as it goes."""
1483
+ digest = hashlib.sha256()
1484
+ size = 0
1485
+ reader = src.open_reader() if hasattr(src, "open_reader") else open(src, "rb")
1486
+ with reader as source, open(dst, "wb") as target:
1487
+ while True:
1488
+ chunk = source.read(1 << 20)
1489
+ if not chunk:
1490
+ break
1491
+ digest.update(chunk)
1492
+ size += len(chunk)
1493
+ target.write(chunk)
1494
+ return size, digest.hexdigest()
1495
+
1496
+
1497
+ def _exported(destination: str, kind: str, name: str, size: int,
1498
+ digest: str) -> dict:
1499
+ return {
1500
+ "kind": kind,
1501
+ "path": os.path.join("native", name),
1502
+ "byteCount": size,
1503
+ "sha256": digest,
1504
+ "availability": "exported",
1505
+ }
1506
+
1507
+
1508
+ def _native_dir(destination: str) -> str:
1509
+ path = os.path.join(destination, "native")
1510
+ os.makedirs(path, exist_ok=True)
1511
+ return path
1512
+
1513
+
1514
+ def write_export(doc: dict, destination, *, engine: str,
1515
+ native_source=None, include_native: bool = True,
1516
+ native_files=()) -> dict:
1517
+ """Write ``conversation.json`` (+ the native artifacts) under ``destination``.
1518
+
1519
+ ``native_source`` is the primary native evidence: a path (Claude) or an
1520
+ already-materialized object (Codex ``thread/read`` response).
1521
+ ``native_files`` is any further on-disk native evidence the engine owns, as
1522
+ ``(kind, filename, path)`` triples — the Codex evidence journal is one. The
1523
+ document's ``nativeArtifacts`` is rewritten to describe exactly what was
1524
+ written, each with a real byte count and SHA-256.
1525
+ """
1526
+ destination = str(destination)
1527
+ os.makedirs(destination, exist_ok=True)
1528
+ artifacts: list[dict] = []
1529
+
1530
+ if include_native and native_source is not None:
1531
+ native_dir = _native_dir(destination)
1532
+ if engine == "claude":
1533
+ name, kind = "claude-transcript.jsonl", "claude-transcript-jsonl"
1534
+ target = os.path.join(native_dir, name)
1535
+ try:
1536
+ size, digest = _copy_hashed(native_source, target)
1537
+ except OSError as error:
1538
+ raise SessionNotFound(
1539
+ f"native transcript not readable: {error.__class__.__name__}",
1540
+ short=doc.get("worker", {}).get("short"),
1541
+ ) from error
1542
+ else:
1543
+ name = "codex-thread.json"
1544
+ source = (native_source.get("historySource")
1545
+ if isinstance(native_source, dict) else None)
1546
+ kind = ("codex-thread-turns-paged" if source == "thread/turns/list"
1547
+ else "codex-thread-items-paged" if source == "thread/items/list"
1548
+ else "codex-thread-read")
1549
+ target = os.path.join(native_dir, name)
1550
+ payload = json.dumps(native_source, indent=2).encode("utf-8")
1551
+ _atomic_write_bytes(target, payload)
1552
+ size, digest = len(payload), hashlib.sha256(payload).hexdigest()
1553
+ artifacts.append(_exported(destination, kind, name, size, digest))
1554
+ elif engine == "claude" and native_source is not None:
1555
+ artifacts = list(doc.get("nativeArtifacts") or [])
1556
+
1557
+ if include_native:
1558
+ for kind, name, source in native_files:
1559
+ target = os.path.join(_native_dir(destination), name)
1560
+ try:
1561
+ size, digest = _copy_hashed(source, target)
1562
+ except FileNotFoundError:
1563
+ # Evidence that disappeared between listing and copying is
1564
+ # simply not claimed. An artifact entry is a promise that the
1565
+ # bytes are there.
1566
+ continue
1567
+ except OSError as error:
1568
+ raise SessionNotFound(
1569
+ f"native {kind} not readable: {error.__class__.__name__}",
1570
+ short=doc.get("worker", {}).get("short"),
1571
+ ) from error
1572
+ artifacts.append(_exported(destination, kind, name, size, digest))
1573
+
1574
+ doc["nativeArtifacts"] = artifacts
1575
+ conversation_path = os.path.join(destination, "conversation.json")
1576
+ _atomic_write_bytes(
1577
+ conversation_path, json.dumps(doc, indent=2).encode("utf-8")
1578
+ )
1579
+ exported = [
1580
+ os.path.join(destination, artifact["path"])
1581
+ for artifact in artifacts if artifact["availability"] == "exported"
1582
+ ]
1583
+ return {
1584
+ "destination": destination,
1585
+ "conversation_path": conversation_path,
1586
+ # The primary native artifact, unchanged; `native_paths` is everything
1587
+ # that was written, in the order `nativeArtifacts` lists it.
1588
+ "native_path": exported[0] if exported else None,
1589
+ "native_paths": exported,
1590
+ }
1591
+
1592
+
1593
+ __all__ = ["SCHEMA_VERSION", "build_claude", "build_codex", "write_export"]