cctally 1.72.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.
@@ -19,11 +19,13 @@ from __future__ import annotations
19
19
 
20
20
  import hashlib
21
21
  import json
22
+ import os
23
+ import re
22
24
  import sqlite3
23
25
 
24
26
  import _lib_codex_conversation as kern
25
27
  from _lib_conversation import _strip_ansi
26
- from _lib_conversation_query import _first_nonblank_line
28
+ from _lib_conversation_query import _FULL_PAYLOAD_CEILING, _first_nonblank_line
27
29
  from _lib_pricing import _calculate_codex_entry_cost
28
30
 
29
31
  # ── constants ────────────────────────────────────────────────────────────────
@@ -37,9 +39,16 @@ CODEX_NORMALIZATION_MIGRATION = "025_codex_conversation_normalization"
37
39
  # fingerprint is a domain-separated hash, NEVER a raw path (privacy-safe).
38
40
  CODEX_ITEM_KEY_DOMAIN = b"cctally-codex-item-key-v1\0"
39
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"
40
45
 
41
46
  CODEX_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
42
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
+
43
52
  # Normalized-message column order — matches CodexNormalizedRow field order so a
44
53
  # SELECT row splats straight into the dataclass.
45
54
  _ROW_COLS = (
@@ -130,6 +139,36 @@ def _item_key_for_item(conversation_key: str, item: dict) -> str:
130
139
  content_digest=anchor.content_digest)
131
140
 
132
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
+
133
172
  # ── row loading + display helpers ─────────────────────────────────────────────
134
173
 
135
174
 
@@ -173,34 +212,52 @@ def _item_kind(item: dict) -> str:
173
212
  return item["anchor_row"].kind # unturned: the row's own provider kind
174
213
 
175
214
 
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."""
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``."""
180
226
  rows = item["rows"]
181
227
  call_owner_count: dict[str, int] = {}
182
228
  for r in rows:
183
229
  if r.kind == "tool_call" and r.call_id:
184
230
  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] = {}
231
+ entries: list[list] = []
232
+ tool_entry_by_call: dict[str, int] = {}
187
233
  for r in rows:
188
234
  text = _row_display(r)
189
235
  detail = _parse_detail(r.detail_json)
190
236
  if (r.kind == "tool_output" and r.call_id
191
237
  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}
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
195
242
  continue
196
243
  block = {
197
244
  "kind": r.kind, "text": text, "detail": detail,
198
245
  "call_id": r.call_id, "timestamp_utc": r.timestamp_utc,
199
246
  }
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
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)]
204
261
 
205
262
 
206
263
  # ── tokens union (§5.6) ───────────────────────────────────────────────────────
@@ -406,6 +463,18 @@ def _conversation_display_title(conn: sqlite3.Connection, conversation_key: str,
406
463
  return _display_chain(fields)
407
464
 
408
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
+
409
478
  # ── threading (§5.5) ──────────────────────────────────────────────────────────
410
479
 
411
480
 
@@ -462,6 +531,65 @@ def _parent_of(conn: sqlite3.Connection, conversation_key: str):
462
531
  return {"conversation_key": parent_ck, "title": _conversation_display_title(conn, parent_ck)}
463
532
 
464
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
+
465
593
  # ── detail assembly (§5.2 / §5.4 / §5.6) ──────────────────────────────────────
466
594
 
467
595
 
@@ -833,7 +961,7 @@ def _collapse_message_hits(conn: sqlite3.Connection, matched_rows: list) -> list
833
961
  collapsed: dict[tuple, dict] = {}
834
962
  for ck, mrows in by_conv.items():
835
963
  pos_map = _pos_to_item_key(conn, ck)
836
- title = _conversation_display_title(conn, ck)
964
+ title, last_act, project_label = _conversation_hit_fields(conn, ck)
837
965
  for source_path, line_offset, kind, disp in mrows:
838
966
  item_key = pos_map.get((source_path, line_offset))
839
967
  if item_key is None:
@@ -841,13 +969,15 @@ def _collapse_message_hits(conn: sqlite3.Connection, matched_rows: list) -> list
841
969
  hit = collapsed.setdefault(
842
970
  (ck, item_key),
843
971
  {"conversation_key": ck, "item_key": item_key, "title": title,
844
- "snippet": None, "_badges": set()})
972
+ "snippet": None, "_badges": set(),
973
+ "last_activity_utc": last_act, "project_label": project_label})
845
974
  hit["_badges"].add(_badge_for_kind(kind))
846
975
  if hit["snippet"] is None:
847
976
  hit["snippet"] = _excerpt(disp)
848
977
  return [
849
978
  {"conversation_key": h["conversation_key"], "item_key": h["item_key"],
850
- "title": h["title"], "snippet": h["snippet"], "badges": sorted(h["_badges"])}
979
+ "title": h["title"], "snippet": h["snippet"], "badges": sorted(h["_badges"]),
980
+ "last_activity_utc": h["last_activity_utc"], "project_label": h["project_label"]}
851
981
  for h in collapsed.values()
852
982
  ]
853
983
 
@@ -856,13 +986,15 @@ def _search_title(conn: sqlite3.Connection, query: str) -> list[dict]:
856
986
  """Title search over the rollup table — identical LIKE semantics in both FTS
857
987
  and LIKE modes (§6.2). Conversation-level hits (no item anchor)."""
858
988
  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
- ]
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
866
998
 
867
999
 
868
1000
  def _search_files(conn: sqlite3.Connection, query: str) -> list[dict]:
@@ -870,7 +1002,7 @@ def _search_files(conn: sqlite3.Connection, query: str) -> list[dict]:
870
1002
  canonical item_key (§6.2)."""
871
1003
  like = f"%{query}%"
872
1004
  pos_cache: dict[str, dict] = {}
873
- title_cache: dict[str, str] = {}
1005
+ fields_cache: dict[str, tuple] = {}
874
1006
  collapsed: dict[tuple, dict] = {}
875
1007
  for ck, message_id, file_path in conn.execute(
876
1008
  "SELECT t.conversation_key, t.message_id, t.file_path "
@@ -883,12 +1015,14 @@ def _search_files(conn: sqlite3.Connection, query: str) -> list[dict]:
883
1015
  continue
884
1016
  if ck not in pos_cache:
885
1017
  pos_cache[ck] = _pos_to_item_key(conn, ck)
886
- title_cache[ck] = _conversation_display_title(conn, ck)
1018
+ fields_cache[ck] = _conversation_hit_fields(conn, ck)
887
1019
  item_key = pos_cache[ck].get((member[0], member[1]))
1020
+ title, last_act, project_label = fields_cache[ck]
888
1021
  hit = collapsed.setdefault(
889
1022
  (ck, item_key),
890
- {"conversation_key": ck, "item_key": item_key, "title": title_cache[ck],
891
- "snippet": _excerpt(file_path), "badges": ["files"]})
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})
892
1026
  return list(collapsed.values())
893
1027
 
894
1028
 
@@ -948,3 +1082,341 @@ def search_codex_conversations(
948
1082
  "status": "ok", "query": query, "hits": page_hits, "total": total,
949
1083
  "mode": mode, "depth": "full", "page": page,
950
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)}