cctally 1.70.0 → 1.72.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,950 @@
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 sqlite3
23
+
24
+ import _lib_codex_conversation as kern
25
+ from _lib_conversation import _strip_ansi
26
+ from _lib_conversation_query import _first_nonblank_line
27
+ from _lib_pricing import _calculate_codex_entry_cost
28
+
29
+ # ── constants ────────────────────────────────────────────────────────────────
30
+
31
+ # Migration whose applied marker makes the normalized corpus authoritative
32
+ # (§3.5). Fresh caches stamp the full registry at creation, so they are always
33
+ # authoritative; a held-lock deferral leaves it pending.
34
+ CODEX_NORMALIZATION_MIGRATION = "025_codex_conversation_normalization"
35
+
36
+ # Domain separations for the opaque item-key encoding (§5.2). The source-path
37
+ # fingerprint is a domain-separated hash, NEVER a raw path (privacy-safe).
38
+ CODEX_ITEM_KEY_DOMAIN = b"cctally-codex-item-key-v1\0"
39
+ CODEX_ITEM_PATH_DOMAIN = b"cctally-codex-item-path-v1\0"
40
+
41
+ CODEX_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
42
+
43
+ # Normalized-message column order — matches CodexNormalizedRow field order so a
44
+ # SELECT row splats straight into the dataclass.
45
+ _ROW_COLS = (
46
+ "conversation_key, source_root_key, source_path, line_offset, timestamp_utc, "
47
+ "turn_id, call_id, kind, event_type, record_family, model, text, "
48
+ "content_digest, content_len, detail_json, search_tool, search_thinking"
49
+ )
50
+
51
+ _SEARCH_BADGE = {
52
+ "user": "prompt",
53
+ "assistant": "assistant",
54
+ "reasoning": "thinking",
55
+ "tool_call": "tools",
56
+ "tool_output": "tools",
57
+ "event": "event",
58
+ }
59
+
60
+
61
+ # ── authority probe (§3.5) ────────────────────────────────────────────────────
62
+
63
+
64
+ def codex_normalization_authoritative(conn: sqlite3.Connection) -> bool:
65
+ """True iff migration 025 is stamped applied — i.e. the normalized corpus is
66
+ authoritative (§3.5). A missing ``schema_migrations`` table (bare
67
+ ``_apply_cache_schema`` conn, or a pre-migration cache) reads as pending."""
68
+ try:
69
+ row = conn.execute(
70
+ "SELECT 1 FROM schema_migrations WHERE name = ?",
71
+ (CODEX_NORMALIZATION_MIGRATION,),
72
+ ).fetchone()
73
+ except sqlite3.OperationalError:
74
+ return False
75
+ return row is not None
76
+
77
+
78
+ # ── item key (§5.2) ───────────────────────────────────────────────────────────
79
+
80
+
81
+ def _source_path_fingerprint(source_path: str | None) -> str:
82
+ """Domain-separated hash of a source path — never the raw path (§5.2)."""
83
+ return hashlib.sha256(
84
+ CODEX_ITEM_PATH_DOMAIN + (source_path or "").encode("utf-8")
85
+ ).hexdigest()[:16]
86
+
87
+
88
+ def codex_item_key(
89
+ conversation_key: str,
90
+ *,
91
+ klass: str,
92
+ turn_id: str | None,
93
+ source_path: str | None,
94
+ line_offset: int | None,
95
+ content_digest: str | None,
96
+ ) -> str:
97
+ """Opaque, qualified, ordinal-free item anchor (§5.2).
98
+
99
+ Response items key on durable native-turn identity ``(conversation_key,
100
+ "turn", turn_id)`` — same-turn content replacement keeps the key. Prompt /
101
+ event / unturned items key on the canonical member's stable physical
102
+ identity + content: ``(conversation_key, "row", fingerprint(source_path),
103
+ line_offset, content_digest)`` — no population-relative ordinals, so
104
+ deleting an earlier duplicate or an out-of-order multi-file append never
105
+ moves an existing key, and a same-offset content replacement changes it.
106
+ """
107
+ if klass == "response":
108
+ parts = ("turn", conversation_key or "", turn_id or "")
109
+ else:
110
+ parts = (
111
+ "row",
112
+ conversation_key or "",
113
+ _source_path_fingerprint(source_path),
114
+ "" if line_offset is None else str(line_offset),
115
+ content_digest or "",
116
+ )
117
+ raw = "\x00".join(parts).encode("utf-8")
118
+ return "civ1_" + hashlib.sha256(CODEX_ITEM_KEY_DOMAIN + raw).hexdigest()[:40]
119
+
120
+
121
+ def _item_key_for_item(conversation_key: str, item: dict) -> str:
122
+ if item["klass"] == "response":
123
+ return codex_item_key(
124
+ conversation_key, klass="response", turn_id=item["turn_id"],
125
+ source_path=None, line_offset=None, content_digest=None)
126
+ anchor = item["anchor_row"]
127
+ return codex_item_key(
128
+ conversation_key, klass=item["klass"], turn_id=item["turn_id"],
129
+ source_path=anchor.source_path, line_offset=anchor.line_offset,
130
+ content_digest=anchor.content_digest)
131
+
132
+
133
+ # ── row loading + display helpers ─────────────────────────────────────────────
134
+
135
+
136
+ def _load_conversation_rows(conn: sqlite3.Connection, conversation_key: str) -> list:
137
+ """A conversation's normalized rows (all files) in physical order — the same
138
+ ``(timestamp_utc, source_path, line_offset)`` order the ingest/rollup writer
139
+ feeds the kernel, so pairing/grouping converge with the stored rollup."""
140
+ return [
141
+ kern.CodexNormalizedRow(*row)
142
+ for row in conn.execute(
143
+ "SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
144
+ "WHERE conversation_key = ? "
145
+ "ORDER BY timestamp_utc, source_path, line_offset",
146
+ (conversation_key,),
147
+ )
148
+ ]
149
+
150
+
151
+ def _row_display(row) -> str:
152
+ """The row's display/search text from whichever column carries it."""
153
+ return row.text or row.search_thinking or row.search_tool or ""
154
+
155
+
156
+ def _parse_detail(detail_json: str | None):
157
+ if not detail_json:
158
+ return None
159
+ try:
160
+ return json.loads(detail_json)
161
+ except (json.JSONDecodeError, TypeError):
162
+ return None
163
+
164
+
165
+ def _item_kind(item: dict) -> str:
166
+ klass = item["klass"]
167
+ if klass == "prompt":
168
+ return "user"
169
+ if klass == "response":
170
+ return "assistant"
171
+ if klass == "event":
172
+ return "event"
173
+ return item["anchor_row"].kind # unturned: the row's own provider kind
174
+
175
+
176
+ def _build_item_blocks(item: dict) -> list[dict]:
177
+ """Assemble an item's blocks, folding each ``tool_output`` into its
178
+ ``tool_call`` block via ``call_id`` when that call_id has exactly one owner
179
+ (§5.2). Physical order within the item is preserved."""
180
+ rows = item["rows"]
181
+ call_owner_count: dict[str, int] = {}
182
+ for r in rows:
183
+ if r.kind == "tool_call" and r.call_id:
184
+ call_owner_count[r.call_id] = call_owner_count.get(r.call_id, 0) + 1
185
+ blocks: list[dict] = []
186
+ tool_block_by_call: dict[str, int] = {}
187
+ for r in rows:
188
+ text = _row_display(r)
189
+ detail = _parse_detail(r.detail_json)
190
+ if (r.kind == "tool_output" and r.call_id
191
+ and call_owner_count.get(r.call_id, 0) == 1
192
+ and r.call_id in tool_block_by_call):
193
+ owner = blocks[tool_block_by_call[r.call_id]]
194
+ owner["output"] = {"text": text, "detail": detail}
195
+ continue
196
+ block = {
197
+ "kind": r.kind, "text": text, "detail": detail,
198
+ "call_id": r.call_id, "timestamp_utc": r.timestamp_utc,
199
+ }
200
+ if r.kind == "tool_call" and r.call_id and call_owner_count.get(r.call_id, 0) == 1:
201
+ tool_block_by_call[r.call_id] = len(blocks)
202
+ blocks.append(block)
203
+ return blocks
204
+
205
+
206
+ # ── tokens union (§5.6) ───────────────────────────────────────────────────────
207
+
208
+
209
+ def _zero_tokens() -> dict:
210
+ return {"input": 0, "output": 0, "cached_input": 0, "reasoning_output": 0}
211
+
212
+
213
+ def _add_tokens(acc: dict, inp: int, out: int, cin: int, rout: int) -> None:
214
+ acc["input"] += inp or 0
215
+ acc["output"] += out or 0
216
+ acc["cached_input"] += cin or 0
217
+ acc["reasoning_output"] += rout or 0
218
+
219
+
220
+ def _tokens_union(tokens: dict) -> dict:
221
+ """Source-tagged provider union — native Codex fields only, never Claude
222
+ cache vocabulary (§5.6 / S0)."""
223
+ return {
224
+ "source": "codex",
225
+ "input": tokens["input"],
226
+ "output": tokens["output"],
227
+ "cached_input": tokens["cached_input"],
228
+ "reasoning_output": tokens["reasoning_output"],
229
+ }
230
+
231
+
232
+ # ── cost attribution (§5.4) ───────────────────────────────────────────────────
233
+
234
+
235
+ def _file_boundaries(conn: sqlite3.Connection, source_path: str) -> list[tuple[int, str | None]]:
236
+ """Ordered ``(line_offset, active_turn)`` boundaries for one file — a
237
+ ``turn_context`` sets the effective turn, a ``session_meta`` resets it to
238
+ ``None`` (a new un-turned segment). Read from the retained physical events so
239
+ an accounting row that precedes the first normalized message still attributes
240
+ to the turn ``turn_context`` already opened (§5.4)."""
241
+ boundaries: list[tuple[int, str | None]] = []
242
+ for off, rtype, tid, payload_json in conn.execute(
243
+ "SELECT line_offset, record_type, turn_id, payload_json "
244
+ "FROM codex_conversation_events "
245
+ "WHERE source_path = ? AND record_type IN ('turn_context','session_meta') "
246
+ "ORDER BY line_offset",
247
+ (source_path,),
248
+ ):
249
+ if rtype == "session_meta":
250
+ boundaries.append((off, None))
251
+ continue
252
+ turn = tid
253
+ if turn is None:
254
+ try:
255
+ payload = (json.loads(payload_json or "{}").get("payload") or {})
256
+ turn = payload.get("turn_id")
257
+ except (json.JSONDecodeError, TypeError, AttributeError):
258
+ turn = None
259
+ boundaries.append((off, turn))
260
+ return boundaries
261
+
262
+
263
+ def _turn_at(boundaries: list[tuple[int, str | None]], offset: int) -> str | None:
264
+ """Nearest preceding turn boundary by file offset (§5.4); ``None`` when the
265
+ row precedes any turn (or sits in an un-turned segment)."""
266
+ turn: str | None = None
267
+ for boff, bturn in boundaries:
268
+ if boff <= offset:
269
+ turn = bturn
270
+ else:
271
+ break
272
+ return turn
273
+
274
+
275
+ def _attribute_costs(conn: sqlite3.Connection, conversation_key: str, effective_speed: str):
276
+ """Attribute each ``codex_session_entries`` row (selected by
277
+ ``conversation_key``) to its nearest-preceding turn, priced unrounded under
278
+ ``effective_speed`` (§5.4). Rows preceding any turn land in the explicit
279
+ unattributed bucket. Returns
280
+ ``(turn_cost, turn_tokens, unattributed_cost, unattributed_tokens, total, conv_tokens)``.
281
+ """
282
+ entries = conn.execute(
283
+ "SELECT source_path, line_offset, model, input_tokens, cached_input_tokens, "
284
+ "output_tokens, reasoning_output_tokens FROM codex_session_entries "
285
+ "WHERE conversation_key = ? ORDER BY source_path, line_offset",
286
+ (conversation_key,),
287
+ ).fetchall()
288
+ boundaries: dict[str, list] = {}
289
+ for source_path in {e[0] for e in entries}:
290
+ boundaries[source_path] = _file_boundaries(conn, source_path)
291
+ turn_cost: dict[str, float] = {}
292
+ turn_tokens: dict[str, dict] = {}
293
+ unattr_cost = 0.0
294
+ unattr_tokens = _zero_tokens()
295
+ total = 0.0
296
+ conv_tokens = _zero_tokens()
297
+ for source_path, offset, model, inp, cin, out, rout in entries:
298
+ priced = _calculate_codex_entry_cost(
299
+ model or "", inp or 0, cin or 0, out or 0, rout or 0, speed=effective_speed)
300
+ total += priced
301
+ _add_tokens(conv_tokens, inp, out, cin, rout)
302
+ turn = _turn_at(boundaries.get(source_path, []), offset)
303
+ if turn is not None:
304
+ turn_cost[turn] = turn_cost.get(turn, 0.0) + priced
305
+ _add_tokens(turn_tokens.setdefault(turn, _zero_tokens()), inp, out, cin, rout)
306
+ else:
307
+ unattr_cost += priced
308
+ _add_tokens(unattr_tokens, inp, out, cin, rout)
309
+ return turn_cost, turn_tokens, unattr_cost, unattr_tokens, total, conv_tokens
310
+
311
+
312
+ def _conversation_total_cost(conn: sqlite3.Connection, conversation_key: str, effective_speed: str) -> float:
313
+ """Lean priced total over a conversation's accounting rows (browse rows,
314
+ child summaries) — same primitive as ``_attribute_costs`` (§5.4)."""
315
+ total = 0.0
316
+ for model, inp, cin, out, rout in conn.execute(
317
+ "SELECT model, input_tokens, cached_input_tokens, output_tokens, "
318
+ "reasoning_output_tokens FROM codex_session_entries WHERE conversation_key = ?",
319
+ (conversation_key,),
320
+ ):
321
+ total += _calculate_codex_entry_cost(
322
+ model or "", inp or 0, cin or 0, out or 0, rout or 0, speed=effective_speed)
323
+ return total
324
+
325
+
326
+ # ── rollup fields (dual-branch: stored fast path vs live recompute) ───────────
327
+
328
+
329
+ def _thread_facts(conn: sqlite3.Connection, conversation_key: str):
330
+ """``(native, root, parent, source_root_key, cwd, git_json)`` for a
331
+ conversation's thread, or ``None`` (no thread row / not-yet-linked)."""
332
+ return conn.execute(
333
+ "SELECT native_thread_id, root_thread_id, parent_thread_id, source_root_key, "
334
+ "cwd, git_json FROM codex_conversation_threads WHERE conversation_key = ?",
335
+ (conversation_key,),
336
+ ).fetchone()
337
+
338
+
339
+ def _rollup_fields(conn: sqlite3.Connection, conversation_key: str, rows: list | None = None):
340
+ """Rollup fields for a conversation — the stored rollup row when present
341
+ (fast path), else a LIVE recompute that reproduces ``_recompute_codex_rollups``
342
+ EXACTLY (§3.2 / §6.1): same kernel helpers (``rollup_item_count``,
343
+ ``derive_title``), same min/max/sorted, and the SAME
344
+ ``_codex_conversation_project_attribution`` the writer uses. Returns ``None``
345
+ when the conversation has no normalized rows."""
346
+ stored = conn.execute(
347
+ "SELECT item_count, started_utc, last_activity_utc, project_key, project_label, "
348
+ "models_json, title, parent_thread_id, source_root_key "
349
+ "FROM codex_conversation_rollups WHERE conversation_key = ?",
350
+ (conversation_key,),
351
+ ).fetchone()
352
+ thread = _thread_facts(conn, conversation_key)
353
+ native = thread[0] if thread else None
354
+ if stored is not None:
355
+ item_count, started, last, project_key, project_label, models_json, title, parent, srk = stored
356
+ models = json.loads(models_json) if models_json else []
357
+ return {
358
+ "item_count": item_count, "started": started, "last": last,
359
+ "project_key": project_key, "project_label": project_label,
360
+ "models": models, "title": title, "parent_thread_id": parent,
361
+ "source_root_key": srk, "native_thread_id": native,
362
+ }
363
+ # Live recompute — MUST mirror _recompute_codex_rollups in _cctally_cache.
364
+ if rows is None:
365
+ rows = _load_conversation_rows(conn, conversation_key)
366
+ if not rows:
367
+ return None
368
+ item_count = kern.rollup_item_count(rows)
369
+ title = kern.derive_title(rows)
370
+ timestamps = [r.timestamp_utc for r in rows if r.timestamp_utc]
371
+ started = min(timestamps) if timestamps else None
372
+ last = max(timestamps) if timestamps else None
373
+ models = sorted({r.model for r in rows if r.model})
374
+ source_root_key = rows[0].source_root_key
375
+ cwd = git_json = parent = None
376
+ if thread is not None:
377
+ native, _root, parent, thread_root, cwd, git_json = thread
378
+ if thread_root:
379
+ source_root_key = thread_root
380
+ from _cctally_cache import _codex_conversation_project_attribution
381
+ project_key, project_label = _codex_conversation_project_attribution(
382
+ source_root_key, cwd, git_json)
383
+ return {
384
+ "item_count": item_count, "started": started, "last": last,
385
+ "project_key": project_key, "project_label": project_label,
386
+ "models": models, "title": title, "parent_thread_id": parent,
387
+ "source_root_key": source_root_key, "native_thread_id": native,
388
+ }
389
+
390
+
391
+ def _short_native(native: str | None) -> str:
392
+ return (native or "")[:8]
393
+
394
+
395
+ def _display_chain(fields: dict) -> str:
396
+ """Read-time display fallback (§4.3): stored title → project_label → short
397
+ native-thread-id prefix."""
398
+ return fields.get("title") or fields.get("project_label") or _short_native(
399
+ fields.get("native_thread_id")) or ""
400
+
401
+
402
+ def _conversation_display_title(conn: sqlite3.Connection, conversation_key: str, rows: list | None = None) -> str:
403
+ fields = _rollup_fields(conn, conversation_key, rows=rows)
404
+ if fields is None:
405
+ return ""
406
+ return _display_chain(fields)
407
+
408
+
409
+ # ── threading (§5.5) ──────────────────────────────────────────────────────────
410
+
411
+
412
+ def _child_summary(conn: sqlite3.Connection, conversation_key: str, effective_speed: str) -> dict:
413
+ fields = _rollup_fields(conn, conversation_key)
414
+ return {
415
+ "conversation_key": conversation_key,
416
+ "title": _display_chain(fields) if fields else "",
417
+ "started_utc": fields["started"] if fields else None,
418
+ "last_activity_utc": fields["last"] if fields else None,
419
+ "item_count": fields["item_count"] if fields else 0,
420
+ "cost_usd": _conversation_total_cost(conn, conversation_key, effective_speed),
421
+ }
422
+
423
+
424
+ def _children_of(conn: sqlite3.Connection, conversation_key: str, effective_speed: str) -> list[dict]:
425
+ """Same-root threads whose ``parent_thread_id`` equals this thread's native
426
+ id (§5.5). Never a filename inference — metadata only."""
427
+ thread = _thread_facts(conn, conversation_key)
428
+ if thread is None:
429
+ return []
430
+ native, _root, _parent, source_root_key, _cwd, _git = thread
431
+ children = [
432
+ _child_summary(conn, child_ck, effective_speed)
433
+ for (child_ck,) in conn.execute(
434
+ "SELECT conversation_key FROM codex_conversation_threads "
435
+ "WHERE source_root_key = ? AND parent_thread_id = ? AND conversation_key != ?",
436
+ (source_root_key, native, conversation_key),
437
+ )
438
+ ]
439
+ children.sort(key=lambda c: (c["last_activity_utc"] or "", c["conversation_key"]))
440
+ return children
441
+
442
+
443
+ def _parent_of(conn: sqlite3.Connection, conversation_key: str):
444
+ """Parent pointer (§5.5): the same-root thread whose native id equals this
445
+ thread's ``parent_thread_id``. A root (parent == self, or absent) has none;
446
+ a fork whose parent is not ingested also returns ``None`` (no key to point
447
+ at)."""
448
+ thread = _thread_facts(conn, conversation_key)
449
+ if thread is None:
450
+ return None
451
+ native, _root, parent, source_root_key, _cwd, _git = thread
452
+ if not parent or parent == native:
453
+ return None
454
+ prow = conn.execute(
455
+ "SELECT conversation_key FROM codex_conversation_threads "
456
+ "WHERE source_root_key = ? AND native_thread_id = ? AND conversation_key != ?",
457
+ (source_root_key, parent, conversation_key),
458
+ ).fetchone()
459
+ if prow is None:
460
+ return None
461
+ parent_ck = prow[0]
462
+ return {"conversation_key": parent_ck, "title": _conversation_display_title(conn, parent_ck)}
463
+
464
+
465
+ # ── detail assembly (§5.2 / §5.4 / §5.6) ──────────────────────────────────────
466
+
467
+
468
+ def _paginate_items(items: list[dict], *, after, before, tail, limit):
469
+ keys = [it["item_key"] for it in items]
470
+ lo, hi = 0, len(items)
471
+ if after is not None and after in keys:
472
+ lo = keys.index(after) + 1
473
+ if before is not None and before in keys:
474
+ hi = keys.index(before)
475
+ window = items[lo:hi]
476
+ if tail is not None:
477
+ cap = min(tail, limit) if limit else tail
478
+ window = window[-cap:] if cap else window
479
+ elif limit:
480
+ window = window[:limit]
481
+ first_key = window[0]["item_key"] if window else None
482
+ last_key = window[-1]["item_key"] if window else None
483
+ has_before = bool(window) and keys.index(first_key) > 0
484
+ has_after = bool(window) and keys.index(last_key) < len(items) - 1
485
+ page = {
486
+ "total": len(items), "returned": len(window),
487
+ "before": first_key if has_before else None,
488
+ "after": last_key if has_after else None,
489
+ "has_before": has_before, "has_after": has_after,
490
+ }
491
+ return window, page
492
+
493
+
494
+ def get_codex_conversation(
495
+ conn: sqlite3.Connection,
496
+ conversation_key: str,
497
+ *,
498
+ effective_speed: str,
499
+ after: str | None = None,
500
+ before: str | None = None,
501
+ tail: int | None = None,
502
+ limit: int = 200,
503
+ ) -> dict:
504
+ """Detail envelope (§5.6): status ``ok`` | ``normalization_pending`` |
505
+ ``not_found``. ``ok`` carries canonical items (mirror-paired, tool-folded),
506
+ per-turn cost with an explicit unattributed bucket, threading, and a page
507
+ over ``item_key``."""
508
+ if not codex_normalization_authoritative(conn):
509
+ return {"status": "normalization_pending", "conversation_key": conversation_key,
510
+ "items": [], "children": []}
511
+ rows = _load_conversation_rows(conn, conversation_key)
512
+ if not rows:
513
+ return {"status": "not_found", "conversation_key": conversation_key}
514
+ kept, _suppressed = kern.pair_mirrors(rows)
515
+ items = kern.canonical_items(kept)
516
+ turn_cost, turn_tokens, unattr_cost, unattr_tokens, total, conv_tokens = _attribute_costs(
517
+ conn, conversation_key, effective_speed)
518
+ # Carrier item per turn: prefer the response item, else the first item of the
519
+ # turn — so every priced turn's cost lands on exactly one item (§5.4 reconcile).
520
+ carriers: dict[str, int] = {}
521
+ for idx, it in enumerate(items):
522
+ if it["klass"] == "response" and it["turn_id"] is not None and it["turn_id"] not in carriers:
523
+ carriers[it["turn_id"]] = idx
524
+ for idx, it in enumerate(items):
525
+ if it["turn_id"] is not None and it["turn_id"] not in carriers:
526
+ carriers[it["turn_id"]] = idx
527
+ # Turns with cost but no carrier item fold into the unattributed bucket.
528
+ leftover_cost = 0.0
529
+ for turn, cost in turn_cost.items():
530
+ if turn not in carriers:
531
+ leftover_cost += cost
532
+ unattributed_cost = unattr_cost + leftover_cost
533
+ built: list[dict] = []
534
+ for idx, it in enumerate(items):
535
+ turn = it["turn_id"]
536
+ cost = None
537
+ tokens = None
538
+ if turn is not None and carriers.get(turn) == idx and turn in turn_cost:
539
+ cost = turn_cost[turn]
540
+ tokens = _tokens_union(turn_tokens[turn])
541
+ built.append({
542
+ "item_key": _item_key_for_item(conversation_key, it),
543
+ "kind": _item_kind(it),
544
+ "timestamp_utc": it["anchor_row"].timestamp_utc,
545
+ "model": it["anchor_row"].model,
546
+ "blocks": _build_item_blocks(it),
547
+ "cost_usd": cost,
548
+ "tokens": tokens,
549
+ })
550
+ page_items, page = _paginate_items(built, after=after, before=before, tail=tail, limit=limit)
551
+ return {
552
+ "status": "ok",
553
+ "conversation_key": conversation_key,
554
+ "title": _conversation_display_title(conn, conversation_key, rows),
555
+ "items": page_items,
556
+ "page": page,
557
+ "children": _children_of(conn, conversation_key, effective_speed),
558
+ "parent": _parent_of(conn, conversation_key),
559
+ "total_cost_usd": total,
560
+ "unattributed_cost_usd": unattributed_cost,
561
+ "tokens": _tokens_union(conv_tokens),
562
+ }
563
+
564
+
565
+ # ── outline assembly (§5.6) ───────────────────────────────────────────────────
566
+
567
+
568
+ def _conversation_files(conn: sqlite3.Connection, conversation_key: str) -> list[dict]:
569
+ return [
570
+ {"file_path": fp, "tool": tool, "count": count}
571
+ for fp, tool, count in conn.execute(
572
+ "SELECT file_path, tool, COUNT(*) FROM codex_conversation_file_touches "
573
+ "WHERE conversation_key = ? GROUP BY file_path, tool ORDER BY file_path, tool",
574
+ (conversation_key,),
575
+ )
576
+ ]
577
+
578
+
579
+ def get_codex_conversation_outline(
580
+ conn: sqlite3.Connection, conversation_key: str, *, effective_speed: str
581
+ ) -> dict:
582
+ """Outline envelope (§5.6): one ``turns[]`` entry per canonical item (label
583
+ via the shared first-non-blank-line helper), plus stats, file touches, and
584
+ child summaries."""
585
+ if not codex_normalization_authoritative(conn):
586
+ return {"status": "normalization_pending", "conversation_key": conversation_key,
587
+ "turns": [], "files": [], "children": []}
588
+ rows = _load_conversation_rows(conn, conversation_key)
589
+ if not rows:
590
+ return {"status": "not_found", "conversation_key": conversation_key}
591
+ kept, _suppressed = kern.pair_mirrors(rows)
592
+ items = kern.canonical_items(kept)
593
+ turns: list[dict] = []
594
+ kind_totals: dict[str, int] = {}
595
+ for it in items:
596
+ anchor_text = _row_display(it["anchor_row"])
597
+ label = _first_nonblank_line(_strip_ansi(anchor_text)) if anchor_text else ""
598
+ kinds: dict[str, int] = {}
599
+ for r in it["rows"]:
600
+ kinds[r.kind] = kinds.get(r.kind, 0) + 1
601
+ kind_totals[r.kind] = kind_totals.get(r.kind, 0) + 1
602
+ turns.append({
603
+ "item_key": _item_key_for_item(conversation_key, it),
604
+ "label": label,
605
+ "timestamp_utc": it["anchor_row"].timestamp_utc,
606
+ "kinds": kinds,
607
+ })
608
+ return {
609
+ "status": "ok",
610
+ "conversation_key": conversation_key,
611
+ "turns": turns,
612
+ "stats": {"items": len(items), "kinds": kind_totals},
613
+ "files": _conversation_files(conn, conversation_key),
614
+ "children": _children_of(conn, conversation_key, effective_speed),
615
+ }
616
+
617
+
618
+ # ── browse (§6.1) ─────────────────────────────────────────────────────────────
619
+
620
+
621
+ def _is_fork(fields: dict) -> bool:
622
+ parent = fields.get("parent_thread_id")
623
+ return bool(parent) and parent != fields.get("native_thread_id")
624
+
625
+
626
+ def _browse_row(conn: sqlite3.Connection, conversation_key: str, effective_speed: str, fields: dict) -> dict:
627
+ return {
628
+ "conversation_key": conversation_key,
629
+ "title": _display_chain(fields),
630
+ "project_key": fields["project_key"],
631
+ "project_label": fields["project_label"],
632
+ "started_utc": fields["started"],
633
+ "last_activity_utc": fields["last"],
634
+ "count": fields["item_count"],
635
+ "cost_usd": _conversation_total_cost(conn, conversation_key, effective_speed),
636
+ "models": list(fields["models"]),
637
+ "parent": _parent_of(conn, conversation_key),
638
+ "is_fork": _is_fork(fields),
639
+ }
640
+
641
+
642
+ def _browse_facets(rows: list[dict]) -> dict:
643
+ """Projects grouped by opaque ``project_key`` (S3 collision-safe — same-label
644
+ distinct roots never merge), models by native model name (§6.1)."""
645
+ projects: dict[str, list] = {}
646
+ models: dict[str, int] = {}
647
+ for row in rows:
648
+ pkey = row["project_key"]
649
+ if pkey:
650
+ entry = projects.setdefault(pkey, [row["project_label"], 0])
651
+ entry[1] += 1
652
+ for model in row["models"] or []:
653
+ models[model] = models.get(model, 0) + 1
654
+ project_facets = [
655
+ {"project_key": pkey, "project_label": label, "count": count}
656
+ for pkey, (label, count) in sorted(
657
+ projects.items(), key=lambda kv: ((kv[1][0] or ""), kv[0]))
658
+ ]
659
+ model_facets = [
660
+ {"model": model, "count": count} for model, count in sorted(models.items())
661
+ ]
662
+ return {"projects": project_facets, "models": model_facets}
663
+
664
+
665
+ def _recent_sort_key(row: dict):
666
+ return (row["last_activity_utc"] or "", row["conversation_key"])
667
+
668
+
669
+ def _paginate_rows(rows: list[dict], *, cursor: str | None, limit: int):
670
+ lo = 0
671
+ if cursor is not None:
672
+ keys = [r["conversation_key"] for r in rows]
673
+ if cursor in keys:
674
+ lo = keys.index(cursor) + 1
675
+ window = rows[lo:lo + limit] if limit else rows[lo:]
676
+ has_more = (lo + len(window)) < len(rows)
677
+ next_cursor = window[-1]["conversation_key"] if (window and has_more) else None
678
+ page = {"total": len(rows), "returned": len(window), "cursor": next_cursor}
679
+ return window, page
680
+
681
+
682
+ def list_codex_conversations(
683
+ conn: sqlite3.Connection,
684
+ *,
685
+ effective_speed: str,
686
+ project_key: str | None = None,
687
+ model: str | None = None,
688
+ limit: int = 50,
689
+ cursor: str | None = None,
690
+ ) -> dict:
691
+ """Browse envelope (§5.6 / §6.1): a page of conversation rows ordered by last
692
+ activity, with project/model facets. Dual-branch — the stored rollup fast
693
+ path when a rollup row is present, else a live recompute that reproduces the
694
+ writer exactly (never an empty rail). Facets are computed over the full set
695
+ (before filtering) so filter options stay available. Pending status while
696
+ migration 025 has not run."""
697
+ if not codex_normalization_authoritative(conn):
698
+ return {"status": "normalization_pending", "rows": [],
699
+ "facets": {"projects": [], "models": []}, "page": {"total": 0}}
700
+ keys = [r[0] for r in conn.execute(
701
+ "SELECT DISTINCT conversation_key FROM codex_conversation_messages")]
702
+ rows: list[dict] = []
703
+ for conversation_key in keys:
704
+ fields = _rollup_fields(conn, conversation_key)
705
+ if fields is None:
706
+ continue
707
+ rows.append(_browse_row(conn, conversation_key, effective_speed, fields))
708
+ facets = _browse_facets(rows)
709
+ filtered = [
710
+ row for row in rows
711
+ if (project_key is None or row["project_key"] == project_key)
712
+ and (model is None or model in (row["models"] or []))
713
+ ]
714
+ filtered.sort(key=_recent_sort_key, reverse=True)
715
+ page_rows, page = _paginate_rows(filtered, cursor=cursor, limit=limit)
716
+ return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
717
+
718
+
719
+ # ── search (§6.2) ─────────────────────────────────────────────────────────────
720
+
721
+
722
+ def _search_mode(conn: sqlite3.Connection) -> str:
723
+ """Honest search mode: ``like`` when the Codex FTS marker is set or the FTS
724
+ vtable is unusable, else ``fts`` (§3.4 / §6.2)."""
725
+ try:
726
+ unavailable = conn.execute(
727
+ "SELECT 1 FROM cache_meta WHERE key='codex_fts_unavailable'").fetchone() is not None
728
+ except sqlite3.OperationalError:
729
+ unavailable = True
730
+ if unavailable:
731
+ return "like"
732
+ try:
733
+ conn.execute("SELECT 1 FROM codex_conversation_fts LIMIT 1")
734
+ except sqlite3.OperationalError:
735
+ return "like"
736
+ return "fts"
737
+
738
+
739
+ def _pos_to_item_key(conn: sqlite3.Connection, conversation_key: str) -> dict:
740
+ """Map every physical row ``(source_path, line_offset)`` of a conversation to
741
+ its canonical ``item_key`` (§6.2). Suppressed mirror members fold to their
742
+ canonical partner's key, so both members of a pair share one item_key and can
743
+ never double-count."""
744
+ rows = _load_conversation_rows(conn, conversation_key)
745
+ partners = kern.pair_mirror_partners(rows)
746
+ kept, _suppressed = kern.pair_mirrors(rows)
747
+ items = kern.canonical_items(kept)
748
+ pos_map: dict[tuple, str] = {}
749
+ for item in items:
750
+ item_key = _item_key_for_item(conversation_key, item)
751
+ for r in item["rows"]:
752
+ pos_map[(r.source_path, r.line_offset)] = item_key
753
+ for sup_idx, canon_idx in partners.items():
754
+ sup = rows[sup_idx]
755
+ canon = rows[canon_idx]
756
+ canon_key = pos_map.get((canon.source_path, canon.line_offset))
757
+ if canon_key is not None:
758
+ pos_map[(sup.source_path, sup.line_offset)] = canon_key
759
+ return pos_map
760
+
761
+
762
+ def _fts_query(query: str, column: str | None) -> str:
763
+ """A safe FTS5 query: each whitespace term becomes a quoted phrase, joined by
764
+ implicit AND (term-wise AND — the documented divergence from LIKE's single
765
+ contiguous substring, §6.2). Each term is optionally scoped to one column."""
766
+ terms = [t for t in query.split() if t]
767
+ if not terms:
768
+ return '""'
769
+ def _term(t: str) -> str:
770
+ phrase = '"' + t.replace('"', '""') + '"'
771
+ return f'{column} : {phrase}' if column else phrase
772
+ return " ".join(_term(t) for t in terms)
773
+
774
+
775
+ _FTS_COLUMN_BY_KIND = {
776
+ "all": None, "prompts": "text", "assistant": "text",
777
+ "tools": "search_tool", "thinking": "search_thinking",
778
+ }
779
+
780
+
781
+ def _matched_message_rows(conn: sqlite3.Connection, query: str, kind: str, mode: str) -> list:
782
+ """Physical message rows matching ``query`` for a message-oriented kind, via
783
+ the FTS path (MATCH with per-kind column scope) or the SQL-bounded LIKE
784
+ mirror. ``prompts``/``assistant`` add the kind filter after the text match."""
785
+ cols = "m.id, m.conversation_key, m.source_path, m.line_offset, m.kind, m.text, m.search_tool, m.search_thinking"
786
+ if mode == "fts":
787
+ fts_query = _fts_query(query, _FTS_COLUMN_BY_KIND[kind])
788
+ rows = list(conn.execute(
789
+ "SELECT " + cols + " FROM codex_conversation_fts f "
790
+ "JOIN codex_conversation_messages m ON m.id = f.rowid "
791
+ "WHERE f.codex_conversation_fts MATCH ?",
792
+ (fts_query,),
793
+ ))
794
+ else:
795
+ like = f"%{query}%"
796
+ if kind == "all":
797
+ cond = "(m.text LIKE ? OR m.search_tool LIKE ? OR m.search_thinking LIKE ?)"
798
+ params: tuple = (like, like, like)
799
+ elif kind in ("prompts", "assistant"):
800
+ cond, params = "m.text LIKE ?", (like,)
801
+ elif kind == "tools":
802
+ cond, params = "m.search_tool LIKE ?", (like,)
803
+ else: # thinking
804
+ cond, params = "m.search_thinking LIKE ?", (like,)
805
+ rows = list(conn.execute(
806
+ "SELECT " + cols + " FROM codex_conversation_messages m WHERE " + cond, params))
807
+ if kind == "prompts":
808
+ rows = [r for r in rows if r[4] == "user"]
809
+ elif kind == "assistant":
810
+ rows = [r for r in rows if r[4] == "assistant"]
811
+ return rows
812
+
813
+
814
+ def _badge_for_kind(kind: str) -> str:
815
+ return _SEARCH_BADGE.get(kind, kind)
816
+
817
+
818
+ def _excerpt(text: str | None) -> str:
819
+ if not text:
820
+ return ""
821
+ collapsed = " ".join(str(text).split())
822
+ return collapsed[:200]
823
+
824
+
825
+ def _collapse_message_hits(conn: sqlite3.Connection, matched_rows: list) -> list[dict]:
826
+ """Collapse matched physical rows to canonical ``item_key`` BEFORE totals /
827
+ badges (§6.2) — both members of a mirror pair map to one item_key, so mirror
828
+ rows never double-count (turned or unturned)."""
829
+ by_conv: dict[str, list] = {}
830
+ for _id, ck, source_path, line_offset, kind, text, stool, sthink in matched_rows:
831
+ by_conv.setdefault(ck, []).append(
832
+ (source_path, line_offset, kind, text or stool or sthink))
833
+ collapsed: dict[tuple, dict] = {}
834
+ for ck, mrows in by_conv.items():
835
+ pos_map = _pos_to_item_key(conn, ck)
836
+ title = _conversation_display_title(conn, ck)
837
+ for source_path, line_offset, kind, disp in mrows:
838
+ item_key = pos_map.get((source_path, line_offset))
839
+ if item_key is None:
840
+ continue
841
+ hit = collapsed.setdefault(
842
+ (ck, item_key),
843
+ {"conversation_key": ck, "item_key": item_key, "title": title,
844
+ "snippet": None, "_badges": set()})
845
+ hit["_badges"].add(_badge_for_kind(kind))
846
+ if hit["snippet"] is None:
847
+ hit["snippet"] = _excerpt(disp)
848
+ return [
849
+ {"conversation_key": h["conversation_key"], "item_key": h["item_key"],
850
+ "title": h["title"], "snippet": h["snippet"], "badges": sorted(h["_badges"])}
851
+ for h in collapsed.values()
852
+ ]
853
+
854
+
855
+ def _search_title(conn: sqlite3.Connection, query: str) -> list[dict]:
856
+ """Title search over the rollup table — identical LIKE semantics in both FTS
857
+ and LIKE modes (§6.2). Conversation-level hits (no item anchor)."""
858
+ like = f"%{query}%"
859
+ return [
860
+ {"conversation_key": ck, "item_key": None, "title": title,
861
+ "snippet": _excerpt(title), "badges": ["title"]}
862
+ for ck, title in conn.execute(
863
+ "SELECT conversation_key, title FROM codex_conversation_rollups "
864
+ "WHERE title LIKE ?", (like,))
865
+ ]
866
+
867
+
868
+ def _search_files(conn: sqlite3.Connection, query: str) -> list[dict]:
869
+ """File-touch search — matches file paths, collapsed to the owning message's
870
+ canonical item_key (§6.2)."""
871
+ like = f"%{query}%"
872
+ pos_cache: dict[str, dict] = {}
873
+ title_cache: dict[str, str] = {}
874
+ collapsed: dict[tuple, dict] = {}
875
+ for ck, message_id, file_path in conn.execute(
876
+ "SELECT t.conversation_key, t.message_id, t.file_path "
877
+ "FROM codex_conversation_file_touches t WHERE t.file_path LIKE ?", (like,),
878
+ ):
879
+ member = conn.execute(
880
+ "SELECT source_path, line_offset FROM codex_conversation_messages WHERE id = ?",
881
+ (message_id,)).fetchone()
882
+ if member is None:
883
+ continue
884
+ if ck not in pos_cache:
885
+ pos_cache[ck] = _pos_to_item_key(conn, ck)
886
+ title_cache[ck] = _conversation_display_title(conn, ck)
887
+ item_key = pos_cache[ck].get((member[0], member[1]))
888
+ hit = collapsed.setdefault(
889
+ (ck, item_key),
890
+ {"conversation_key": ck, "item_key": item_key, "title": title_cache[ck],
891
+ "snippet": _excerpt(file_path), "badges": ["files"]})
892
+ return list(collapsed.values())
893
+
894
+
895
+ def _paginate_hits(hits: list[dict], *, cursor: str | None, limit: int):
896
+ lo = 0
897
+ if cursor is not None:
898
+ cursor_keys = [f'{h["conversation_key"]}\x00{h["item_key"] or ""}' for h in hits]
899
+ if cursor in cursor_keys:
900
+ lo = cursor_keys.index(cursor) + 1
901
+ window = hits[lo:lo + limit] if limit else hits[lo:]
902
+ has_more = (lo + len(window)) < len(hits)
903
+ next_cursor = None
904
+ if window and has_more:
905
+ last = window[-1]
906
+ next_cursor = f'{last["conversation_key"]}\x00{last["item_key"] or ""}'
907
+ return window, {"returned": len(window), "cursor": next_cursor}
908
+
909
+
910
+ def search_codex_conversations(
911
+ conn: sqlite3.Connection,
912
+ query: str,
913
+ *,
914
+ kind: str = "all",
915
+ effective_speed: str,
916
+ limit: int = 20,
917
+ cursor: str | None = None,
918
+ ) -> dict:
919
+ """Search envelope (§5.6 / §6.2): kind → column mapping over the FTS path
920
+ (per-kind MATCH + column scope) or the SQL-bounded LIKE mirror (single
921
+ substring). Both paths collapse physical matches to ``item_key`` before
922
+ totals/badges/pagination, so mirror rows never double-count. ``mode`` is
923
+ honest (``fts``/``like``); ``depth`` is ``full`` unconditionally (the Codex
924
+ corpus is born-full). ``query`` is echoed verbatim. Pending status while
925
+ migration 025 has not run.
926
+
927
+ ``effective_speed`` is accepted for signature parity across the kernels;
928
+ search does not price (results are navigation, not cost).
929
+ """
930
+ del effective_speed # search does not price
931
+ mode = _search_mode(conn)
932
+ if not codex_normalization_authoritative(conn):
933
+ return {"status": "normalization_pending", "query": query, "hits": [],
934
+ "total": 0, "mode": mode, "depth": "full"}
935
+ if kind not in CODEX_SEARCH_KINDS:
936
+ kind = "all"
937
+ if kind == "title":
938
+ hits = _search_title(conn, query)
939
+ elif kind == "files":
940
+ hits = _search_files(conn, query)
941
+ else:
942
+ hits = _collapse_message_hits(
943
+ conn, _matched_message_rows(conn, query, kind, mode))
944
+ hits.sort(key=lambda h: (h["conversation_key"], h["item_key"] or ""))
945
+ total = len(hits)
946
+ page_hits, page = _paginate_hits(hits, cursor=cursor, limit=limit)
947
+ return {
948
+ "status": "ok", "query": query, "hits": page_hits, "total": total,
949
+ "mode": mode, "depth": "full", "page": page,
950
+ }