cctally 1.39.0 → 1.41.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.
@@ -16,6 +16,7 @@ import json as _json
16
16
  import os
17
17
  import re
18
18
  import sqlite3
19
+ from datetime import datetime as _datetime
19
20
 
20
21
  # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
21
22
  # mirror — imported by the dashboard's conversation endpoints at runtime.
@@ -393,14 +394,15 @@ def _turn_usage_map(conn, turn_keys):
393
394
  return usage
394
395
 
395
396
 
396
- def get_conversation(conn, session_id, *, after=None, limit=500):
397
- """Reader payload for one session (spec §3.2). Returns None for an unknown
398
- session. Dedups logical messages by (session_id, uuid) (canonical = earliest
399
- timestamp), groups assistant fragments into turn items by (msg_id, req_id),
400
- joins cost once, anchors a turn on its prose-bearing fragment, and exposes
401
- every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
402
- id); ~500 items/page."""
403
- limit = max(1, min(int(limit), 1000))
397
+ def _assemble_session(conn, session_id):
398
+ """Shared assembly for get_conversation / get_conversation_outline (#177 S5).
399
+
400
+ Runs the full dedup turn-grouping fold sweep → meta-classify →
401
+ cost/usage-stamp pipeline over the WHOLE session and returns the
402
+ pre-pagination state, so the outline's turns match the reader's items 1:1
403
+ BY CONSTRUCTION (Codex F8 — one grouping pass, never two implementations).
404
+ Returns None for an unknown session.
405
+ """
404
406
  exists = conn.execute(
405
407
  "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
406
408
  (session_id,)).fetchone()
@@ -693,6 +695,26 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
693
695
  for it in items:
694
696
  it.pop("_source_tool_use_id", None)
695
697
 
698
+ return {"items": items, "logical": logical,
699
+ "subagent_meta": subagent_meta, "header_cost": header_cost}
700
+
701
+
702
+ def get_conversation(conn, session_id, *, after=None, limit=500):
703
+ """Reader payload for one session (spec §3.2). Returns None for an unknown
704
+ session. Dedups logical messages by (session_id, uuid) (canonical = earliest
705
+ timestamp), groups assistant fragments into turn items by (msg_id, req_id),
706
+ joins cost once, anchors a turn on its prose-bearing fragment, and exposes
707
+ every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
708
+ id); ~500 items/page."""
709
+ limit = max(1, min(int(limit), 1000))
710
+ asm = _assemble_session(conn, session_id)
711
+ if asm is None:
712
+ return None
713
+ items = asm["items"]
714
+ logical = asm["logical"]
715
+ subagent_meta = asm["subagent_meta"]
716
+ header_cost = asm["header_cost"]
717
+
696
718
  # Cursor pagination over the item list (anchored to each item's canonical id).
697
719
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
698
720
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -743,6 +765,104 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
743
765
  }
744
766
 
745
767
 
