cctally 1.40.0 → 1.42.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 +15 -0
- package/bin/_cctally_cache.py +103 -6
- package/bin/_cctally_dashboard.py +60 -3
- package/bin/_cctally_db.py +179 -45
- package/bin/_lib_conversation.py +142 -23
- package/bin/_lib_conversation_query.py +447 -60
- package/dashboard/static/assets/{index-BXm_vBKA.css → index-3yVZt8cV.css} +1 -1
- package/dashboard/static/assets/{index-BtbVKnkm.js → index-DDuPO7EI.js} +34 -34
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
|
@@ -29,25 +29,15 @@ from _lib_conversation import _stringify
|
|
|
29
29
|
# #177 S4: the media-route reader walks a content array with the SAME ordinal
|
|
30
30
|
# generator the ingest placeholders used, so "media item N" addresses one item.
|
|
31
31
|
from _lib_conversation import iter_media_items
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
_MARKER_RE = re.compile(
|
|
42
|
-
r"\s*(?:<(" + "|".join(_MARKER_TAGS) + r")>(?:(?!</\1>)[\s\S])*</\1>\s*)+"
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _is_system_marker(text) -> bool:
|
|
47
|
-
"""True iff `text` is ONLY concatenated command-marker wrappers (slash-command
|
|
48
|
-
plumbing) — the title-derivation skip predicate. `fullmatch` reproduces the TS
|
|
49
|
-
`^\\s*…\\s*$` anchor (no `$`-before-trailing-`\\n` foot-gun)."""
|
|
50
|
-
return bool(text) and _MARKER_RE.fullmatch(text) is not None
|
|
32
|
+
# #186: the marker predicate moved DOWN to the parser layer (the parser now
|
|
33
|
+
# classifies command-marker user rows as META at ingest). Re-export the names
|
|
34
|
+
# here for back-compat so existing `from _lib_conversation_query import
|
|
35
|
+
# _is_system_marker` importers (and the title-skip path below) keep resolving.
|
|
36
|
+
from _lib_conversation import _MARKER_TAGS, _MARKER_RE, _is_system_marker
|
|
37
|
+
# #186: read-time ANSI strip for rows already indexed with raw SGR (no forced
|
|
38
|
+
# re-ingest). Scoped to prose/thinking/title/label — NEVER tool_result (Bash
|
|
39
|
+
# AnsiText boundary). Shares the parser's regex so ingest and read-time agree.
|
|
40
|
+
from _lib_conversation import _strip_ansi
|
|
51
41
|
|
|
52
42
|
|
|
53
43
|
_TITLE_MAX = 120
|
|
@@ -58,7 +48,7 @@ def _title_from_text(text) -> str:
|
|
|
58
48
|
trailing '…' ONLY when truncated (rstrip before the ellipsis). '' if none.
|
|
59
49
|
Semantics IDENTICAL to the client deriveReaderTitle (#165 P2.5)."""
|
|
60
50
|
for line in (text or "").split("\n"):
|
|
61
|
-
s = line.strip()
|
|
51
|
+
s = _strip_ansi(line).strip() # #186: strip SGR from pre-fix dirty rows
|
|
62
52
|
if s:
|
|
63
53
|
return (s[:_TITLE_MAX].rstrip() + "…") if len(s) > _TITLE_MAX else s
|
|
64
54
|
return ""
|
|
@@ -126,22 +116,56 @@ def _meta_classify(item, allow_human_fallback):
|
|
|
126
116
|
pre-reingest window — see _reingest_pending). After the reingest a 'human'
|
|
127
117
|
row keeping the preamble is a real user prompt, so it stays a "You" turn
|
|
128
118
|
rather than being hidden in a collapsed skill pill (Codex code-review P1).
|
|
129
|
-
- command
|
|
130
|
-
|
|
131
|
-
|
|
119
|
+
- command: a true 'meta' row ALWAYS, plus — the #186 read-time fallback — a
|
|
120
|
+
'human' row whose body is command plumbing AND whose blocks are all text.
|
|
121
|
+
The marker regex is self-identifying (no read-time-recovery hazard, unlike
|
|
122
|
+
generic injected context), so command recovery for a pre-fix human row is
|
|
123
|
+
ungated by ``allow_human_fallback``. The all-text guard mirrors the ingest
|
|
124
|
+
branch (Codex P1b) so an attachment-bearing row is never folded.
|
|
125
|
+
- context: ONLY for a true 'meta' row (the remaining injected-content case).
|
|
126
|
+
A non-skill, non-command 'human' row stays human — generic injected context
|
|
127
|
+
can't be recovered read-time without isMeta; it lands on the next
|
|
132
128
|
sync-triggered reingest."""
|
|
133
129
|
is_meta = item["kind"] == "meta"
|
|
134
130
|
body = item.get("text") or _join_text_blocks(item.get("blocks"))
|
|
135
131
|
first = _first_nonblank_line(body)
|
|
136
132
|
if first.startswith(_SKILL_PREAMBLE) and (is_meta or allow_human_fallback):
|
|
137
133
|
return ("skill", _skill_name_from_preamble(first), body)
|
|
134
|
+
# Command plumbing: self-identifying, so safe to recover for a pre-fix human
|
|
135
|
+
# row too — but only when ALL blocks are text (mirror the ingest all-text
|
|
136
|
+
# guard so an attachment-bearing row is never folded). Runs ABOVE the
|
|
137
|
+
# not-is_meta guard precisely so a stale entry_type='human' command echo
|
|
138
|
+
# reclassifies read-time (#186).
|
|
139
|
+
all_text = all(b.get("kind") == "text" for b in (item.get("blocks") or []))
|
|
140
|
+
if _is_system_marker(body) and (is_meta or all_text):
|
|
141
|
+
return ("command", None, body)
|
|
138
142
|
if not is_meta:
|
|
139
143
|
return None
|
|
140
|
-
if _is_system_marker(body):
|
|
141
|
-
return ("command", None, body)
|
|
142
144
|
return ("context", None, body)
|
|
143
145
|
|
|
144
146
|
|
|
147
|
+
# #186 belt-and-suspenders, title-only: a deliberately-broader skip predicate
|
|
148
|
+
# that drops a title candidate wrapped entirely in `command-*` / `local-command-*`
|
|
149
|
+
# plumbing — a tag-name PREFIX shape, NOT the strict known-tag list. The \1
|
|
150
|
+
# backref forces each close tag to match its open tag; the unrolled-lazy body is
|
|
151
|
+
# linear-time (no ReDoS). Used ONLY in title selection, where being liberal is
|
|
152
|
+
# safe: the worst case is the title falls back to the next line or the project
|
|
153
|
+
# label — never hiding content (that fold-to-pill decision keeps strict
|
|
154
|
+
# `_is_system_marker`, where a false positive WOULD hide real user text). A
|
|
155
|
+
# future unrecognized `local-command-foo` tag thus degrades to "skip the title"
|
|
156
|
+
# rather than "poison the title."
|
|
157
|
+
_CMD_FAMILY_RE = re.compile(
|
|
158
|
+
r"\s*(?:<((?:local-)?command-[a-z-]+)>(?:(?!</\1>)[\s\S])*</\1>\s*)+"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _looks_like_command_plumbing(text) -> bool:
|
|
163
|
+
"""Title-only liberal skip: the whole text is one or more
|
|
164
|
+
command-*/local-command-* wrappers (prefix shape). `fullmatch` anchors the
|
|
165
|
+
whole string. See `_CMD_FAMILY_RE`."""
|
|
166
|
+
return bool(text) and _CMD_FAMILY_RE.fullmatch(text) is not None
|
|
167
|
+
|
|
168
|
+
|
|
145
169
|
def _session_titles_map(conn, session_ids):
|
|
146
170
|
"""{sid: title} for the first non-marker, non-blank MAIN-session human line
|
|
147
171
|
per session (read-time, no migration). Windowed to the earliest 12 human
|
|
@@ -178,7 +202,7 @@ def _session_titles_map(conn, session_ids):
|
|
|
178
202
|
for sid, text in rows:
|
|
179
203
|
if sid in titles:
|
|
180
204
|
continue # already resolved to the first non-marker
|
|
181
|
-
if _is_system_marker(text):
|
|
205
|
+
if _is_system_marker(text) or _looks_like_command_plumbing(text):
|
|
182
206
|
continue
|
|
183
207
|
if skip_skill_titles and _first_nonblank_line(text).startswith(_SKILL_PREAMBLE):
|
|
184
208
|
continue
|
|
@@ -745,8 +769,17 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
745
769
|
# Stamp the session_id into each anchor (spec anchor is (session_id, uuid);
|
|
746
770
|
# the dict literals are built session-agnostic, so fill it here where the
|
|
747
771
|
# session id is known). NOT a no-op — the endpoint/clients rely on it.
|
|
772
|
+
# #186: ALSO strip ANSI from the displayed prose/thinking text of each page
|
|
773
|
+
# item before emit, so a pre-fix row already indexed with raw SGR renders
|
|
774
|
+
# clean (the read-time half of the no-forced-reingest contract). tool_result
|
|
775
|
+
# blocks are EXCLUDED — Bash AnsiText (#177 S3) renders their SGR colors.
|
|
748
776
|
for it in page:
|
|
749
777
|
it["anchor"]["session_id"] = session_id
|
|
778
|
+
if it.get("text"):
|
|
779
|
+
it["text"] = _strip_ansi(it["text"])
|
|
780
|
+
for b in it["blocks"]:
|
|
781
|
+
if b.get("kind") in ("text", "thinking") and b.get("text"):
|
|
782
|
+
b["text"] = _strip_ansi(b["text"])
|
|
750
783
|
|
|
751
784
|
first = logical[0]
|
|
752
785
|
last = logical[-1]
|
|
@@ -769,9 +802,10 @@ _OUTLINE_LABEL_CAP = 120
|
|
|
769
802
|
|
|
770
803
|
|
|
771
804
|
def _outline_label(text):
|
|
772
|
-
"""First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none).
|
|
805
|
+
"""First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none).
|
|
806
|
+
Read-time ANSI strip (#186) so a pre-fix dirty row's label is clean."""
|
|
773
807
|
for ln in (text or "").splitlines():
|
|
774
|
-
s = ln.strip()
|
|
808
|
+
s = _strip_ansi(ln).strip()
|
|
775
809
|
if s:
|
|
776
810
|
return s[:_OUTLINE_LABEL_CAP]
|
|
777
811
|
return ""
|
|
@@ -1083,38 +1117,74 @@ def _fts_flag_unavailable(conn) -> bool:
|
|
|
1083
1117
|
return bool(row and row[0])
|
|
1084
1118
|
|
|
1085
1119
|
|
|
1120
|
+
def _search_depth(conn) -> str:
|
|
1121
|
+
"""'prose-only' while migration 010's column split is pending, else 'full'
|
|
1122
|
+
(#177 S6). Mirrors ``_cctally_db.conversation_search_depth`` but reads the
|
|
1123
|
+
flag inline (the kernel never imports the db sibling — same pattern as
|
|
1124
|
+
``_fts_flag_unavailable``). An OperationalError (no cache_meta) → 'full'."""
|
|
1125
|
+
try:
|
|
1126
|
+
pending = conn.execute(
|
|
1127
|
+
"SELECT 1 FROM cache_meta "
|
|
1128
|
+
"WHERE key='conversation_search_split_pending'").fetchone()
|
|
1129
|
+
except sqlite3.OperationalError:
|
|
1130
|
+
return "full"
|
|
1131
|
+
return "prose-only" if pending else "full"
|
|
1132
|
+
|
|
1133
|
+
|
|
1086
1134
|
def search_conversations(conn, query, *, limit=50, offset=0,
|
|
1087
|
-
fts_available=None) -> dict:
|
|
1135
|
+
kind="all", fts_available=None) -> dict:
|
|
1088
1136
|
"""Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
|
|
1089
1137
|
snippet); else a LIKE scan with a manual snippet. Hits deduped by
|
|
1090
1138
|
(session_id, uuid); each carries the turn's cost. `fts_available` overrides
|
|
1091
|
-
detection (test seam / explicit LIKE).
|
|
1139
|
+
detection (test seam / explicit LIKE).
|
|
1140
|
+
|
|
1141
|
+
#177 S6: ``kind`` (one of ``_SEARCH_KINDS``) scopes the search to a column
|
|
1142
|
+
family — ``all`` is unfiltered, ``prompts``/``assistant`` filter the prose
|
|
1143
|
+
column + entry_type, ``tools``/``thinking`` filter the split index columns.
|
|
1144
|
+
Every hit gains ``match_kinds`` (sorted ``['tool', 'thinking']`` badges;
|
|
1145
|
+
prose never badges). The response carries additive ``kind`` + ``search_depth``
|
|
1146
|
+
so the client can degrade the Tools/Thinking facets during the one-time
|
|
1147
|
+
column split (``search_depth == 'prose-only'`` short-circuits those two
|
|
1148
|
+
kinds to empty). An unknown ``kind`` raises ``ValueError`` (route → 400)."""
|
|
1149
|
+
if kind not in _SEARCH_KINDS:
|
|
1150
|
+
raise ValueError(f"unknown kind: {kind}")
|
|
1092
1151
|
q = (query or "").strip()
|
|
1093
1152
|
limit = max(1, min(int(limit), 200))
|
|
1094
1153
|
offset = max(0, int(offset))
|
|
1095
1154
|
if fts_available is None:
|
|
1096
1155
|
fts_available = not _fts_flag_unavailable(conn)
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1156
|
+
depth = _search_depth(conn)
|
|
1157
|
+
mode = "fts" if fts_available else "like"
|
|
1158
|
+
base = {"query": q, "mode": mode, "hits": [], "total": 0,
|
|
1159
|
+
"kind": kind, "search_depth": depth}
|
|
1160
|
+
# Prose-only interim: the split columns are not yet indexed, so tools /
|
|
1161
|
+
# thinking can't match — short-circuit them to empty (spec §1 interim).
|
|
1162
|
+
if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
|
|
1163
|
+
return base
|
|
1100
1164
|
if fts_available:
|
|
1101
1165
|
try:
|
|
1102
|
-
|
|
1166
|
+
out = _search_fts(conn, q, limit, offset, kind, depth)
|
|
1167
|
+
out.update(kind=kind, search_depth=depth)
|
|
1168
|
+
return out
|
|
1103
1169
|
except sqlite3.OperationalError:
|
|
1104
1170
|
pass # corrupt/missing FTS at query time → fall through to LIKE
|
|
1105
|
-
|
|
1171
|
+
out = _search_like(conn, q, limit, offset, kind, depth)
|
|
1172
|
+
out.update(kind=kind, mode="like", search_depth=depth)
|
|
1173
|
+
return out
|
|
1106
1174
|
|
|
1107
1175
|
|
|
1108
|
-
def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id):
|
|
1176
|
+
def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id, match_kinds=None):
|
|
1109
1177
|
"""Build one hit WITHOUT cost — cost is batched onto the FINAL page in
|
|
1110
1178
|
_attach_costs (I1: no per-hit _turn_cost_map round-trip). The turn key rides
|
|
1111
|
-
on the private `_turn_key` field until the batch maps it to `cost_usd`.
|
|
1179
|
+
on the private `_turn_key` field until the batch maps it to `cost_usd`.
|
|
1180
|
+
#177 S6: ``match_kinds`` (sorted non-prose badges) is attached per hit."""
|
|
1112
1181
|
return {
|
|
1113
1182
|
"session_id": sid,
|
|
1114
1183
|
"uuid": uuid_,
|
|
1115
1184
|
"project_label": _project_label(cwd),
|
|
1116
1185
|
"ts": ts,
|
|
1117
1186
|
"snippet": snippet,
|
|
1187
|
+
"match_kinds": match_kinds or [],
|
|
1118
1188
|
"_turn_key": (msg_id, req_id) if msg_id is not None and req_id is not None
|
|
1119
1189
|
else None,
|
|
1120
1190
|
}
|
|
@@ -1154,16 +1224,18 @@ def _like_pattern(q):
|
|
|
1154
1224
|
+ "%")
|
|
1155
1225
|
|
|
1156
1226
|
|
|
1157
|
-
def _fts_snippets(conn, fts_q, ids):
|
|
1227
|
+
def _fts_snippets(conn, fts_q, ids, col=0):
|
|
1158
1228
|
"""{rowid: snippet} for the page rowids ONLY (#149). snippet() needs an
|
|
1159
1229
|
active MATCH, so it can't be deferred to an outer query over the page CTE;
|
|
1160
1230
|
a second bounded MATCH restricted to the page rowids generates snippets for
|
|
1161
|
-
at most one page of hits instead of every corpus match.
|
|
1231
|
+
at most one page of hits instead of every corpus match. #177 S6: ``col``
|
|
1232
|
+
selects which FTS column the snippet is drawn from (0=text, 1=search_tool,
|
|
1233
|
+
2=search_thinking) so a tool/thinking hit shows its matching content."""
|
|
1162
1234
|
if not ids:
|
|
1163
1235
|
return {}
|
|
1164
1236
|
ph = ",".join("?" for _ in ids)
|
|
1165
1237
|
rows = conn.execute(
|
|
1166
|
-
"SELECT cm.id, snippet(conversation_fts,
|
|
1238
|
+
f"SELECT cm.id, snippet(conversation_fts, {int(col)}, '[', ']', ' … ', 12) "
|
|
1167
1239
|
"FROM conversation_fts "
|
|
1168
1240
|
"JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1169
1241
|
f"WHERE conversation_fts MATCH ? AND cm.id IN ({ph})",
|
|
@@ -1186,10 +1258,54 @@ def _texts_for_ids(conn, ids):
|
|
|
1186
1258
|
return {r[0]: r[1] for r in rows}
|
|
1187
1259
|
|
|
1188
1260
|
|
|
1189
|
-
|
|
1261
|
+
# #177 S6: FTS-column index → badge label (col 0 = prose = no badge).
|
|
1262
|
+
_KIND_PROBE_COLUMNS = (("search_tool", "tool"), ("search_thinking", "thinking"))
|
|
1263
|
+
_SNIPPET_COL_PREFERENCE = (("tool", 1), ("thinking", 2)) # prose (0) is default
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _match_kinds(conn, fts_q, rids_by_group):
|
|
1267
|
+
"""{group -> sorted [badges]} via marker-based column probes (spec F3).
|
|
1268
|
+
|
|
1269
|
+
A column "matched" iff a column-filtered sub-MATCH returns its rowid — NOT
|
|
1270
|
+
iff snippet() is non-empty (snippet returns the column's unmarked text for
|
|
1271
|
+
non-matching columns). Probes aggregate across ALL matched rowids of each
|
|
1272
|
+
page group's ``(session_id, uuid)``, so a multi-row hit badges completely.
|
|
1273
|
+
Prose (col 0) is never a badge. ``fts_q`` is the un-column-filtered term
|
|
1274
|
+
expression (the per-column wrapping is applied here)."""
|
|
1275
|
+
all_rids = sorted({r for rids in rids_by_group.values() for r in rids})
|
|
1276
|
+
if not all_rids:
|
|
1277
|
+
return {grp: [] for grp in rids_by_group}
|
|
1278
|
+
ph = ",".join("?" for _ in all_rids)
|
|
1279
|
+
hits_by_col = {}
|
|
1280
|
+
for col, label in _KIND_PROBE_COLUMNS:
|
|
1281
|
+
got = conn.execute(
|
|
1282
|
+
"SELECT conversation_fts.rowid FROM conversation_fts "
|
|
1283
|
+
f"WHERE conversation_fts MATCH ? AND conversation_fts.rowid IN ({ph})",
|
|
1284
|
+
(f"{{{col}}}: ({fts_q})", *all_rids),
|
|
1285
|
+
).fetchall()
|
|
1286
|
+
hits_by_col[label] = {r[0] for r in got}
|
|
1287
|
+
return {grp: [lbl for (_c, lbl) in _KIND_PROBE_COLUMNS
|
|
1288
|
+
if set(rids) & hits_by_col[lbl]]
|
|
1289
|
+
for grp, rids in rids_by_group.items()}
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _search_fts(conn, q, limit, offset, kind, depth):
|
|
1190
1293
|
# All of dedup + paging + total live in SQL (#149) so Python never holds
|
|
1191
1294
|
# more than one page of hits/snippets, regardless of corpus match count.
|
|
1192
|
-
|
|
1295
|
+
#
|
|
1296
|
+
# #177 S6: prose-only interim runs the LEGACY single-column shape (the split
|
|
1297
|
+
# columns are not yet indexed) — no column filter, no badge/snippet probes
|
|
1298
|
+
# against search_tool/search_thinking (those columns aren't in the legacy
|
|
1299
|
+
# FTS table). Full mode applies the kind column filter + entry_type predicate
|
|
1300
|
+
# and the marker-based badges.
|
|
1301
|
+
legacy = depth == "prose-only"
|
|
1302
|
+
fts_q = _fts_query(q, prefix_last=True)
|
|
1303
|
+
# legacy single-column MATCH (prose), no column filter; full mode applies
|
|
1304
|
+
# the kind column filter.
|
|
1305
|
+
match_expr = fts_q if legacy else _kind_match_expr(kind, fts_q)
|
|
1306
|
+
entry_type = _KIND_ENTRY_TYPE.get(kind)
|
|
1307
|
+
et_pred = " AND cm.entry_type = ?" if entry_type is not None else ""
|
|
1308
|
+
et_args = (entry_type,) if entry_type is not None else ()
|
|
1193
1309
|
# Exact post-dedup logical total — counted in C with no snippet generation
|
|
1194
1310
|
# and no Python row materialization.
|
|
1195
1311
|
total = conn.execute(
|
|
@@ -1197,8 +1313,8 @@ def _search_fts(conn, q, limit, offset):
|
|
|
1197
1313
|
" SELECT DISTINCT cm.session_id, cm.uuid "
|
|
1198
1314
|
" FROM conversation_fts "
|
|
1199
1315
|
" JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1200
|
-
" WHERE conversation_fts MATCH ?)",
|
|
1201
|
-
(
|
|
1316
|
+
f" WHERE conversation_fts MATCH ?{et_pred})",
|
|
1317
|
+
(match_expr, *et_args),
|
|
1202
1318
|
).fetchone()[0]
|
|
1203
1319
|
# One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
|
|
1204
1320
|
# the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
|
|
@@ -1209,16 +1325,20 @@ def _search_fts(conn, q, limit, offset):
|
|
|
1209
1325
|
# bm25 is materialized as a plain `rank` column in the inner `matched` CTE
|
|
1210
1326
|
# before the window function runs: FTS5 auxiliary functions (bm25/snippet)
|
|
1211
1327
|
# may only be used directly against the MATCH query, NOT inside a window
|
|
1212
|
-
# ORDER BY ("unable to use function bm25 in the requested context").
|
|
1328
|
+
# ORDER BY ("unable to use function bm25 in the requested context"). Weights
|
|
1329
|
+
# (prose > tool > thinking) only apply to the multi-column (full) shape;
|
|
1330
|
+
# the legacy single-column table takes the plain bm25.
|
|
1331
|
+
bm25_expr = ("bm25(conversation_fts)" if legacy
|
|
1332
|
+
else "bm25(conversation_fts, 10.0, 3.0, 1.0)")
|
|
1213
1333
|
page = conn.execute(
|
|
1214
1334
|
"WITH matched AS ("
|
|
1215
1335
|
" SELECT cm.id AS rid, cm.session_id AS sid, cm.uuid AS uuid, "
|
|
1216
1336
|
" cm.timestamp_utc AS ts, cm.cwd AS cwd, "
|
|
1217
1337
|
" cm.msg_id AS mid, cm.req_id AS rqd, "
|
|
1218
|
-
"
|
|
1338
|
+
f" {bm25_expr} AS rank "
|
|
1219
1339
|
" FROM conversation_fts "
|
|
1220
1340
|
" JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1221
|
-
" WHERE conversation_fts MATCH ?), "
|
|
1341
|
+
f" WHERE conversation_fts MATCH ?{et_pred}), "
|
|
1222
1342
|
"ranked AS ("
|
|
1223
1343
|
" SELECT *, ROW_NUMBER() OVER ("
|
|
1224
1344
|
" PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
|
|
@@ -1226,26 +1346,101 @@ def _search_fts(conn, q, limit, offset):
|
|
|
1226
1346
|
" FROM matched) "
|
|
1227
1347
|
"SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
|
|
1228
1348
|
"ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
|
|
1229
|
-
(
|
|
1349
|
+
(match_expr, *et_args, limit, offset),
|
|
1230
1350
|
).fetchall()
|
|
1231
|
-
|
|
1232
|
-
|
|
1351
|
+
page_groups = {(sid, uuid): rid for (rid, sid, uuid, ts, cwd, mid, rqd) in page}
|
|
1352
|
+
if legacy:
|
|
1353
|
+
badges = {grp: [] for grp in page_groups}
|
|
1354
|
+
else:
|
|
1355
|
+
rids_by_group = _all_matched_rids_by_group(
|
|
1356
|
+
conn, match_expr, et_pred, et_args, list(page_groups))
|
|
1357
|
+
badges = _match_kinds(conn, fts_q, rids_by_group)
|
|
1358
|
+
snips = _fts_snippets(conn, match_expr, [r[0] for r in page], col=0)
|
|
1359
|
+
# For hits badged tool/thinking but with no prose match, draw the snippet
|
|
1360
|
+
# from the matched column instead (prose → tool → thinking preference).
|
|
1361
|
+
if not legacy:
|
|
1362
|
+
snips = _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips)
|
|
1363
|
+
hits = [_row_to_hit(uuid, sid, ts, cwd, snips.get(rid, ""), mid, rqd,
|
|
1364
|
+
match_kinds=badges.get((sid, uuid), []))
|
|
1233
1365
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
1234
1366
|
return {"query": q, "mode": "fts",
|
|
1235
1367
|
"hits": _attach_titles(conn, _attach_costs(conn, hits)),
|
|
1236
1368
|
"total": total}
|
|
1237
1369
|
|
|
1238
1370
|
|
|
1239
|
-
def
|
|
1371
|
+
def _all_matched_rids_by_group(conn, match_expr, et_pred, et_args, groups):
|
|
1372
|
+
"""{(sid, uuid) -> [rids]} for the page groups: ALL matched physical rows of
|
|
1373
|
+
each group (not just the rank-survivor), so badges aggregate completely."""
|
|
1374
|
+
if not groups:
|
|
1375
|
+
return {}
|
|
1376
|
+
rids_by_group = {g: [] for g in groups}
|
|
1377
|
+
rows = conn.execute(
|
|
1378
|
+
"SELECT cm.id, cm.session_id, cm.uuid FROM conversation_fts "
|
|
1379
|
+
"JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1380
|
+
f"WHERE conversation_fts MATCH ?{et_pred}",
|
|
1381
|
+
(match_expr, *et_args),
|
|
1382
|
+
).fetchall()
|
|
1383
|
+
for rid, sid, uuid in rows:
|
|
1384
|
+
g = (sid, uuid)
|
|
1385
|
+
if g in rids_by_group:
|
|
1386
|
+
rids_by_group[g].append(rid)
|
|
1387
|
+
return rids_by_group
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
|
|
1391
|
+
"""Replace a hit's prose snippet with its matched column's snippet when the
|
|
1392
|
+
prose column did NOT match (prose → tool → thinking preference). Probes
|
|
1393
|
+
which column matched the survivor rowid, then re-snippets that column."""
|
|
1394
|
+
by_col = {} # snippet column index -> [rids needing it]
|
|
1395
|
+
for (rid, sid, uuid, ts, cwd, mid, rqd) in page:
|
|
1396
|
+
grp = (sid, uuid)
|
|
1397
|
+
kinds = badges.get(grp, [])
|
|
1398
|
+
if not kinds:
|
|
1399
|
+
continue # prose hit (or unbadged) — keep col-0 snippet
|
|
1400
|
+
# Does THIS survivor row match prose? If so keep col 0.
|
|
1401
|
+
prose_hit = conn.execute(
|
|
1402
|
+
"SELECT 1 FROM conversation_fts "
|
|
1403
|
+
"WHERE conversation_fts MATCH ? AND conversation_fts.rowid = ?",
|
|
1404
|
+
(f"{{text}}: ({fts_q})", rid)).fetchone()
|
|
1405
|
+
if prose_hit:
|
|
1406
|
+
continue
|
|
1407
|
+
for label, col in _SNIPPET_COL_PREFERENCE:
|
|
1408
|
+
if label in kinds:
|
|
1409
|
+
by_col.setdefault(col, []).append(rid)
|
|
1410
|
+
break
|
|
1411
|
+
for col, rids in by_col.items():
|
|
1412
|
+
col_fts = _kind_match_expr(
|
|
1413
|
+
"tools" if col == 1 else "thinking", fts_q)
|
|
1414
|
+
alt = _fts_snippets(conn, col_fts, rids, col=col)
|
|
1415
|
+
snips.update(alt)
|
|
1416
|
+
return snips
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _search_like(conn, q, limit, offset, kind, depth):
|
|
1240
1420
|
# SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
|
|
1241
1421
|
# COUNT + page each scan the table once (the degraded path already lacks an
|
|
1242
|
-
# index for the substring match).
|
|
1422
|
+
# index for the substring match). #177 S6: kind → column list (single-
|
|
1423
|
+
# substring semantics preserved — a documented degraded divergence from FTS
|
|
1424
|
+
# term-wise AND); badges from per-column LIKE probes on the page rows.
|
|
1425
|
+
legacy = depth == "prose-only"
|
|
1243
1426
|
like = _like_pattern(q)
|
|
1427
|
+
if legacy:
|
|
1428
|
+
cols = ["text"]
|
|
1429
|
+
else:
|
|
1430
|
+
cols = ({"prompts": ["text"], "assistant": ["text"],
|
|
1431
|
+
"tools": ["search_tool"], "thinking": ["search_thinking"]}
|
|
1432
|
+
.get(kind, ["text", "search_tool", "search_thinking"]))
|
|
1433
|
+
col_pred = "(" + " OR ".join(
|
|
1434
|
+
f"{c} LIKE ? ESCAPE '\\' AND {c} != ''" for c in cols) + ")"
|
|
1435
|
+
like_args = tuple(like for _ in cols)
|
|
1436
|
+
entry_type = _KIND_ENTRY_TYPE.get(kind)
|
|
1437
|
+
et_pred = " AND entry_type = ?" if entry_type is not None else ""
|
|
1438
|
+
et_args = (entry_type,) if entry_type is not None else ()
|
|
1244
1439
|
total = conn.execute(
|
|
1245
1440
|
"SELECT COUNT(*) FROM ("
|
|
1246
1441
|
" SELECT DISTINCT session_id, uuid FROM conversation_messages "
|
|
1247
|
-
" WHERE
|
|
1248
|
-
(
|
|
1442
|
+
f" WHERE {col_pred}{et_pred})",
|
|
1443
|
+
(*like_args, *et_args),
|
|
1249
1444
|
).fetchone()[0]
|
|
1250
1445
|
page = conn.execute(
|
|
1251
1446
|
"WITH ranked AS ("
|
|
@@ -1256,25 +1451,217 @@ def _search_like(conn, q, limit, offset):
|
|
|
1256
1451
|
" ORDER BY timestamp_utc DESC, id DESC"
|
|
1257
1452
|
" ) AS rn "
|
|
1258
1453
|
" FROM conversation_messages "
|
|
1259
|
-
" WHERE
|
|
1454
|
+
f" WHERE {col_pred}{et_pred}) "
|
|
1260
1455
|
"SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
|
|
1261
1456
|
"ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
|
|
1262
|
-
(
|
|
1457
|
+
(*like_args, *et_args, limit, offset),
|
|
1263
1458
|
).fetchall()
|
|
1264
1459
|
texts = _texts_for_ids(conn, [r[0] for r in page])
|
|
1460
|
+
if legacy:
|
|
1461
|
+
badges = {(sid, uuid): [] for (rid, sid, uuid, *_r) in page}
|
|
1462
|
+
else:
|
|
1463
|
+
badges = _like_badges(conn, like, list(
|
|
1464
|
+
{(sid, uuid) for (rid, sid, uuid, *_r) in page}))
|
|
1265
1465
|
hits = [_row_to_hit(uuid, sid, ts, cwd,
|
|
1266
|
-
_manual_snippet(texts.get(rid, ""), q), mid, rqd
|
|
1466
|
+
_manual_snippet(texts.get(rid, ""), q), mid, rqd,
|
|
1467
|
+
match_kinds=badges.get((sid, uuid), []))
|
|
1267
1468
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
1268
1469
|
return {"query": q, "mode": "like",
|
|
1269
1470
|
"hits": _attach_titles(conn, _attach_costs(conn, hits)),
|
|
1270
1471
|
"total": total}
|
|
1271
1472
|
|
|
1272
1473
|
|
|
1273
|
-
def
|
|
1474
|
+
def _like_badges(conn, like, groups):
|
|
1475
|
+
"""{(sid, uuid) -> sorted [badges]} via per-column LIKE probes across all
|
|
1476
|
+
physical rows of each page group (LIKE degraded mode; spec F7)."""
|
|
1477
|
+
if not groups:
|
|
1478
|
+
return {}
|
|
1479
|
+
out = {g: [] for g in groups}
|
|
1480
|
+
ph = " OR ".join("(session_id=? AND uuid=?)" for _ in groups)
|
|
1481
|
+
flat = [v for g in groups for v in g]
|
|
1482
|
+
for col, label in _KIND_PROBE_COLUMNS:
|
|
1483
|
+
rows = conn.execute(
|
|
1484
|
+
f"SELECT DISTINCT session_id, uuid FROM conversation_messages "
|
|
1485
|
+
f"WHERE {col} LIKE ? ESCAPE '\\' AND {col} != '' AND ({ph})",
|
|
1486
|
+
(like, *flat)).fetchall()
|
|
1487
|
+
for sid, uuid in rows:
|
|
1488
|
+
if (sid, uuid) in out:
|
|
1489
|
+
out[(sid, uuid)].append(label)
|
|
1490
|
+
return {g: sorted(v) for g, v in out.items()}
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
# #177 S6: kind facets. `all` is the unfiltered MATCH; `prompts`/`assistant`
|
|
1494
|
+
# filter the prose column AND the entry_type; `tools`/`thinking` filter the
|
|
1495
|
+
# split index columns. Validated in search_conversations / find_in_conversation
|
|
1496
|
+
# (an unknown kind raises ValueError → the route maps it to a 400).
|
|
1497
|
+
_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
1498
|
+
_KIND_COLUMN = {"prompts": "text", "assistant": "text",
|
|
1499
|
+
"tools": "search_tool", "thinking": "search_thinking"}
|
|
1500
|
+
_KIND_ENTRY_TYPE = {"prompts": "human", "assistant": "assistant"}
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _fts_query(q, prefix_last=False):
|
|
1274
1504
|
"""Quote each whitespace term as an FTS5 string literal so punctuation /
|
|
1275
|
-
operators in user input can't error the MATCH or inject FTS syntax.
|
|
1505
|
+
operators in user input can't error the MATCH or inject FTS syntax. When
|
|
1506
|
+
``prefix_last`` is set, the final term gets a trailing ``*`` (valid FTS5
|
|
1507
|
+
quoted-prefix syntax) so ``cache.d`` matches ``cache.db`` while typing — a
|
|
1508
|
+
``*`` INSIDE the quotes is a literal char, so the prefix marker lives
|
|
1509
|
+
outside the closing quote (#177 S6)."""
|
|
1276
1510
|
terms = [t for t in q.split() if t]
|
|
1277
|
-
|
|
1511
|
+
if not terms:
|
|
1512
|
+
return '""'
|
|
1513
|
+
quoted = ['"' + t.replace('"', '""') + '"' for t in terms]
|
|
1514
|
+
if prefix_last:
|
|
1515
|
+
quoted[-1] += "*"
|
|
1516
|
+
return " ".join(quoted)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _kind_match_expr(kind, fts_q):
|
|
1520
|
+
"""Wrap the term expression in a column filter for the kind (#177 S6).
|
|
1521
|
+
``all`` stays unfiltered; ``prompts``/``assistant`` filter the prose column
|
|
1522
|
+
(the entry_type split is a separate SQL predicate, applied by the caller)."""
|
|
1523
|
+
col = _KIND_COLUMN.get(kind)
|
|
1524
|
+
return f"{{{col}}}: ({fts_q})" if col else fts_q
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# ===========================================================================
|
|
1528
|
+
# #177 S6: in-conversation find — rendered-turn anchors (spec §2 find endpoint).
|
|
1529
|
+
# ===========================================================================
|
|
1530
|
+
|
|
1531
|
+
_FIND_ANCHOR_CAP = 500
|
|
1532
|
+
|
|
1533
|
+
# Which physical-row columns the find match probes per kind, and the badge label
|
|
1534
|
+
# each non-prose column contributes. ``text`` maps to the synthetic ``prose``
|
|
1535
|
+
# label so a prose-only match still anchors a turn but never badges.
|
|
1536
|
+
_FIND_KIND_COLUMNS = {
|
|
1537
|
+
"all": (("text", "prose"), ("search_tool", "tool"),
|
|
1538
|
+
("search_thinking", "thinking")),
|
|
1539
|
+
"prompts": (("text", "prose"),),
|
|
1540
|
+
"assistant": (("text", "prose"),),
|
|
1541
|
+
"tools": (("search_tool", "tool"),),
|
|
1542
|
+
"thinking": (("search_thinking", "thinking"),),
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def find_in_conversation(conn, session_id, query, *, kind="all",
|
|
1547
|
+
fts_available=None, cap=_FIND_ANCHOR_CAP):
|
|
1548
|
+
"""Document-ordered rendered-turn anchors for in-conversation find (#177 S6).
|
|
1549
|
+
|
|
1550
|
+
Anchor identity is rendered-turn identity (spec F1): the FTS/LIKE match for
|
|
1551
|
+
the session yields physical-row uuids, then ``_assemble_session`` (the S5
|
|
1552
|
+
outline precedent — 1:1 grouping parity by construction) maps each matched
|
|
1553
|
+
row onto its rendered item via ``member_uuids``. Matched rows folding into
|
|
1554
|
+
the same item (assistant fragments, owned tool results, skill bodies)
|
|
1555
|
+
collapse to ONE anchor whose ``match_kinds`` aggregates across its matched
|
|
1556
|
+
members; document order = assembly order (bm25 unused here). ``total`` counts
|
|
1557
|
+
rendered-turn anchors PRE-cap; the list caps at ``cap`` with
|
|
1558
|
+
``anchors_truncated``. Returns None for an unknown session; an unknown
|
|
1559
|
+
``kind`` raises ValueError (route → 400). Empty/whitespace query → empty;
|
|
1560
|
+
prose-only depth + tools/thinking kinds → empty (the split index is pending)."""
|
|
1561
|
+
if kind not in _SEARCH_KINDS:
|
|
1562
|
+
raise ValueError(f"unknown kind: {kind}")
|
|
1563
|
+
# Cheap existence probe (one indexed SELECT) BEFORE the full assembly, so an
|
|
1564
|
+
# empty/prose-only-blocked query opening the find bar pays nothing — yet the
|
|
1565
|
+
# unknown-session → None contract (the route's 404) is preserved, including
|
|
1566
|
+
# for an empty query (assembly used to run first and gave the same answer).
|
|
1567
|
+
if conn.execute(
|
|
1568
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
1569
|
+
(session_id,)).fetchone() is None:
|
|
1570
|
+
return None
|
|
1571
|
+
depth = _search_depth(conn)
|
|
1572
|
+
if fts_available is None:
|
|
1573
|
+
fts_available = not _fts_flag_unavailable(conn)
|
|
1574
|
+
q = (query or "").strip()
|
|
1575
|
+
base = {"total": 0, "anchors": [], "anchors_truncated": False,
|
|
1576
|
+
"search_depth": depth, "kind": kind,
|
|
1577
|
+
"mode": "fts" if fts_available else "like"}
|
|
1578
|
+
if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
|
|
1579
|
+
return base
|
|
1580
|
+
asm = _assemble_session(conn, session_id)
|
|
1581
|
+
if asm is None:
|
|
1582
|
+
return None
|
|
1583
|
+
mode, matched = _find_matched_rows(
|
|
1584
|
+
conn, session_id, q, kind, depth, fts_available)
|
|
1585
|
+
# matched: {uuid -> set of labels in {"prose", "tool", "thinking"}}
|
|
1586
|
+
anchors = []
|
|
1587
|
+
for it in asm["items"]:
|
|
1588
|
+
hit_kinds = set()
|
|
1589
|
+
hit = False
|
|
1590
|
+
for mu in it["member_uuids"]:
|
|
1591
|
+
labels = matched.get(mu)
|
|
1592
|
+
if labels:
|
|
1593
|
+
hit = True
|
|
1594
|
+
hit_kinds |= labels
|
|
1595
|
+
if hit:
|
|
1596
|
+
anchors.append({
|
|
1597
|
+
"uuid": it["anchor"]["uuid"],
|
|
1598
|
+
"match_kinds": sorted(k for k in hit_kinds if k != "prose")})
|
|
1599
|
+
total = len(anchors)
|
|
1600
|
+
return {**base, "mode": mode, "total": total,
|
|
1601
|
+
"anchors": anchors[:cap], "anchors_truncated": total > cap}
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _find_matched_rows(conn, session_id, q, kind, depth, fts_available):
|
|
1605
|
+
"""({mode}, {uuid -> {labels}}) for one session. Runs a column-scoped MATCH
|
|
1606
|
+
(or LIKE) per relevant column and tags each matched row's uuid with that
|
|
1607
|
+
column's label. Prose-only depth uses the legacy single-column FTS (the
|
|
1608
|
+
split columns aren't indexed yet) for prose-bearing kinds."""
|
|
1609
|
+
if fts_available:
|
|
1610
|
+
try:
|
|
1611
|
+
return "fts", _find_matched_fts(conn, session_id, q, kind, depth)
|
|
1612
|
+
except sqlite3.OperationalError:
|
|
1613
|
+
pass # corrupt/missing FTS → fall through to LIKE
|
|
1614
|
+
return "like", _find_matched_like(conn, session_id, q, kind, depth)
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _find_kind_columns(kind, depth):
|
|
1618
|
+
"""The (column, label) probes the find match runs for this (kind, depth).
|
|
1619
|
+
Prose-only depth has only the legacy prose column indexed, so the split
|
|
1620
|
+
tool/thinking columns drop out (a prose-bearing kind keeps its prose probe;
|
|
1621
|
+
tools/thinking yield nothing). Shared by _find_matched_fts / _find_matched_like
|
|
1622
|
+
so the two paths can never disagree on which columns a kind probes."""
|
|
1623
|
+
if depth == "prose-only":
|
|
1624
|
+
return (("text", "prose"),) if kind in ("all", "prompts", "assistant") else ()
|
|
1625
|
+
return _FIND_KIND_COLUMNS[kind]
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _find_matched_fts(conn, session_id, q, kind, depth):
|
|
1629
|
+
fts_q = _fts_query(q, prefix_last=True)
|
|
1630
|
+
# entry_type predicate + the prose-only legacy MATCH shape are loop-invariant
|
|
1631
|
+
# — compute once. Legacy single-column FTS indexes prose only, so it MATCHes
|
|
1632
|
+
# the bare term (no column filter); full mode wraps each column.
|
|
1633
|
+
et = _KIND_ENTRY_TYPE.get(kind)
|
|
1634
|
+
et_pred = " AND cm.entry_type = ?" if et is not None else ""
|
|
1635
|
+
et_args = (et,) if et is not None else ()
|
|
1636
|
+
legacy = depth == "prose-only"
|
|
1637
|
+
out = {}
|
|
1638
|
+
for col, label in _find_kind_columns(kind, depth):
|
|
1639
|
+
match_expr = fts_q if legacy else f"{{{col}}}: ({fts_q})"
|
|
1640
|
+
rows = conn.execute(
|
|
1641
|
+
"SELECT cm.uuid FROM conversation_fts "
|
|
1642
|
+
"JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
|
|
1643
|
+
f"WHERE conversation_fts MATCH ? AND cm.session_id = ?{et_pred}",
|
|
1644
|
+
(match_expr, session_id, *et_args)).fetchall()
|
|
1645
|
+
for (u,) in rows:
|
|
1646
|
+
out.setdefault(u, set()).add(label)
|
|
1647
|
+
return out
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _find_matched_like(conn, session_id, q, kind, depth):
|
|
1651
|
+
like = _like_pattern(q)
|
|
1652
|
+
et = _KIND_ENTRY_TYPE.get(kind)
|
|
1653
|
+
et_pred = " AND entry_type = ?" if et is not None else ""
|
|
1654
|
+
et_args = (et,) if et is not None else ()
|
|
1655
|
+
out = {}
|
|
1656
|
+
for col, label in _find_kind_columns(kind, depth):
|
|
1657
|
+
rows = conn.execute(
|
|
1658
|
+
f"SELECT uuid FROM conversation_messages "
|
|
1659
|
+
f"WHERE session_id = ? AND {col} LIKE ? ESCAPE '\\' "
|
|
1660
|
+
f"AND {col} != ''{et_pred}",
|
|
1661
|
+
(session_id, like, *et_args)).fetchall()
|
|
1662
|
+
for (u,) in rows:
|
|
1663
|
+
out.setdefault(u, set()).add(label)
|
|
1664
|
+
return out
|
|
1278
1665
|
|
|
1279
1666
|
|
|
1280
1667
|
def _manual_snippet(text, q, width=80):
|