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