768
+ _OUTLINE_LABEL_CAP = 120
769
+
770
+
771
+ def _outline_label(text):
772
+ """First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none)."""
773
+ for ln in (text or "").splitlines():
774
+ s = ln.strip()
775
+ if s:
776
+ return s[:_OUTLINE_LABEL_CAP]
777
+ return ""
778
+
779
+
780
+ def _parse_outline_ts(ts):
781
+ if not ts:
782
+ return None
783
+ try:
784
+ return _datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
785
+ except ValueError:
786
+ return None
787
+
788
+
789
+ def get_conversation_outline(conn, session_id):
790
+ """Full-session per-turn skeleton + aggregates (#177 S5, spec §1).
791
+
792
+ No pagination — every grouped turn, but only skeleton fields (no inputs,
793
+ no result bodies, no full prose). `ts` is NULLABLE (Codex F6); stats and
794
+ every consumer tolerate it. Stats derive from the SAME assembled items the
795
+ reader pages (Codex F8). Returns None for an unknown session.
796
+ """
797
+ asm = _assemble_session(conn, session_id)
798
+ if asm is None:
799
+ return None
800
+ items, logical = asm["items"], asm["logical"]
801
+ turns = []
802
+ turn_counts = {"total": 0, "human": 0, "assistant": 0, "tool_result": 0, "meta": 0}
803
+ tool_counts, models = {}, {}
804
+ error_count = 0
805
+ tokens = {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
806
+ for it in items:
807
+ kind = it["kind"]
808
+ turn_counts["total"] += 1
809
+ if kind in turn_counts:
810
+ turn_counts[kind] += 1
811
+ t = {"uuid": it["anchor"]["uuid"], "kind": kind, "ts": it["ts"],
812
+ "label": _outline_label(it.get("text", "")),
813
+ "member_uuids": list(it["member_uuids"]),
814
+ "subagent_key": it["subagent_key"], "parent_uuid": it["parent_uuid"],
815
+ "is_sidechain": it["is_sidechain"]}
816
+ tools, thinking = [], []
817
+ for b in it["blocks"]:
818
+ bk = b.get("kind")
819
+ if bk in ("tool_call", "tool_use"):
820
+ res = b.get("result")
821
+ err = bool(res and res.get("is_error"))
822
+ tools.append({"name": b.get("name"), "is_error": err})
823
+ elif bk == "tool_result": # orphan error channel (spec delta b)
824
+ tools.append({"name": None, "is_error": bool(b.get("is_error"))})
825
+ elif bk == "thinking":
826
+ ln = _outline_label(b.get("text", ""))
827
+ if ln:
828
+ thinking.append(ln)
829
+ for tref in tools:
830
+ if tref["is_error"]:
831
+ error_count += 1
832
+ if tref["name"]:
833
+ tool_counts[tref["name"]] = tool_counts.get(tref["name"], 0) + 1
834
+ if tools:
835
+ t["tools"] = tools
836
+ if thinking:
837
+ t["thinking"] = thinking
838
+ if kind == "assistant":
839
+ if it.get("model"):
840
+ t["model"] = it["model"]
841
+ models[it["model"]] = models.get(it["model"], 0) + 1
842
+ tok = it.get("tokens")
843
+ if tok is not None:
844
+ t["tokens"] = tok
845
+ for k in tokens:
846
+ tokens[k] += tok.get(k, 0)
847
+ if kind == "meta":
848
+ t["meta_kind"] = it.get("meta_kind")
849
+ t["skill_name"] = it.get("skill_name")
850
+ if not t["label"]:
851
+ t["label"] = _outline_label(it.get("skill_name") or "")
852
+ turns.append(t)
853
+ ts_vals = [r[2] for r in logical if r[2]]
854
+ d0 = _parse_outline_ts(ts_vals[0] if ts_vals else None)
855
+ d1 = _parse_outline_ts(ts_vals[-1] if ts_vals else None)
856
+ duration = int((d1 - d0).total_seconds()) if d0 and d1 else None
857
+ return {"session_id": session_id,
858
+ "subagent_meta": asm["subagent_meta"],
859
+ "stats": {"turns": turn_counts, "tool_counts": tool_counts,
860
+ "error_count": error_count, "models": models,
861
+ "duration_seconds": duration, "tokens": tokens,
862
+ "cost_usd": asm["header_cost"]},
863
+ "turns": turns}
864
+
865
+
746
866
  _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
747
867
 
748
868
 
@@ -963,38 +1083,74 @@ def _fts_flag_unavailable(conn) -> bool:
963
1083
  return bool(row and row[0])
964
1084
 
965
1085
 
1086
+ def _search_depth(conn) -> str:
1087
+ """'prose-only' while migration 010's column split is pending, else 'full'
1088
+ (#177 S6). Mirrors ``_cctally_db.conversation_search_depth`` but reads the
1089
+ flag inline (the kernel never imports the db sibling — same pattern as
1090
+ ``_fts_flag_unavailable``). An OperationalError (no cache_meta) → 'full'."""
1091
+ try:
1092
+ pending = conn.execute(
1093
+ "SELECT 1 FROM cache_meta "
1094
+ "WHERE key='conversation_search_split_pending'").fetchone()
1095
+ except sqlite3.OperationalError:
1096
+ return "full"
1097
+ return "prose-only" if pending else "full"
1098
+
1099
+
966
1100
  def search_conversations(conn, query, *, limit=50, offset=0,
967
- fts_available=None) -> dict:
1101
+ kind="all", fts_available=None) -> dict:
968
1102
  """Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
969
1103
  snippet); else a LIKE scan with a manual snippet. Hits deduped by
970
1104
  (session_id, uuid); each carries the turn's cost. `fts_available` overrides
971
- detection (test seam / explicit LIKE)."""
1105
+ detection (test seam / explicit LIKE).
1106
+
1107
+ #177 S6: ``kind`` (one of ``_SEARCH_KINDS``) scopes the search to a column
1108
+ family — ``all`` is unfiltered, ``prompts``/``assistant`` filter the prose
1109
+ column + entry_type, ``tools``/``thinking`` filter the split index columns.
1110
+ Every hit gains ``match_kinds`` (sorted ``['tool', 'thinking']`` badges;
1111
+ prose never badges). The response carries additive ``kind`` + ``search_depth``
1112
+ so the client can degrade the Tools/Thinking facets during the one-time
1113
+ column split (``search_depth == 'prose-only'`` short-circuits those two
1114
+ kinds to empty). An unknown ``kind`` raises ``ValueError`` (route → 400)."""
1115
+ if kind not in _SEARCH_KINDS:
1116
+ raise ValueError(f"unknown kind: {kind}")
972
1117
  q = (query or "").strip()
973
1118
  limit = max(1, min(int(limit), 200))
974
1119
  offset = max(0, int(offset))
975
1120
  if fts_available is None:
976
1121
  fts_available = not _fts_flag_unavailable(conn)
977
- if not q:
978
- return {"query": q, "mode": "fts" if fts_available else "like",
979
- "hits": [], "total": 0}
1122
+ depth = _search_depth(conn)
1123
+ mode = "fts" if fts_available else "like"
1124
+ base = {"query": q, "mode": mode, "hits": [], "total": 0,
1125
+ "kind": kind, "search_depth": depth}
1126
+ # Prose-only interim: the split columns are not yet indexed, so tools /
1127
+ # thinking can't match — short-circuit them to empty (spec §1 interim).
1128
+ if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
1129
+ return base
980
1130
  if fts_available:
981
1131
  try:
982
- return _search_fts(conn, q, limit, offset)
1132
+ out = _search_fts(conn, q, limit, offset, kind, depth)
1133
+ out.update(kind=kind, search_depth=depth)
1134
+ return out
983
1135
  except sqlite3.OperationalError:
984
1136
  pass # corrupt/missing FTS at query time → fall through to LIKE
985
- return _search_like(conn, q, limit, offset)
1137
+ out = _search_like(conn, q, limit, offset, kind, depth)
1138
+ out.update(kind=kind, mode="like", search_depth=depth)
1139
+ return out
986
1140
 
987
1141
 
988
- def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id):
1142
+ def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id, match_kinds=None):
989
1143
  """Build one hit WITHOUT cost — cost is batched onto the FINAL page in
