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