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.
- package/CHANGELOG.md +11 -0
- package/bin/_cctally_cache.py +245 -50
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +500 -28
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +269 -7
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +4 -1
|
@@ -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
|
|
177
|
-
"""Assemble an item's blocks
|
|
178
|
-
|
|
179
|
-
(§
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
193
|
-
owner =
|
|
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"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 =
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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)}
|