990
1144
  _attach_costs (I1: no per-hit _turn_cost_map round-trip). The turn key rides
991
- on the private `_turn_key` field until the batch maps it to `cost_usd`."""
1145
+ on the private `_turn_key` field until the batch maps it to `cost_usd`.
1146
+ #177 S6: ``match_kinds`` (sorted non-prose badges) is attached per hit."""
992
1147
  return {
993
1148
  "session_id": sid,
994
1149
  "uuid": uuid_,
995
1150
  "project_label": _project_label(cwd),
996
1151
  "ts": ts,
997
1152
  "snippet": snippet,
1153
+ "match_kinds": match_kinds or [],
998
1154
  "_turn_key": (msg_id, req_id) if msg_id is not None and req_id is not None
999
1155
  else None,
1000
1156
  }
@@ -1034,16 +1190,18 @@ def _like_pattern(q):
1034
1190
  + "%")
1035
1191
 
1036
1192
 
1037
- def _fts_snippets(conn, fts_q, ids):
1193
+ def _fts_snippets(conn, fts_q, ids, col=0):
1038
1194
  """{rowid: snippet} for the page rowids ONLY (#149). snippet() needs an
1039
1195
  active MATCH, so it can't be deferred to an outer query over the page CTE;
1040
1196
  a second bounded MATCH restricted to the page rowids generates snippets for
1041
- at most one page of hits instead of every corpus match."""
1197
+ at most one page of hits instead of every corpus match. #177 S6: ``col``
1198
+ selects which FTS column the snippet is drawn from (0=text, 1=search_tool,
1199
+ 2=search_thinking) so a tool/thinking hit shows its matching content."""
1042
1200
  if not ids:
1043
1201
  return {}
1044
1202
  ph = ",".join("?" for _ in ids)
1045
1203
  rows = conn.execute(
1046
- "SELECT cm.id, snippet(conversation_fts, 0, '[', ']', ' … ', 12) "
1204
+ f"SELECT cm.id, snippet(conversation_fts, {int(col)}, '[', ']', ' … ', 12) "
1047
1205
  "FROM conversation_fts "
1048
1206
  "JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1049
1207
  f"WHERE conversation_fts MATCH ? AND cm.id IN ({ph})",
@@ -1066,10 +1224,54 @@ def _texts_for_ids(conn, ids):
1066
1224
  return {r[0]: r[1] for r in rows}
1067
1225
 
1068
1226
 
1069
- def _search_fts(conn, q, limit, offset):
1227
+ # #177 S6: FTS-column index → badge label (col 0 = prose = no badge).
1228
+ _KIND_PROBE_COLUMNS = (("search_tool", "tool"), ("search_thinking", "thinking"))
1229
+ _SNIPPET_COL_PREFERENCE = (("tool", 1), ("thinking", 2)) # prose (0) is default
1230
+
1231
+
1232
+ def _match_kinds(conn, fts_q, rids_by_group):
1233
+ """{group -> sorted [badges]} via marker-based column probes (spec F3).
1234
+
1235
+ A column "matched" iff a column-filtered sub-MATCH returns its rowid — NOT
1236
+ iff snippet() is non-empty (snippet returns the column's unmarked text for
1237
+ non-matching columns). Probes aggregate across ALL matched rowids of each
1238
+ page group's ``(session_id, uuid)``, so a multi-row hit badges completely.
1239
+ Prose (col 0) is never a badge. ``fts_q`` is the un-column-filtered term
1240
+ expression (the per-column wrapping is applied here)."""
1241
+ all_rids = sorted({r for rids in rids_by_group.values() for r in rids})
1242
+ if not all_rids:
1243
+ return {grp: [] for grp in rids_by_group}
1244
+ ph = ",".join("?" for _ in all_rids)
1245
+ hits_by_col = {}
1246
+ for col, label in _KIND_PROBE_COLUMNS:
1247
+ got = conn.execute(
1248
+ "SELECT conversation_fts.rowid FROM conversation_fts "
1249
+ f"WHERE conversation_fts MATCH ? AND conversation_fts.rowid IN ({ph})",
1250
+ (f"{{{col}}}: ({fts_q})", *all_rids),
1251
+ ).fetchall()
1252
+ hits_by_col[label] = {r[0] for r in got}
1253
+ return {grp: [lbl for (_c, lbl) in _KIND_PROBE_COLUMNS
1254
+ if set(rids) & hits_by_col[lbl]]
1255
+ for grp, rids in rids_by_group.items()}
1256
+
1257
+
1258
+ def _search_fts(conn, q, limit, offset, kind, depth):
1070
1259
  # All of dedup + paging + total live in SQL (#149) so Python never holds
1071
1260
  # more than one page of hits/snippets, regardless of corpus match count.
1072
- fts_q = _fts_query(q)
1261
+ #
1262
+ # #177 S6: prose-only interim runs the LEGACY single-column shape (the split
1263
+ # columns are not yet indexed) — no column filter, no badge/snippet probes
1264
+ # against search_tool/search_thinking (those columns aren't in the legacy
1265
+ # FTS table). Full mode applies the kind column filter + entry_type predicate
1266
+ # and the marker-based badges.
1267
+ legacy = depth == "prose-only"
1268
+ fts_q = _fts_query(q, prefix_last=True)
1269
+ # legacy single-column MATCH (prose), no column filter; full mode applies
1270
+ # the kind column filter.
1271
+ match_expr = fts_q if legacy else _kind_match_expr(kind, fts_q)
1272
+ entry_type = _KIND_ENTRY_TYPE.get(kind)
1273
+ et_pred = " AND cm.entry_type = ?" if entry_type is not None else ""
1274
+ et_args = (entry_type,) if entry_type is not None else ()
1073
1275
  # Exact post-dedup logical total — counted in C with no snippet generation
1074
1276
  # and no Python row materialization.
1075
1277
  total = conn.execute(
@@ -1077,8 +1279,8 @@ def _search_fts(conn, q, limit, offset):
1077
1279
  " SELECT DISTINCT cm.session_id, cm.uuid "
1078
1280
  " FROM conversation_fts "
1079
1281
  " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1080
- " WHERE conversation_fts MATCH ?)",
1081
- (fts_q,),
1282
+ f" WHERE conversation_fts MATCH ?{et_pred})",
1283
+ (match_expr, *et_args),
1082
1284
  ).fetchone()[0]
1083
1285
  # One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
1084
1286
  # the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
@@ -1089,16 +1291,20 @@ def _search_fts(conn, q, limit, offset):
1089
1291
  # bm25 is materialized as a plain `rank` column in the inner `matched` CTE
1090
1292
  # before the window function runs: FTS5 auxiliary functions (bm25/snippet)
1091
1293
  # may only be used directly against the MATCH query, NOT inside a window
1092
- # ORDER BY ("unable to use function bm25 in the requested context").
1294
+ # ORDER BY ("unable to use function bm25 in the requested context"). Weights
1295
+ # (prose > tool > thinking) only apply to the multi-column (full) shape;
1296
+ # the legacy single-column table takes the plain bm25.
1297
+ bm25_expr = ("bm25(conversation_fts)" if legacy
1298
+ else "bm25(conversation_fts, 10.0, 3.0, 1.0)")
1093
1299
  page = conn.execute(
1094
1300
  "WITH matched AS ("
1095
1301
  " SELECT cm.id AS rid, cm.session_id AS sid, cm.uuid AS uuid, "
1096
1302
  " cm.timestamp_utc AS ts, cm.cwd AS cwd, "
1097
1303
  " cm.msg_id AS mid, cm.req_id AS rqd, "
1098
- " bm25(conversation_fts) AS rank "
1304
+ f" {bm25_expr} AS rank "
1099
1305
  " FROM conversation_fts "
1100
1306
  " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1101
- " WHERE conversation_fts MATCH ?), "
1307
+ f" WHERE conversation_fts MATCH ?{et_pred}), "
1102
1308
  "ranked AS ("
1103
1309
  " SELECT *, ROW_NUMBER() OVER ("
1104
1310
  " PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
@@ -1106,26 +1312,101 @@ def _search_fts(conn, q, limit, offset):
1106
1312
  " FROM matched) "
1107
1313
  "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
1108
1314
  "ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
1109
- (fts_q, limit, offset),
1315
+ (match_expr, *et_args, limit, offset),
1110
1316
  ).fetchall()
1111
- snips = _fts_snippets(conn, fts_q, [r[0] for r in page])
1112
- hits = [_row_to_hit(uuid, sid, ts, cwd, snips.get(rid, ""), mid, rqd)
1317
+ page_groups = {(sid, uuid): rid for (rid, sid, uuid, ts, cwd, mid, rqd) in page}
1318
+ if legacy:
1319
+ badges = {grp: [] for grp in page_groups}
1320
+ else:
1321
+ rids_by_group = _all_matched_rids_by_group(
1322
+ conn, match_expr, et_pred, et_args, list(page_groups))
1323
+ badges = _match_kinds(conn, fts_q, rids_by_group)
1324
+ snips = _fts_snippets(conn, match_expr, [r[0] for r in page], col=0)
1325
+ # For hits badged tool/thinking but with no prose match, draw the snippet
1326
+ # from the matched column instead (prose → tool → thinking preference).
1327
+ if not legacy:
1328
+ snips = _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips)
1329
+ hits = [_row_to_hit(uuid, sid, ts, cwd, snips.get(rid, ""), mid, rqd,
1330
+ match_kinds=badges.get((sid, uuid), []))
1113
1331
  for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
1114
1332
  return {"query": q, "mode": "fts",
1115
1333
  "hits": _attach_titles(conn, _attach_costs(conn, hits)),
1116
1334
  "total": total}
1117
1335
 
1118
1336
 
1119
- def _search_like(conn, q, limit, offset):
1337
+ def _all_matched_rids_by_group(conn, match_expr, et_pred, et_args, groups):
1338
+ """{(sid, uuid) -> [rids]} for the page groups: ALL matched physical rows of
1339
+ each group (not just the rank-survivor), so badges aggregate completely."""
1340
+ if not groups:
1341
+ return {}
1342
+ rids_by_group = {g: [] for g in groups}
1343
+ rows = conn.execute(
1344
+ "SELECT cm.id, cm.session_id, cm.uuid FROM conversation_fts "
1345
+ "JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1346
+ f"WHERE conversation_fts MATCH ?{et_pred}",
1347
+ (match_expr, *et_args),
1348
+ ).fetchall()
1349
+ for rid, sid, uuid in rows:
1350
+ g = (sid, uuid)
1351
+ if g in rids_by_group:
1352
+ rids_by_group[g].append(rid)
1353
+ return rids_by_group
1354
+
1355
+
1356
+ def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
1357
+ """Replace a hit's prose snippet with its matched column's snippet when the
1358
+ prose column did NOT match (prose → tool → thinking preference). Probes
1359
+ which column matched the survivor rowid, then re-snippets that column."""
1360
+ by_col = {} # snippet column index -> [rids needing it]
1361
+ for (rid, sid, uuid, ts, cwd, mid, rqd) in page:
1362
+ grp = (sid, uuid)
1363
+ kinds = badges.get(grp, [])
1364
+ if not kinds:
1365
+ continue # prose hit (or unbadged) — keep col-0 snippet
1366
+ # Does THIS survivor row match prose? If so keep col 0.
1367
+ prose_hit = conn.execute(
1368
+ "SELECT 1 FROM conversation_fts "
1369
+ "WHERE conversation_fts MATCH ? AND conversation_fts.rowid = ?",
1370
+ (f"{{text}}: ({fts_q})", rid)).fetchone()
1371
+ if prose_hit:
1372
+ continue
1373
+ for label, col in _SNIPPET_COL_PREFERENCE:
1374
+ if label in kinds:
1375
+ by_col.setdefault(col, []).append(rid)
1376
+ break
1377
+ for col, rids in by_col.items():
1378
+ col_fts = _kind_match_expr(
1379
+ "tools" if col == 1 else "thinking", fts_q)
1380
+ alt = _fts_snippets(conn, col_fts, rids, col=col)
1381
+ snips.update(alt)
1382
+ return snips
1383
+
1384
+
1385
+ def _search_like(conn, q, limit, offset, kind, depth):
1120
1386
  # SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
1121
1387
  # COUNT + page each scan the table once (the degraded path already lacks an
1122
- # index for the substring match).
1388
+ # index for the substring match). #177 S6: kind → column list (single-
1389
+ # substring semantics preserved — a documented degraded divergence from FTS
1390
+ # term-wise AND); badges from per-column LIKE probes on the page rows.
1391
+ legacy = depth == "prose-only"
1123
1392
  like = _like_pattern(q)
1393
+ if legacy:
1394
+ cols = ["text"]
1395
+ else:
1396
+ cols = ({"prompts": ["text"], "assistant": ["text"],
1397
+ "tools": ["search_tool"], "thinking": ["search_thinking"]}
1398
+ .get(kind, ["text", "search_tool", "search_thinking"]))
1399
+ col_pred = "(" + " OR ".join(
1400
+ f"{c} LIKE ? ESCAPE '\\' AND {c} != ''" for c in cols) + ")"
1401
+ like_args = tuple(like for _ in cols)
1402
+ entry_type = _KIND_ENTRY_TYPE.get(kind)
1403
+ et_pred = " AND entry_type = ?" if entry_type is not None else ""
1404
+ et_args = (entry_type,) if entry_type is not None else ()
1124
1405
  total = conn.execute(
1125
1406
  "SELECT COUNT(*) FROM ("
1126
1407
  " SELECT DISTINCT session_id, uuid FROM conversation_messages "
1127
- " WHERE text LIKE ? ESCAPE '\\' AND text != '')",
1128
- (like,),
1408
+ f" WHERE {col_pred}{et_pred})",
1409
+ (*like_args, *et_args),
1129
1410
  ).fetchone()[0]
1130
1411
  page = conn.execute(
1131
1412
  "WITH ranked AS ("
@@ -1136,25 +1417,217 @@ def _search_like(conn, q, limit, offset):
1136
1417
  " ORDER BY timestamp_utc DESC, id DESC"
1137
1418
  " ) AS rn "
1138
1419
  " FROM conversation_messages "
1139
- " WHERE text LIKE ? ESCAPE '\\' AND text != '') "
1420
+ f" WHERE {col_pred}{et_pred}) "
1140
1421
  "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
1141
1422
  "ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
1142
- (like, limit, offset),
1423
+ (*like_args, *et_args, limit, offset),
1143
1424
  ).fetchall()
1144
1425
  texts = _texts_for_ids(conn, [r[0] for r in page])
1426
+ if legacy:
1427
+ badges = {(sid, uuid): [] for (rid, sid, uuid, *_r) in page}
1428
+ else:
1429
+ badges = _like_badges(conn, like, list(
1430
+ {(sid, uuid) for (rid, sid, uuid, *_r) in page}))
1145
1431
  hits = [_row_to_hit(uuid, sid, ts, cwd,
1146
- _manual_snippet(texts.get(rid, ""), q), mid, rqd)
1432
+ _manual_snippet(texts.get(rid, ""), q), mid, rqd,
1433
+ match_kinds=badges.get((sid, uuid), []))
1147
1434
  for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
1148
1435
  return {"query": q, "mode": "like",
1149
1436
  "hits": _attach_titles(conn, _attach_costs(conn, hits)),
1150
1437
  "total": total}
1151
1438
 
1152
1439
 
1153
- def _fts_query(q):
1440
+ def _like_badges(conn, like, groups):
1441
+ """{(sid, uuid) -> sorted [badges]} via per-column LIKE probes across all
1442
+ physical rows of each page group (LIKE degraded mode; spec F7)."""
1443
+ if not groups:
1444
+ return {}
1445
+ out = {g: [] for g in groups}
1446
+ ph = " OR ".join("(session_id=? AND uuid=?)" for _ in groups)
1447
+ flat = [v for g in groups for v in g]
1448
+ for col, label in _KIND_PROBE_COLUMNS:
1449
+ rows = conn.execute(
1450
+ f"SELECT DISTINCT session_id, uuid FROM conversation_messages "
1451
+ f"WHERE {col} LIKE ? ESCAPE '\\' AND {col} != '' AND ({ph})",
1452
+ (like, *flat)).fetchall()
1453
+ for sid, uuid in rows:
1454
+ if (sid, uuid) in out:
1455
+ out[(sid, uuid)].append(label)
1456
+ return {g: sorted(v) for g, v in out.items()}
1457
+
1458
+
1459
+ # #177 S6: kind facets. `all` is the unfiltered MATCH; `prompts`/`assistant`
1460
+ # filter the prose column AND the entry_type; `tools`/`thinking` filter the
1461
+ # split index columns. Validated in search_conversations / find_in_conversation
1462
+ # (an unknown kind raises ValueError → the route maps it to a 400).
1463
+ _SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
1464
+ _KIND_COLUMN = {"prompts": "text", "assistant": "text",
1465
+ "tools": "search_tool", "thinking": "search_thinking"}
1466
+ _KIND_ENTRY_TYPE = {"prompts": "human", "assistant": "assistant"}
1467
+
1468
+
1469
+ def _fts_query(q, prefix_last=False):
1154
1470
  """Quote each whitespace term as an FTS5 string literal so punctuation /
