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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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"]
|