cctally 1.71.0 → 1.73.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/CHANGELOG.md +20 -0
- package/bin/_cctally_cache.py +501 -49
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_db.py +286 -1
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +1422 -0
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +809 -0
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +8 -1
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
"""#294 S6 — provider-neutral conversation dispatch + Claude envelope adapter.
|
|
2
|
+
|
|
3
|
+
The thin identity-keyed dispatch S7 wires to routes (§5.1, §5.6). A bare Claude
|
|
4
|
+
``session_id`` routes to the EXISTING Claude query kernels (``_lib_conversation``
|
|
5
|
+
/ ``_lib_conversation_query`` — byte-untouched); an opaque ``IdentityV1``
|
|
6
|
+
conversation key routes by its ``source`` (``codex`` → the Codex kernels;
|
|
7
|
+
``claude`` → the bare-session path). Both providers return the SAME neutral
|
|
8
|
+
envelope family (browse / detail / outline / search), with provider-truthful
|
|
9
|
+
item/block kinds and a source-tagged ``tokens`` union.
|
|
10
|
+
|
|
11
|
+
The Claude side is a PURE per-field adapter (no Claude kernel edits): it re-shapes
|
|
12
|
+
the existing kernel outputs into the neutral envelopes and performs the
|
|
13
|
+
bidirectional cursor translation between uuid-based neutral ``item_key`` cursors
|
|
14
|
+
and Claude's internal rowid anchors (§5.6). All reads are adapter-level over the
|
|
15
|
+
untouched Claude tables via the public kernel helpers.
|
|
16
|
+
|
|
17
|
+
Public names (imported by the S7 wiring layer — do not rename):
|
|
18
|
+
``ConversationRef``, ``resolve_conversation_ref``, ``neutral_browse``,
|
|
19
|
+
``neutral_detail``, ``neutral_outline``, ``neutral_search``.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import dataclasses
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import sqlite3
|
|
29
|
+
|
|
30
|
+
import _lib_codex_conversation_query as q
|
|
31
|
+
import _lib_conversation_query as lcq
|
|
32
|
+
from _lib_source_identity import canonical_identity_from_root_key
|
|
33
|
+
|
|
34
|
+
# ── constants ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
# A bare Claude session id is a canonical UUID (Claude Code's sessionId shape).
|
|
37
|
+
# Anything that is neither an IdentityV1 key nor a UUID is garbage -> None.
|
|
38
|
+
_UUID_RE = re.compile(
|
|
39
|
+
r"\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
40
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Opaque, privacy-safe encoding of Claude's ``(session_id, uuid)`` anchor into a
|
|
44
|
+
# neutral ``item_key`` (§5.6 adapter table). Domain-separated + truncated hash —
|
|
45
|
+
# never a reversible wrap of the raw ids. Resolution is by matching against the
|
|
46
|
+
# assembled items' anchors (canonical rendered-anchor rule), never a decode.
|
|
47
|
+
CLAUDE_ITEM_KEY_DOMAIN = b"cctally-claude-item-key-v1\0"
|
|
48
|
+
|
|
49
|
+
_DEFAULT_SPEED = "standard"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── ConversationRef + resolution (§5.1) ───────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass(frozen=True)
|
|
56
|
+
class ConversationRef:
|
|
57
|
+
"""A resolved conversation reference.
|
|
58
|
+
|
|
59
|
+
``source`` — ``"claude"`` | ``"codex"``.
|
|
60
|
+
``conversation_key`` — opaque IdentityV1 form (minted for bare Claude session
|
|
61
|
+
ids; the input key echoed for IdentityV1 inputs).
|
|
62
|
+
``native_key`` — bare ``session_id`` (claude) / native thread id (codex).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
source: str
|
|
66
|
+
conversation_key: str
|
|
67
|
+
native_key: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mint_claude_conversation_key(session_id: str) -> str:
|
|
71
|
+
"""IdentityV1 ``claude``/``conversation`` key around a bare session id (§5.6).
|
|
72
|
+
Unqualified (no source root / parent) so S7 can mint qualified variants
|
|
73
|
+
later without a kernel change; both resolve to the same bare-session path."""
|
|
74
|
+
return canonical_identity_from_root_key(
|
|
75
|
+
"claude", "conversation", None, session_id, None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decode_identity_v1(key: str):
|
|
79
|
+
"""Decode a ``v1.<b64url>`` IdentityV1 key to its payload dict, or ``None``
|
|
80
|
+
when it is not a well-formed IdentityV1 (§5.1). Never raises."""
|
|
81
|
+
if not isinstance(key, str) or not key.startswith("v1."):
|
|
82
|
+
return None
|
|
83
|
+
b64 = key[3:]
|
|
84
|
+
padded = b64 + "=" * (-len(b64) % 4)
|
|
85
|
+
try:
|
|
86
|
+
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
87
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
88
|
+
except (ValueError, TypeError, json.JSONDecodeError):
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(payload, dict) or payload.get("version") != 1:
|
|
91
|
+
return None
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_conversation_ref(ref: str) -> "ConversationRef | None":
|
|
96
|
+
"""Resolve a neutral reference to its provider (§5.1).
|
|
97
|
+
|
|
98
|
+
- a bare Claude ``session_id`` (UUID shape) → ``claude`` (key minted);
|
|
99
|
+
- an ``IdentityV1`` ``codex``/``conversation`` key → ``codex``;
|
|
100
|
+
- an ``IdentityV1`` ``claude``/``conversation`` key → ``claude`` (resolved to
|
|
101
|
+
the bare-session path via ``nativeKey``);
|
|
102
|
+
- invalid / non-``conversation`` / cross-kind / garbage → ``None``.
|
|
103
|
+
|
|
104
|
+
There is NEVER a cross-provider fallback: a Codex key whose ``nativeKey`` UUID
|
|
105
|
+
also exists as a Claude session resolves ``codex``-only, because routing keys
|
|
106
|
+
solely on the decoded ``source`` field — collisions cannot cross-route by
|
|
107
|
+
construction."""
|
|
108
|
+
if not isinstance(ref, str) or not ref:
|
|
109
|
+
return None
|
|
110
|
+
payload = _decode_identity_v1(ref)
|
|
111
|
+
if payload is not None:
|
|
112
|
+
source = payload.get("source")
|
|
113
|
+
kind = payload.get("resourceKind")
|
|
114
|
+
native = payload.get("nativeKey")
|
|
115
|
+
if kind != "conversation" or source not in ("claude", "codex"):
|
|
116
|
+
return None
|
|
117
|
+
if not isinstance(native, str) or not native:
|
|
118
|
+
return None
|
|
119
|
+
# Both codex and claude IdentityV1 inputs echo the input key; a claude
|
|
120
|
+
# opaque key resolves to its bare-session native id (no re-mint).
|
|
121
|
+
return ConversationRef(source=source, conversation_key=ref, native_key=native)
|
|
122
|
+
if _UUID_RE.match(ref):
|
|
123
|
+
return ConversationRef(
|
|
124
|
+
source="claude",
|
|
125
|
+
conversation_key=_mint_claude_conversation_key(ref),
|
|
126
|
+
native_key=ref,
|
|
127
|
+
)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Claude adapter: item key + tokens union (§5.6) ────────────────────────────
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _claude_item_key(session_id: str, uuid: str | None) -> str:
|
|
135
|
+
"""Opaque neutral ``item_key`` over Claude's ``(session_id, uuid)`` anchor
|
|
136
|
+
(§5.6). Same anchor Claude's kernels already use — the dispatch layer
|
|
137
|
+
guarantees Codex and Claude keys never mix (distinct domain + prefix)."""
|
|
138
|
+
raw = "\x00".join(("claude-row", session_id or "", uuid or "")).encode("utf-8")
|
|
139
|
+
return "cliv1_" + hashlib.sha256(CLAUDE_ITEM_KEY_DOMAIN + raw).hexdigest()[:40]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _claude_tokens_union(tokens: dict | None):
|
|
143
|
+
"""Source-tagged Claude ``tokens`` union member (§5.6). Native Claude cache
|
|
144
|
+
vocabulary only — ``cache_create``/``cache_read``, never Codex fields. The
|
|
145
|
+
assembly stamps ``cache_creation``; the neutral field is ``cache_create``."""
|
|
146
|
+
if not isinstance(tokens, dict):
|
|
147
|
+
return None
|
|
148
|
+
return {
|
|
149
|
+
"source": "claude",
|
|
150
|
+
"input": tokens.get("input", 0) or 0,
|
|
151
|
+
"output": tokens.get("output", 0) or 0,
|
|
152
|
+
"cache_create": tokens.get("cache_creation", 0) or 0,
|
|
153
|
+
"cache_read": tokens.get("cache_read", 0) or 0,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _sum_claude_tokens(items: list) -> dict:
|
|
158
|
+
"""Conversation-level Claude ``tokens`` union, summed over every assistant
|
|
159
|
+
turn's per-turn ``tokens`` (§5.6)."""
|
|
160
|
+
acc = {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
|
|
161
|
+
for it in items:
|
|
162
|
+
tok = it.get("tokens")
|
|
163
|
+
if not isinstance(tok, dict):
|
|
164
|
+
continue
|
|
165
|
+
acc["input"] += tok.get("input", 0) or 0
|
|
166
|
+
acc["output"] += tok.get("output", 0) or 0
|
|
167
|
+
acc["cache_create"] += tok.get("cache_creation", 0) or 0
|
|
168
|
+
acc["cache_read"] += tok.get("cache_read", 0) or 0
|
|
169
|
+
return {"source": "claude", **acc}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Claude adapter: project attribution (§5.6) ────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _claude_project_attribution(
|
|
176
|
+
cwd: object, cache: dict | None = None
|
|
177
|
+
) -> tuple[str | None, str | None]:
|
|
178
|
+
"""``(project_key, project_label)`` for a Claude session's canonical project
|
|
179
|
+
identity (§5.6 adapter table). Derives the OPAQUE qualified key from the
|
|
180
|
+
observed ``cwd`` git-root via the ``opaque_project_key`` precedent — two
|
|
181
|
+
Claude projects sharing a basename resolve to the SAME display label but
|
|
182
|
+
DISTINCT ``project_key``s (their git-root bucket paths differ), so they never
|
|
183
|
+
merge in neutral facets/filters. The basename label stays display-only.
|
|
184
|
+
|
|
185
|
+
``cache`` is ``_resolve_project_key``'s memo dict; callers resolving many
|
|
186
|
+
rows MUST share one dict across the batch, or every row repeats the
|
|
187
|
+
realpath + parent-``.git`` stat walk the memo exists to avoid.
|
|
188
|
+
|
|
189
|
+
Degrades to ``(None, None)`` when ``cwd`` is absent or the S3 kernel is
|
|
190
|
+
unavailable — never guesses."""
|
|
191
|
+
if not isinstance(cwd, str) or not cwd:
|
|
192
|
+
return None, None
|
|
193
|
+
try:
|
|
194
|
+
from _cctally_cache import _resolve_project_key
|
|
195
|
+
from _cctally_source_analytics import _project_label
|
|
196
|
+
from _lib_source_identity import source_root_key
|
|
197
|
+
from _lib_source_analytics import opaque_project_key
|
|
198
|
+
except Exception:
|
|
199
|
+
return None, None
|
|
200
|
+
project = _resolve_project_key(cwd, "git-root", cache if cache is not None else {})
|
|
201
|
+
resolved_key = project.bucket_path
|
|
202
|
+
cwd_label = _project_label(cwd)
|
|
203
|
+
project_label = (
|
|
204
|
+
cwd_label if cwd_label in {"(home)", "(root)"}
|
|
205
|
+
else _project_label(project.display_key)
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
# The git-root bucket path fully identifies the project; deriving the
|
|
209
|
+
# root key from it keeps same-basename/different-root projects distinct.
|
|
210
|
+
root_key = source_root_key(resolved_key)
|
|
211
|
+
return opaque_project_key("claude", root_key, resolved_key), project_label
|
|
212
|
+
except ValueError:
|
|
213
|
+
return None, None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── Claude adapter: browse (§5.6 / §6.1) ──────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _all_claude_conversations(conn: sqlite3.Connection) -> list[dict]:
|
|
220
|
+
"""Every Claude browse row (all history), paging the existing rollup/live
|
|
221
|
+
``list_conversations`` kernel to completion. Reused so cost/title/models stay
|
|
222
|
+
the kernel's exact values (no re-implementation)."""
|
|
223
|
+
out: list[dict] = []
|
|
224
|
+
offset = 0
|
|
225
|
+
while True:
|
|
226
|
+
res = lcq.list_conversations(conn, sort="recent", limit=200, offset=offset)
|
|
227
|
+
out.extend(res["conversations"])
|
|
228
|
+
page = res["page"]
|
|
229
|
+
if not page.get("has_more"):
|
|
230
|
+
break
|
|
231
|
+
offset = page.get("next_offset")
|
|
232
|
+
if offset is None:
|
|
233
|
+
break
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _claude_browse_row(
|
|
238
|
+
conn: sqlite3.Connection, conv: dict, cwd: object, attribution_cache: dict
|
|
239
|
+
) -> dict:
|
|
240
|
+
project_key, project_label = _claude_project_attribution(cwd, attribution_cache)
|
|
241
|
+
return {
|
|
242
|
+
"conversation_key": _mint_claude_conversation_key(conv["session_id"]),
|
|
243
|
+
"title": conv["title"],
|
|
244
|
+
# project_key is the opaque qualified identity; project_label is the
|
|
245
|
+
# display-only basename (may collide across roots — the key does not).
|
|
246
|
+
"project_key": project_key,
|
|
247
|
+
"project_label": project_label if project_label is not None else conv["project_label"],
|
|
248
|
+
"started_utc": conv["started_utc"],
|
|
249
|
+
"last_activity_utc": conv["last_activity_utc"],
|
|
250
|
+
# count = Claude's existing physical message count (provider-defined
|
|
251
|
+
# count semantics, §5.6 adapter table) — NOT the Codex rendered-item count.
|
|
252
|
+
"count": conv["msg_count"],
|
|
253
|
+
"cost_usd": conv["cost_usd"],
|
|
254
|
+
"models": list(conv.get("models") or []),
|
|
255
|
+
# Claude has no native conversation threading — no parent, never a fork.
|
|
256
|
+
"parent": None,
|
|
257
|
+
"is_fork": False,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _claude_browse(
|
|
262
|
+
conn: sqlite3.Connection,
|
|
263
|
+
*,
|
|
264
|
+
effective_speed: str,
|
|
265
|
+
project_key: str | None = None,
|
|
266
|
+
model: str | None = None,
|
|
267
|
+
limit: int = 50,
|
|
268
|
+
cursor: str | None = None,
|
|
269
|
+
) -> dict:
|
|
270
|
+
"""Claude browse envelope (§5.6 / §6.1). Claude is always authoritative — the
|
|
271
|
+
status is always ``ok`` (never ``normalization_pending``). Facets are built
|
|
272
|
+
over the full row set (before filtering); rows are ordered by last activity
|
|
273
|
+
and paginated by ``conversation_key`` cursor — the SAME facet/sort/paginate
|
|
274
|
+
helpers the Codex browse uses (cross-provider envelope parity)."""
|
|
275
|
+
del effective_speed # Claude cost is already materialized on the rollup rows.
|
|
276
|
+
convs = _all_claude_conversations(conn)
|
|
277
|
+
session_ids = [c["session_id"] for c in convs]
|
|
278
|
+
meta = lcq._session_latest_meta_map(conn, session_ids)
|
|
279
|
+
# One shared memo dict for the whole batch: many sessions share a cwd, and
|
|
280
|
+
# _resolve_project_key's fast-path only helps when the dict persists.
|
|
281
|
+
attribution_cache: dict = {}
|
|
282
|
+
rows = [
|
|
283
|
+
_claude_browse_row(
|
|
284
|
+
conn,
|
|
285
|
+
conv,
|
|
286
|
+
meta.get(conv["session_id"], (None, None))[0],
|
|
287
|
+
attribution_cache,
|
|
288
|
+
)
|
|
289
|
+
for conv in convs
|
|
290
|
+
]
|
|
291
|
+
facets = q._browse_facets(rows)
|
|
292
|
+
filtered = [
|
|
293
|
+
row for row in rows
|
|
294
|
+
if (project_key is None or row["project_key"] == project_key)
|
|
295
|
+
and (model is None or model in (row["models"] or []))
|
|
296
|
+
]
|
|
297
|
+
filtered.sort(key=q._recent_sort_key, reverse=True)
|
|
298
|
+
page_rows, page = q._paginate_rows(filtered, cursor=cursor, limit=limit)
|
|
299
|
+
return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ── Claude adapter: detail with cursor translation (§5.6) ─────────────────────
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _map_claude_item(session_id: str, it: dict) -> dict:
|
|
306
|
+
"""One Claude assembled item → the neutral detail item shape (§5.6). Claude's
|
|
307
|
+
kinds/blocks pass through untranslated (both vocabularies are provider-truthful
|
|
308
|
+
values of the same required field)."""
|
|
309
|
+
return {
|
|
310
|
+
"item_key": _claude_item_key(session_id, it["anchor"]["uuid"]),
|
|
311
|
+
"kind": it["kind"],
|
|
312
|
+
"timestamp_utc": it.get("ts"),
|
|
313
|
+
"model": it.get("model"),
|
|
314
|
+
"blocks": it.get("blocks", []),
|
|
315
|
+
"cost_usd": it.get("cost_usd"),
|
|
316
|
+
"tokens": _claude_tokens_union(it.get("tokens")),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _resolve_claude_cursor(items: list, keys: list, cursor: str | None):
|
|
321
|
+
"""Translate a neutral ``item_key`` cursor to Claude's internal rowid anchor
|
|
322
|
+
via the canonical rendered-anchor rule (§5.6). Returns
|
|
323
|
+
``(internal_id, "ok")`` when the cursor matches an emitted item anchor, or
|
|
324
|
+
``(None, "not_found")`` when the uuid is physically present but folded out of
|
|
325
|
+
the rendered items, or absent entirely (pruned/rewritten history) — never a
|
|
326
|
+
silent restart, never a mistaken-stale from an arbitrary duplicate."""
|
|
327
|
+
if cursor is None:
|
|
328
|
+
return None, "ok"
|
|
329
|
+
try:
|
|
330
|
+
idx = keys.index(cursor)
|
|
331
|
+
except ValueError:
|
|
332
|
+
return None, "not_found"
|
|
333
|
+
return items[idx]["anchor"]["id"], "ok"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _claude_detail(
|
|
337
|
+
conn: sqlite3.Connection,
|
|
338
|
+
session_id: str,
|
|
339
|
+
conversation_key: str,
|
|
340
|
+
*,
|
|
341
|
+
after: str | None = None,
|
|
342
|
+
before: str | None = None,
|
|
343
|
+
tail: object = None,
|
|
344
|
+
limit: int | None = None,
|
|
345
|
+
) -> dict:
|
|
346
|
+
"""Claude detail envelope (§5.6). Never emits ``normalization_pending``
|
|
347
|
+
(Claude is always authoritative). ``unattributed_cost_usd`` is absent (Codex
|
|
348
|
+
only); ``children``/``parent`` are empty/None (no native threading). Cursors
|
|
349
|
+
round-trip through the uuid-based ``item_key`` anchor."""
|
|
350
|
+
asm = lcq._assemble_session_memoized(conn, session_id)
|
|
351
|
+
if asm is None:
|
|
352
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
353
|
+
items_all = asm["items"]
|
|
354
|
+
# The canonical rendered anchors (assembly already dedups duplicate uuids and
|
|
355
|
+
# promotes the rendered fragment); the cursor must match one of THESE.
|
|
356
|
+
keys = [_claude_item_key(session_id, it["anchor"]["uuid"]) for it in items_all]
|
|
357
|
+
internal_after, st = _resolve_claude_cursor(items_all, keys, after)
|
|
358
|
+
if st == "not_found":
|
|
359
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
360
|
+
internal_before, st = _resolve_claude_cursor(items_all, keys, before)
|
|
361
|
+
if st == "not_found":
|
|
362
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
363
|
+
res = lcq.get_conversation(
|
|
364
|
+
conn, session_id,
|
|
365
|
+
after=internal_after, before=internal_before,
|
|
366
|
+
tail=bool(tail), limit=limit if limit is not None else 500,
|
|
367
|
+
)
|
|
368
|
+
page_items = res["items"]
|
|
369
|
+
neutral_items = [_map_claude_item(session_id, it) for it in page_items]
|
|
370
|
+
if page_items:
|
|
371
|
+
has_after = bool(res["page"]["has_more"])
|
|
372
|
+
has_before = bool(res["page"]["has_prev"])
|
|
373
|
+
after_cur = _claude_item_key(session_id, page_items[-1]["anchor"]["uuid"]) if has_after else None
|
|
374
|
+
before_cur = _claude_item_key(session_id, page_items[0]["anchor"]["uuid"]) if has_before else None
|
|
375
|
+
else:
|
|
376
|
+
has_after = has_before = False
|
|
377
|
+
after_cur = before_cur = None
|
|
378
|
+
page = {
|
|
379
|
+
"total": len(items_all), "returned": len(page_items),
|
|
380
|
+
"before": before_cur, "after": after_cur,
|
|
381
|
+
"has_before": has_before, "has_after": has_after,
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
"status": "ok",
|
|
385
|
+
"conversation_key": conversation_key,
|
|
386
|
+
"title": res["title"],
|
|
387
|
+
"items": neutral_items,
|
|
388
|
+
"page": page,
|
|
389
|
+
"children": [],
|
|
390
|
+
"parent": None,
|
|
391
|
+
"total_cost_usd": res["cost_usd"],
|
|
392
|
+
"tokens": _sum_claude_tokens(items_all),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ── Claude adapter: outline (§5.6) ────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _claude_outline(
|
|
400
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str,
|
|
401
|
+
) -> dict:
|
|
402
|
+
"""Claude outline envelope (§5.6). Reuses the existing kernel outline for
|
|
403
|
+
``stats``/``files``; the per-turn ``item_key`` + block-kind counts are built
|
|
404
|
+
from the SAME assembled items the detail pages, so outline and detail item
|
|
405
|
+
keys align exactly. ``children`` is empty (no native threading)."""
|
|
406
|
+
o = lcq.get_conversation_outline(conn, session_id)
|
|
407
|
+
if o is None:
|
|
408
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
409
|
+
asm = lcq._assemble_session_memoized(conn, session_id)
|
|
410
|
+
turns = []
|
|
411
|
+
for it in asm["items"]:
|
|
412
|
+
kinds: dict[str, int] = {}
|
|
413
|
+
for b in it.get("blocks", []):
|
|
414
|
+
bk = b.get("kind")
|
|
415
|
+
if bk:
|
|
416
|
+
kinds[bk] = kinds.get(bk, 0) + 1
|
|
417
|
+
turns.append({
|
|
418
|
+
"item_key": _claude_item_key(session_id, it["anchor"]["uuid"]),
|
|
419
|
+
"label": lcq._outline_label(it.get("text", "")),
|
|
420
|
+
"timestamp_utc": it.get("ts"),
|
|
421
|
+
"kinds": kinds,
|
|
422
|
+
})
|
|
423
|
+
return {
|
|
424
|
+
"status": "ok",
|
|
425
|
+
"conversation_key": conversation_key,
|
|
426
|
+
"turns": turns,
|
|
427
|
+
"stats": o["stats"],
|
|
428
|
+
"files": o["files"],
|
|
429
|
+
"children": [],
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ── Claude adapter: search (§5.6 / §6.2) ──────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _claude_search_hit_fields(conn: sqlite3.Connection, session_ids: list) -> dict:
|
|
437
|
+
"""``{session_id: (last_activity_utc, project_label)}`` for the search hits'
|
|
438
|
+
conversations (§3.7). ``last_activity_utc`` is the conversation's
|
|
439
|
+
``MAX(timestamp_utc)`` (conversation-level, explicitly NOT the matched row's
|
|
440
|
+
own time); ``project_label`` is the session's display-only project attribution.
|
|
441
|
+
Resolved once per distinct non-empty session id."""
|
|
442
|
+
sids = [s for s in dict.fromkeys(session_ids) if s]
|
|
443
|
+
if not sids:
|
|
444
|
+
return {}
|
|
445
|
+
last_map: dict = {}
|
|
446
|
+
placeholders = ",".join("?" for _ in sids)
|
|
447
|
+
try:
|
|
448
|
+
for sid, last in conn.execute(
|
|
449
|
+
f"SELECT session_id, MAX(timestamp_utc) FROM conversation_messages "
|
|
450
|
+
f"WHERE session_id IN ({placeholders}) GROUP BY session_id", sids):
|
|
451
|
+
last_map[sid] = last
|
|
452
|
+
except sqlite3.OperationalError:
|
|
453
|
+
pass
|
|
454
|
+
meta = lcq._session_latest_meta_map(conn, sids)
|
|
455
|
+
attribution_cache: dict = {}
|
|
456
|
+
out: dict = {}
|
|
457
|
+
for sid in sids:
|
|
458
|
+
cwd = meta.get(sid, (None, None))[0]
|
|
459
|
+
_pk, project_label = _claude_project_attribution(cwd, attribution_cache)
|
|
460
|
+
out[sid] = (last_map.get(sid), project_label)
|
|
461
|
+
return out
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _claude_search(
|
|
465
|
+
conn: sqlite3.Connection,
|
|
466
|
+
query: str,
|
|
467
|
+
*,
|
|
468
|
+
kind: str = "all",
|
|
469
|
+
limit: int = 20,
|
|
470
|
+
cursor: str | None = None,
|
|
471
|
+
) -> dict:
|
|
472
|
+
"""Claude search envelope (§5.6). Reuses the existing Claude search kernel
|
|
473
|
+
(FTS5/LIKE, the #149 shape); hits are re-shaped to the neutral union with a
|
|
474
|
+
``(session_id, uuid)`` ``item_key`` and cursor pagination over the kernel's
|
|
475
|
+
offset paging."""
|
|
476
|
+
try:
|
|
477
|
+
offset = int(cursor) if cursor is not None else 0
|
|
478
|
+
except (TypeError, ValueError):
|
|
479
|
+
offset = 0
|
|
480
|
+
res = lcq.search_conversations(
|
|
481
|
+
conn, query, kind=kind, limit=limit, offset=max(0, offset))
|
|
482
|
+
raw_hits = res.get("hits", [])
|
|
483
|
+
# §3.7 conversation-level fields, resolved once per distinct session.
|
|
484
|
+
fields = _claude_search_hit_fields(
|
|
485
|
+
conn, [h.get("session_id") for h in raw_hits])
|
|
486
|
+
hits = []
|
|
487
|
+
for h in raw_hits:
|
|
488
|
+
sid = h.get("session_id")
|
|
489
|
+
uuid = h.get("uuid")
|
|
490
|
+
last_act, project_label = fields.get(sid, (None, None))
|
|
491
|
+
hits.append({
|
|
492
|
+
"conversation_key": _mint_claude_conversation_key(sid) if sid else None,
|
|
493
|
+
"item_key": _claude_item_key(sid, uuid) if (sid and uuid) else None,
|
|
494
|
+
"title": h.get("title"),
|
|
495
|
+
"snippet": h.get("snippet"),
|
|
496
|
+
"badges": list(h.get("match_kinds") or []),
|
|
497
|
+
"last_activity_utc": last_act,
|
|
498
|
+
"project_label": project_label,
|
|
499
|
+
})
|
|
500
|
+
total = res.get("total", len(hits))
|
|
501
|
+
returned = len(hits)
|
|
502
|
+
next_offset = offset + returned
|
|
503
|
+
next_cursor = str(next_offset) if next_offset < total else None
|
|
504
|
+
return {
|
|
505
|
+
"status": "ok",
|
|
506
|
+
"query": res.get("query", query),
|
|
507
|
+
"hits": hits,
|
|
508
|
+
"total": total,
|
|
509
|
+
"mode": res.get("mode"),
|
|
510
|
+
"depth": res.get("search_depth"),
|
|
511
|
+
"page": {"returned": returned, "cursor": next_cursor},
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ── neutral entry points (§5.6) ───────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def neutral_browse(
|
|
519
|
+
conn: sqlite3.Connection, *, source: str, effective_speed: str | None = None,
|
|
520
|
+
**filters,
|
|
521
|
+
) -> dict:
|
|
522
|
+
"""Browse envelope for one source (§5.6). Codex routes to
|
|
523
|
+
``list_codex_conversations`` (with the ``normalization_pending`` status while
|
|
524
|
+
migration 025 has not run); Claude routes to the adapter (always ``ok``).
|
|
525
|
+
``filters``: ``project_key``, ``model``, ``limit``, ``cursor``."""
|
|
526
|
+
speed = effective_speed or _DEFAULT_SPEED
|
|
527
|
+
if source == "codex":
|
|
528
|
+
return q.list_codex_conversations(conn, effective_speed=speed, **filters)
|
|
529
|
+
if source == "claude":
|
|
530
|
+
return _claude_browse(conn, effective_speed=speed, **filters)
|
|
531
|
+
raise ValueError(f"unknown source: {source!r}")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def neutral_detail(
|
|
535
|
+
conn: sqlite3.Connection, ref: str, *, effective_speed: str | None = None,
|
|
536
|
+
after: str | None = None, before: str | None = None,
|
|
537
|
+
tail: object = None, limit: int | None = None,
|
|
538
|
+
) -> dict:
|
|
539
|
+
"""Detail envelope for a neutral reference (§5.6). Unknown/garbage refs → the
|
|
540
|
+
``not_found`` envelope echoing the requested reference."""
|
|
541
|
+
cref = resolve_conversation_ref(ref)
|
|
542
|
+
if cref is None:
|
|
543
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
544
|
+
speed = effective_speed or _DEFAULT_SPEED
|
|
545
|
+
if cref.source == "codex":
|
|
546
|
+
return q.get_codex_conversation(
|
|
547
|
+
conn, cref.conversation_key, effective_speed=speed,
|
|
548
|
+
after=after, before=before, tail=tail,
|
|
549
|
+
limit=limit if limit is not None else 200)
|
|
550
|
+
return _claude_detail(
|
|
551
|
+
conn, cref.native_key, cref.conversation_key,
|
|
552
|
+
after=after, before=before, tail=tail, limit=limit)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def neutral_outline(
|
|
556
|
+
conn: sqlite3.Connection, ref: str, *, effective_speed: str | None = None,
|
|
557
|
+
) -> dict:
|
|
558
|
+
"""Outline envelope for a neutral reference (§5.6)."""
|
|
559
|
+
cref = resolve_conversation_ref(ref)
|
|
560
|
+
if cref is None:
|
|
561
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
562
|
+
speed = effective_speed or _DEFAULT_SPEED
|
|
563
|
+
if cref.source == "codex":
|
|
564
|
+
return q.get_codex_conversation_outline(
|
|
565
|
+
conn, cref.conversation_key, effective_speed=speed)
|
|
566
|
+
return _claude_outline(conn, cref.native_key, cref.conversation_key)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def neutral_search(
|
|
570
|
+
conn: sqlite3.Connection, query: str, *, source: str, kind: str = "all",
|
|
571
|
+
effective_speed: str | None = None, limit: int = 20, cursor: str | None = None,
|
|
572
|
+
) -> dict:
|
|
573
|
+
"""Search envelope for one source (§5.6). Search is per-source by
|
|
574
|
+
construction — the kernels never merge providers.
|
|
575
|
+
|
|
576
|
+
§4.3 external cursor codec: the ``cursor`` argument is the EXTERNAL
|
|
577
|
+
(base64url) form and the returned ``page.cursor`` is likewise external — the
|
|
578
|
+
kernel's raw cursor (a NUL-separated ``conversation_key\\x00item_key`` for
|
|
579
|
+
Codex; an offset for Claude) never leaves the process. An undecodable incoming
|
|
580
|
+
cursor raises :class:`InvalidSearchCursor` (the route/CLI maps it to 400 /
|
|
581
|
+
exit 2)."""
|
|
582
|
+
speed = effective_speed or _DEFAULT_SPEED
|
|
583
|
+
raw_cursor = decode_search_cursor(cursor) if cursor is not None else None
|
|
584
|
+
if source == "codex":
|
|
585
|
+
env = q.search_codex_conversations(
|
|
586
|
+
conn, query, kind=kind, effective_speed=speed,
|
|
587
|
+
limit=limit, cursor=raw_cursor)
|
|
588
|
+
elif source == "claude":
|
|
589
|
+
env = _claude_search(conn, query, kind=kind, limit=limit, cursor=raw_cursor)
|
|
590
|
+
else:
|
|
591
|
+
raise ValueError(f"unknown source: {source!r}")
|
|
592
|
+
page = env.get("page")
|
|
593
|
+
if isinstance(page, dict) and page.get("cursor") is not None:
|
|
594
|
+
page["cursor"] = encode_search_cursor(page["cursor"])
|
|
595
|
+
return env
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def neutral_events_preflight(conn: sqlite3.Connection, ref: str) -> dict:
|
|
599
|
+
"""Live-tail SSE preflight for a ``v1.`` key (spec §5.2): resolve →
|
|
600
|
+
normalization authority (Codex) → conversation existence. The ``/events``
|
|
601
|
+
route answers a non-``ok`` result as plain JSON per §2.3 BEFORE committing
|
|
602
|
+
any SSE bytes — never a 200-SSE stream with nothing to say.
|
|
603
|
+
|
|
604
|
+
Returns a status dict:
|
|
605
|
+
- ``ok`` — carries ``source`` (``"claude"``/``"codex"``) and ``native_key``
|
|
606
|
+
for the watch loop's provider-specific mechanics;
|
|
607
|
+
- ``normalization_pending`` — Codex only, migration 025 not yet stamped (a
|
|
608
|
+
legitimate empty state → HTTP 200 JSON);
|
|
609
|
+
- ``not_found`` — an unresolvable/garbage ref, or a resolved ref whose
|
|
610
|
+
conversation has no rows (→ HTTP 404 JSON).
|
|
611
|
+
|
|
612
|
+
Cheap: a resolve + one authority probe + one existence probe — never a full
|
|
613
|
+
detail assembly. Uniform for both providers (a ``v1.`` Claude key preflights
|
|
614
|
+
the same way, then reuses the existing Claude mechanics internally)."""
|
|
615
|
+
cref = resolve_conversation_ref(ref)
|
|
616
|
+
if cref is None:
|
|
617
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
618
|
+
if cref.source == "codex":
|
|
619
|
+
if not q.codex_normalization_authoritative(conn):
|
|
620
|
+
return {"status": "normalization_pending",
|
|
621
|
+
"conversation_key": cref.conversation_key}
|
|
622
|
+
if not q.codex_conversation_exists(conn, cref.conversation_key):
|
|
623
|
+
return {"status": "not_found",
|
|
624
|
+
"conversation_key": cref.conversation_key}
|
|
625
|
+
return {"status": "ok", "conversation_key": cref.conversation_key,
|
|
626
|
+
"source": "codex", "native_key": cref.native_key}
|
|
627
|
+
# claude — the bare-session path reached via a v1.claude key.
|
|
628
|
+
if not lcq.conversation_exists(conn, cref.native_key):
|
|
629
|
+
return {"status": "not_found", "conversation_key": cref.conversation_key}
|
|
630
|
+
return {"status": "ok", "conversation_key": cref.conversation_key,
|
|
631
|
+
"source": "claude", "native_key": cref.native_key}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ── external search-cursor codec (§4.3) ───────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class InvalidSearchCursor(ValueError):
|
|
638
|
+
"""An external search cursor that is not well-formed unpadded base64url over a
|
|
639
|
+
UTF-8 kernel cursor (§4.3). The route/CLI maps it to 400 / exit 2."""
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def encode_search_cursor(raw: str | None) -> str | None:
|
|
643
|
+
"""Encode a raw kernel search cursor to its external unpadded-base64url form
|
|
644
|
+
(§4.3). ``None`` (no next page) stays ``None``. The kernel cursor embeds a NUL
|
|
645
|
+
separator for Codex, so the raw value never leaves the process."""
|
|
646
|
+
if raw is None:
|
|
647
|
+
return None
|
|
648
|
+
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def decode_search_cursor(external: str | None) -> str | None:
|
|
652
|
+
"""Decode an external unpadded-base64url search cursor back to the raw kernel
|
|
653
|
+
cursor (§4.3). ``None`` stays ``None``; an undecodable value raises
|
|
654
|
+
:class:`InvalidSearchCursor`."""
|
|
655
|
+
if external is None:
|
|
656
|
+
return None
|
|
657
|
+
if not isinstance(external, str):
|
|
658
|
+
raise InvalidSearchCursor(external)
|
|
659
|
+
padded = external + "=" * (-len(external) % 4)
|
|
660
|
+
try:
|
|
661
|
+
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
|
662
|
+
except (ValueError, TypeError):
|
|
663
|
+
raise InvalidSearchCursor(external)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ── S7 entity operations: find / prompts / export / payload (§3) ──────────────
|
|
667
|
+
|
|
668
|
+
# Shared in-conversation find taxonomy (both providers; byte-equal tuples).
|
|
669
|
+
_FIND_KINDS = lcq._FIND_KINDS
|
|
670
|
+
# The whole-conversation export scope (the only scope a Codex ref accepts, §3.3).
|
|
671
|
+
_EXPORT_DEFAULT_SCOPE = "all"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _claude_find(
|
|
675
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str, query: str,
|
|
676
|
+
*, kind: str, regex: bool, case: bool,
|
|
677
|
+
) -> dict:
|
|
678
|
+
"""Claude ``neutral_find`` adapter (§3.1): wraps ``find_in_conversation`` and
|
|
679
|
+
re-anchors each uuid anchor to its neutral ``item_key`` — byte-equal to the
|
|
680
|
+
ones detail serves — so S8's FindBar navigates both providers with one
|
|
681
|
+
contract. Never emits ``normalization_pending`` (Claude is authoritative)."""
|
|
682
|
+
res = lcq.find_in_conversation(
|
|
683
|
+
conn, session_id, query, kind=kind, regex=regex, case=case)
|
|
684
|
+
if res is None:
|
|
685
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
686
|
+
anchors = [
|
|
687
|
+
{"item_key": _claude_item_key(session_id, a["uuid"]),
|
|
688
|
+
"match_kinds": a["match_kinds"]}
|
|
689
|
+
for a in res["anchors"]
|
|
690
|
+
]
|
|
691
|
+
return {
|
|
692
|
+
"status": "ok", "conversation_key": conversation_key,
|
|
693
|
+
"total": res["total"], "anchors": anchors,
|
|
694
|
+
"anchors_truncated": res["anchors_truncated"],
|
|
695
|
+
"search_depth": res["search_depth"], "kind": res["kind"], "mode": res["mode"],
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _claude_prompts(
|
|
700
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str
|
|
701
|
+
) -> dict:
|
|
702
|
+
"""Claude ``neutral_prompts`` adapter (§3.2): wraps ``get_conversation_prompts``
|
|
703
|
+
and re-anchors each uuid to its neutral ``item_key``."""
|
|
704
|
+
res = lcq.get_conversation_prompts(conn, session_id)
|
|
705
|
+
if res is None:
|
|
706
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
707
|
+
prompts = [
|
|
708
|
+
{"item_key": _claude_item_key(session_id, p["uuid"]), "text": p["text"]}
|
|
709
|
+
for p in res["prompts"]
|
|
710
|
+
]
|
|
711
|
+
return {"status": "ok", "conversation_key": conversation_key, "prompts": prompts}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _claude_export(
|
|
715
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str, scope: str
|
|
716
|
+
) -> dict:
|
|
717
|
+
"""Claude ``neutral_export`` adapter (§3.3): wraps ``get_conversation_export``,
|
|
718
|
+
passing ``scope`` through — all existing Claude scopes keep working unchanged."""
|
|
719
|
+
md = lcq.get_conversation_export(conn, session_id, scope)
|
|
720
|
+
if md is None:
|
|
721
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
722
|
+
return {"status": "ok", "conversation_key": conversation_key, "markdown": md}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def neutral_find(
|
|
726
|
+
conn: sqlite3.Connection, ref: str, query: str, *, kind: str = "all",
|
|
727
|
+
regex: bool = False, case: bool = False, effective_speed: str | None = None,
|
|
728
|
+
) -> dict:
|
|
729
|
+
"""In-conversation find for a neutral reference (§3.1). An unknown ``kind``
|
|
730
|
+
raises ``ValueError`` (route → 400); an unknown/garbage ref → ``not_found``.
|
|
731
|
+
Find never prices (``effective_speed`` accepted for signature symmetry)."""
|
|
732
|
+
del effective_speed # find never prices
|
|
733
|
+
if kind not in _FIND_KINDS:
|
|
734
|
+
raise ValueError(f"unknown kind: {kind}")
|
|
735
|
+
cref = resolve_conversation_ref(ref)
|
|
736
|
+
if cref is None:
|
|
737
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
738
|
+
if cref.source == "codex":
|
|
739
|
+
return q.find_in_codex_conversation(
|
|
740
|
+
conn, cref.conversation_key, query, kind=kind, regex=regex, case=case)
|
|
741
|
+
return _claude_find(
|
|
742
|
+
conn, cref.native_key, cref.conversation_key, query,
|
|
743
|
+
kind=kind, regex=regex, case=case)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def neutral_prompts(conn: sqlite3.Connection, ref: str) -> dict:
|
|
747
|
+
"""Prompt spine for a neutral reference (§3.2)."""
|
|
748
|
+
cref = resolve_conversation_ref(ref)
|
|
749
|
+
if cref is None:
|
|
750
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
751
|
+
if cref.source == "codex":
|
|
752
|
+
return q.codex_conversation_prompts(conn, cref.conversation_key)
|
|
753
|
+
return _claude_prompts(conn, cref.native_key, cref.conversation_key)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def neutral_export(
|
|
757
|
+
conn: sqlite3.Connection, ref: str, *, scope: str = _EXPORT_DEFAULT_SCOPE,
|
|
758
|
+
effective_speed: str | None = None,
|
|
759
|
+
) -> dict:
|
|
760
|
+
"""Whole-conversation Markdown export for a neutral reference (§3.3).
|
|
761
|
+
|
|
762
|
+
Claude passes ``scope`` through unchanged. The Codex adapter accepts ONLY the
|
|
763
|
+
default whole-conversation scope; any other scope value for a Codex ref is a
|
|
764
|
+
``validation_error`` status (the route/CLI maps it to 400 / exit 2), never a
|
|
765
|
+
silent fallback."""
|
|
766
|
+
cref = resolve_conversation_ref(ref)
|
|
767
|
+
if cref is None:
|
|
768
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
769
|
+
if cref.source == "codex":
|
|
770
|
+
if scope not in (None, _EXPORT_DEFAULT_SCOPE):
|
|
771
|
+
return {"status": "validation_error",
|
|
772
|
+
"conversation_key": cref.conversation_key,
|
|
773
|
+
"reason": "scope", "detail": scope}
|
|
774
|
+
speed = effective_speed or _DEFAULT_SPEED
|
|
775
|
+
return q.get_codex_conversation_export(
|
|
776
|
+
conn, cref.conversation_key, effective_speed=speed)
|
|
777
|
+
return _claude_export(
|
|
778
|
+
conn, cref.native_key, cref.conversation_key,
|
|
779
|
+
scope if scope is not None else _EXPORT_DEFAULT_SCOPE)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def neutral_payload(
|
|
783
|
+
conn: sqlite3.Connection, ref: str, *, which: str,
|
|
784
|
+
tool_use_id: str | None = None, block_key: str | None = None,
|
|
785
|
+
) -> dict:
|
|
786
|
+
"""Full-payload readback for a neutral reference (§3.4).
|
|
787
|
+
|
|
788
|
+
Provider-specific selectors: Claude keeps its existing contract (``tool_use_id``
|
|
789
|
+
+ ``which ∈ {input, result}``), Codex uses ``block_key`` + ``which ∈ {call,
|
|
790
|
+
output}``. Statuses: ``ok`` | ``not_found`` (404) | ``gone`` (410 — the physical
|
|
791
|
+
record moved/mutated)."""
|
|
792
|
+
cref = resolve_conversation_ref(ref)
|
|
793
|
+
if cref is None:
|
|
794
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
795
|
+
if cref.source == "codex":
|
|
796
|
+
if not block_key or which not in ("call", "output"):
|
|
797
|
+
return {"status": "not_found", "block_key": block_key, "which": which}
|
|
798
|
+
return q.read_codex_payload(conn, cref.conversation_key, block_key, which)
|
|
799
|
+
# Claude: tool_use_id + which={input,result}, contract unchanged.
|
|
800
|
+
if not tool_use_id or which not in ("input", "result"):
|
|
801
|
+
return {"status": "not_found", "tool_use_id": tool_use_id, "which": which}
|
|
802
|
+
loc = lcq.locate_tool_payload(conn, cref.native_key, tool_use_id, which)
|
|
803
|
+
if loc is None:
|
|
804
|
+
return {"status": "not_found", "tool_use_id": tool_use_id, "which": which}
|
|
805
|
+
source_path, byte_offset = loc
|
|
806
|
+
payload = lcq.read_full_payload(source_path, byte_offset, tool_use_id, which)
|
|
807
|
+
if payload is None:
|
|
808
|
+
return {"status": "gone", "tool_use_id": tool_use_id, "which": which}
|
|
809
|
+
return {"status": "ok", **payload}
|