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.
@@ -0,0 +1,1422 @@
1
+ """#294 S6 — Codex conversation query kernels (detail, outline, browse, search).
2
+
3
+ Builds on the pure normalization kernel ``_lib_codex_conversation`` (mirror
4
+ pairing, canonical item grouping, title derivation) to assemble the read-side
5
+ neutral envelopes S7 wires to routes. Every public function returns a complete
6
+ ``status``-tagged envelope per the §5.6 per-kernel status matrix.
7
+
8
+ Reads only (no ingest, no config reads). ``effective_speed`` is an explicit
9
+ kernel parameter — pricing edits and the fast-tier multiplier are resolved by
10
+ the caller at its I/O boundary, never here (§5.4).
11
+
12
+ Public names imported verbatim by the S7 dispatch layer — do not rename:
13
+ ``codex_normalization_authoritative``, ``codex_item_key``,
14
+ ``get_codex_conversation``, ``get_codex_conversation_outline``,
15
+ ``list_codex_conversations``, ``search_codex_conversations``,
16
+ ``CODEX_SEARCH_KINDS``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import sqlite3
25
+
26
+ import _lib_codex_conversation as kern
27
+ from _lib_conversation import _strip_ansi
28
+ from _lib_conversation_query import _FULL_PAYLOAD_CEILING, _first_nonblank_line
29
+ from _lib_pricing import _calculate_codex_entry_cost
30
+
31
+ # ── constants ────────────────────────────────────────────────────────────────
32
+
33
+ # Migration whose applied marker makes the normalized corpus authoritative
34
+ # (§3.5). Fresh caches stamp the full registry at creation, so they are always
35
+ # authoritative; a held-lock deferral leaves it pending.
36
+ CODEX_NORMALIZATION_MIGRATION = "025_codex_conversation_normalization"
37
+
38
+ # Domain separations for the opaque item-key encoding (§5.2). The source-path
39
+ # fingerprint is a domain-separated hash, NEVER a raw path (privacy-safe).
40
+ CODEX_ITEM_KEY_DOMAIN = b"cctally-codex-item-key-v1\0"
41
+ CODEX_ITEM_PATH_DOMAIN = b"cctally-codex-item-path-v1\0"
42
+ # S7 §3.4: opaque payload-block anchor over a tool_call row's row-class identity.
43
+ # Same domain-separated hash family as codex_item_key's row class, distinct domain.
44
+ CODEX_BLOCK_KEY_DOMAIN = b"cctally-codex-block-key-v1\0"
45
+
46
+ CODEX_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
47
+
48
+ # In-conversation find taxonomy (S7 §3.1) — byte-equal to the Claude _FIND_KINDS
49
+ # tuple (no title/files: those are cross-conversation search axes only).
50
+ CODEX_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
51
+
52
+ # Normalized-message column order — matches CodexNormalizedRow field order so a
53
+ # SELECT row splats straight into the dataclass.
54
+ _ROW_COLS = (
55
+ "conversation_key, source_root_key, source_path, line_offset, timestamp_utc, "
56
+ "turn_id, call_id, kind, event_type, record_family, model, text, "
57
+ "content_digest, content_len, detail_json, search_tool, search_thinking"
58
+ )
59
+
60
+ _SEARCH_BADGE = {
61
+ "user": "prompt",
62
+ "assistant": "assistant",
63
+ "reasoning": "thinking",
64
+ "tool_call": "tools",
65
+ "tool_output": "tools",
66
+ "event": "event",
67
+ }
68
+
69
+
70
+ # ── authority probe (§3.5) ────────────────────────────────────────────────────
71
+
72
+
73
+ def codex_normalization_authoritative(conn: sqlite3.Connection) -> bool:
74
+ """True iff migration 025 is stamped applied — i.e. the normalized corpus is
75
+ authoritative (§3.5). A missing ``schema_migrations`` table (bare
76
+ ``_apply_cache_schema`` conn, or a pre-migration cache) reads as pending."""
77
+ try:
78
+ row = conn.execute(
79
+ "SELECT 1 FROM schema_migrations WHERE name = ?",
80
+ (CODEX_NORMALIZATION_MIGRATION,),
81
+ ).fetchone()
82
+ except sqlite3.OperationalError:
83
+ return False
84
+ return row is not None
85
+
86
+
87
+ # ── item key (§5.2) ───────────────────────────────────────────────────────────
88
+
89
+
90
+ def _source_path_fingerprint(source_path: str | None) -> str:
91
+ """Domain-separated hash of a source path — never the raw path (§5.2)."""
92
+ return hashlib.sha256(
93
+ CODEX_ITEM_PATH_DOMAIN + (source_path or "").encode("utf-8")
94
+ ).hexdigest()[:16]
95
+
96
+
97
+ def codex_item_key(
98
+ conversation_key: str,
99
+ *,
100
+ klass: str,
101
+ turn_id: str | None,
102
+ source_path: str | None,
103
+ line_offset: int | None,
104
+ content_digest: str | None,
105
+ ) -> str:
106
+ """Opaque, qualified, ordinal-free item anchor (§5.2).
107
+
108
+ Response items key on durable native-turn identity ``(conversation_key,
109
+ "turn", turn_id)`` — same-turn content replacement keeps the key. Prompt /
110
+ event / unturned items key on the canonical member's stable physical
111
+ identity + content: ``(conversation_key, "row", fingerprint(source_path),
112
+ line_offset, content_digest)`` — no population-relative ordinals, so
113
+ deleting an earlier duplicate or an out-of-order multi-file append never
114
+ moves an existing key, and a same-offset content replacement changes it.
115
+ """
116
+ if klass == "response":
117
+ parts = ("turn", conversation_key or "", turn_id or "")
118
+ else:
119
+ parts = (
120
+ "row",
121
+ conversation_key or "",
122
+ _source_path_fingerprint(source_path),
123
+ "" if line_offset is None else str(line_offset),
124
+ content_digest or "",
125
+ )
126
+ raw = "\x00".join(parts).encode("utf-8")
127
+ return "civ1_" + hashlib.sha256(CODEX_ITEM_KEY_DOMAIN + raw).hexdigest()[:40]
128
+
129
+
130
+ def _item_key_for_item(conversation_key: str, item: dict) -> str:
131
+ if item["klass"] == "response":
132
+ return codex_item_key(
133
+ conversation_key, klass="response", turn_id=item["turn_id"],
134
+ source_path=None, line_offset=None, content_digest=None)
135
+ anchor = item["anchor_row"]
136
+ return codex_item_key(
137
+ conversation_key, klass=item["klass"], turn_id=item["turn_id"],
138
+ source_path=anchor.source_path, line_offset=anchor.line_offset,
139
+ content_digest=anchor.content_digest)
140
+
141
+
142
+ def codex_block_key(
143
+ conversation_key: str,
144
+ *,
145
+ source_path: str | None,
146
+ line_offset: int | None,
147
+ content_digest: str | None,
148
+ ) -> str:
149
+ """Opaque, ordinal-free payload-block anchor over a tool_call row's row-class
150
+ identity (§3.4). Same domain-separated hash family as ``codex_item_key``'s row
151
+ class — ``(conversation_key, fingerprint(source_path), line_offset,
152
+ content_digest)`` — with a DISTINCT domain, so a block key never collides with
153
+ an item key. Stable per block, unique per tool_call physical row: a same-offset
154
+ content replacement changes it (content_digest moves), an out-of-order append
155
+ elsewhere leaves it (no population-relative ordinals)."""
156
+ parts = (
157
+ conversation_key or "",
158
+ _source_path_fingerprint(source_path),
159
+ "" if line_offset is None else str(line_offset),
160
+ content_digest or "",
161
+ )
162
+ raw = "\x00".join(parts).encode("utf-8")
163
+ return "cbk1_" + hashlib.sha256(CODEX_BLOCK_KEY_DOMAIN + raw).hexdigest()[:40]
164
+
165
+
166
+ def _block_key_for_row(row) -> str:
167
+ return codex_block_key(
168
+ row.conversation_key, source_path=row.source_path,
169
+ line_offset=row.line_offset, content_digest=row.content_digest)
170
+
171
+
172
+ # ── row loading + display helpers ─────────────────────────────────────────────
173
+
174
+
175
+ def _load_conversation_rows(conn: sqlite3.Connection, conversation_key: str) -> list:
176
+ """A conversation's normalized rows (all files) in physical order — the same
177
+ ``(timestamp_utc, source_path, line_offset)`` order the ingest/rollup writer
178
+ feeds the kernel, so pairing/grouping converge with the stored rollup."""
179
+ return [
180
+ kern.CodexNormalizedRow(*row)
181
+ for row in conn.execute(
182
+ "SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
183
+ "WHERE conversation_key = ? "
184
+ "ORDER BY timestamp_utc, source_path, line_offset",
185
+ (conversation_key,),
186
+ )
187
+ ]
188
+
189
+
190
+ def _row_display(row) -> str:
191
+ """The row's display/search text from whichever column carries it."""
192
+ return row.text or row.search_thinking or row.search_tool or ""
193
+
194
+
195
+ def _parse_detail(detail_json: str | None):
196
+ if not detail_json:
197
+ return None
198
+ try:
199
+ return json.loads(detail_json)
200
+ except (json.JSONDecodeError, TypeError):
201
+ return None
202
+
203
+
204
+ def _item_kind(item: dict) -> str:
205
+ klass = item["klass"]
206
+ if klass == "prompt":
207
+ return "user"
208
+ if klass == "response":
209
+ return "assistant"
210
+ if klass == "event":
211
+ return "event"
212
+ return item["anchor_row"].kind # unturned: the row's own provider kind
213
+
214
+
215
+ def _item_blocks_with_rows(item: dict) -> list[list]:
216
+ """Assemble an item's blocks (the historical ``_build_item_blocks`` behaviour)
217
+ AND expose each block's underlying rows, so the detail renderer and the payload
218
+ locator (§3.4) share ONE folding rule. Each entry is a 3-list
219
+ ``[block_dict, primary_row, output_row_or_None]``: a ``tool_output`` folds into
220
+ a preceding ``tool_call`` block only when its ``call_id`` is non-empty, owned by
221
+ exactly one tool_call, and that call was already seen (call precedes output).
222
+ Physical order within the item is preserved.
223
+
224
+ Every ``tool_call`` block additionally carries an opaque ``block_key`` (§3.4) —
225
+ the payload-capable anchor. Non-tool blocks carry no ``block_key``."""
226
+ rows = item["rows"]
227
+ call_owner_count: dict[str, int] = {}
228
+ for r in rows:
229
+ if r.kind == "tool_call" and r.call_id:
230
+ call_owner_count[r.call_id] = call_owner_count.get(r.call_id, 0) + 1
231
+ entries: list[list] = []
232
+ tool_entry_by_call: dict[str, int] = {}
233
+ for r in rows:
234
+ text = _row_display(r)
235
+ detail = _parse_detail(r.detail_json)
236
+ if (r.kind == "tool_output" and r.call_id
237
+ and call_owner_count.get(r.call_id, 0) == 1
238
+ and r.call_id in tool_entry_by_call):
239
+ owner = entries[tool_entry_by_call[r.call_id]]
240
+ owner[0]["output"] = {"text": text, "detail": detail}
241
+ owner[2] = r
242
+ continue
243
+ block = {
244
+ "kind": r.kind, "text": text, "detail": detail,
245
+ "call_id": r.call_id, "timestamp_utc": r.timestamp_utc,
246
+ }
247
+ if r.kind == "tool_call":
248
+ block["block_key"] = _block_key_for_row(r)
249
+ if r.call_id and call_owner_count.get(r.call_id, 0) == 1:
250
+ tool_entry_by_call[r.call_id] = len(entries)
251
+ entries.append([block, r, None])
252
+ return entries
253
+
254
+
255
+ def _build_item_blocks(item: dict) -> list[dict]:
256
+ """Assemble an item's blocks, folding each ``tool_output`` into its
257
+ ``tool_call`` block via ``call_id`` when that call_id has exactly one owner
258
+ (§5.2). Physical order within the item is preserved. Thin projection of
259
+ ``_item_blocks_with_rows`` — the single source of truth for the folding rule."""
260
+ return [entry[0] for entry in _item_blocks_with_rows(item)]
261
+
262
+
263
+ # ── tokens union (§5.6) ───────────────────────────────────────────────────────
264
+
265
+
266
+ def _zero_tokens() -> dict:
267
+ return {"input": 0, "output": 0, "cached_input": 0, "reasoning_output": 0}
268
+
269
+
270
+ def _add_tokens(acc: dict, inp: int, out: int, cin: int, rout: int) -> None:
271
+ acc["input"] += inp or 0
272
+ acc["output"] += out or 0
273
+ acc["cached_input"] += cin or 0
274
+ acc["reasoning_output"] += rout or 0
275
+
276
+
277
+ def _tokens_union(tokens: dict) -> dict:
278
+ """Source-tagged provider union — native Codex fields only, never Claude
279
+ cache vocabulary (§5.6 / S0)."""
280
+ return {
281
+ "source": "codex",
282
+ "input": tokens["input"],
283
+ "output": tokens["output"],
284
+ "cached_input": tokens["cached_input"],
285
+ "reasoning_output": tokens["reasoning_output"],
286
+ }
287
+
288
+
289
+ # ── cost attribution (§5.4) ───────────────────────────────────────────────────
290
+
291
+
292
+ def _file_boundaries(conn: sqlite3.Connection, source_path: str) -> list[tuple[int, str | None]]:
293
+ """Ordered ``(line_offset, active_turn)`` boundaries for one file — a
294
+ ``turn_context`` sets the effective turn, a ``session_meta`` resets it to
295
+ ``None`` (a new un-turned segment). Read from the retained physical events so
296
+ an accounting row that precedes the first normalized message still attributes
297
+ to the turn ``turn_context`` already opened (§5.4)."""
298
+ boundaries: list[tuple[int, str | None]] = []
299
+ for off, rtype, tid, payload_json in conn.execute(
300
+ "SELECT line_offset, record_type, turn_id, payload_json "
301
+ "FROM codex_conversation_events "
302
+ "WHERE source_path = ? AND record_type IN ('turn_context','session_meta') "
303
+ "ORDER BY line_offset",
304
+ (source_path,),
305
+ ):
306
+ if rtype == "session_meta":
307
+ boundaries.append((off, None))
308
+ continue
309
+ turn = tid
310
+ if turn is None:
311
+ try:
312
+ payload = (json.loads(payload_json or "{}").get("payload") or {})
313
+ turn = payload.get("turn_id")
314
+ except (json.JSONDecodeError, TypeError, AttributeError):
315
+ turn = None
316
+ boundaries.append((off, turn))
317
+ return boundaries
318
+
319
+
320
+ def _turn_at(boundaries: list[tuple[int, str | None]], offset: int) -> str | None:
321
+ """Nearest preceding turn boundary by file offset (§5.4); ``None`` when the
322
+ row precedes any turn (or sits in an un-turned segment)."""
323
+ turn: str | None = None
324
+ for boff, bturn in boundaries:
325
+ if boff <= offset:
326
+ turn = bturn
327
+ else:
328
+ break
329
+ return turn
330
+
331
+
332
+ def _attribute_costs(conn: sqlite3.Connection, conversation_key: str, effective_speed: str):
333
+ """Attribute each ``codex_session_entries`` row (selected by
334
+ ``conversation_key``) to its nearest-preceding turn, priced unrounded under
335
+ ``effective_speed`` (§5.4). Rows preceding any turn land in the explicit
336
+ unattributed bucket. Returns
337
+ ``(turn_cost, turn_tokens, unattributed_cost, unattributed_tokens, total, conv_tokens)``.
338
+ """
339
+ entries = conn.execute(
340
+ "SELECT source_path, line_offset, model, input_tokens, cached_input_tokens, "
341
+ "output_tokens, reasoning_output_tokens FROM codex_session_entries "
342
+ "WHERE conversation_key = ? ORDER BY source_path, line_offset",
343
+ (conversation_key,),
344
+ ).fetchall()
345
+ boundaries: dict[str, list] = {}
346
+ for source_path in {e[0] for e in entries}:
347
+ boundaries[source_path] = _file_boundaries(conn, source_path)
348
+ turn_cost: dict[str, float] = {}
349
+ turn_tokens: dict[str, dict] = {}
350
+ unattr_cost = 0.0
351
+ unattr_tokens = _zero_tokens()
352
+ total = 0.0
353
+ conv_tokens = _zero_tokens()
354
+ for source_path, offset, model, inp, cin, out, rout in entries:
355
+ priced = _calculate_codex_entry_cost(
356
+ model or "", inp or 0, cin or 0, out or 0, rout or 0, speed=effective_speed)
357
+ total += priced
358
+ _add_tokens(conv_tokens, inp, out, cin, rout)
359
+ turn = _turn_at(boundaries.get(source_path, []), offset)
360
+ if turn is not None:
361
+ turn_cost[turn] = turn_cost.get(turn, 0.0) + priced
362
+ _add_tokens(turn_tokens.setdefault(turn, _zero_tokens()), inp, out, cin, rout)
363
+ else:
364
+ unattr_cost += priced
365
+ _add_tokens(unattr_tokens, inp, out, cin, rout)
366
+ return turn_cost, turn_tokens, unattr_cost, unattr_tokens, total, conv_tokens
367
+
368
+
369
+ def _conversation_total_cost(conn: sqlite3.Connection, conversation_key: str, effective_speed: str) -> float:
370
+ """Lean priced total over a conversation's accounting rows (browse rows,
371
+ child summaries) — same primitive as ``_attribute_costs`` (§5.4)."""
372
+ total = 0.0
373
+ for model, inp, cin, out, rout in conn.execute(
374
+ "SELECT model, input_tokens, cached_input_tokens, output_tokens, "
375
+ "reasoning_output_tokens FROM codex_session_entries WHERE conversation_key = ?",
376
+ (conversation_key,),
377
+ ):
378
+ total += _calculate_codex_entry_cost(
379
+ model or "", inp or 0, cin or 0, out or 0, rout or 0, speed=effective_speed)
380
+ return total
381
+
382
+
383
+ # ── rollup fields (dual-branch: stored fast path vs live recompute) ───────────
384
+
385
+
386
+ def _thread_facts(conn: sqlite3.Connection, conversation_key: str):
387
+ """``(native, root, parent, source_root_key, cwd, git_json)`` for a
388
+ conversation's thread, or ``None`` (no thread row / not-yet-linked)."""
389
+ return conn.execute(
390
+ "SELECT native_thread_id, root_thread_id, parent_thread_id, source_root_key, "
391
+ "cwd, git_json FROM codex_conversation_threads WHERE conversation_key = ?",
392
+ (conversation_key,),
393
+ ).fetchone()
394
+
395
+
396
+ def _rollup_fields(conn: sqlite3.Connection, conversation_key: str, rows: list | None = None):
397
+ """Rollup fields for a conversation — the stored rollup row when present
398
+ (fast path), else a LIVE recompute that reproduces ``_recompute_codex_rollups``
399
+ EXACTLY (§3.2 / §6.1): same kernel helpers (``rollup_item_count``,
400
+ ``derive_title``), same min/max/sorted, and the SAME
401
+ ``_codex_conversation_project_attribution`` the writer uses. Returns ``None``
402
+ when the conversation has no normalized rows."""
403
+ stored = conn.execute(
404
+ "SELECT item_count, started_utc, last_activity_utc, project_key, project_label, "
405
+ "models_json, title, parent_thread_id, source_root_key "
406
+ "FROM codex_conversation_rollups WHERE conversation_key = ?",
407
+ (conversation_key,),
408
+ ).fetchone()
409
+ thread = _thread_facts(conn, conversation_key)
410
+ native = thread[0] if thread else None
411
+ if stored is not None:
412
+ item_count, started, last, project_key, project_label, models_json, title, parent, srk = stored
413
+ models = json.loads(models_json) if models_json else []
414
+ return {
415
+ "item_count": item_count, "started": started, "last": last,
416
+ "project_key": project_key, "project_label": project_label,
417
+ "models": models, "title": title, "parent_thread_id": parent,
418
+ "source_root_key": srk, "native_thread_id": native,
419
+ }
420
+ # Live recompute — MUST mirror _recompute_codex_rollups in _cctally_cache.
421
+ if rows is None:
422
+ rows = _load_conversation_rows(conn, conversation_key)
423
+ if not rows:
424
+ return None
425
+ item_count = kern.rollup_item_count(rows)
426
+ title = kern.derive_title(rows)
427
+ timestamps = [r.timestamp_utc for r in rows if r.timestamp_utc]
428
+ started = min(timestamps) if timestamps else None
429
+ last = max(timestamps) if timestamps else None
430
+ models = sorted({r.model for r in rows if r.model})
431
+ source_root_key = rows[0].source_root_key
432
+ cwd = git_json = parent = None
433
+ if thread is not None:
434
+ native, _root, parent, thread_root, cwd, git_json = thread
435
+ if thread_root:
436
+ source_root_key = thread_root
437
+ from _cctally_cache import _codex_conversation_project_attribution
438
+ project_key, project_label = _codex_conversation_project_attribution(
439
+ source_root_key, cwd, git_json)
440
+ return {
441
+ "item_count": item_count, "started": started, "last": last,
442
+ "project_key": project_key, "project_label": project_label,
443
+ "models": models, "title": title, "parent_thread_id": parent,
444
+ "source_root_key": source_root_key, "native_thread_id": native,
445
+ }
446
+
447
+
448
+ def _short_native(native: str | None) -> str:
449
+ return (native or "")[:8]
450
+
451
+
452
+ def _display_chain(fields: dict) -> str:
453
+ """Read-time display fallback (§4.3): stored title → project_label → short
454
+ native-thread-id prefix."""
455
+ return fields.get("title") or fields.get("project_label") or _short_native(
456
+ fields.get("native_thread_id")) or ""
457
+
458
+
459
+ def _conversation_display_title(conn: sqlite3.Connection, conversation_key: str, rows: list | None = None) -> str:
460
+ fields = _rollup_fields(conn, conversation_key, rows=rows)
461
+ if fields is None:
462
+ return ""
463
+ return _display_chain(fields)
464
+
465
+
466
+ def _conversation_hit_fields(conn: sqlite3.Connection, conversation_key: str):
467
+ """``(title, last_activity_utc, project_label)`` for a search hit's conversation
468
+ (§3.7). ONE ``_rollup_fields`` resolution (stored fast path or the identical
469
+ live recompute), so the neutral search hit carries the conversation-level
470
+ last-activity time (explicitly NOT the matched row's own timestamp) and a
471
+ nullable project label without a per-row lookup."""
472
+ fields = _rollup_fields(conn, conversation_key)
473
+ if fields is None:
474
+ return "", None, None
475
+ return _display_chain(fields), fields.get("last"), fields.get("project_label")
476
+
477
+
478
+ # ── threading (§5.5) ──────────────────────────────────────────────────────────
479
+
480
+
481
+ def _child_summary(conn: sqlite3.Connection, conversation_key: str, effective_speed: str) -> dict:
482
+ fields = _rollup_fields(conn, conversation_key)
483
+ return {
484
+ "conversation_key": conversation_key,
485
+ "title": _display_chain(fields) if fields else "",
486
+ "started_utc": fields["started"] if fields else None,
487
+ "last_activity_utc": fields["last"] if fields else None,
488
+ "item_count": fields["item_count"] if fields else 0,
489
+ "cost_usd": _conversation_total_cost(conn, conversation_key, effective_speed),
490
+ }
491
+
492
+
493
+ def _children_of(conn: sqlite3.Connection, conversation_key: str, effective_speed: str) -> list[dict]:
494
+ """Same-root threads whose ``parent_thread_id`` equals this thread's native
495
+ id (§5.5). Never a filename inference — metadata only."""
496
+ thread = _thread_facts(conn, conversation_key)
497
+ if thread is None:
498
+ return []
499
+ native, _root, _parent, source_root_key, _cwd, _git = thread
500
+ children = [
501
+ _child_summary(conn, child_ck, effective_speed)
502
+ for (child_ck,) in conn.execute(
503
+ "SELECT conversation_key FROM codex_conversation_threads "
504
+ "WHERE source_root_key = ? AND parent_thread_id = ? AND conversation_key != ?",
505
+ (source_root_key, native, conversation_key),
506
+ )
507
+ ]
508
+ children.sort(key=lambda c: (c["last_activity_utc"] or "", c["conversation_key"]))
509
+ return children
510
+
511
+
512
+ def _parent_of(conn: sqlite3.Connection, conversation_key: str):
513
+ """Parent pointer (§5.5): the same-root thread whose native id equals this
514
+ thread's ``parent_thread_id``. A root (parent == self, or absent) has none;
515
+ a fork whose parent is not ingested also returns ``None`` (no key to point
516
+ at)."""
517
+ thread = _thread_facts(conn, conversation_key)
518
+ if thread is None:
519
+ return None
520
+ native, _root, parent, source_root_key, _cwd, _git = thread
521
+ if not parent or parent == native:
522
+ return None
523
+ prow = conn.execute(
524
+ "SELECT conversation_key FROM codex_conversation_threads "
525
+ "WHERE source_root_key = ? AND native_thread_id = ? AND conversation_key != ?",
526
+ (source_root_key, parent, conversation_key),
527
+ ).fetchone()
528
+ if prow is None:
529
+ return None
530
+ parent_ck = prow[0]
531
+ return {"conversation_key": parent_ck, "title": _conversation_display_title(conn, parent_ck)}
532
+
533
+
534
+ def codex_conversation_exists(conn: sqlite3.Connection, conversation_key: str) -> bool:
535
+ """Cheap existence probe (spec §5.2) — True iff any normalized
536
+ ``codex_conversation_messages`` row carries ``conversation_key``. Used by the
537
+ live-tail SSE preflight for the neutral existence decision. A missing table
538
+ (bare ``_apply_cache_schema`` conn) reads as absent."""
539
+ try:
540
+ row = conn.execute(
541
+ "SELECT 1 FROM codex_conversation_messages "
542
+ "WHERE conversation_key = ? LIMIT 1",
543
+ (conversation_key,),
544
+ ).fetchone()
545
+ except sqlite3.OperationalError:
546
+ return False
547
+ return row is not None
548
+
549
+
550
+ def codex_conversation_source_paths(
551
+ conn: sqlite3.Connection, conversation_key: str
552
+ ) -> list[str]:
553
+ """Distinct ``source_path``s backing one Codex conversation (spec §5.3): its
554
+ OWN normalized rows plus its CURRENT children's (children resolved via
555
+ ``codex_conversation_threads`` parent links — same-root threads whose
556
+ ``parent_thread_id`` equals this thread's native id, never a filename
557
+ inference). This is the file set the live-tail watch loop polls; it widens as
558
+ a child thread is ingested. Empty for an unknown / not-yet-normalized
559
+ conversation."""
560
+ keys = [conversation_key]
561
+ thread = _thread_facts(conn, conversation_key)
562
+ if thread is not None:
563
+ native, _root, _parent, source_root_key, _cwd, _git = thread
564
+ if native is not None:
565
+ keys.extend(
566
+ child_ck
567
+ for (child_ck,) in conn.execute(
568
+ "SELECT conversation_key FROM codex_conversation_threads "
569
+ "WHERE source_root_key = ? AND parent_thread_id = ? "
570
+ "AND conversation_key != ?",
571
+ (source_root_key, native, conversation_key),
572
+ )
573
+ )
574
+ placeholders = ",".join("?" for _ in keys)
575
+ try:
576
+ rows = conn.execute(
577
+ "SELECT DISTINCT source_path FROM codex_conversation_messages "
578
+ f"WHERE conversation_key IN ({placeholders}) "
579
+ "AND source_path IS NOT NULL",
580
+ keys,
581
+ ).fetchall()
582
+ except sqlite3.OperationalError:
583
+ return []
584
+ paths: list[str] = []
585
+ seen: set[str] = set()
586
+ for (sp,) in rows:
587
+ if sp not in seen:
588
+ seen.add(sp)
589
+ paths.append(sp)
590
+ return paths
591
+
592
+
593
+ # ── detail assembly (§5.2 / §5.4 / §5.6) ──────────────────────────────────────
594
+
595
+
596
+ def _paginate_items(items: list[dict], *, after, before, tail, limit):
597
+ keys = [it["item_key"] for it in items]
598
+ lo, hi = 0, len(items)
599
+ if after is not None and after in keys:
600
+ lo = keys.index(after) + 1
601
+ if before is not None and before in keys:
602
+ hi = keys.index(before)
603
+ window = items[lo:hi]
604
+ if tail is not None:
605
+ cap = min(tail, limit) if limit else tail
606
+ window = window[-cap:] if cap else window
607
+ elif limit:
608
+ window = window[:limit]
609
+ first_key = window[0]["item_key"] if window else None
610
+ last_key = window[-1]["item_key"] if window else None
611
+ has_before = bool(window) and keys.index(first_key) > 0
612
+ has_after = bool(window) and keys.index(last_key) < len(items) - 1
613
+ page = {
614
+ "total": len(items), "returned": len(window),
615
+ "before": first_key if has_before else None,
616
+ "after": last_key if has_after else None,
617
+ "has_before": has_before, "has_after": has_after,
618
+ }
619
+ return window, page
620
+
621
+
622
+ def get_codex_conversation(
623
+ conn: sqlite3.Connection,
624
+ conversation_key: str,
625
+ *,
626
+ effective_speed: str,
627
+ after: str | None = None,
628
+ before: str | None = None,
629
+ tail: int | None = None,
630
+ limit: int = 200,
631
+ ) -> dict:
632
+ """Detail envelope (§5.6): status ``ok`` | ``normalization_pending`` |
633
+ ``not_found``. ``ok`` carries canonical items (mirror-paired, tool-folded),
634
+ per-turn cost with an explicit unattributed bucket, threading, and a page
635
+ over ``item_key``."""
636
+ if not codex_normalization_authoritative(conn):
637
+ return {"status": "normalization_pending", "conversation_key": conversation_key,
638
+ "items": [], "children": []}
639
+ rows = _load_conversation_rows(conn, conversation_key)
640
+ if not rows:
641
+ return {"status": "not_found", "conversation_key": conversation_key}
642
+ kept, _suppressed = kern.pair_mirrors(rows)
643
+ items = kern.canonical_items(kept)
644
+ turn_cost, turn_tokens, unattr_cost, unattr_tokens, total, conv_tokens = _attribute_costs(
645
+ conn, conversation_key, effective_speed)
646
+ # Carrier item per turn: prefer the response item, else the first item of the
647
+ # turn — so every priced turn's cost lands on exactly one item (§5.4 reconcile).
648
+ carriers: dict[str, int] = {}
649
+ for idx, it in enumerate(items):
650
+ if it["klass"] == "response" and it["turn_id"] is not None and it["turn_id"] not in carriers:
651
+ carriers[it["turn_id"]] = idx
652
+ for idx, it in enumerate(items):
653
+ if it["turn_id"] is not None and it["turn_id"] not in carriers:
654
+ carriers[it["turn_id"]] = idx
655
+ # Turns with cost but no carrier item fold into the unattributed bucket.
656
+ leftover_cost = 0.0
657
+ for turn, cost in turn_cost.items():
658
+ if turn not in carriers:
659
+ leftover_cost += cost
660
+ unattributed_cost = unattr_cost + leftover_cost
661
+ built: list[dict] = []
662
+ for idx, it in enumerate(items):
663
+ turn = it["turn_id"]
664
+ cost = None
665
+ tokens = None
666
+ if turn is not None and carriers.get(turn) == idx and turn in turn_cost:
667
+ cost = turn_cost[turn]
668
+ tokens = _tokens_union(turn_tokens[turn])
669
+ built.append({
670
+ "item_key": _item_key_for_item(conversation_key, it),
671
+ "kind": _item_kind(it),
672
+ "timestamp_utc": it["anchor_row"].timestamp_utc,
673
+ "model": it["anchor_row"].model,
674
+ "blocks": _build_item_blocks(it),
675
+ "cost_usd": cost,
676
+ "tokens": tokens,
677
+ })
678
+ page_items, page = _paginate_items(built, after=after, before=before, tail=tail, limit=limit)
679
+ return {
680
+ "status": "ok",
681
+ "conversation_key": conversation_key,
682
+ "title": _conversation_display_title(conn, conversation_key, rows),
683
+ "items": page_items,
684
+ "page": page,
685
+ "children": _children_of(conn, conversation_key, effective_speed),
686
+ "parent": _parent_of(conn, conversation_key),
687
+ "total_cost_usd": total,
688
+ "unattributed_cost_usd": unattributed_cost,
689
+ "tokens": _tokens_union(conv_tokens),
690
+ }
691
+
692
+
693
+ # ── outline assembly (§5.6) ───────────────────────────────────────────────────
694
+
695
+
696
+ def _conversation_files(conn: sqlite3.Connection, conversation_key: str) -> list[dict]:
697
+ return [
698
+ {"file_path": fp, "tool": tool, "count": count}
699
+ for fp, tool, count in conn.execute(
700
+ "SELECT file_path, tool, COUNT(*) FROM codex_conversation_file_touches "
701
+ "WHERE conversation_key = ? GROUP BY file_path, tool ORDER BY file_path, tool",
702
+ (conversation_key,),
703
+ )
704
+ ]
705
+
706
+
707
+ def get_codex_conversation_outline(
708
+ conn: sqlite3.Connection, conversation_key: str, *, effective_speed: str
709
+ ) -> dict:
710
+ """Outline envelope (§5.6): one ``turns[]`` entry per canonical item (label
711
+ via the shared first-non-blank-line helper), plus stats, file touches, and
712
+ child summaries."""
713
+ if not codex_normalization_authoritative(conn):
714
+ return {"status": "normalization_pending", "conversation_key": conversation_key,
715
+ "turns": [], "files": [], "children": []}
716
+ rows = _load_conversation_rows(conn, conversation_key)
717
+ if not rows:
718
+ return {"status": "not_found", "conversation_key": conversation_key}
719
+ kept, _suppressed = kern.pair_mirrors(rows)
720
+ items = kern.canonical_items(kept)
721
+ turns: list[dict] = []
722
+ kind_totals: dict[str, int] = {}
723
+ for it in items:
724
+ anchor_text = _row_display(it["anchor_row"])
725
+ label = _first_nonblank_line(_strip_ansi(anchor_text)) if anchor_text else ""
726
+ kinds: dict[str, int] = {}
727
+ for r in it["rows"]:
728
+ kinds[r.kind] = kinds.get(r.kind, 0) + 1
729
+ kind_totals[r.kind] = kind_totals.get(r.kind, 0) + 1
730
+ turns.append({
731
+ "item_key": _item_key_for_item(conversation_key, it),
732
+ "label": label,
733
+ "timestamp_utc": it["anchor_row"].timestamp_utc,
734
+ "kinds": kinds,
735
+ })
736
+ return {
737
+ "status": "ok",
738
+ "conversation_key": conversation_key,
739
+ "turns": turns,
740
+ "stats": {"items": len(items), "kinds": kind_totals},
741
+ "files": _conversation_files(conn, conversation_key),
742
+ "children": _children_of(conn, conversation_key, effective_speed),
743
+ }
744
+
745
+
746
+ # ── browse (§6.1) ─────────────────────────────────────────────────────────────
747
+
748
+
749
+ def _is_fork(fields: dict) -> bool:
750
+ parent = fields.get("parent_thread_id")
751
+ return bool(parent) and parent != fields.get("native_thread_id")
752
+
753
+
754
+ def _browse_row(conn: sqlite3.Connection, conversation_key: str, effective_speed: str, fields: dict) -> dict:
755
+ return {
756
+ "conversation_key": conversation_key,
757
+ "title": _display_chain(fields),
758
+ "project_key": fields["project_key"],
759
+ "project_label": fields["project_label"],
760
+ "started_utc": fields["started"],
761
+ "last_activity_utc": fields["last"],
762
+ "count": fields["item_count"],
763
+ "cost_usd": _conversation_total_cost(conn, conversation_key, effective_speed),
764
+ "models": list(fields["models"]),
765
+ "parent": _parent_of(conn, conversation_key),
766
+ "is_fork": _is_fork(fields),
767
+ }
768
+
769
+
770
+ def _browse_facets(rows: list[dict]) -> dict:
771
+ """Projects grouped by opaque ``project_key`` (S3 collision-safe — same-label
772
+ distinct roots never merge), models by native model name (§6.1)."""
773
+ projects: dict[str, list] = {}
774
+ models: dict[str, int] = {}
775
+ for row in rows:
776
+ pkey = row["project_key"]
777
+ if pkey:
778
+ entry = projects.setdefault(pkey, [row["project_label"], 0])
779
+ entry[1] += 1
780
+ for model in row["models"] or []:
781
+ models[model] = models.get(model, 0) + 1
782
+ project_facets = [
783
+ {"project_key": pkey, "project_label": label, "count": count}
784
+ for pkey, (label, count) in sorted(
785
+ projects.items(), key=lambda kv: ((kv[1][0] or ""), kv[0]))
786
+ ]
787
+ model_facets = [
788
+ {"model": model, "count": count} for model, count in sorted(models.items())
789
+ ]
790
+ return {"projects": project_facets, "models": model_facets}
791
+
792
+
793
+ def _recent_sort_key(row: dict):
794
+ return (row["last_activity_utc"] or "", row["conversation_key"])
795
+
796
+
797
+ def _paginate_rows(rows: list[dict], *, cursor: str | None, limit: int):
798
+ lo = 0
799
+ if cursor is not None:
800
+ keys = [r["conversation_key"] for r in rows]
801
+ if cursor in keys:
802
+ lo = keys.index(cursor) + 1
803
+ window = rows[lo:lo + limit] if limit else rows[lo:]
804
+ has_more = (lo + len(window)) < len(rows)
805
+ next_cursor = window[-1]["conversation_key"] if (window and has_more) else None
806
+ page = {"total": len(rows), "returned": len(window), "cursor": next_cursor}
807
+ return window, page
808
+
809
+
810
+ def list_codex_conversations(
811
+ conn: sqlite3.Connection,
812
+ *,
813
+ effective_speed: str,
814
+ project_key: str | None = None,
815
+ model: str | None = None,
816
+ limit: int = 50,
817
+ cursor: str | None = None,
818
+ ) -> dict:
819
+ """Browse envelope (§5.6 / §6.1): a page of conversation rows ordered by last
820
+ activity, with project/model facets. Dual-branch — the stored rollup fast
821
+ path when a rollup row is present, else a live recompute that reproduces the
822
+ writer exactly (never an empty rail). Facets are computed over the full set
823
+ (before filtering) so filter options stay available. Pending status while
824
+ migration 025 has not run."""
825
+ if not codex_normalization_authoritative(conn):
826
+ return {"status": "normalization_pending", "rows": [],
827
+ "facets": {"projects": [], "models": []}, "page": {"total": 0}}
828
+ keys = [r[0] for r in conn.execute(
829
+ "SELECT DISTINCT conversation_key FROM codex_conversation_messages")]
830
+ rows: list[dict] = []
831
+ for conversation_key in keys:
832
+ fields = _rollup_fields(conn, conversation_key)
833
+ if fields is None:
834
+ continue
835
+ rows.append(_browse_row(conn, conversation_key, effective_speed, fields))
836
+ facets = _browse_facets(rows)
837
+ filtered = [
838
+ row for row in rows
839
+ if (project_key is None or row["project_key"] == project_key)
840
+ and (model is None or model in (row["models"] or []))
841
+ ]
842
+ filtered.sort(key=_recent_sort_key, reverse=True)
843
+ page_rows, page = _paginate_rows(filtered, cursor=cursor, limit=limit)
844
+ return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
845
+
846
+
847
+ # ── search (§6.2) ─────────────────────────────────────────────────────────────
848
+
849
+
850
+ def _search_mode(conn: sqlite3.Connection) -> str:
851
+ """Honest search mode: ``like`` when the Codex FTS marker is set or the FTS
852
+ vtable is unusable, else ``fts`` (§3.4 / §6.2)."""
853
+ try:
854
+ unavailable = conn.execute(
855
+ "SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'").fetchone() is not None
856
+ except sqlite3.OperationalError:
857
+ unavailable = True
858
+ if unavailable:
859
+ return "like"
860
+ try:
861
+ conn.execute("SELECT 1 FROM codex_conversation_fts LIMIT 1")
862
+ except sqlite3.OperationalError:
863
+ return "like"
864
+ return "fts"
865
+
866
+
867
+ def _pos_to_item_key(conn: sqlite3.Connection, conversation_key: str) -> dict:
868
+ """Map every physical row ``(source_path, line_offset)`` of a conversation to
869
+ its canonical ``item_key`` (§6.2). Suppressed mirror members fold to their
870
+ canonical partner's key, so both members of a pair share one item_key and can
871
+ never double-count."""
872
+ rows = _load_conversation_rows(conn, conversation_key)
873
+ partners = kern.pair_mirror_partners(rows)
874
+ kept, _suppressed = kern.pair_mirrors(rows)
875
+ items = kern.canonical_items(kept)
876
+ pos_map: dict[tuple, str] = {}
877
+ for item in items:
878
+ item_key = _item_key_for_item(conversation_key, item)
879
+ for r in item["rows"]:
880
+ pos_map[(r.source_path, r.line_offset)] = item_key
881
+ for sup_idx, canon_idx in partners.items():
882
+ sup = rows[sup_idx]
883
+ canon = rows[canon_idx]
884
+ canon_key = pos_map.get((canon.source_path, canon.line_offset))
885
+ if canon_key is not None:
886
+ pos_map[(sup.source_path, sup.line_offset)] = canon_key
887
+ return pos_map
888
+
889
+
890
+ def _fts_query(query: str, column: str | None) -> str:
891
+ """A safe FTS5 query: each whitespace term becomes a quoted phrase, joined by
892
+ implicit AND (term-wise AND — the documented divergence from LIKE's single
893
+ contiguous substring, §6.2). Each term is optionally scoped to one column."""
894
+ terms = [t for t in query.split() if t]
895
+ if not terms:
896
+ return '""'
897
+ def _term(t: str) -> str:
898
+ phrase = '"' + t.replace('"', '""') + '"'
899
+ return f'{column} : {phrase}' if column else phrase
900
+ return " ".join(_term(t) for t in terms)
901
+
902
+
903
+ _FTS_COLUMN_BY_KIND = {
904
+ "all": None, "prompts": "text", "assistant": "text",
905
+ "tools": "search_tool", "thinking": "search_thinking",
906
+ }
907
+
908
+
909
+ def _matched_message_rows(conn: sqlite3.Connection, query: str, kind: str, mode: str) -> list:
910
+ """Physical message rows matching ``query`` for a message-oriented kind, via
911
+ the FTS path (MATCH with per-kind column scope) or the SQL-bounded LIKE
912
+ mirror. ``prompts``/``assistant`` add the kind filter after the text match."""
913
+ cols = "m.id, m.conversation_key, m.source_path, m.line_offset, m.kind, m.text, m.search_tool, m.search_thinking"
914
+ if mode == "fts":
915
+ fts_query = _fts_query(query, _FTS_COLUMN_BY_KIND[kind])
916
+ rows = list(conn.execute(
917
+ "SELECT " + cols + " FROM codex_conversation_fts f "
918
+ "JOIN codex_conversation_messages m ON m.id = f.rowid "
919
+ "WHERE f.codex_conversation_fts MATCH ?",
920
+ (fts_query,),
921
+ ))
922
+ else:
923
+ like = f"%{query}%"
924
+ if kind == "all":
925
+ cond = "(m.text LIKE ? OR m.search_tool LIKE ? OR m.search_thinking LIKE ?)"
926
+ params: tuple = (like, like, like)
927
+ elif kind in ("prompts", "assistant"):
928
+ cond, params = "m.text LIKE ?", (like,)
929
+ elif kind == "tools":
930
+ cond, params = "m.search_tool LIKE ?", (like,)
931
+ else: # thinking
932
+ cond, params = "m.search_thinking LIKE ?", (like,)
933
+ rows = list(conn.execute(
934
+ "SELECT " + cols + " FROM codex_conversation_messages m WHERE " + cond, params))
935
+ if kind == "prompts":
936
+ rows = [r for r in rows if r[4] == "user"]
937
+ elif kind == "assistant":
938
+ rows = [r for r in rows if r[4] == "assistant"]
939
+ return rows
940
+
941
+
942
+ def _badge_for_kind(kind: str) -> str:
943
+ return _SEARCH_BADGE.get(kind, kind)
944
+
945
+
946
+ def _excerpt(text: str | None) -> str:
947
+ if not text:
948
+ return ""
949
+ collapsed = " ".join(str(text).split())
950
+ return collapsed[:200]
951
+
952
+
953
+ def _collapse_message_hits(conn: sqlite3.Connection, matched_rows: list) -> list[dict]:
954
+ """Collapse matched physical rows to canonical ``item_key`` BEFORE totals /
955
+ badges (§6.2) — both members of a mirror pair map to one item_key, so mirror
956
+ rows never double-count (turned or unturned)."""
957
+ by_conv: dict[str, list] = {}
958
+ for _id, ck, source_path, line_offset, kind, text, stool, sthink in matched_rows:
959
+ by_conv.setdefault(ck, []).append(
960
+ (source_path, line_offset, kind, text or stool or sthink))
961
+ collapsed: dict[tuple, dict] = {}
962
+ for ck, mrows in by_conv.items():
963
+ pos_map = _pos_to_item_key(conn, ck)
964
+ title, last_act, project_label = _conversation_hit_fields(conn, ck)
965
+ for source_path, line_offset, kind, disp in mrows:
966
+ item_key = pos_map.get((source_path, line_offset))
967
+ if item_key is None:
968
+ continue
969
+ hit = collapsed.setdefault(
970
+ (ck, item_key),
971
+ {"conversation_key": ck, "item_key": item_key, "title": title,
972
+ "snippet": None, "_badges": set(),
973
+ "last_activity_utc": last_act, "project_label": project_label})
974
+ hit["_badges"].add(_badge_for_kind(kind))
975
+ if hit["snippet"] is None:
976
+ hit["snippet"] = _excerpt(disp)
977
+ return [
978
+ {"conversation_key": h["conversation_key"], "item_key": h["item_key"],
979
+ "title": h["title"], "snippet": h["snippet"], "badges": sorted(h["_badges"]),
980
+ "last_activity_utc": h["last_activity_utc"], "project_label": h["project_label"]}
981
+ for h in collapsed.values()
982
+ ]
983
+
984
+
985
+ def _search_title(conn: sqlite3.Connection, query: str) -> list[dict]:
986
+ """Title search over the rollup table — identical LIKE semantics in both FTS
987
+ and LIKE modes (§6.2). Conversation-level hits (no item anchor)."""
988
+ like = f"%{query}%"
989
+ hits = []
990
+ for ck, title, last_act, project_label in conn.execute(
991
+ "SELECT conversation_key, title, last_activity_utc, project_label "
992
+ "FROM codex_conversation_rollups WHERE title LIKE ?", (like,)):
993
+ hits.append(
994
+ {"conversation_key": ck, "item_key": None, "title": title,
995
+ "snippet": _excerpt(title), "badges": ["title"],
996
+ "last_activity_utc": last_act, "project_label": project_label})
997
+ return hits
998
+
999
+
1000
+ def _search_files(conn: sqlite3.Connection, query: str) -> list[dict]:
1001
+ """File-touch search — matches file paths, collapsed to the owning message's
1002
+ canonical item_key (§6.2)."""
1003
+ like = f"%{query}%"
1004
+ pos_cache: dict[str, dict] = {}
1005
+ fields_cache: dict[str, tuple] = {}
1006
+ collapsed: dict[tuple, dict] = {}
1007
+ for ck, message_id, file_path in conn.execute(
1008
+ "SELECT t.conversation_key, t.message_id, t.file_path "
1009
+ "FROM codex_conversation_file_touches t WHERE t.file_path LIKE ?", (like,),
1010
+ ):
1011
+ member = conn.execute(
1012
+ "SELECT source_path, line_offset FROM codex_conversation_messages WHERE id = ?",
1013
+ (message_id,)).fetchone()
1014
+ if member is None:
1015
+ continue
1016
+ if ck not in pos_cache:
1017
+ pos_cache[ck] = _pos_to_item_key(conn, ck)
1018
+ fields_cache[ck] = _conversation_hit_fields(conn, ck)
1019
+ item_key = pos_cache[ck].get((member[0], member[1]))
1020
+ title, last_act, project_label = fields_cache[ck]
1021
+ hit = collapsed.setdefault(
1022
+ (ck, item_key),
1023
+ {"conversation_key": ck, "item_key": item_key, "title": title,
1024
+ "snippet": _excerpt(file_path), "badges": ["files"],
1025
+ "last_activity_utc": last_act, "project_label": project_label})
1026
+ return list(collapsed.values())
1027
+
1028
+
1029
+ def _paginate_hits(hits: list[dict], *, cursor: str | None, limit: int):
1030
+ lo = 0
1031
+ if cursor is not None:
1032
+ cursor_keys = [f'{h["conversation_key"]}\x00{h["item_key"] or ""}' for h in hits]
1033
+ if cursor in cursor_keys:
1034
+ lo = cursor_keys.index(cursor) + 1
1035
+ window = hits[lo:lo + limit] if limit else hits[lo:]
1036
+ has_more = (lo + len(window)) < len(hits)
1037
+ next_cursor = None
1038
+ if window and has_more:
1039
+ last = window[-1]
1040
+ next_cursor = f'{last["conversation_key"]}\x00{last["item_key"] or ""}'
1041
+ return window, {"returned": len(window), "cursor": next_cursor}
1042
+
1043
+
1044
+ def search_codex_conversations(
1045
+ conn: sqlite3.Connection,
1046
+ query: str,
1047
+ *,
1048
+ kind: str = "all",
1049
+ effective_speed: str,
1050
+ limit: int = 20,
1051
+ cursor: str | None = None,
1052
+ ) -> dict:
1053
+ """Search envelope (§5.6 / §6.2): kind → column mapping over the FTS path
1054
+ (per-kind MATCH + column scope) or the SQL-bounded LIKE mirror (single
1055
+ substring). Both paths collapse physical matches to ``item_key`` before
1056
+ totals/badges/pagination, so mirror rows never double-count. ``mode`` is
1057
+ honest (``fts``/``like``); ``depth`` is ``full`` unconditionally (the Codex
1058
+ corpus is born-full). ``query`` is echoed verbatim. Pending status while
1059
+ migration 025 has not run.
1060
+
1061
+ ``effective_speed`` is accepted for signature parity across the kernels;
1062
+ search does not price (results are navigation, not cost).
1063
+ """
1064
+ del effective_speed # search does not price
1065
+ mode = _search_mode(conn)
1066
+ if not codex_normalization_authoritative(conn):
1067
+ return {"status": "normalization_pending", "query": query, "hits": [],
1068
+ "total": 0, "mode": mode, "depth": "full"}
1069
+ if kind not in CODEX_SEARCH_KINDS:
1070
+ kind = "all"
1071
+ if kind == "title":
1072
+ hits = _search_title(conn, query)
1073
+ elif kind == "files":
1074
+ hits = _search_files(conn, query)
1075
+ else:
1076
+ hits = _collapse_message_hits(
1077
+ conn, _matched_message_rows(conn, query, kind, mode))
1078
+ hits.sort(key=lambda h: (h["conversation_key"], h["item_key"] or ""))
1079
+ total = len(hits)
1080
+ page_hits, page = _paginate_hits(hits, cursor=cursor, limit=limit)
1081
+ return {
1082
+ "status": "ok", "query": query, "hits": page_hits, "total": total,
1083
+ "mode": mode, "depth": "full", "page": page,
1084
+ }
1085
+
1086
+
1087
+ # ── in-conversation find (§3.1) ───────────────────────────────────────────────
1088
+
1089
+ # Claude cap parity: the anchor list caps at 500 (bin/_lib_conversation_query.py
1090
+ # ::_FIND_ANCHOR_CAP), with anchors_truncated when more anchors exist pre-cap.
1091
+ _CODEX_FIND_ANCHOR_CAP = 500
1092
+ # Bound the regex/case Python scan (ReDoS/perf), mirroring the Claude find guard.
1093
+ _CODEX_FIND_REGEX_MAX_LEN = 1000
1094
+ _CODEX_FIND_SCAN_TEXT_CAP = 200_000
1095
+
1096
+ # Per-kind (column, badge-label) probes over the normalized message columns, and
1097
+ # the per-kind row-kind filter. ``text`` maps to the synthetic ``prose`` label so a
1098
+ # prose-only match anchors a turn but never badges (Claude find parity).
1099
+ _CODEX_FIND_COLUMNS = {
1100
+ "all": (("text", "prose"), ("search_tool", "tool"),
1101
+ ("search_thinking", "thinking")),
1102
+ "prompts": (("text", "prose"),),
1103
+ "assistant": (("text", "prose"),),
1104
+ "tools": (("search_tool", "tool"),),
1105
+ "thinking": (("search_thinking", "thinking"),),
1106
+ }
1107
+ _CODEX_FIND_ROWKIND = {"prompts": "user", "assistant": "assistant"}
1108
+
1109
+
1110
+ def _codex_find_matched_fts(conn, conversation_key, query, cols, rowkind):
1111
+ """``{(source_path, line_offset) -> {labels}}`` for one conversation's rows
1112
+ matching ``query`` via the FTS path (per-column MATCH, conversation-scoped)."""
1113
+ out: dict[tuple, set] = {}
1114
+ for col, label in cols:
1115
+ fts_query = _fts_query(query, col)
1116
+ rk_pred = " AND m.kind = ?" if rowkind else ""
1117
+ rk_args = (rowkind,) if rowkind else ()
1118
+ rows = conn.execute(
1119
+ "SELECT m.source_path, m.line_offset FROM codex_conversation_fts f "
1120
+ "JOIN codex_conversation_messages m ON m.id = f.rowid "
1121
+ "WHERE f.codex_conversation_fts MATCH ? AND m.conversation_key = ?" + rk_pred,
1122
+ (fts_query, conversation_key, *rk_args)).fetchall()
1123
+ for sp, lo in rows:
1124
+ out.setdefault((sp, lo), set()).add(label)
1125
+ return out
1126
+
1127
+
1128
+ def _codex_find_matched_like(conn, conversation_key, query, cols, rowkind):
1129
+ """LIKE mirror of ``_codex_find_matched_fts`` — single contiguous substring,
1130
+ conversation-scoped. Plain ``%query%`` (matching the Codex search kernel, which
1131
+ does not ESCAPE), so find and search stay consistent for one provider."""
1132
+ like = f"%{query}%"
1133
+ out: dict[tuple, set] = {}
1134
+ for col, label in cols:
1135
+ rk_pred = " AND kind = ?" if rowkind else ""
1136
+ rk_args = (rowkind,) if rowkind else ()
1137
+ rows = conn.execute(
1138
+ f"SELECT source_path, line_offset FROM codex_conversation_messages "
1139
+ f"WHERE conversation_key = ? AND {col} LIKE ? AND {col} != ''" + rk_pred,
1140
+ (conversation_key, like, *rk_args)).fetchall()
1141
+ for sp, lo in rows:
1142
+ out.setdefault((sp, lo), set()).add(label)
1143
+ return out
1144
+
1145
+
1146
+ def _codex_find_matched_scan(conn, conversation_key, query, cols, rowkind, regex, case):
1147
+ """Physical-row regex/case scan over one conversation's normalized columns —
1148
+ honest parity with the Claude find scan. Each scanned value is clipped to
1149
+ ``_CODEX_FIND_SCAN_TEXT_CAP`` before the predicate. Precondition: ``regex or
1150
+ case`` (the FTS/LIKE path owns plain case-insensitive substring)."""
1151
+ if regex:
1152
+ rx = re.compile(query, 0 if case else re.IGNORECASE)
1153
+ pred = lambda text: rx.search(text) is not None
1154
+ else: # case-sensitive substring
1155
+ pred = lambda text: query in text
1156
+ rk_pred = " AND kind = ?" if rowkind else ""
1157
+ rk_args = (rowkind,) if rowkind else ()
1158
+ col_list = ", ".join(c for c, _ in cols)
1159
+ rows = conn.execute(
1160
+ f"SELECT source_path, line_offset, {col_list} FROM codex_conversation_messages "
1161
+ f"WHERE conversation_key = ?" + rk_pred,
1162
+ (conversation_key, *rk_args)).fetchall()
1163
+ out: dict[tuple, set] = {}
1164
+ for row in rows:
1165
+ sp, lo = row[0], row[1]
1166
+ for idx, (_col, label) in enumerate(cols):
1167
+ val = row[2 + idx]
1168
+ if val and pred(val[:_CODEX_FIND_SCAN_TEXT_CAP]):
1169
+ out.setdefault((sp, lo), set()).add(label)
1170
+ return out
1171
+
1172
+
1173
+ def find_in_codex_conversation(
1174
+ conn: sqlite3.Connection,
1175
+ conversation_key: str,
1176
+ query: str,
1177
+ *,
1178
+ kind: str = "all",
1179
+ cap: int = _CODEX_FIND_ANCHOR_CAP,
1180
+ regex: bool = False,
1181
+ case: bool = False,
1182
+ ) -> dict:
1183
+ """Document-ordered rendered-item anchors for in-conversation find (§3.1).
1184
+
1185
+ The Codex analogue of ``find_in_conversation``: the SAME kind taxonomy
1186
+ (``CODEX_FIND_KINDS`` == Claude ``_FIND_KINDS``), the same result-cap
1187
+ semantics, honest FTS-vs-LIKE mode selection (``_search_mode``), and hits
1188
+ anchored by ``item_key`` values byte-equal to the ones detail serves — so S8's
1189
+ FindBar navigates both providers with one contract. Mirror-paired physical hits
1190
+ collapse to their canonical item (via ``_pos_to_item_key``), so a find never
1191
+ surfaces a suppressed duplicate detail never renders.
1192
+
1193
+ Status-tagged envelope: ``ok`` | ``normalization_pending`` | ``not_found``.
1194
+ ``regex``/``case`` (parity with the Claude find) bypass FTS/LIKE for a bounded
1195
+ physical-row scan of the normalized columns; an unknown ``kind`` raises
1196
+ ``ValueError`` (the route maps to 400)."""
1197
+ if kind not in CODEX_FIND_KINDS:
1198
+ raise ValueError(f"unknown kind: {kind}")
1199
+ scan = bool(regex or case)
1200
+ mode = ("regex" if regex else "like") if scan else _search_mode(conn)
1201
+ base = {"status": "ok", "conversation_key": conversation_key, "total": 0,
1202
+ "anchors": [], "anchors_truncated": False, "search_depth": "full",
1203
+ "kind": kind, "mode": mode}
1204
+ if not codex_normalization_authoritative(conn):
1205
+ return {**base, "status": "normalization_pending"}
1206
+ rows = _load_conversation_rows(conn, conversation_key)
1207
+ if not rows:
1208
+ return {"status": "not_found", "conversation_key": conversation_key}
1209
+ q = (query or "").strip()
1210
+ if not q:
1211
+ return base
1212
+ cols = _CODEX_FIND_COLUMNS[kind]
1213
+ rowkind = _CODEX_FIND_ROWKIND.get(kind)
1214
+ if scan:
1215
+ if len(q) > _CODEX_FIND_REGEX_MAX_LEN:
1216
+ return base
1217
+ matched = _codex_find_matched_scan(conn, conversation_key, q, cols, rowkind, regex, case)
1218
+ elif mode == "fts":
1219
+ try:
1220
+ matched = _codex_find_matched_fts(conn, conversation_key, q, cols, rowkind)
1221
+ except sqlite3.OperationalError:
1222
+ mode = "like"
1223
+ matched = _codex_find_matched_like(conn, conversation_key, q, cols, rowkind)
1224
+ else:
1225
+ matched = _codex_find_matched_like(conn, conversation_key, q, cols, rowkind)
1226
+ base["mode"] = mode
1227
+ if not matched:
1228
+ return base
1229
+ # Collapse matched physical positions to canonical item_key (mirror-safe), then
1230
+ # emit anchors in detail document order.
1231
+ pos_map = _pos_to_item_key(conn, conversation_key)
1232
+ by_item: dict[str, set] = {}
1233
+ for pos, labels in matched.items():
1234
+ item_key = pos_map.get(pos)
1235
+ if item_key is None:
1236
+ continue
1237
+ by_item.setdefault(item_key, set()).update(labels)
1238
+ kept, _suppressed = kern.pair_mirrors(rows)
1239
+ items = kern.canonical_items(kept)
1240
+ anchors = []
1241
+ for it in items:
1242
+ item_key = _item_key_for_item(conversation_key, it)
1243
+ if item_key in by_item:
1244
+ anchors.append({
1245
+ "item_key": item_key,
1246
+ "match_kinds": sorted(l for l in by_item[item_key] if l != "prose")})
1247
+ total = len(anchors)
1248
+ return {**base, "total": total, "anchors": anchors[:cap],
1249
+ "anchors_truncated": total > cap}
1250
+
1251
+
1252
+ # ── prompts spine (§3.2) ──────────────────────────────────────────────────────
1253
+
1254
+
1255
+ def codex_conversation_prompts(conn: sqlite3.Connection, conversation_key: str) -> dict:
1256
+ """Prompt-class canonical items → ``{conversation_key, prompts:[{item_key,
1257
+ text}]}`` (§3.2) — ``item_key`` where Claude has ``uuid``. Prompt class = the
1258
+ same predicate ``derive_title`` uses (a ``prompt`` item, or an un-turned ``user``
1259
+ item). Status-tagged: ``ok`` | ``normalization_pending`` | ``not_found``."""
1260
+ if not codex_normalization_authoritative(conn):
1261
+ return {"status": "normalization_pending",
1262
+ "conversation_key": conversation_key, "prompts": []}
1263
+ rows = _load_conversation_rows(conn, conversation_key)
1264
+ if not rows:
1265
+ return {"status": "not_found", "conversation_key": conversation_key}
1266
+ kept, _suppressed = kern.pair_mirrors(rows)
1267
+ items = kern.canonical_items(kept)
1268
+ prompts = []
1269
+ for it in items:
1270
+ if it["klass"] == "prompt" or (
1271
+ it["klass"] == "unturned" and it["anchor_row"].kind == "user"):
1272
+ prompts.append({
1273
+ "item_key": _item_key_for_item(conversation_key, it),
1274
+ "text": it["anchor_row"].text or ""})
1275
+ return {"status": "ok", "conversation_key": conversation_key, "prompts": prompts}
1276
+
1277
+
1278
+ # ── payload locate + full re-read (§3.4) ──────────────────────────────────────
1279
+
1280
+
1281
+ def _codex_source_root_path(conn: sqlite3.Connection, source_root_key: str | None):
1282
+ """``canonical_root_path`` for a source-root key, or ``None`` when unknown."""
1283
+ if not source_root_key:
1284
+ return None
1285
+ row = conn.execute(
1286
+ "SELECT canonical_root_path FROM codex_source_roots WHERE source_root_key = ?",
1287
+ (source_root_key,)).fetchone()
1288
+ return row[0] if row else None
1289
+
1290
+
1291
+ def _within_root(source_path: str | None, root_path: str | None) -> bool:
1292
+ """True iff the ``realpath``-resolved ``source_path`` stays strictly inside the
1293
+ ``realpath``-resolved ``root_path`` (§3.4 containment guard). A symlink escaping
1294
+ the canonical root resolves outside and fails; a miss is a 404, never a read."""
1295
+ if not source_path or not root_path:
1296
+ return False
1297
+ try:
1298
+ real_file = os.path.realpath(source_path)
1299
+ real_root = os.path.realpath(root_path)
1300
+ return os.path.commonpath([real_file, real_root]) == real_root
1301
+ except (OSError, ValueError):
1302
+ return False
1303
+
1304
+
1305
+ def _reread_codex_full_content(conn: sqlite3.Connection, row):
1306
+ """Re-read the physical line at ``(row.source_path, row.line_offset)``, validate
1307
+ it against the stored ``codex_conversation_events.payload_json`` for that exact
1308
+ position (§3.4 structural gone-check — the canonical FULL record, not
1309
+ ``content_digest``, which hashes only extracted text and misses a structural
1310
+ mutation such as a changed ``call_id``), and return ``(full_content, truncated)``
1311
+ for the row's normalized side, or ``None`` when gone (missing file, truncation
1312
+ below the stored offset, or a canonical-record mismatch).
1313
+
1314
+ The full pre-cap content is re-derived through the SAME ``_extract`` the
1315
+ normalizer uses — which is how payload serves content beyond the normalized
1316
+ ``CODEX_TEXT_CAP``. Truncation/``truncated`` is against ``_FULL_PAYLOAD_CEILING``
1317
+ (1,000,000 Python characters), the same ceiling the Claude payload path uses."""
1318
+ stored = conn.execute(
1319
+ "SELECT payload_json FROM codex_conversation_events "
1320
+ "WHERE source_path = ? AND line_offset = ?",
1321
+ (row.source_path, row.line_offset)).fetchone()
1322
+ if stored is None:
1323
+ return None
1324
+ try:
1325
+ with open(row.source_path, "rb") as fh:
1326
+ fh.seek(row.line_offset)
1327
+ line = fh.readline()
1328
+ except OSError:
1329
+ return None
1330
+ try:
1331
+ obj = json.loads(line.decode("utf-8"))
1332
+ except (UnicodeDecodeError, ValueError, TypeError):
1333
+ return None
1334
+ if not isinstance(obj, dict):
1335
+ return None
1336
+ try:
1337
+ if kern._canonical_json(obj) != stored[0]:
1338
+ return None
1339
+ except (TypeError, ValueError):
1340
+ return None
1341
+ record_type = obj.get("type") or obj.get("record_type")
1342
+ payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
1343
+ extracted = kern._extract(record_type, payload)
1344
+ if extracted is None:
1345
+ return None
1346
+ content = extracted.content_text or ""
1347
+ truncated = len(content) > _FULL_PAYLOAD_CEILING
1348
+ return content[:_FULL_PAYLOAD_CEILING], truncated
1349
+
1350
+
1351
+ def _locate_payload_block(conn: sqlite3.Connection, conversation_key: str, block_key: str):
1352
+ """``(call_row, output_row_or_None)`` for the tool_call block addressed by
1353
+ ``block_key`` (§3.4), or ``None`` when no block matches. The output partner
1354
+ follows EXACTLY ``_item_blocks_with_rows``' folding rule (same canonical item,
1355
+ unique nonempty ``call_id``, call precedes output)."""
1356
+ rows = _load_conversation_rows(conn, conversation_key)
1357
+ if not rows:
1358
+ return None
1359
+ kept, _suppressed = kern.pair_mirrors(rows)
1360
+ items = kern.canonical_items(kept)
1361
+ for item in items:
1362
+ for _block, call_row, output_row in _item_blocks_with_rows(item):
1363
+ if call_row.kind != "tool_call":
1364
+ continue
1365
+ if _block_key_for_row(call_row) == block_key:
1366
+ return call_row, output_row
1367
+ return None
1368
+
1369
+
1370
+ def read_codex_payload(
1371
+ conn: sqlite3.Connection, conversation_key: str, block_key: str, which: str
1372
+ ) -> dict:
1373
+ """Locate + full re-read for a Codex detail payload block (§3.4).
1374
+
1375
+ Selector: ``block_key`` (required) + ``which ∈ {call, output}``. A call-id-less
1376
+ (or unpaired) call is call-only — ``which=output`` for it → ``not_found`` (no
1377
+ adjacency pairing is introduced). Success envelope (pinned):
1378
+ ``{"status":"ok","block_key","which","content","truncated"}`` where ``content``
1379
+ is the selected side's full text from the re-read record and ``truncated``
1380
+ reflects ``_FULL_PAYLOAD_CEILING``. ``gone`` (→ HTTP 410) means the physical
1381
+ record moved/mutated; ``not_found`` (→ 404) means no such block, no output
1382
+ partner, or a containment miss (a read is never attempted outside the root)."""
1383
+ miss = {"status": "not_found", "block_key": block_key, "which": which}
1384
+ if which not in ("call", "output"):
1385
+ return miss
1386
+ located = _locate_payload_block(conn, conversation_key, block_key)
1387
+ if located is None:
1388
+ return miss
1389
+ call_row, output_row = located
1390
+ target = call_row if which == "call" else output_row
1391
+ if target is None: # which=output for a call-id-less / unpaired call
1392
+ return miss
1393
+ # Containment guard (Codex-only; the Claude path has no equivalent) BEFORE any
1394
+ # read: a symlink/traversal escaping the canonical root is a 404, never a read.
1395
+ root_path = _codex_source_root_path(conn, target.source_root_key)
1396
+ if not _within_root(target.source_path, root_path):
1397
+ return miss
1398
+ outcome = _reread_codex_full_content(conn, target)
1399
+ if outcome is None:
1400
+ return {"status": "gone", "block_key": block_key, "which": which}
1401
+ content, truncated = outcome
1402
+ return {"status": "ok", "block_key": block_key, "which": which,
1403
+ "content": content, "truncated": truncated}
1404
+
1405
+
1406
+ # ── whole-conversation export (§3.3) ──────────────────────────────────────────
1407
+
1408
+
1409
+ def get_codex_conversation_export(
1410
+ conn: sqlite3.Connection, conversation_key: str, *, effective_speed: str
1411
+ ) -> dict:
1412
+ """Whole-conversation Markdown export envelope (§3.3). Assembles the full
1413
+ detail with NO pagination (``limit=0``), then renders via the pure Codex export
1414
+ module. Status-tagged: ``ok`` (carries ``markdown``) | ``normalization_pending``
1415
+ | ``not_found`` — the dispatch/transport layers map those to bytes/HTTP."""
1416
+ detail = get_codex_conversation(
1417
+ conn, conversation_key, effective_speed=effective_speed, limit=0)
1418
+ if detail.get("status") != "ok":
1419
+ return {"status": detail.get("status"), "conversation_key": conversation_key}
1420
+ from _lib_codex_conversation_export import render_codex_conversation_markdown
1421
+ return {"status": "ok", "conversation_key": conversation_key,
1422
+ "markdown": render_codex_conversation_markdown(detail)}