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,1339 @@
|
|
|
1
|
+
"""Engine-neutral bounded normalized conversation paging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import copy
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ccx.codex import runtime as codex_runtime
|
|
15
|
+
from ccx.ops import conversation as conv
|
|
16
|
+
from ccx.system.bounded import BoundedValue, bound_value, omit_value
|
|
17
|
+
from ccx.errors import (
|
|
18
|
+
CursorInvalidated,
|
|
19
|
+
InvalidRequest,
|
|
20
|
+
OperationTimeout,
|
|
21
|
+
ProviderProtocolError,
|
|
22
|
+
SessionUnavailable,
|
|
23
|
+
)
|
|
24
|
+
from ccx.system.sanitize import safe_native
|
|
25
|
+
from ccx.claude.transcript import (
|
|
26
|
+
PinnedNativeSource,
|
|
27
|
+
_complete_line_end,
|
|
28
|
+
_has_queued_command_marker,
|
|
29
|
+
_is_local_command_output,
|
|
30
|
+
_iter_transcript,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_CURSOR_PREFIX = "ccxcp2."
|
|
34
|
+
_CURSOR_VERSION = 3
|
|
35
|
+
_DEFAULT_TARGET = 40
|
|
36
|
+
_MAX_TARGET = 100
|
|
37
|
+
|
|
38
|
+
# V1 has a 4 MiB process-response ceiling. Keep the canonical conversation-page
|
|
39
|
+
# result a full MiB below it so the protocol envelope/request id can never push a
|
|
40
|
+
# valid page over the transport guard. This is a CCX policy, not a caller knob.
|
|
41
|
+
PAGE_RESULT_MAX_BYTES = 3 * 1024 * 1024
|
|
42
|
+
_PAGE_FIELD_MAX_BYTES = 64 * 1024
|
|
43
|
+
_PAGE_ITEM_MAX_BYTES = 256 * 1024
|
|
44
|
+
_PAGE_SINGLE_RECORD_MAX_BYTES = PAGE_RESULT_MAX_BYTES - 256 * 1024
|
|
45
|
+
_PROTECTED_STRING_MAX_BYTES = 4 * 1024
|
|
46
|
+
_PAGE_IDENTITY_STUB_MAX_ITEMS = 64
|
|
47
|
+
_PROTECTED_ITEM_FIELDS = frozenset({
|
|
48
|
+
"kind", "id", "nativeType", "name", "status", "callId",
|
|
49
|
+
"clientId", "origin", "model", "phase", "unpaired",
|
|
50
|
+
})
|
|
51
|
+
_PROTECTED_SUMMARY_FIELDS = frozenset({"verb", "target", "description"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _encode_cursor(payload: dict[str, Any]) -> str:
|
|
55
|
+
raw = json.dumps(
|
|
56
|
+
{"v": _CURSOR_VERSION, **payload},
|
|
57
|
+
sort_keys=True,
|
|
58
|
+
separators=(",", ":"),
|
|
59
|
+
ensure_ascii=False,
|
|
60
|
+
).encode("utf-8")
|
|
61
|
+
return _CURSOR_PREFIX + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _decode_cursor(cursor: str, *, engine: str, worker_id: str) -> dict[str, Any]:
|
|
65
|
+
if not isinstance(cursor, str) or not cursor.startswith(_CURSOR_PREFIX):
|
|
66
|
+
raise InvalidRequest("invalid conversation page cursor")
|
|
67
|
+
token = cursor[len(_CURSOR_PREFIX):]
|
|
68
|
+
try:
|
|
69
|
+
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
|
70
|
+
payload = json.loads(raw)
|
|
71
|
+
except (ValueError, UnicodeDecodeError, binascii.Error) as exc:
|
|
72
|
+
raise InvalidRequest("invalid conversation page cursor") from exc
|
|
73
|
+
if not isinstance(payload, dict) or payload.get("v") != _CURSOR_VERSION:
|
|
74
|
+
raise CursorInvalidated(
|
|
75
|
+
"conversation page cursor version is no longer valid", short=worker_id[:8]
|
|
76
|
+
)
|
|
77
|
+
if payload.get("engine") != engine or payload.get("worker_id") != worker_id:
|
|
78
|
+
raise InvalidRequest("conversation page cursor belongs to another worker")
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _validate_options(
|
|
83
|
+
source_record_target: int,
|
|
84
|
+
max_tool_result_bytes: int | None,
|
|
85
|
+
timeout: float,
|
|
86
|
+
) -> None:
|
|
87
|
+
if (
|
|
88
|
+
isinstance(source_record_target, bool)
|
|
89
|
+
or not isinstance(source_record_target, int)
|
|
90
|
+
or not 1 <= source_record_target <= _MAX_TARGET
|
|
91
|
+
):
|
|
92
|
+
raise InvalidRequest(
|
|
93
|
+
f"source_record_target must be between 1 and {_MAX_TARGET}"
|
|
94
|
+
)
|
|
95
|
+
conv._requested_label(max_tool_result_bytes)
|
|
96
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
97
|
+
raise InvalidRequest("conversation page timeout must be greater than zero")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _encoded_size(value: Any) -> int:
|
|
101
|
+
return len(json.dumps(
|
|
102
|
+
value, ensure_ascii=False, allow_nan=False, separators=(",", ":"), sort_keys=True
|
|
103
|
+
).encode("utf-8"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _nested_path(prefix: str, relative: str) -> str:
|
|
107
|
+
if not relative or relative == "$":
|
|
108
|
+
return prefix
|
|
109
|
+
if relative.startswith("["):
|
|
110
|
+
return f"{prefix}{relative}"
|
|
111
|
+
return f"{prefix}.{relative}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _truncation(
|
|
115
|
+
path: str,
|
|
116
|
+
bounded: BoundedValue,
|
|
117
|
+
*,
|
|
118
|
+
source: str = "ccx-page",
|
|
119
|
+
force_omitted: bool = False,
|
|
120
|
+
original_byte_count: int | None = None,
|
|
121
|
+
returned_byte_count: int | None = None,
|
|
122
|
+
sha256: str | None = None,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
omitted = force_omitted or bounded.omitted
|
|
125
|
+
value = {
|
|
126
|
+
"path": path,
|
|
127
|
+
"source": source,
|
|
128
|
+
"original_byte_count": (
|
|
129
|
+
bounded.original_byte_count if original_byte_count is None else original_byte_count
|
|
130
|
+
),
|
|
131
|
+
"returned_byte_count": (
|
|
132
|
+
(0 if omitted else bounded.returned_byte_count)
|
|
133
|
+
if returned_byte_count is None
|
|
134
|
+
else returned_byte_count
|
|
135
|
+
),
|
|
136
|
+
"sha256": bounded.sha256 if sha256 is None else sha256,
|
|
137
|
+
}
|
|
138
|
+
if omitted:
|
|
139
|
+
value["omitted"] = True
|
|
140
|
+
if bounded.changed_paths:
|
|
141
|
+
value["paths"] = [
|
|
142
|
+
_nested_path(path, relative) for relative in bounded.changed_paths
|
|
143
|
+
]
|
|
144
|
+
if bounded.changed_path_count:
|
|
145
|
+
value["changed_path_count"] = bounded.changed_path_count
|
|
146
|
+
if bounded.omitted_count:
|
|
147
|
+
value["omitted_path_count"] = bounded.omitted_count
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _existing_tool_result_truncation(result: dict[str, Any]) -> dict[str, Any] | None:
|
|
152
|
+
if result.get("truncated") is not True:
|
|
153
|
+
return None
|
|
154
|
+
original = result.get("originalByteCount")
|
|
155
|
+
returned = result.get("returnedByteCount")
|
|
156
|
+
digest = result.get("contentSha256")
|
|
157
|
+
if (
|
|
158
|
+
not isinstance(original, int)
|
|
159
|
+
or isinstance(original, bool)
|
|
160
|
+
or not isinstance(returned, int)
|
|
161
|
+
or isinstance(returned, bool)
|
|
162
|
+
or not isinstance(digest, str)
|
|
163
|
+
or not digest
|
|
164
|
+
):
|
|
165
|
+
return None
|
|
166
|
+
return {
|
|
167
|
+
"path": "result.content",
|
|
168
|
+
"source": "max_tool_result_bytes",
|
|
169
|
+
"original_byte_count": original,
|
|
170
|
+
"returned_byte_count": returned,
|
|
171
|
+
"sha256": digest,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _project_protected(
|
|
176
|
+
key: str, entry: Any, truncations: list[dict[str, Any]]
|
|
177
|
+
) -> Any:
|
|
178
|
+
bounded = bound_value(entry, _PROTECTED_STRING_MAX_BYTES)
|
|
179
|
+
if bounded.truncated:
|
|
180
|
+
truncations.append(_truncation(key, bounded))
|
|
181
|
+
return bounded.value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _project_summary(entry: Any, truncations: list[dict[str, Any]]) -> Any:
|
|
185
|
+
if not isinstance(entry, dict):
|
|
186
|
+
return _project_protected("summary", entry, truncations)
|
|
187
|
+
value: dict[str, Any] = {}
|
|
188
|
+
for key, field in entry.items():
|
|
189
|
+
if field is None:
|
|
190
|
+
continue
|
|
191
|
+
limit = (
|
|
192
|
+
_PROTECTED_STRING_MAX_BYTES
|
|
193
|
+
if key in _PROTECTED_SUMMARY_FIELDS
|
|
194
|
+
else _PAGE_FIELD_MAX_BYTES
|
|
195
|
+
)
|
|
196
|
+
bounded = bound_value(field, limit)
|
|
197
|
+
if not bounded.omitted:
|
|
198
|
+
value[key] = bounded.value
|
|
199
|
+
if bounded.truncated:
|
|
200
|
+
truncations.append(_truncation(f"summary.{key}", bounded))
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _project_item_once(item: dict[str, Any], field_limit: int) -> dict[str, Any]:
|
|
205
|
+
value: dict[str, Any] = {}
|
|
206
|
+
truncations: list[dict[str, Any]] = []
|
|
207
|
+
for key, entry in item.items():
|
|
208
|
+
if key in ("ordinal", "startedAtMs", "completedAtMs", "timingSource"):
|
|
209
|
+
continue
|
|
210
|
+
if entry is None:
|
|
211
|
+
continue
|
|
212
|
+
if key in _PROTECTED_ITEM_FIELDS:
|
|
213
|
+
value[key] = _project_protected(key, entry, truncations)
|
|
214
|
+
continue
|
|
215
|
+
if key == "summary":
|
|
216
|
+
value[key] = _project_summary(entry, truncations)
|
|
217
|
+
continue
|
|
218
|
+
if key == "result" and isinstance(entry, dict):
|
|
219
|
+
projected = copy.deepcopy(entry)
|
|
220
|
+
existing = _existing_tool_result_truncation(projected)
|
|
221
|
+
if existing is not None:
|
|
222
|
+
truncations.append(existing)
|
|
223
|
+
if projected.get("content") is not None:
|
|
224
|
+
bounded = bound_value(projected["content"], field_limit)
|
|
225
|
+
if bounded.omitted:
|
|
226
|
+
projected.pop("content", None)
|
|
227
|
+
else:
|
|
228
|
+
projected["content"] = bounded.value
|
|
229
|
+
if bounded.truncated:
|
|
230
|
+
if existing is not None:
|
|
231
|
+
truncations.append({
|
|
232
|
+
"path": "result.content",
|
|
233
|
+
"source": "ccx-page",
|
|
234
|
+
"original_byte_count": existing["original_byte_count"],
|
|
235
|
+
"returned_byte_count": (
|
|
236
|
+
0 if bounded.omitted else bounded.returned_byte_count
|
|
237
|
+
),
|
|
238
|
+
"sha256": existing["sha256"],
|
|
239
|
+
**({"omitted": True} if bounded.omitted else {}),
|
|
240
|
+
})
|
|
241
|
+
projected["originalByteCount"] = existing["original_byte_count"]
|
|
242
|
+
projected["contentSha256"] = existing["sha256"]
|
|
243
|
+
else:
|
|
244
|
+
truncations.append(_truncation("result.content", bounded))
|
|
245
|
+
projected["originalByteCount"] = bounded.original_byte_count
|
|
246
|
+
projected["contentSha256"] = bounded.sha256
|
|
247
|
+
projected["truncated"] = True
|
|
248
|
+
projected["returnedByteCount"] = (
|
|
249
|
+
0 if bounded.omitted else bounded.returned_byte_count
|
|
250
|
+
)
|
|
251
|
+
value[key] = projected
|
|
252
|
+
continue
|
|
253
|
+
bounded = bound_value(entry, field_limit)
|
|
254
|
+
if not bounded.omitted:
|
|
255
|
+
value[key] = bounded.value
|
|
256
|
+
if bounded.truncated:
|
|
257
|
+
truncations.append(_truncation(key, bounded))
|
|
258
|
+
if truncations:
|
|
259
|
+
value["truncations"] = truncations
|
|
260
|
+
return value
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _minimal_public_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
264
|
+
value: dict[str, Any] = {}
|
|
265
|
+
truncations: list[dict[str, Any]] = []
|
|
266
|
+
for key, entry in item.items():
|
|
267
|
+
if key in _PROTECTED_ITEM_FIELDS and entry is not None:
|
|
268
|
+
value[key] = _project_protected(key, entry, truncations)
|
|
269
|
+
if item.get("summary") is not None:
|
|
270
|
+
value["summary"] = _project_summary(item["summary"], truncations)
|
|
271
|
+
|
|
272
|
+
for key, entry in item.items():
|
|
273
|
+
if (
|
|
274
|
+
key in _PROTECTED_ITEM_FIELDS
|
|
275
|
+
or key == "summary"
|
|
276
|
+
or key in ("ordinal", "startedAtMs", "completedAtMs", "timingSource")
|
|
277
|
+
or entry is None
|
|
278
|
+
):
|
|
279
|
+
continue
|
|
280
|
+
if key == "result" and isinstance(entry, dict):
|
|
281
|
+
existing = _existing_tool_result_truncation(entry)
|
|
282
|
+
if existing is not None:
|
|
283
|
+
truncations.append(existing)
|
|
284
|
+
content = entry.get("content")
|
|
285
|
+
if content is not None:
|
|
286
|
+
if existing is not None:
|
|
287
|
+
truncations.append({
|
|
288
|
+
"path": "result.content",
|
|
289
|
+
"source": "ccx-page",
|
|
290
|
+
"original_byte_count": existing["original_byte_count"],
|
|
291
|
+
"returned_byte_count": 0,
|
|
292
|
+
"sha256": existing["sha256"],
|
|
293
|
+
"omitted": True,
|
|
294
|
+
})
|
|
295
|
+
else:
|
|
296
|
+
truncations.append(_truncation(
|
|
297
|
+
"result.content", omit_value(content), force_omitted=True
|
|
298
|
+
))
|
|
299
|
+
else:
|
|
300
|
+
truncations.append(_truncation(
|
|
301
|
+
"result", omit_value(entry), force_omitted=True
|
|
302
|
+
))
|
|
303
|
+
continue
|
|
304
|
+
truncations.append(_truncation(
|
|
305
|
+
key, omit_value(entry), force_omitted=True
|
|
306
|
+
))
|
|
307
|
+
if truncations:
|
|
308
|
+
value["truncations"] = truncations
|
|
309
|
+
return value
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _public_item(
|
|
313
|
+
item: dict[str, Any], *, item_budget: int = _PAGE_ITEM_MAX_BYTES
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
variable = sum(
|
|
316
|
+
1
|
|
317
|
+
for key, entry in item.items()
|
|
318
|
+
if key not in _PROTECTED_ITEM_FIELDS
|
|
319
|
+
and key != "summary"
|
|
320
|
+
and key not in ("ordinal", "startedAtMs", "completedAtMs", "timingSource")
|
|
321
|
+
and entry is not None
|
|
322
|
+
)
|
|
323
|
+
field_limit = min(
|
|
324
|
+
_PAGE_FIELD_MAX_BYTES,
|
|
325
|
+
max(256, item_budget // max(1, variable + 1)),
|
|
326
|
+
)
|
|
327
|
+
while True:
|
|
328
|
+
value = _project_item_once(item, field_limit)
|
|
329
|
+
if _encoded_size(value) <= item_budget:
|
|
330
|
+
return value
|
|
331
|
+
if field_limit <= 256:
|
|
332
|
+
break
|
|
333
|
+
field_limit = max(256, field_limit // 2)
|
|
334
|
+
return _minimal_public_item(item)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _project_record_identity(
|
|
338
|
+
path: str, value: Any, truncations: list[dict[str, Any]]
|
|
339
|
+
) -> Any:
|
|
340
|
+
if not isinstance(value, str):
|
|
341
|
+
return copy.deepcopy(value)
|
|
342
|
+
bounded = bound_value(value, _PROTECTED_STRING_MAX_BYTES)
|
|
343
|
+
if bounded.truncated:
|
|
344
|
+
truncations.append(_truncation(path, bounded))
|
|
345
|
+
return bounded.value
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _record_value(
|
|
349
|
+
*,
|
|
350
|
+
turn_id: Any,
|
|
351
|
+
source_record_id: Any,
|
|
352
|
+
timestamp_ms: Any,
|
|
353
|
+
duration_ms: Any,
|
|
354
|
+
items: list[dict[str, Any]],
|
|
355
|
+
usage: dict[str, Any] | None,
|
|
356
|
+
record_truncations: list[dict[str, Any]],
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
value = {
|
|
359
|
+
"turn_id": turn_id,
|
|
360
|
+
"source_record_id": source_record_id,
|
|
361
|
+
"timestamp_ms": timestamp_ms,
|
|
362
|
+
"duration_ms": duration_ms,
|
|
363
|
+
"usage": usage,
|
|
364
|
+
"items": items,
|
|
365
|
+
}
|
|
366
|
+
if record_truncations:
|
|
367
|
+
value["truncations"] = record_truncations
|
|
368
|
+
return value
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _public_record(
|
|
372
|
+
record: dict[str, Any], counters: conv._Counters | None = None
|
|
373
|
+
) -> dict[str, Any] | None:
|
|
374
|
+
raw_items = [item for item in (record.get("items") or []) if isinstance(item, dict)]
|
|
375
|
+
raw_usage = record.get("usage")
|
|
376
|
+
if not raw_items and not raw_usage:
|
|
377
|
+
return None
|
|
378
|
+
counters = counters or conv._Counters()
|
|
379
|
+
|
|
380
|
+
record_truncations: list[dict[str, Any]] = []
|
|
381
|
+
turn_id = _project_record_identity(
|
|
382
|
+
"turn_id", record.get("turn_id"), record_truncations
|
|
383
|
+
)
|
|
384
|
+
source_record_id = _project_record_identity(
|
|
385
|
+
"source_record_id", record.get("source_record_id"), record_truncations
|
|
386
|
+
)
|
|
387
|
+
timestamp_ms = record.get("timestamp_ms")
|
|
388
|
+
duration_ms = record.get("duration_ms")
|
|
389
|
+
usage = copy.deepcopy(raw_usage) if isinstance(raw_usage, dict) else None
|
|
390
|
+
if isinstance(raw_usage, dict):
|
|
391
|
+
bounded_usage = bound_value(raw_usage, _PAGE_FIELD_MAX_BYTES)
|
|
392
|
+
usage = None if bounded_usage.omitted else bounded_usage.value
|
|
393
|
+
if bounded_usage.truncated:
|
|
394
|
+
record_truncations.append(_truncation("usage", bounded_usage))
|
|
395
|
+
|
|
396
|
+
fair_item_budget = max(
|
|
397
|
+
2048, (_PAGE_SINGLE_RECORD_MAX_BYTES - 32 * 1024) // max(1, len(raw_items))
|
|
398
|
+
)
|
|
399
|
+
# Start with a richer per-item preview than the strict fair share, then let
|
|
400
|
+
# the record-level loop shrink it only when the aggregate record actually
|
|
401
|
+
# needs that reduction. This keeps the shrink loop meaningful while bounding
|
|
402
|
+
# it to at most a small number of passes for high-cardinality records.
|
|
403
|
+
item_budget = min(_PAGE_ITEM_MAX_BYTES, fair_item_budget * 2)
|
|
404
|
+
while True:
|
|
405
|
+
items = [_public_item(item, item_budget=item_budget) for item in raw_items]
|
|
406
|
+
value = _record_value(
|
|
407
|
+
turn_id=turn_id, source_record_id=source_record_id,
|
|
408
|
+
timestamp_ms=timestamp_ms, duration_ms=duration_ms,
|
|
409
|
+
items=items, usage=usage, record_truncations=list(record_truncations),
|
|
410
|
+
)
|
|
411
|
+
if _encoded_size(value) <= _PAGE_SINGLE_RECORD_MAX_BYTES:
|
|
412
|
+
return value
|
|
413
|
+
if item_budget <= 2048:
|
|
414
|
+
break
|
|
415
|
+
item_budget = max(2048, item_budget // 2)
|
|
416
|
+
|
|
417
|
+
# Pathological child cardinality/protected metadata must not make one source
|
|
418
|
+
# record a permanent cursor barrier. Keep a bounded source-order prefix of
|
|
419
|
+
# identity-only stubs and explicitly count every omitted normalized item.
|
|
420
|
+
stub_count = min(len(raw_items), _PAGE_IDENTITY_STUB_MAX_ITEMS)
|
|
421
|
+
while True:
|
|
422
|
+
stubs = [_minimal_public_item(item) for item in raw_items[:stub_count]]
|
|
423
|
+
value = _record_value(
|
|
424
|
+
turn_id=turn_id, source_record_id=source_record_id,
|
|
425
|
+
timestamp_ms=timestamp_ms, duration_ms=duration_ms,
|
|
426
|
+
items=stubs, usage=usage, record_truncations=list(record_truncations),
|
|
427
|
+
)
|
|
428
|
+
if _encoded_size(value) <= _PAGE_SINGLE_RECORD_MAX_BYTES or stub_count == 0:
|
|
429
|
+
break
|
|
430
|
+
stub_count //= 2
|
|
431
|
+
|
|
432
|
+
omitted = len(raw_items) - stub_count
|
|
433
|
+
if omitted:
|
|
434
|
+
counters.omitted += omitted
|
|
435
|
+
counters.warn(
|
|
436
|
+
f"{omitted} normalized item(s) omitted from one conversation source "
|
|
437
|
+
"record to fit the bounded page"
|
|
438
|
+
)
|
|
439
|
+
return value
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _truncated_field_count(records: list[dict[str, Any]]) -> int:
|
|
443
|
+
return sum(
|
|
444
|
+
len(record.get("truncations") or [])
|
|
445
|
+
+ sum(
|
|
446
|
+
len(item.get("truncations") or [])
|
|
447
|
+
for item in (record.get("items") or [])
|
|
448
|
+
if isinstance(item, dict)
|
|
449
|
+
)
|
|
450
|
+
for record in records
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _bounded_warnings(counters: conv._Counters) -> tuple[list[str], int]:
|
|
455
|
+
warnings: list[str] = []
|
|
456
|
+
truncated = 0
|
|
457
|
+
for warning in counters.warning_list():
|
|
458
|
+
bounded = bound_value(str(warning), _PROTECTED_STRING_MAX_BYTES)
|
|
459
|
+
warnings.append(str(bounded.value))
|
|
460
|
+
if bounded.truncated:
|
|
461
|
+
truncated += 1
|
|
462
|
+
return warnings, truncated
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _last_resort_record_stub(
|
|
466
|
+
record: dict[str, Any], counters: conv._Counters
|
|
467
|
+
) -> dict[str, Any]:
|
|
468
|
+
value = copy.deepcopy(record)
|
|
469
|
+
omitted = len(value.get("items") or [])
|
|
470
|
+
value["items"] = []
|
|
471
|
+
if omitted:
|
|
472
|
+
counters.omitted += omitted
|
|
473
|
+
counters.warn(
|
|
474
|
+
f"{omitted} projected item(s) omitted from one conversation source "
|
|
475
|
+
"record at the final page-budget fence"
|
|
476
|
+
)
|
|
477
|
+
return value
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _page_completeness(
|
|
481
|
+
counters: conv._Counters,
|
|
482
|
+
*,
|
|
483
|
+
requested_source_record_target: int,
|
|
484
|
+
returned_source_record_count: int,
|
|
485
|
+
response_budget_limited: bool,
|
|
486
|
+
truncated_field_count: int,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
if counters.malformed or counters.omitted:
|
|
489
|
+
status = "partial"
|
|
490
|
+
elif counters.truncated or truncated_field_count or response_budget_limited:
|
|
491
|
+
status = "bounded"
|
|
492
|
+
else:
|
|
493
|
+
status = "complete"
|
|
494
|
+
warnings, warning_truncated_count = _bounded_warnings(counters)
|
|
495
|
+
return {
|
|
496
|
+
"status": status,
|
|
497
|
+
"truncated_item_count": counters.truncated,
|
|
498
|
+
"truncated_field_count": truncated_field_count,
|
|
499
|
+
"omitted_item_count": counters.omitted,
|
|
500
|
+
"malformed_line_count": counters.malformed,
|
|
501
|
+
"requested_source_record_target": requested_source_record_target,
|
|
502
|
+
"returned_source_record_count": returned_source_record_count,
|
|
503
|
+
"response_budget_limited": response_budget_limited,
|
|
504
|
+
"warning_truncated_count": warning_truncated_count,
|
|
505
|
+
"warnings": warnings,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _page_result(
|
|
510
|
+
worker: str,
|
|
511
|
+
records: list[dict[str, Any]],
|
|
512
|
+
older_cursor: str | None,
|
|
513
|
+
counters: conv._Counters,
|
|
514
|
+
*,
|
|
515
|
+
requested_source_record_target: int,
|
|
516
|
+
response_budget_limited: bool,
|
|
517
|
+
) -> dict[str, Any]:
|
|
518
|
+
return {
|
|
519
|
+
"worker": worker,
|
|
520
|
+
"records": records,
|
|
521
|
+
"older_cursor": older_cursor,
|
|
522
|
+
"completeness": _page_completeness(
|
|
523
|
+
counters,
|
|
524
|
+
requested_source_record_target=requested_source_record_target,
|
|
525
|
+
returned_source_record_count=len(records),
|
|
526
|
+
response_budget_limited=response_budget_limited,
|
|
527
|
+
truncated_field_count=_truncated_field_count(records),
|
|
528
|
+
),
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _event_fingerprint(evt: dict[str, Any]) -> str:
|
|
533
|
+
return hashlib.sha256(
|
|
534
|
+
json.dumps(
|
|
535
|
+
safe_native(evt),
|
|
536
|
+
sort_keys=True,
|
|
537
|
+
separators=(",", ":"),
|
|
538
|
+
ensure_ascii=False,
|
|
539
|
+
default=str,
|
|
540
|
+
).encode("utf-8")
|
|
541
|
+
).hexdigest()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _ordinary_user_boundary(evt: dict[str, Any]) -> bool:
|
|
545
|
+
if evt.get("type") != "user" or evt.get("isMeta") or evt.get("isCompactSummary"):
|
|
546
|
+
return False
|
|
547
|
+
if _has_queued_command_marker(evt):
|
|
548
|
+
return False
|
|
549
|
+
content = (evt.get("message") or {}).get("content")
|
|
550
|
+
if isinstance(content, str):
|
|
551
|
+
if conv._command_from_text(content) is not None:
|
|
552
|
+
return False
|
|
553
|
+
return bool(content.strip()) and not _is_local_command_output(content)
|
|
554
|
+
if not isinstance(content, list):
|
|
555
|
+
return False
|
|
556
|
+
for block in content:
|
|
557
|
+
if not isinstance(block, dict):
|
|
558
|
+
continue
|
|
559
|
+
kind = block.get("type")
|
|
560
|
+
if kind == "tool_result":
|
|
561
|
+
continue
|
|
562
|
+
if kind == "text":
|
|
563
|
+
if (block.get("text") or "").strip():
|
|
564
|
+
return True
|
|
565
|
+
continue
|
|
566
|
+
if kind is not None:
|
|
567
|
+
return True
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _boundary_from_event(evt: dict[str, Any]) -> dict[str, Any] | None:
|
|
572
|
+
offset = evt.get("_ccx_start_offset")
|
|
573
|
+
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
|
574
|
+
return None
|
|
575
|
+
return {
|
|
576
|
+
"offset": offset,
|
|
577
|
+
"source_record_id": evt.get("uuid"),
|
|
578
|
+
"fingerprint": _event_fingerprint(evt),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _validate_anchor(
|
|
583
|
+
path: str,
|
|
584
|
+
candidate_offset: int,
|
|
585
|
+
*,
|
|
586
|
+
before_offset: int,
|
|
587
|
+
) -> dict[str, Any] | None:
|
|
588
|
+
for evt in _iter_transcript(
|
|
589
|
+
path, after_offset=candidate_offset, before_offset=before_offset
|
|
590
|
+
):
|
|
591
|
+
if not conv._claude_page_bearing(evt):
|
|
592
|
+
continue
|
|
593
|
+
if not _ordinary_user_boundary(evt):
|
|
594
|
+
return None
|
|
595
|
+
return _boundary_from_event(evt)
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _event_at(path: str, offset: int, *, before_offset: int) -> dict[str, Any] | None:
|
|
600
|
+
for evt in _iter_transcript(path, after_offset=offset, before_offset=before_offset):
|
|
601
|
+
if evt.get("_ccx_start_offset") != offset:
|
|
602
|
+
return None
|
|
603
|
+
return evt
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _context_end_offset(
|
|
608
|
+
path: str,
|
|
609
|
+
cut_offset: int,
|
|
610
|
+
*,
|
|
611
|
+
source_eof: int,
|
|
612
|
+
) -> int:
|
|
613
|
+
"""Upper normalization fence for the history immediately before a page cut.
|
|
614
|
+
|
|
615
|
+
A real user prompt starts a fresh semantic group. To render records just
|
|
616
|
+
before a cut, look ahead only until the next such prompt; that is enough to
|
|
617
|
+
pair tool results and assistant fragments from the same turn without
|
|
618
|
+
reparsing every newer turn through current EOF.
|
|
619
|
+
"""
|
|
620
|
+
for evt in _iter_transcript(
|
|
621
|
+
path, after_offset=cut_offset, before_offset=source_eof
|
|
622
|
+
):
|
|
623
|
+
start = evt.get("_ccx_start_offset")
|
|
624
|
+
if not isinstance(start, int) or start < cut_offset:
|
|
625
|
+
continue
|
|
626
|
+
if _ordinary_user_boundary(evt):
|
|
627
|
+
return start
|
|
628
|
+
return source_eof
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _record_matches(
|
|
632
|
+
path: str,
|
|
633
|
+
offset: int,
|
|
634
|
+
source_record_id: Any,
|
|
635
|
+
fingerprint: Any,
|
|
636
|
+
*,
|
|
637
|
+
before_offset: int,
|
|
638
|
+
) -> bool:
|
|
639
|
+
evt = _event_at(path, offset, before_offset=before_offset)
|
|
640
|
+
if evt is None:
|
|
641
|
+
return False
|
|
642
|
+
actual_id = evt.get("uuid")
|
|
643
|
+
if isinstance(source_record_id, str) and source_record_id:
|
|
644
|
+
return actual_id == source_record_id
|
|
645
|
+
return isinstance(fingerprint, str) and _event_fingerprint(evt) == fingerprint
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _reverse_lines(
|
|
649
|
+
path: str | PinnedNativeSource, *, before_offset: int,
|
|
650
|
+
block_size: int = 64 * 1024,
|
|
651
|
+
):
|
|
652
|
+
"""Yield (start offset, raw line) backwards from a certified source fence.
|
|
653
|
+
|
|
654
|
+
The pinned reader uses pread, not the mutable native pathname or shared fd
|
|
655
|
+
seek position. A row longer than a read block is collected piece by piece
|
|
656
|
+
and joined once, so a multi-MB row costs its size, not its size squared.
|
|
657
|
+
"""
|
|
658
|
+
try:
|
|
659
|
+
reader = (path.open_reader() if isinstance(path, PinnedNativeSource)
|
|
660
|
+
else open(path, "rb"))
|
|
661
|
+
with reader as handle:
|
|
662
|
+
position = before_offset
|
|
663
|
+
pieces: list[bytes] = [] # the current row's tail, newest piece first
|
|
664
|
+
while position > 0:
|
|
665
|
+
size = min(block_size, position)
|
|
666
|
+
position -= size
|
|
667
|
+
handle.seek(position)
|
|
668
|
+
data = handle.read(size)
|
|
669
|
+
cut = len(data)
|
|
670
|
+
while (newline := data.rfind(b"\n", 0, cut)) >= 0:
|
|
671
|
+
pieces.append(data[newline + 1:cut])
|
|
672
|
+
raw = b"".join(reversed(pieces))
|
|
673
|
+
pieces = []
|
|
674
|
+
if raw:
|
|
675
|
+
yield position + newline + 1, raw
|
|
676
|
+
cut = newline
|
|
677
|
+
pieces.append(data[:cut])
|
|
678
|
+
raw = b"".join(reversed(pieces))
|
|
679
|
+
if raw:
|
|
680
|
+
yield 0, raw
|
|
681
|
+
except FileNotFoundError:
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _reverse_boundary_events(path: str | PinnedNativeSource, *, before_offset: int):
|
|
686
|
+
"""Find ordinary-user starts newest-first, without hashing skipped rows."""
|
|
687
|
+
for start, raw in _reverse_lines(path, before_offset=before_offset):
|
|
688
|
+
raw = raw.strip()
|
|
689
|
+
if not raw:
|
|
690
|
+
continue
|
|
691
|
+
try:
|
|
692
|
+
evt = json.loads(raw)
|
|
693
|
+
except (ValueError, UnicodeDecodeError):
|
|
694
|
+
continue
|
|
695
|
+
if isinstance(evt, dict):
|
|
696
|
+
if evt.get("isSidechain") is True:
|
|
697
|
+
continue
|
|
698
|
+
evt["_ccx_start_offset"] = start
|
|
699
|
+
if _ordinary_user_boundary(evt):
|
|
700
|
+
yield evt
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def claude_tail_offset(path: str | PinnedNativeSource, *, before_offset: int,
|
|
704
|
+
turns: int) -> int:
|
|
705
|
+
"""Where the newest ``turns`` user turns start, reading backwards; 0 if fewer."""
|
|
706
|
+
seen = 0
|
|
707
|
+
for evt in _reverse_boundary_events(path, before_offset=before_offset):
|
|
708
|
+
seen += 1
|
|
709
|
+
if seen >= turns:
|
|
710
|
+
return evt["_ccx_start_offset"]
|
|
711
|
+
return 0
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _normalize_claude_window(
|
|
715
|
+
path: str,
|
|
716
|
+
*,
|
|
717
|
+
after_offset: int,
|
|
718
|
+
before_offset: int,
|
|
719
|
+
max_tool_result_bytes: int | None,
|
|
720
|
+
) -> tuple[list[dict[str, Any]], conv._Counters]:
|
|
721
|
+
raw_stats: dict[str, int] = {"malformedLines": 0}
|
|
722
|
+
events = list(
|
|
723
|
+
_iter_transcript(
|
|
724
|
+
path,
|
|
725
|
+
raw_stats,
|
|
726
|
+
after_offset=after_offset,
|
|
727
|
+
before_offset=before_offset,
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
counters = conv._Counters()
|
|
731
|
+
counters.malformed = raw_stats.get("malformedLines", 0)
|
|
732
|
+
if counters.malformed:
|
|
733
|
+
counters.warn(
|
|
734
|
+
f"{counters.malformed} malformed native JSONL line(s) skipped"
|
|
735
|
+
)
|
|
736
|
+
_turns, _model, records = conv._normalize_claude_events(
|
|
737
|
+
events, counters, max_tool_result_bytes
|
|
738
|
+
)
|
|
739
|
+
return records, counters
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _bearing_before(
|
|
743
|
+
records: list[dict[str, Any]],
|
|
744
|
+
cut_offset: int,
|
|
745
|
+
) -> list[dict[str, Any]]:
|
|
746
|
+
return [
|
|
747
|
+
record for record in records
|
|
748
|
+
if record.get("_ccx_page_bearing")
|
|
749
|
+
and isinstance(record.get("_ccx_start_offset"), int)
|
|
750
|
+
and record["_ccx_start_offset"] < cut_offset
|
|
751
|
+
]
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _choose_claude_window(
|
|
755
|
+
path: str,
|
|
756
|
+
source: dict[str, int],
|
|
757
|
+
*,
|
|
758
|
+
cut_offset: int,
|
|
759
|
+
normalization_end_offset: int,
|
|
760
|
+
target: int,
|
|
761
|
+
max_tool_result_bytes: int | None,
|
|
762
|
+
anchor_hint: dict[str, Any] | None = None,
|
|
763
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]], conv._Counters]:
|
|
764
|
+
def normalize_from(boundary: dict[str, Any]):
|
|
765
|
+
records, counters = _normalize_claude_window(
|
|
766
|
+
path,
|
|
767
|
+
after_offset=boundary["offset"],
|
|
768
|
+
before_offset=normalization_end_offset,
|
|
769
|
+
max_tool_result_bytes=max_tool_result_bytes,
|
|
770
|
+
)
|
|
771
|
+
return _bearing_before(records, cut_offset), counters
|
|
772
|
+
|
|
773
|
+
# Older-page cursors carry a validated normalization anchor.
|
|
774
|
+
if (
|
|
775
|
+
isinstance(anchor_hint, dict)
|
|
776
|
+
and isinstance(anchor_hint.get("offset"), int)
|
|
777
|
+
and 0 <= anchor_hint["offset"] < cut_offset
|
|
778
|
+
):
|
|
779
|
+
eligible, counters = normalize_from(anchor_hint)
|
|
780
|
+
if len(eligible) >= target or anchor_hint["offset"] == 0:
|
|
781
|
+
return anchor_hint, eligible, counters
|
|
782
|
+
|
|
783
|
+
# Probe the same 4, 8, 16... user starts as the old forward full scan, but
|
|
784
|
+
# stop reading the pinned source as soon as a window has enough records.
|
|
785
|
+
# If fewer than the next probe distance exist, also try the oldest start.
|
|
786
|
+
distance = 4
|
|
787
|
+
count = 0
|
|
788
|
+
last_probed = 0
|
|
789
|
+
oldest = None
|
|
790
|
+
for evt in _reverse_boundary_events(path, before_offset=cut_offset):
|
|
791
|
+
count += 1
|
|
792
|
+
oldest = evt
|
|
793
|
+
if count != distance:
|
|
794
|
+
continue
|
|
795
|
+
eligible, counters = normalize_from({"offset": evt["_ccx_start_offset"]})
|
|
796
|
+
if len(eligible) >= target:
|
|
797
|
+
return _boundary_from_event(evt), eligible, counters
|
|
798
|
+
last_probed = count
|
|
799
|
+
distance *= 2
|
|
800
|
+
if oldest is not None and count != last_probed:
|
|
801
|
+
eligible, counters = normalize_from({"offset": oldest["_ccx_start_offset"]})
|
|
802
|
+
if len(eligible) >= target:
|
|
803
|
+
return _boundary_from_event(oldest), eligible, counters
|
|
804
|
+
|
|
805
|
+
zero = {"offset": 0, "source_record_id": None, "fingerprint": None}
|
|
806
|
+
eligible, counters = normalize_from(zero)
|
|
807
|
+
return zero, eligible, counters
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _source_transition_is_append_only(
|
|
811
|
+
before: dict[str, int],
|
|
812
|
+
after: dict[str, int],
|
|
813
|
+
) -> bool:
|
|
814
|
+
"""Whether the after snapshot is compatible with an as-of-before EOF read.
|
|
815
|
+
|
|
816
|
+
Claude's native transcript is append-only in ordinary operation. Growth
|
|
817
|
+
beyond the snapshotted complete-line EOF is harmless because every bounded
|
|
818
|
+
parser is fenced at that earlier EOF. Replacement, truncation, or a same-size
|
|
819
|
+
rewrite invalidates the read.
|
|
820
|
+
"""
|
|
821
|
+
if before["dev"] != after["dev"] or before["ino"] != after["ino"]:
|
|
822
|
+
return False
|
|
823
|
+
if after["eof"] < before["eof"] or after["size"] < before["eof"]:
|
|
824
|
+
return False
|
|
825
|
+
if after["eof"] > before["eof"]:
|
|
826
|
+
return True
|
|
827
|
+
if after["size"] > before["size"]:
|
|
828
|
+
# A partial trailing append need not advance the complete-line EOF yet.
|
|
829
|
+
return True
|
|
830
|
+
if after["size"] != before["size"]:
|
|
831
|
+
return False
|
|
832
|
+
return after["mtime_ns"] == before["mtime_ns"]
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _unstable_source_error(short: str, *, cursor: bool) -> Exception:
|
|
836
|
+
if cursor:
|
|
837
|
+
return CursorInvalidated(
|
|
838
|
+
"conversation page source changed during the read; restart paging",
|
|
839
|
+
short=short,
|
|
840
|
+
)
|
|
841
|
+
return SessionUnavailable(
|
|
842
|
+
"Claude transcript changed non-append-only while reading; retry the page",
|
|
843
|
+
short=short,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _validate_claude_cursor(
|
|
848
|
+
payload: dict[str, Any],
|
|
849
|
+
short: str,
|
|
850
|
+
path: str,
|
|
851
|
+
source: dict[str, int],
|
|
852
|
+
scope: dict[str, str],
|
|
853
|
+
) -> tuple[int, int]:
|
|
854
|
+
if any(payload.get(key) != scope[key] for key in (
|
|
855
|
+
"root", "generation", "session_id"
|
|
856
|
+
)):
|
|
857
|
+
raise CursorInvalidated(
|
|
858
|
+
"Conversation worker changed; restart paging", short=short
|
|
859
|
+
)
|
|
860
|
+
if payload.get("dev") != source["dev"] or payload.get("ino") != source["ino"]:
|
|
861
|
+
raise CursorInvalidated(
|
|
862
|
+
"conversation page source was replaced; restart paging", short=short
|
|
863
|
+
)
|
|
864
|
+
cut_offset = payload.get("cut_offset")
|
|
865
|
+
if (
|
|
866
|
+
not isinstance(cut_offset, int)
|
|
867
|
+
or isinstance(cut_offset, bool)
|
|
868
|
+
or cut_offset < 0
|
|
869
|
+
or cut_offset >= source["eof"]
|
|
870
|
+
):
|
|
871
|
+
raise CursorInvalidated(
|
|
872
|
+
"conversation page source was truncated; restart paging", short=short
|
|
873
|
+
)
|
|
874
|
+
if not _record_matches(
|
|
875
|
+
path,
|
|
876
|
+
cut_offset,
|
|
877
|
+
payload.get("cut_id"),
|
|
878
|
+
payload.get("cut_fingerprint"),
|
|
879
|
+
before_offset=source["eof"],
|
|
880
|
+
):
|
|
881
|
+
raise CursorInvalidated(
|
|
882
|
+
"Conversation history changed; restart paging",
|
|
883
|
+
short=short,
|
|
884
|
+
)
|
|
885
|
+
# The fingerprint must match even when the original row has a native UUID.
|
|
886
|
+
# Both proofs are carried so an edited cursor cannot silently loosen either.
|
|
887
|
+
cut = _event_at(path, cut_offset, before_offset=source["eof"])
|
|
888
|
+
if cut is None or payload.get("cut_fingerprint") != _event_fingerprint(cut):
|
|
889
|
+
raise CursorInvalidated("conversation page cut fingerprint changed", short=short)
|
|
890
|
+
# Check the original snapshot fence, not a newly appended tail. A cursor
|
|
891
|
+
# may remain valid after append but must not shorten semantic lookahead.
|
|
892
|
+
source_eof = payload.get("source_eof")
|
|
893
|
+
if (type(source_eof) is not int or not cut_offset < source_eof <= source["eof"]):
|
|
894
|
+
raise CursorInvalidated("Conversation source changed; restart paging", short=short)
|
|
895
|
+
context_end_offset = payload.get("context_end_offset")
|
|
896
|
+
if (
|
|
897
|
+
not isinstance(context_end_offset, int)
|
|
898
|
+
or isinstance(context_end_offset, bool)
|
|
899
|
+
or context_end_offset < cut_offset
|
|
900
|
+
or context_end_offset > source_eof
|
|
901
|
+
or context_end_offset != _context_end_offset(
|
|
902
|
+
path, cut_offset, source_eof=source_eof
|
|
903
|
+
)
|
|
904
|
+
):
|
|
905
|
+
raise CursorInvalidated(
|
|
906
|
+
"Conversation context changed; restart paging",
|
|
907
|
+
short=short,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
anchor_offset = payload.get("anchor_offset")
|
|
911
|
+
if (type(anchor_offset) is not int or anchor_offset < 0
|
|
912
|
+
or anchor_offset >= cut_offset):
|
|
913
|
+
raise CursorInvalidated("conversation page anchor is no longer valid", short=short)
|
|
914
|
+
# Byte zero may be a synthetic fallback or a real ordinary-user anchor.
|
|
915
|
+
anchor = _validate_anchor(path, anchor_offset, before_offset=source_eof)
|
|
916
|
+
if anchor_offset == 0 and (anchor is None or anchor["offset"] != 0):
|
|
917
|
+
if payload.get("anchor_id") is not None or payload.get("anchor_fingerprint") is not None:
|
|
918
|
+
raise CursorInvalidated("conversation page zero anchor changed", short=short)
|
|
919
|
+
else:
|
|
920
|
+
if (
|
|
921
|
+
anchor is None
|
|
922
|
+
or anchor["offset"] != anchor_offset
|
|
923
|
+
or _cursor_id(anchor.get("source_record_id")) != payload.get("anchor_id")
|
|
924
|
+
or anchor.get("fingerprint") != payload.get("anchor_fingerprint")
|
|
925
|
+
):
|
|
926
|
+
raise CursorInvalidated(
|
|
927
|
+
"conversation page normalization anchor is no longer valid; restart paging",
|
|
928
|
+
short=short,
|
|
929
|
+
)
|
|
930
|
+
return cut_offset, context_end_offset
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _cursor_id(value: Any) -> str | None:
|
|
934
|
+
if not isinstance(value, str) or not value:
|
|
935
|
+
return None
|
|
936
|
+
if len(value.encode("utf-8")) > _PROTECTED_STRING_MAX_BYTES:
|
|
937
|
+
return None
|
|
938
|
+
return value
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _claude_older_cursor(
|
|
942
|
+
scope: dict[str, str],
|
|
943
|
+
short: str,
|
|
944
|
+
path: str,
|
|
945
|
+
source: dict[str, int],
|
|
946
|
+
anchor: dict[str, Any],
|
|
947
|
+
eligible: list[dict[str, Any]],
|
|
948
|
+
first: dict[str, Any] | None,
|
|
949
|
+
) -> str | None:
|
|
950
|
+
if first is None:
|
|
951
|
+
return None
|
|
952
|
+
first_offset = first["_ccx_start_offset"]
|
|
953
|
+
has_older_here = any(
|
|
954
|
+
isinstance(record.get("_ccx_start_offset"), int)
|
|
955
|
+
and record["_ccx_start_offset"] < first_offset
|
|
956
|
+
for record in eligible
|
|
957
|
+
)
|
|
958
|
+
has_older_before_anchor = anchor["offset"] > 0
|
|
959
|
+
if first_offset <= 0 or not (
|
|
960
|
+
has_older_here or has_older_before_anchor
|
|
961
|
+
):
|
|
962
|
+
return None
|
|
963
|
+
if first_offset == anchor["offset"]:
|
|
964
|
+
# The first retained record can be the normalization anchor itself.
|
|
965
|
+
# An older cursor must point to a strictly earlier anchor, or its next
|
|
966
|
+
# read fails validation even when the original transcript is unchanged.
|
|
967
|
+
# Rebase on the preceding ordinary user, using the same pinned source;
|
|
968
|
+
# if none exists, the byte-zero fallback covers the earlier prelude.
|
|
969
|
+
previous = next(_reverse_boundary_events(path, before_offset=first_offset), None)
|
|
970
|
+
anchor = (_boundary_from_event(previous) if previous is not None else
|
|
971
|
+
{"offset": 0, "source_record_id": None, "fingerprint": None})
|
|
972
|
+
context_end_offset = _context_end_offset(
|
|
973
|
+
path, first_offset, source_eof=source["eof"]
|
|
974
|
+
)
|
|
975
|
+
return _encode_cursor({
|
|
976
|
+
"engine": "claude",
|
|
977
|
+
"worker_id": scope["worker_id"],
|
|
978
|
+
"root": scope["root"],
|
|
979
|
+
"generation": scope["generation"],
|
|
980
|
+
"session_id": scope["session_id"],
|
|
981
|
+
"dev": source["dev"],
|
|
982
|
+
"ino": source["ino"],
|
|
983
|
+
"source_eof": source["eof"],
|
|
984
|
+
"anchor_offset": anchor["offset"],
|
|
985
|
+
"anchor_id": _cursor_id(anchor.get("source_record_id")),
|
|
986
|
+
"anchor_fingerprint": anchor.get("fingerprint"),
|
|
987
|
+
"cut_offset": first_offset,
|
|
988
|
+
"context_end_offset": context_end_offset,
|
|
989
|
+
"cut_id": _cursor_id(first.get("source_record_id")),
|
|
990
|
+
"cut_fingerprint": first.get("_ccx_fingerprint"),
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _read_claude_page_once(
|
|
995
|
+
path: str,
|
|
996
|
+
worker: str,
|
|
997
|
+
source: dict[str, int],
|
|
998
|
+
scope: dict[str, str],
|
|
999
|
+
*,
|
|
1000
|
+
cursor: str | None,
|
|
1001
|
+
source_record_target: int,
|
|
1002
|
+
max_tool_result_bytes: int | None,
|
|
1003
|
+
timeout: float,
|
|
1004
|
+
) -> dict[str, Any]:
|
|
1005
|
+
short = scope["worker_id"][:8]
|
|
1006
|
+
cut_offset = source["eof"]
|
|
1007
|
+
normalization_end_offset = source["eof"]
|
|
1008
|
+
anchor_hint = None
|
|
1009
|
+
if cursor is not None:
|
|
1010
|
+
payload = _decode_cursor(
|
|
1011
|
+
cursor, engine="claude", worker_id=scope["worker_id"]
|
|
1012
|
+
)
|
|
1013
|
+
cut_offset, normalization_end_offset = _validate_claude_cursor(
|
|
1014
|
+
payload, short, path, source, scope
|
|
1015
|
+
)
|
|
1016
|
+
anchor_hint = {
|
|
1017
|
+
"offset": payload.get("anchor_offset"),
|
|
1018
|
+
"source_record_id": payload.get("anchor_id"),
|
|
1019
|
+
"fingerprint": payload.get("anchor_fingerprint"),
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
anchor, eligible, counters = _choose_claude_window(
|
|
1023
|
+
path,
|
|
1024
|
+
source,
|
|
1025
|
+
cut_offset=cut_offset,
|
|
1026
|
+
normalization_end_offset=normalization_end_offset,
|
|
1027
|
+
target=source_record_target,
|
|
1028
|
+
max_tool_result_bytes=max_tool_result_bytes,
|
|
1029
|
+
anchor_hint=anchor_hint,
|
|
1030
|
+
)
|
|
1031
|
+
selected = eligible[-source_record_target:]
|
|
1032
|
+
public_pairs: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
|
1033
|
+
for record in selected:
|
|
1034
|
+
public = _public_record(record, counters)
|
|
1035
|
+
if public is not None:
|
|
1036
|
+
public_pairs.append((record, public))
|
|
1037
|
+
|
|
1038
|
+
keep = len(public_pairs)
|
|
1039
|
+
response_budget_limited = False
|
|
1040
|
+
while True:
|
|
1041
|
+
retained = public_pairs[-keep:] if keep else []
|
|
1042
|
+
public_records = [public for _record, public in retained]
|
|
1043
|
+
budget_dropped_public_records = keep < len(public_pairs)
|
|
1044
|
+
if not selected:
|
|
1045
|
+
first = None
|
|
1046
|
+
elif not public_pairs or not budget_dropped_public_records:
|
|
1047
|
+
# Empty/non-public source records still advance the native cursor.
|
|
1048
|
+
# Keep the first selected source cut unless the byte budget actually
|
|
1049
|
+
# removed public records from the front of this page.
|
|
1050
|
+
first = selected[0]
|
|
1051
|
+
else:
|
|
1052
|
+
first = retained[0][0] if retained else selected[0]
|
|
1053
|
+
older_cursor = _claude_older_cursor(
|
|
1054
|
+
scope,
|
|
1055
|
+
short,
|
|
1056
|
+
path,
|
|
1057
|
+
source,
|
|
1058
|
+
anchor,
|
|
1059
|
+
eligible,
|
|
1060
|
+
first,
|
|
1061
|
+
)
|
|
1062
|
+
result = _page_result(
|
|
1063
|
+
worker,
|
|
1064
|
+
public_records,
|
|
1065
|
+
older_cursor,
|
|
1066
|
+
counters,
|
|
1067
|
+
requested_source_record_target=source_record_target,
|
|
1068
|
+
response_budget_limited=response_budget_limited,
|
|
1069
|
+
)
|
|
1070
|
+
if _encoded_size(result) <= PAGE_RESULT_MAX_BYTES:
|
|
1071
|
+
return result
|
|
1072
|
+
response_budget_limited = True
|
|
1073
|
+
if keep <= 1:
|
|
1074
|
+
if public_records:
|
|
1075
|
+
public_records = [_last_resort_record_stub(public_records[0], counters)]
|
|
1076
|
+
result = _page_result(
|
|
1077
|
+
worker,
|
|
1078
|
+
public_records,
|
|
1079
|
+
older_cursor,
|
|
1080
|
+
counters,
|
|
1081
|
+
requested_source_record_target=source_record_target,
|
|
1082
|
+
response_budget_limited=True,
|
|
1083
|
+
)
|
|
1084
|
+
return result
|
|
1085
|
+
keep = max(1, keep // 2)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def read_claude_page(
|
|
1089
|
+
pinned_path: str | PinnedNativeSource,
|
|
1090
|
+
worker: str,
|
|
1091
|
+
*,
|
|
1092
|
+
root: str | os.PathLike[str],
|
|
1093
|
+
generation: str,
|
|
1094
|
+
session_id: str,
|
|
1095
|
+
source_stat: os.stat_result,
|
|
1096
|
+
end_offset: int,
|
|
1097
|
+
cursor: str | None,
|
|
1098
|
+
source_record_target: int = _DEFAULT_TARGET,
|
|
1099
|
+
max_tool_result_bytes: int | None = 16_384,
|
|
1100
|
+
timeout: float = 30,
|
|
1101
|
+
) -> dict[str, Any]:
|
|
1102
|
+
"""One certified original-source Claude page, using the opener's held fd."""
|
|
1103
|
+
_validate_options(source_record_target, max_tool_result_bytes, timeout)
|
|
1104
|
+
scope = {
|
|
1105
|
+
"root": hashlib.sha256(os.fsencode(root)).hexdigest(),
|
|
1106
|
+
"generation": generation,
|
|
1107
|
+
"worker_id": worker.partition(":")[2],
|
|
1108
|
+
"session_id": session_id,
|
|
1109
|
+
}
|
|
1110
|
+
source = {
|
|
1111
|
+
"dev": int(source_stat.st_dev), "ino": int(source_stat.st_ino),
|
|
1112
|
+
"size": int(source_stat.st_size), "eof": end_offset,
|
|
1113
|
+
"mtime_ns": int(source_stat.st_mtime_ns),
|
|
1114
|
+
}
|
|
1115
|
+
page = _read_claude_page_once(
|
|
1116
|
+
pinned_path, worker, source, scope, cursor=cursor,
|
|
1117
|
+
source_record_target=source_record_target,
|
|
1118
|
+
max_tool_result_bytes=max_tool_result_bytes, timeout=timeout,
|
|
1119
|
+
)
|
|
1120
|
+
# The native opener still holds this same inode. Never reopen or restat the
|
|
1121
|
+
# original pathname, inspect another source, or retry against a new file.
|
|
1122
|
+
fd = (pinned_path.fd if isinstance(pinned_path, PinnedNativeSource)
|
|
1123
|
+
else int(pinned_path.rsplit("/", 1)[-1]))
|
|
1124
|
+
after_stat = os.fstat(fd)
|
|
1125
|
+
after = {
|
|
1126
|
+
"dev": int(after_stat.st_dev), "ino": int(after_stat.st_ino),
|
|
1127
|
+
"size": int(after_stat.st_size),
|
|
1128
|
+
"eof": _complete_line_end(fd, after_stat.st_size),
|
|
1129
|
+
"mtime_ns": int(after_stat.st_mtime_ns),
|
|
1130
|
+
}
|
|
1131
|
+
if not _source_transition_is_append_only(source, after):
|
|
1132
|
+
raise _unstable_source_error(scope["worker_id"][:8], cursor=cursor is not None)
|
|
1133
|
+
return page
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _codex_timestamp(evidence: dict[str, Any] | None) -> int | None:
|
|
1137
|
+
if not isinstance(evidence, dict):
|
|
1138
|
+
return None
|
|
1139
|
+
completed = evidence.get("completedAtMs")
|
|
1140
|
+
if isinstance(completed, int) and not isinstance(completed, bool):
|
|
1141
|
+
return completed
|
|
1142
|
+
started = evidence.get("startedAtMs")
|
|
1143
|
+
if isinstance(started, int) and not isinstance(started, bool):
|
|
1144
|
+
return started
|
|
1145
|
+
return None
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _codex_duration(evidence: dict[str, Any] | None) -> int | None:
|
|
1149
|
+
if not isinstance(evidence, dict):
|
|
1150
|
+
return None
|
|
1151
|
+
started = evidence.get("startedAtMs")
|
|
1152
|
+
completed = evidence.get("completedAtMs")
|
|
1153
|
+
if (
|
|
1154
|
+
isinstance(started, int)
|
|
1155
|
+
and not isinstance(started, bool)
|
|
1156
|
+
and isinstance(completed, int)
|
|
1157
|
+
and not isinstance(completed, bool)
|
|
1158
|
+
and completed >= started
|
|
1159
|
+
):
|
|
1160
|
+
return completed - started
|
|
1161
|
+
return None
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def read_codex_page(
|
|
1165
|
+
rec: dict[str, Any],
|
|
1166
|
+
worker: str,
|
|
1167
|
+
*,
|
|
1168
|
+
root: str | os.PathLike[str],
|
|
1169
|
+
generation: str,
|
|
1170
|
+
worker_id: str,
|
|
1171
|
+
thread_id: str,
|
|
1172
|
+
expected_source: str,
|
|
1173
|
+
evidence_index: dict[str, Any],
|
|
1174
|
+
foreign_evidence: bool,
|
|
1175
|
+
cursor: str | None,
|
|
1176
|
+
source_record_target: int = _DEFAULT_TARGET,
|
|
1177
|
+
max_tool_result_bytes: int | None = 16_384,
|
|
1178
|
+
timeout: float = 30,
|
|
1179
|
+
) -> dict[str, Any]:
|
|
1180
|
+
_validate_options(source_record_target, max_tool_result_bytes, timeout)
|
|
1181
|
+
short = worker_id[:8] # Diagnostic label only; never owner/source authority.
|
|
1182
|
+
scope = {
|
|
1183
|
+
"root": hashlib.sha256(os.fsencode(root)).hexdigest(),
|
|
1184
|
+
"generation": generation,
|
|
1185
|
+
"worker_id": worker_id,
|
|
1186
|
+
"thread_id": thread_id,
|
|
1187
|
+
}
|
|
1188
|
+
provider_cursor = None
|
|
1189
|
+
if cursor is not None:
|
|
1190
|
+
payload = _decode_cursor(cursor, engine="codex", worker_id=worker_id)
|
|
1191
|
+
if (payload.get("root") != scope["root"]
|
|
1192
|
+
or payload.get("generation") != generation
|
|
1193
|
+
or payload.get("thread_id") != thread_id):
|
|
1194
|
+
raise CursorInvalidated(
|
|
1195
|
+
"Codex conversation changed; restart paging", short=short,
|
|
1196
|
+
)
|
|
1197
|
+
provider_cursor = payload.get("provider_cursor")
|
|
1198
|
+
if not isinstance(provider_cursor, str) or not provider_cursor:
|
|
1199
|
+
raise CursorInvalidated(
|
|
1200
|
+
"Codex cursor expired; restart paging", short=short,
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
# Only a source-attested explicit empty-history record is a zero-RPC EOF;
|
|
1204
|
+
# an older cursor can never originate from that thread. Decode it first so
|
|
1205
|
+
# wrong-owner and legacy tokens keep their normal typed refusals.
|
|
1206
|
+
if rec.get("historyKnownEmpty") is True:
|
|
1207
|
+
if cursor is not None:
|
|
1208
|
+
raise CursorInvalidated(
|
|
1209
|
+
"Codex conversation has no earlier messages", short=short,
|
|
1210
|
+
)
|
|
1211
|
+
counters = conv._Counters()
|
|
1212
|
+
if foreign_evidence:
|
|
1213
|
+
counters.warn("excluded native evidence from a foreign Codex thread")
|
|
1214
|
+
return _page_result(
|
|
1215
|
+
worker, [], None, counters,
|
|
1216
|
+
requested_source_record_target=source_record_target,
|
|
1217
|
+
response_budget_limited=False,
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
evidence_items = evidence_index.get("items") or {}
|
|
1221
|
+
evidence_clients = evidence_index.get("clients") or {}
|
|
1222
|
+
observed_model = rec.get("resolvedModel")
|
|
1223
|
+
fetch_target = source_record_target
|
|
1224
|
+
response_budget_limited = False
|
|
1225
|
+
deadline = time.monotonic() + float(timeout)
|
|
1226
|
+
|
|
1227
|
+
while True:
|
|
1228
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
1229
|
+
if remaining <= 0:
|
|
1230
|
+
raise OperationTimeout(
|
|
1231
|
+
"Codex conversation page deadline expired", short=short
|
|
1232
|
+
)
|
|
1233
|
+
page = codex_runtime.list_thread_items(
|
|
1234
|
+
thread_id,
|
|
1235
|
+
worker_id=worker_id,
|
|
1236
|
+
expected_source=expected_source,
|
|
1237
|
+
cwd=rec.get("cwd"),
|
|
1238
|
+
cursor=provider_cursor,
|
|
1239
|
+
limit=fetch_target,
|
|
1240
|
+
sort_direction="desc",
|
|
1241
|
+
timeout=remaining,
|
|
1242
|
+
)
|
|
1243
|
+
native_items = page.get("data") if isinstance(page, dict) else None
|
|
1244
|
+
if not isinstance(native_items, list):
|
|
1245
|
+
raise SessionUnavailable(
|
|
1246
|
+
"Codex thread item page returned malformed data", short=short
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
counters = conv._Counters()
|
|
1250
|
+
if foreign_evidence:
|
|
1251
|
+
counters.warn("excluded native evidence from a foreign Codex thread")
|
|
1252
|
+
records: list[dict[str, Any]] = []
|
|
1253
|
+
for wrapper in native_items:
|
|
1254
|
+
if not isinstance(wrapper, dict):
|
|
1255
|
+
counters.omitted += 1
|
|
1256
|
+
continue
|
|
1257
|
+
turn_id = wrapper.get("turnId")
|
|
1258
|
+
entry = wrapper.get("item")
|
|
1259
|
+
if not isinstance(entry, dict):
|
|
1260
|
+
counters.omitted += 1
|
|
1261
|
+
continue
|
|
1262
|
+
children = conv._codex_item(
|
|
1263
|
+
entry, counters, max_tool_result_bytes, observed_model
|
|
1264
|
+
)
|
|
1265
|
+
turn_evidence = evidence_items.get(turn_id) or {}
|
|
1266
|
+
turn_clients = evidence_clients.get(turn_id) or {}
|
|
1267
|
+
item_evidence = conv._evidence_entry(
|
|
1268
|
+
entry, turn_evidence, turn_clients
|
|
1269
|
+
)
|
|
1270
|
+
conv._apply_timing(children, item_evidence)
|
|
1271
|
+
public = _public_record({
|
|
1272
|
+
"turn_id": turn_id,
|
|
1273
|
+
"source_record_id": entry.get("id"),
|
|
1274
|
+
"timestamp_ms": _codex_timestamp(item_evidence),
|
|
1275
|
+
"duration_ms": _codex_duration(item_evidence),
|
|
1276
|
+
"usage": None,
|
|
1277
|
+
"items": children,
|
|
1278
|
+
}, counters)
|
|
1279
|
+
if public is not None:
|
|
1280
|
+
records.append(public)
|
|
1281
|
+
|
|
1282
|
+
# Native desc is transport-efficient; public page order is chronological.
|
|
1283
|
+
records.reverse()
|
|
1284
|
+
next_cursor = page.get("nextCursor")
|
|
1285
|
+
if next_cursor is not None and (
|
|
1286
|
+
not isinstance(next_cursor, str) or not next_cursor
|
|
1287
|
+
):
|
|
1288
|
+
raise ProviderProtocolError(
|
|
1289
|
+
"Codex history cursor is invalid; restart paging", short=short,
|
|
1290
|
+
)
|
|
1291
|
+
older_cursor = (
|
|
1292
|
+
_encode_cursor({
|
|
1293
|
+
**scope, "engine": "codex", "provider_cursor": next_cursor,
|
|
1294
|
+
})
|
|
1295
|
+
if next_cursor is not None else None
|
|
1296
|
+
)
|
|
1297
|
+
result = _page_result(
|
|
1298
|
+
worker,
|
|
1299
|
+
records,
|
|
1300
|
+
older_cursor,
|
|
1301
|
+
counters,
|
|
1302
|
+
requested_source_record_target=source_record_target,
|
|
1303
|
+
response_budget_limited=response_budget_limited,
|
|
1304
|
+
)
|
|
1305
|
+
if _encoded_size(result) <= PAGE_RESULT_MAX_BYTES:
|
|
1306
|
+
return result
|
|
1307
|
+
response_budget_limited = True
|
|
1308
|
+
if fetch_target <= 1:
|
|
1309
|
+
if len(native_items) > fetch_target:
|
|
1310
|
+
# A provider that ignores limit=1 gives us no cursor boundary
|
|
1311
|
+
# immediately after only the newest record. Returning its
|
|
1312
|
+
# nextCursor after dropping siblings would skip history. Refuse
|
|
1313
|
+
# this provider-contract violation rather than invent a cursor.
|
|
1314
|
+
raise ProviderProtocolError(
|
|
1315
|
+
"Codex thread item page exceeded requested limit at final "
|
|
1316
|
+
"conversation read limit",
|
|
1317
|
+
short=short,
|
|
1318
|
+
)
|
|
1319
|
+
if records:
|
|
1320
|
+
records = [_last_resort_record_stub(records[-1], counters)]
|
|
1321
|
+
return _page_result(
|
|
1322
|
+
worker,
|
|
1323
|
+
records,
|
|
1324
|
+
older_cursor,
|
|
1325
|
+
counters,
|
|
1326
|
+
requested_source_record_target=source_record_target,
|
|
1327
|
+
response_budget_limited=True,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
# Refetch from the same incoming provider cursor with a smaller native
|
|
1331
|
+
# limit. Base the next request on what the provider actually returned,
|
|
1332
|
+
# not merely what we asked for, and spend one shared caller deadline.
|
|
1333
|
+
previous_target = fetch_target
|
|
1334
|
+
fetch_target = max(1, min(fetch_target, len(native_items)) // 2)
|
|
1335
|
+
if fetch_target >= previous_target:
|
|
1336
|
+
fetch_target = max(1, previous_target // 2)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
__all__ = ["read_claude_page", "read_codex_page"]
|