1155
- operators in user input can't error the MATCH or inject FTS syntax."""
1471
+ operators in user input can't error the MATCH or inject FTS syntax. When
1472
+ ``prefix_last`` is set, the final term gets a trailing ``*`` (valid FTS5
1473
+ quoted-prefix syntax) so ``cache.d`` matches ``cache.db`` while typing — a
1474
+ ``*`` INSIDE the quotes is a literal char, so the prefix marker lives
1475
+ outside the closing quote (#177 S6)."""
1156
1476
  terms = [t for t in q.split() if t]
1157
- return " ".join('"' + t.replace('"', '""') + '"' for t in terms) or '""'
1477
+ if not terms:
1478
+ return '""'
1479
+ quoted = ['"' + t.replace('"', '""') + '"' for t in terms]
1480
+ if prefix_last:
1481
+ quoted[-1] += "*"
1482
+ return " ".join(quoted)
1483
+
1484
+
1485
+ def _kind_match_expr(kind, fts_q):
1486
+ """Wrap the term expression in a column filter for the kind (#177 S6).
1487
+ ``all`` stays unfiltered; ``prompts``/``assistant`` filter the prose column
1488
+ (the entry_type split is a separate SQL predicate, applied by the caller)."""
1489
+ col = _KIND_COLUMN.get(kind)
1490
+ return f"{{{col}}}: ({fts_q})" if col else fts_q
1491
+
1492
+
1493
+ # ===========================================================================
1494
+ # #177 S6: in-conversation find — rendered-turn anchors (spec §2 find endpoint).
1495
+ # ===========================================================================
1496
+
1497
+ _FIND_ANCHOR_CAP = 500
1498
+
1499
+ # Which physical-row columns the find match probes per kind, and the badge label
1500
+ # each non-prose column contributes. ``text`` maps to the synthetic ``prose``
1501
+ # label so a prose-only match still anchors a turn but never badges.
1502
+ _FIND_KIND_COLUMNS = {
1503
+ "all": (("text", "prose"), ("search_tool", "tool"),
1504
+ ("search_thinking", "thinking")),
1505
+ "prompts": (("text", "prose"),),
1506
+ "assistant": (("text", "prose"),),
1507
+ "tools": (("search_tool", "tool"),),
1508
+ "thinking": (("search_thinking", "thinking"),),
1509
+ }
1510
+
1511
+
1512
+ def find_in_conversation(conn, session_id, query, *, kind="all",
1513
+ fts_available=None, cap=_FIND_ANCHOR_CAP):
1514
+ """Document-ordered rendered-turn anchors for in-conversation find (#177 S6).
1515
+
1516
+ Anchor identity is rendered-turn identity (spec F1): the FTS/LIKE match for
1517
+ the session yields physical-row uuids, then ``_assemble_session`` (the S5
1518
+ outline precedent — 1:1 grouping parity by construction) maps each matched
1519
+ row onto its rendered item via ``member_uuids``. Matched rows folding into
1520
+ the same item (assistant fragments, owned tool results, skill bodies)
1521
+ collapse to ONE anchor whose ``match_kinds`` aggregates across its matched
1522
+ members; document order = assembly order (bm25 unused here). ``total`` counts
1523
+ rendered-turn anchors PRE-cap; the list caps at ``cap`` with
1524
+ ``anchors_truncated``. Returns None for an unknown session; an unknown
1525
+ ``kind`` raises ValueError (route → 400). Empty/whitespace query → empty;
1526
+ prose-only depth + tools/thinking kinds → empty (the split index is pending)."""
1527
+ if kind not in _SEARCH_KINDS:
1528
+ raise ValueError(f"unknown kind: {kind}")
1529
+ # Cheap existence probe (one indexed SELECT) BEFORE the full assembly, so an
1530
+ # empty/prose-only-blocked query opening the find bar pays nothing — yet the
1531
+ # unknown-session → None contract (the route's 404) is preserved, including
1532
+ # for an empty query (assembly used to run first and gave the same answer).
1533
+ if conn.execute(
1534
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
1535
+ (session_id,)).fetchone() is None:
1536
+ return None
1537
+ depth = _search_depth(conn)
1538
+ if fts_available is None:
1539
+ fts_available = not _fts_flag_unavailable(conn)
1540
+ q = (query or "").strip()
1541
+ base = {"total": 0, "anchors": [], "anchors_truncated": False,
1542
+ "search_depth": depth, "kind": kind,
1543
+ "mode": "fts" if fts_available else "like"}
1544
+ if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
1545
+ return base
1546
+ asm = _assemble_session(conn, session_id)
1547
+ if asm is None:
1548
+ return None
1549
+ mode, matched = _find_matched_rows(
1550
+ conn, session_id, q, kind, depth, fts_available)
1551
+ # matched: {uuid -> set of labels in {"prose", "tool", "thinking"}}
1552
+ anchors = []
1553
+ for it in asm["items"]:
1554
+ hit_kinds = set()
1555
+ hit = False
1556
+ for mu in it["member_uuids"]:
1557
+ labels = matched.get(mu)
1558
+ if labels:
1559
+ hit = True
1560
+ hit_kinds |= labels
1561
+ if hit:
1562
+ anchors.append({
1563
+ "uuid": it["anchor"]["uuid"],
1564
+ "match_kinds": sorted(k for k in hit_kinds if k != "prose")})
1565
+ total = len(anchors)
1566
+ return {**base, "mode": mode, "total": total,
1567
+ "anchors": anchors[:cap], "anchors_truncated": total > cap}
1568
+
1569
+
1570
+ def _find_matched_rows(conn, session_id, q, kind, depth, fts_available):
1571
+ """({mode}, {uuid -> {labels}}) for one session. Runs a column-scoped MATCH
1572
+ (or LIKE) per relevant column and tags each matched row's uuid with that
1573
+ column's label. Prose-only depth uses the legacy single-column FTS (the
1574
+ split columns aren't indexed yet) for prose-bearing kinds."""
1575
+ if fts_available:
1576
+ try:
1577
+ return "fts", _find_matched_fts(conn, session_id, q, kind, depth)
1578
+ except sqlite3.OperationalError:
1579
+ pass # corrupt/missing FTS → fall through to LIKE
1580
+ return "like", _find_matched_like(conn, session_id, q, kind, depth)
1581
+
1582
+
1583
+ def _find_kind_columns(kind, depth):
1584
+ """The (column, label) probes the find match runs for this (kind, depth).
1585
+ Prose-only depth has only the legacy prose column indexed, so the split
1586
+ tool/thinking columns drop out (a prose-bearing kind keeps its prose probe;
1587
+ tools/thinking yield nothing). Shared by _find_matched_fts / _find_matched_like
1588
+ so the two paths can never disagree on which columns a kind probes."""
1589
+ if depth == "prose-only":
1590
+ return (("text", "prose"),) if kind in ("all", "prompts", "assistant") else ()
1591
+ return _FIND_KIND_COLUMNS[kind]
1592
+
1593
+
1594
+ def _find_matched_fts(conn, session_id, q, kind, depth):
1595
+ fts_q = _fts_query(q, prefix_last=True)
1596
+ # entry_type predicate + the prose-only legacy MATCH shape are loop-invariant
1597
+ # — compute once. Legacy single-column FTS indexes prose only, so it MATCHes
1598
+ # the bare term (no column filter); full mode wraps each column.
1599
+ et = _KIND_ENTRY_TYPE.get(kind)
1600
+ et_pred = " AND cm.entry_type = ?" if et is not None else ""
1601
+ et_args = (et,) if et is not None else ()
1602
+ legacy = depth == "prose-only"
1603
+ out = {}
1604
+ for col, label in _find_kind_columns(kind, depth):
1605
+ match_expr = fts_q if legacy else f"{{{col}}}: ({fts_q})"
1606
+ rows = conn.execute(
1607
+ "SELECT cm.uuid FROM conversation_fts "
1608
+ "JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1609
+ f"WHERE conversation_fts MATCH ? AND cm.session_id = ?{et_pred}",
1610
+ (match_expr, session_id, *et_args)).fetchall()
1611
+ for (u,) in rows:
1612
+ out.setdefault(u, set()).add(label)
1613
+ return out
1614
+
1615
+
1616
+ def _find_matched_like(conn, session_id, q, kind, depth):
1617
+ like = _like_pattern(q)
1618
+ et = _KIND_ENTRY_TYPE.get(kind)
1619
+ et_pred = " AND entry_type = ?" if et is not None else ""
1620
+ et_args = (et,) if et is not None else ()
1621
+ out = {}
1622
+ for col, label in _find_kind_columns(kind, depth):
1623
+ rows = conn.execute(
1624
+ f"SELECT uuid FROM conversation_messages "
1625
+ f"WHERE session_id = ? AND {col} LIKE ? ESCAPE '\\' "
1626
+ f"AND {col} != ''{et_pred}",
1627
+ (session_id, like, *et_args)).fetchall()
1628
+ for (u,) in rows:
1629
+ out.setdefault(u, set()).add(label)
1630
+ return out
1158
1631
 
1159
1632
 
1160
1633
  def _manual_snippet(text, q, width=80):