@threadbase-sh/scanner 0.12.3 → 0.13.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/dist/index.js CHANGED
@@ -146,18 +146,43 @@ function readGitBranch(projectPath) {
146
146
  import FlexSearchModule from "flexsearch";
147
147
 
148
148
  // src/search-matches.ts
149
+ var FTS_HIT_OPEN = "";
150
+ var FTS_HIT_CLOSE = "";
151
+ var FTS_SNIPPET_TOKENS = 16;
152
+ var FTS_ELLIPSIS = "\u2026";
153
+ var CONTENT_FIELD = "content";
154
+ function parseFtsSnippet(raw) {
155
+ if (!raw?.includes(FTS_HIT_OPEN)) return null;
156
+ const highlights = [];
157
+ let snippet = "";
158
+ let openAt = -1;
159
+ for (const ch of raw) {
160
+ if (ch === FTS_HIT_OPEN) {
161
+ openAt = snippet.length;
162
+ continue;
163
+ }
164
+ if (ch === FTS_HIT_CLOSE) {
165
+ if (openAt >= 0 && snippet.length > openAt) {
166
+ highlights.push({ start: openAt, end: snippet.length });
167
+ }
168
+ openAt = -1;
169
+ continue;
170
+ }
171
+ snippet += ch;
172
+ }
173
+ return highlights.length > 0 ? { snippet, highlights } : null;
174
+ }
149
175
  function generateMatches(meta, query) {
150
176
  const matches = [];
151
177
  const lowerQuery = query.toLowerCase();
152
178
  const fields = [
153
- ["contentSnippet", meta.contentSnippet],
154
- ["projectName", meta.projectName],
155
- ["sessionId", meta.sessionId],
156
179
  ["sessionName", meta.sessionName],
157
- ["account", meta.account],
158
- ["model", meta.model || ""],
180
+ ["projectName", meta.projectName],
159
181
  ["gitBranch", meta.gitBranch || ""],
160
- ["toolNames", meta.toolNames.join(" ")]
182
+ ["toolNames", meta.toolNames.join(" ")],
183
+ ["model", meta.model || ""],
184
+ ["account", meta.account],
185
+ ["sessionId", meta.sessionId]
161
186
  ];
162
187
  for (const [field, value] of fields) {
163
188
  const idx = value.toLowerCase().indexOf(lowerQuery);
@@ -172,6 +197,20 @@ function generateMatches(meta, query) {
172
197
  }
173
198
  return matches.length > 0 ? matches : [{ field: "preview", snippet: meta.preview }];
174
199
  }
200
+ function buildContentMatch(searchContent, query) {
201
+ if (!searchContent || !query.trim()) return null;
202
+ const idx = searchContent.toLowerCase().indexOf(query.toLowerCase());
203
+ if (idx === -1) return null;
204
+ const start = Math.max(0, idx - 80);
205
+ const end = Math.min(searchContent.length, idx + query.length + 120);
206
+ const body = searchContent.slice(start, end).replace(/\s+/g, " ").trim();
207
+ const hitAt = body.toLowerCase().indexOf(query.toLowerCase());
208
+ const prefix = start > 0 ? FTS_ELLIPSIS : "";
209
+ const suffix = end < searchContent.length ? FTS_ELLIPSIS : "";
210
+ const snippet = `${prefix}${body}${suffix}`;
211
+ const highlights = hitAt === -1 ? [] : [{ start: hitAt + prefix.length, end: hitAt + prefix.length + query.length }];
212
+ return { field: CONTENT_FIELD, snippet, highlights };
213
+ }
175
214
 
176
215
  // src/indexer.ts
177
216
  var FlexSearch = FlexSearchModule.default ?? FlexSearchModule;
@@ -179,6 +218,10 @@ var SearchIndexer = class {
179
218
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
219
  index;
181
220
  documents = /* @__PURE__ */ new Map();
221
+ // The indexed body per conversation, kept so a hit can produce a real excerpt
222
+ // instead of falling back to an unrelated preview. Sized by the caller (the
223
+ // scanner tail-caps it to the content tier) — see the note on addDocument.
224
+ searchContents = /* @__PURE__ */ new Map();
182
225
  constructor() {
183
226
  this.index = this.createIndex();
184
227
  }
@@ -204,25 +247,25 @@ var SearchIndexer = class {
204
247
  cache: 100
205
248
  });
206
249
  }
207
- addDocument(meta) {
250
+ // `searchContent` is the combined search document (text + thinking + tools),
251
+ // NOT meta.contentSnippet.
252
+ //
253
+ // It arrives pre-capped. This index is `tokenize: "forward"` at resolution 9,
254
+ // which stores every prefix of every token, so it is a resident-memory
255
+ // structure whose cost is very different from the on-disk FTS index. Feeding
256
+ // it the full ~128 KB budget across a few hundred conversations would be a
257
+ // multi-hundred-MB-to-GB index. The scanner therefore caps this to the active
258
+ // tier's snippetMax and accepts that `persistent: false` has lower recall than
259
+ // SQLite — an honest, documented divergence rather than a silent one.
260
+ addDocument(meta, searchContent = "") {
208
261
  this.documents.set(meta.id, meta);
209
- this.index.add({
210
- id: meta.id,
211
- content: meta.contentSnippet,
212
- projectName: meta.projectName,
213
- projectPath: meta.projectPath,
214
- sessionId: meta.sessionId,
215
- sessionName: meta.sessionName,
216
- account: meta.account,
217
- model: meta.model || "",
218
- gitBranch: meta.gitBranch || "",
219
- toolNames: meta.toolNames.join(" ")
220
- });
262
+ this.searchContents.set(meta.id, searchContent);
263
+ this.index.add(toIndexDoc(meta, searchContent));
221
264
  }
222
- buildIndex(metas) {
265
+ buildIndex(metas, searchContents) {
223
266
  this.clear();
224
267
  for (const meta of metas) {
225
- this.addDocument(meta);
268
+ this.addDocument(meta, searchContents?.get(meta.id) ?? "");
226
269
  }
227
270
  getLogger().debug({ docCount: metas.length }, "indexer: built");
228
271
  }
@@ -242,14 +285,26 @@ var SearchIndexer = class {
242
285
  seen.add(id);
243
286
  const meta = this.documents.get(id);
244
287
  if (!meta) continue;
245
- const matches = generateMatches(meta, query);
246
- searchResults.push({ meta, score: 1, matches });
288
+ searchResults.push({
289
+ meta,
290
+ score: 1,
291
+ matches: this.matchesFor(meta, query)
292
+ });
247
293
  if (searchResults.length >= limit) break;
248
294
  }
249
295
  if (searchResults.length >= limit) break;
250
296
  }
251
297
  return searchResults;
252
298
  }
299
+ // Body context first (that is what explains why the result appeared), then any
300
+ // metadata matches. Only when neither hits does generateMatches' preview
301
+ // fallback stand in.
302
+ matchesFor(meta, query) {
303
+ const contentMatch = buildContentMatch(this.searchContents.get(meta.id) ?? "", query);
304
+ const metaMatches = generateMatches(meta, query);
305
+ if (!contentMatch) return metaMatches;
306
+ return [contentMatch, ...metaMatches.filter((m) => m.field !== "preview")];
307
+ }
253
308
  getRecent(limit) {
254
309
  return Array.from(this.documents.values()).sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, limit).map((meta) => ({
255
310
  meta,
@@ -263,31 +318,37 @@ var SearchIndexer = class {
263
318
  // Replace an already-indexed document in place. FlexSearch's `add` does not
264
319
  // overwrite an existing id, so a single-file refresh must go through
265
320
  // `update` to avoid stale matches lingering in the index.
266
- updateDocument(meta) {
321
+ updateDocument(meta, searchContent = "") {
267
322
  this.documents.set(meta.id, meta);
268
- this.index.update({
269
- id: meta.id,
270
- content: meta.contentSnippet,
271
- projectName: meta.projectName,
272
- projectPath: meta.projectPath,
273
- sessionId: meta.sessionId,
274
- sessionName: meta.sessionName,
275
- account: meta.account,
276
- model: meta.model || "",
277
- gitBranch: meta.gitBranch || "",
278
- toolNames: meta.toolNames.join(" ")
279
- });
323
+ this.searchContents.set(meta.id, searchContent);
324
+ this.index.update(toIndexDoc(meta, searchContent));
280
325
  }
281
326
  removeDocument(id) {
282
327
  this.documents.delete(id);
328
+ this.searchContents.delete(id);
283
329
  this.index.remove(id);
284
330
  }
285
331
  clear() {
286
332
  this.documents.clear();
333
+ this.searchContents.clear();
287
334
  this.index = this.createIndex();
288
335
  getLogger().trace("indexer: cleared");
289
336
  }
290
337
  };
338
+ function toIndexDoc(meta, searchContent) {
339
+ return {
340
+ id: meta.id,
341
+ content: searchContent,
342
+ projectName: meta.projectName,
343
+ projectPath: meta.projectPath,
344
+ sessionId: meta.sessionId,
345
+ sessionName: meta.sessionName,
346
+ account: meta.account,
347
+ model: meta.model || "",
348
+ gitBranch: meta.gitBranch || "",
349
+ toolNames: meta.toolNames.join(" ")
350
+ };
351
+ }
291
352
 
292
353
  // src/parser.ts
293
354
  import { createReadStream } from "fs";
@@ -453,7 +514,7 @@ function cleanSystemTags(text) {
453
514
  }
454
515
 
455
516
  // src/parser.ts
456
- async function parseMeta(filePath, account, tier) {
517
+ async function parseMeta(filePath, account, tier, onEntry) {
457
518
  const log = getLogger();
458
519
  log.trace({ filePath, account, tier: tier.name }, "parseMeta: start");
459
520
  const state = initialReducerState();
@@ -470,6 +531,7 @@ async function parseMeta(filePath, account, tier) {
470
531
  continue;
471
532
  }
472
533
  reduceLine(state, entry, tier);
534
+ onEntry?.(entry);
473
535
  }
474
536
  } catch (err) {
475
537
  log.warn({ filePath, err }, "parseMeta: read failed");
@@ -1224,7 +1286,7 @@ import { setImmediate as yieldToEventLoop2 } from "timers/promises";
1224
1286
  import { createReadStream as createReadStream3 } from "fs";
1225
1287
  import { setImmediate as yieldToEventLoop } from "timers/promises";
1226
1288
  var YIELD_EVERY_LINES = 500;
1227
- async function tailReduce(filePath, startOffset, startLine, state, tier) {
1289
+ async function tailReduce(filePath, startOffset, startLine, state, tier, onEntry) {
1228
1290
  const stream = createReadStream3(filePath, { start: startOffset, encoding: "utf8" });
1229
1291
  let buffer = "";
1230
1292
  let offset = startOffset;
@@ -1240,7 +1302,9 @@ async function tailReduce(filePath, startOffset, startLine, state, tier) {
1240
1302
  buffer = buffer.slice(nl + 1);
1241
1303
  if (text.length > 0) {
1242
1304
  try {
1243
- reduceLine(state, JSON.parse(text), tier);
1305
+ const entry = JSON.parse(text);
1306
+ reduceLine(state, entry, tier);
1307
+ onEntry?.(entry);
1244
1308
  } catch {
1245
1309
  state.badJsonLines++;
1246
1310
  }
@@ -1476,7 +1540,7 @@ import { statSync as statSync2 } from "fs";
1476
1540
  // src/providers/parse.ts
1477
1541
  import { createReadStream as createReadStream5 } from "fs";
1478
1542
  import { createInterface as createInterface3 } from "readline";
1479
- async function parseMetaWithProvider(provider, filePath, account, tier) {
1543
+ async function parseMetaWithProvider(provider, filePath, account, tier, onEntry) {
1480
1544
  const log = getLogger();
1481
1545
  const acc = provider.createEmptyAccumulator();
1482
1546
  const rl = createInterface3({
@@ -1494,6 +1558,7 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
1494
1558
  }
1495
1559
  try {
1496
1560
  provider.reduceEntry(acc, entry, tier);
1561
+ onEntry?.(entry);
1497
1562
  } catch (err) {
1498
1563
  log.warn({ filePath, provider: provider.name, err }, "provider reduce threw; line skipped");
1499
1564
  }
@@ -1505,6 +1570,151 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
1505
1570
  return provider.finalize(acc, filePath, account, tier);
1506
1571
  }
1507
1572
 
1573
+ // src/search-document.ts
1574
+ var SEARCH_BUDGET = {
1575
+ textMax: 64 * 1024,
1576
+ thinkingMax: 32 * 1024,
1577
+ toolsMax: 32 * 1024,
1578
+ toolPayloadMax: 4 * 1024
1579
+ };
1580
+ var SEP = "\n\n";
1581
+ function emptySearchDocument() {
1582
+ return { text: "", thinking: "", tools: "" };
1583
+ }
1584
+ function appendSearchDelta(doc, delta) {
1585
+ return {
1586
+ text: tailAppend(doc.text, delta.text, SEARCH_BUDGET.textMax),
1587
+ thinking: tailAppend(doc.thinking, delta.thinking, SEARCH_BUDGET.thinkingMax),
1588
+ tools: tailAppend(doc.tools, delta.tools, SEARCH_BUDGET.toolsMax)
1589
+ };
1590
+ }
1591
+ function tailAppend(current, incoming, max) {
1592
+ if (!incoming) return current;
1593
+ const joined = current ? current + SEP + incoming : incoming;
1594
+ return joined.length <= max ? joined : joined.slice(-max);
1595
+ }
1596
+ function combineSearchContent(doc) {
1597
+ return [doc.text, doc.thinking, doc.tools].filter(Boolean).join(SEP);
1598
+ }
1599
+ function capToolPayload(value) {
1600
+ const raw = stringifyPayload(value);
1601
+ if (!raw) return "";
1602
+ return raw.length > SEARCH_BUDGET.toolPayloadMax ? raw.slice(0, SEARCH_BUDGET.toolPayloadMax) : raw;
1603
+ }
1604
+ function stringifyPayload(value) {
1605
+ if (value === null || value === void 0) return "";
1606
+ if (typeof value === "string") return value;
1607
+ try {
1608
+ return JSON.stringify(value) ?? "";
1609
+ } catch {
1610
+ return "";
1611
+ }
1612
+ }
1613
+ function extractSearchDelta(entry) {
1614
+ if (entry.type === "response_item" || entry.type === "session_meta") {
1615
+ return extractCodexDelta(entry);
1616
+ }
1617
+ return extractClaudeDelta(entry);
1618
+ }
1619
+ function extractClaudeDelta(entry) {
1620
+ const type = entry.type;
1621
+ if (type !== "user" && type !== "assistant") return emptySearchDocument();
1622
+ if (entry.isMeta) return emptySearchDocument();
1623
+ const msg = entry.message;
1624
+ const content = msg?.content;
1625
+ const tools = [
1626
+ extractClaudeToolContent(content),
1627
+ // Claude stores the rich/structured tool result at the JSONL entry's top
1628
+ // level, not inside message.content — indexing only message.content would
1629
+ // miss most real tool output (file reads, command stdout).
1630
+ capToolPayload(entry.toolUseResult)
1631
+ ].filter(Boolean).join(SEP);
1632
+ return {
1633
+ text: extractClaudeText(content),
1634
+ thinking: type === "assistant" ? extractThinking(content).content : "",
1635
+ tools
1636
+ };
1637
+ }
1638
+ function extractClaudeText(content) {
1639
+ if (typeof content === "string") return cleanSystemTags(content);
1640
+ if (!Array.isArray(content)) return "";
1641
+ const parts = [];
1642
+ for (const item of content) {
1643
+ if (typeof item === "string") {
1644
+ const cleaned = cleanSystemTags(item);
1645
+ if (cleaned) parts.push(cleaned);
1646
+ } else if (item?.type === "text" && typeof item.text === "string") {
1647
+ const cleaned = cleanSystemTags(item.text);
1648
+ if (cleaned) parts.push(cleaned);
1649
+ }
1650
+ }
1651
+ return parts.join(SEP);
1652
+ }
1653
+ function extractClaudeToolContent(content) {
1654
+ if (!Array.isArray(content)) return "";
1655
+ const parts = [];
1656
+ for (const item of content) {
1657
+ if (item?.type === "tool_use") {
1658
+ const capped = capToolPayload(item.input);
1659
+ if (capped) parts.push(capped);
1660
+ } else if (item?.type === "tool_result") {
1661
+ const capped = capToolPayload(item.content);
1662
+ if (capped) parts.push(capped);
1663
+ }
1664
+ }
1665
+ return parts.join(SEP);
1666
+ }
1667
+ function extractCodexDelta(entry) {
1668
+ const payload = entry.payload;
1669
+ if (!payload || typeof payload !== "object") return emptySearchDocument();
1670
+ const ptype = payload.type;
1671
+ if (ptype === "function_call" || ptype === "custom_tool_call") {
1672
+ return { text: "", thinking: "", tools: capToolPayload(payload.arguments) };
1673
+ }
1674
+ if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
1675
+ return { text: "", thinking: "", tools: capToolPayload(payload.output) };
1676
+ }
1677
+ if (ptype === "reasoning") {
1678
+ return { text: "", thinking: extractCodexReasoning(payload), tools: "" };
1679
+ }
1680
+ if (ptype === "message") {
1681
+ const role = payload.role;
1682
+ if (role !== "user" && role !== "assistant") return emptySearchDocument();
1683
+ return { text: extractCodexText2(payload.content), thinking: "", tools: "" };
1684
+ }
1685
+ return emptySearchDocument();
1686
+ }
1687
+ function extractCodexReasoning(payload) {
1688
+ const parts = [];
1689
+ for (const key of ["summary", "content"]) {
1690
+ const blocks = payload[key];
1691
+ if (!Array.isArray(blocks)) continue;
1692
+ for (const block of blocks) {
1693
+ if (typeof block === "string") parts.push(block);
1694
+ else if (typeof block?.text === "string") parts.push(block.text);
1695
+ }
1696
+ }
1697
+ return parts.filter(Boolean).join(SEP);
1698
+ }
1699
+ function extractCodexText2(content) {
1700
+ if (typeof content === "string") return cleanSystemTags(content);
1701
+ if (!Array.isArray(content)) return "";
1702
+ const parts = [];
1703
+ for (const item of content) {
1704
+ if (typeof item === "string") {
1705
+ const cleaned = cleanSystemTags(item);
1706
+ if (cleaned) parts.push(cleaned);
1707
+ continue;
1708
+ }
1709
+ const t = item?.type;
1710
+ if ((t === "input_text" || t === "output_text" || t === "text") && typeof item.text === "string") {
1711
+ const cleaned = cleanSystemTags(item.text);
1712
+ if (cleaned) parts.push(cleaned);
1713
+ }
1714
+ }
1715
+ return parts.join(SEP);
1716
+ }
1717
+
1508
1718
  // src/tiers.ts
1509
1719
  var DEFAULT_TIERS = {
1510
1720
  standard: { name: "standard", previewMax: 200, snippetMax: 5e3 },
@@ -1526,7 +1736,7 @@ import { mkdirSync } from "fs";
1526
1736
  import { dirname as dirname3 } from "path";
1527
1737
 
1528
1738
  // src/persistent/schema.ts
1529
- var SCHEMA_VERSION = 4;
1739
+ var SCHEMA_VERSION = 5;
1530
1740
  var SCHEMA_SQL = `
1531
1741
  CREATE TABLE IF NOT EXISTS conversation_files (
1532
1742
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1626,13 +1836,27 @@ CREATE INDEX IF NOT EXISTS idx_conversations_account_recent ON conversations(acc
1626
1836
  CREATE INDEX IF NOT EXISTS idx_conversations_subagent_recent ON conversations(is_subagent, timestamp DESC);
1627
1837
  CREATE INDEX IF NOT EXISTS idx_conversations_team_recent ON conversations(team_name, timestamp DESC);
1628
1838
 
1629
- -- Full-text search index over conversation content + metadata. Kept separate
1630
- -- from the metadata tables so list-screen queries stay small and fast.
1631
- -- source_path is UNINDEXED (stored, not tokenized) and links back to a
1632
- -- conversations row. One FTS row per conversation, replaced on each upsert.
1839
+ -- Full-text search index over conversation body + metadata. Kept separate from
1840
+ -- the metadata tables so list-screen queries stay small and fast. One FTS row
1841
+ -- per conversation, replaced on each upsert.
1842
+ --
1843
+ -- The row's rowid IS conversation_files.id. That is load-bearing, not cosmetic:
1844
+ -- FTS5's query planner only handles MATCH, rowid and rank, so any other
1845
+ -- constraint (including a source_path equality on an UNINDEXED column) has no
1846
+ -- index and linear-scans the whole table. At ~128 KB of body per row that would
1847
+ -- mean scanning the entire corpus on every append. Look rows up by rowid.
1848
+ -- source_path stays stored-but-UNINDEXED so a search hit can resolve back to a
1849
+ -- conversations row.
1850
+ --
1851
+ -- Body is three columns, not one: text > thinking > tools priority has to
1852
+ -- survive an append (a new user message belongs in the text column, not after
1853
+ -- the tool output already written). They are deliberately NOT concatenated into
1854
+ -- a fourth column - that would double-weight body hits and inflate bm25 length.
1633
1855
  CREATE VIRTUAL TABLE IF NOT EXISTS conversation_messages_fts USING fts5(
1634
1856
  source_path UNINDEXED,
1635
- content,
1857
+ text,
1858
+ thinking,
1859
+ tools,
1636
1860
  project_name,
1637
1861
  session_id,
1638
1862
  session_name,
@@ -1702,6 +1926,24 @@ function runMigrations(db) {
1702
1926
  if (current >= 1 && current < 3 && tableExists(db, "conversations")) {
1703
1927
  db.exec("UPDATE conversations SET provider = 'claude-code' WHERE provider = 'threadbase'");
1704
1928
  }
1929
+ if (current >= 1 && current < 5) {
1930
+ db.exec("DROP TABLE IF EXISTS conversation_messages_fts");
1931
+ if (tableExists(db, "conversation_files")) {
1932
+ const assignments = [];
1933
+ if (hasColumn(db, "conversation_files", "last_indexed_offset")) {
1934
+ assignments.push("last_indexed_offset = 0");
1935
+ }
1936
+ if (hasColumn(db, "conversation_files", "last_indexed_line")) {
1937
+ assignments.push("last_indexed_line = 0");
1938
+ }
1939
+ if (hasColumn(db, "conversation_files", "reducer_state")) {
1940
+ assignments.push("reducer_state = NULL");
1941
+ }
1942
+ if (assignments.length > 0) {
1943
+ db.exec(`UPDATE conversation_files SET ${assignments.join(", ")}`);
1944
+ }
1945
+ }
1946
+ }
1705
1947
  db.exec(SCHEMA_SQL);
1706
1948
  db.pragma(`user_version = ${SCHEMA_VERSION}`);
1707
1949
  }
@@ -1723,6 +1965,7 @@ function openDatabase(dbPath) {
1723
1965
  db.pragma("synchronous = NORMAL");
1724
1966
  db.pragma("temp_store = MEMORY");
1725
1967
  db.pragma("foreign_keys = ON");
1968
+ db.pragma("busy_timeout = 5000");
1726
1969
  runMigrations(db);
1727
1970
  getLogger().debug({ dbPath }, "db: opened");
1728
1971
  return db;
@@ -2170,22 +2413,31 @@ var ConversationsRepo = class {
2170
2413
  };
2171
2414
 
2172
2415
  // src/persistent/repositories/fts.repo.ts
2416
+ var BODY_COLUMNS = [
2417
+ { name: "text", index: 1 },
2418
+ { name: "thinking", index: 2 },
2419
+ { name: "tools", index: 3 }
2420
+ ];
2173
2421
  var FtsRepo = class {
2174
2422
  constructor(db) {
2175
2423
  this.db = db;
2176
2424
  }
2177
2425
  db;
2178
- upsert(meta) {
2426
+ upsert(rowId, meta, doc) {
2179
2427
  const sourcePath = canonicalPath(meta.id);
2180
2428
  const tx = this.db.transaction(() => {
2181
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2429
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
2182
2430
  this.db.prepare(
2183
2431
  `INSERT INTO conversation_messages_fts
2184
- (source_path, content, project_name, session_id, session_name, account, model, branch, tool_names)
2185
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
2432
+ (rowid, source_path, text, thinking, tools,
2433
+ project_name, session_id, session_name, account, model, branch, tool_names)
2434
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2186
2435
  ).run(
2436
+ rowId,
2187
2437
  sourcePath,
2188
- meta.contentSnippet ?? "",
2438
+ doc.text,
2439
+ doc.thinking,
2440
+ doc.tools,
2189
2441
  meta.projectName ?? "",
2190
2442
  meta.sessionId ?? "",
2191
2443
  meta.sessionName ?? "",
@@ -2197,26 +2449,94 @@ var FtsRepo = class {
2197
2449
  });
2198
2450
  tx();
2199
2451
  }
2200
- remove(sourcePath) {
2201
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(canonicalPath(sourcePath));
2452
+ // Current durable buckets for a conversation, so an append can tail-extend
2453
+ // them without reparsing the file. Returns null when no row exists yet.
2454
+ readDocument(rowId) {
2455
+ const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
2456
+ if (!row) return null;
2457
+ return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
2202
2458
  }
2203
- // Ranked source_paths matching the query, best first. Returns [] on an empty
2204
- // query (callers fall back to a recency listing).
2205
- search(query, limit) {
2459
+ // True when the stored row already equals what we would write, so an append
2460
+ // can skip re-tokenizing ~128 KB of body for nothing.
2461
+ //
2462
+ // The check covers the metadata columns too, not just the buckets: a
2463
+ // newly-seen tool name or a late-resolved session_name changes metadata while
2464
+ // the body is byte-identical, and nothing else ever rewrites this row — so
2465
+ // skipping on "body unchanged" alone would strand that stale value forever.
2466
+ isCurrent(rowId, meta, doc) {
2467
+ const row = this.db.prepare(
2468
+ `SELECT text, thinking, tools,
2469
+ project_name, session_id, session_name, account, model, branch, tool_names
2470
+ FROM conversation_messages_fts WHERE rowid = ?`
2471
+ ).get(rowId);
2472
+ if (!row) return false;
2473
+ return row.text === doc.text && row.thinking === doc.thinking && row.tools === doc.tools && row.project_name === (meta.projectName ?? "") && row.session_id === (meta.sessionId ?? "") && row.session_name === (meta.sessionName ?? "") && row.account === (meta.account ?? "") && row.model === (meta.model ?? "") && row.branch === (meta.gitBranch ?? "") && row.tool_names === meta.toolNames.join(" ");
2474
+ }
2475
+ remove(rowId) {
2476
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
2477
+ }
2478
+ // Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
2479
+ // with a wide corpus a popular term matches far more conversations than one
2480
+ // page, so post-filtering an already-truncated list would report "no results"
2481
+ // for queries that do have them.
2482
+ search(query, limit, filters = {}) {
2206
2483
  const match = toMatchQuery(query);
2207
2484
  if (!match) return [];
2485
+ const snippetSelects = BODY_COLUMNS.map(
2486
+ (c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
2487
+ ).join(", ");
2488
+ const params = [];
2489
+ for (const _col of BODY_COLUMNS) {
2490
+ params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
2491
+ }
2492
+ params.push(match);
2493
+ const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
2494
+ if (filters.account) {
2495
+ where.push("c.account = ?");
2496
+ params.push(filters.account);
2497
+ }
2498
+ if (filters.provider) {
2499
+ where.push("c.provider = ?");
2500
+ params.push(filters.provider);
2501
+ }
2502
+ if (filters.project) {
2503
+ where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
2504
+ const like = `%${filters.project.toLowerCase()}%`;
2505
+ params.push(like, like);
2506
+ }
2507
+ if (filters.since) {
2508
+ where.push("c.timestamp >= ?");
2509
+ params.push(filters.since.toISOString());
2510
+ }
2511
+ if (filters.include === "conversations") {
2512
+ where.push("c.is_subagent = 0 AND c.is_teammate = 0");
2513
+ } else if (filters.include === "subagents") {
2514
+ where.push("c.is_subagent = 1");
2515
+ } else if (filters.include === "teammates") {
2516
+ where.push("c.is_teammate = 1");
2517
+ }
2518
+ params.push(limit);
2208
2519
  const rows = this.db.prepare(
2209
- `SELECT source_path FROM conversation_messages_fts
2210
- WHERE conversation_messages_fts MATCH ?
2520
+ `SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
2521
+ FROM conversation_messages_fts
2522
+ JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
2523
+ WHERE ${where.join(" AND ")}
2211
2524
  ORDER BY rank
2212
2525
  LIMIT ?`
2213
- ).all(match, limit);
2214
- return rows.map((r) => r.source_path);
2526
+ ).all(...params);
2527
+ return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
2215
2528
  }
2216
2529
  count() {
2217
2530
  return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
2218
2531
  }
2219
2532
  };
2533
+ function pickBodySnippet(row) {
2534
+ for (const col of BODY_COLUMNS) {
2535
+ const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
2536
+ if (parsed) return parsed;
2537
+ }
2538
+ return null;
2539
+ }
2220
2540
  function toMatchQuery(query) {
2221
2541
  const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
2222
2542
  if (terms.length === 0) return "";
@@ -2396,9 +2716,13 @@ var PersistentEngine = class {
2396
2716
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
2397
2717
  const startOffset = resume ? existing.last_indexed_offset : 0;
2398
2718
  const startLine = resume ? existing.last_indexed_line : 0;
2719
+ let searchDelta = emptySearchDocument();
2720
+ const collectSearch = (entry) => {
2721
+ searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
2722
+ };
2399
2723
  let result;
2400
2724
  try {
2401
- result = await tailReduce(filePath, startOffset, startLine, state, tier);
2725
+ result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
2402
2726
  } catch (err) {
2403
2727
  log.warn({ filePath, err }, "persistent: tail read failed");
2404
2728
  return { meta: null, change };
@@ -2411,9 +2735,13 @@ var PersistentEngine = class {
2411
2735
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2412
2736
  const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2413
2737
  const fileId = this.files.ensure(filePath, account);
2738
+ const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
2739
+ const searchDoc = appendSearchDelta(base, searchDelta);
2414
2740
  const upsert = this.db.transaction(() => {
2415
2741
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2416
- this.fts.upsert(meta);
2742
+ if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
2743
+ this.fts.upsert(fileId, meta, searchDoc);
2744
+ }
2417
2745
  if (!resume) this.checkpoints.remove(filePath);
2418
2746
  this.files.updateCursor(fileId, {
2419
2747
  sizeBytes: stat4.size,
@@ -2456,7 +2784,10 @@ var PersistentEngine = class {
2456
2784
  // any change reparses from 0 again. No reducer_state is persisted.
2457
2785
  async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
2458
2786
  const log = getLogger();
2459
- const meta = await parseMetaWithProvider(provider, filePath, account, tier);
2787
+ let searchDoc = emptySearchDocument();
2788
+ const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
2789
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
2790
+ });
2460
2791
  if (!meta) {
2461
2792
  this.markDeleted(filePath);
2462
2793
  return null;
@@ -2468,7 +2799,9 @@ var PersistentEngine = class {
2468
2799
  const fileId = this.files.ensure(filePath, account);
2469
2800
  const upsert = this.db.transaction(() => {
2470
2801
  this.conversations.upsert(fileId, meta, meta.messageCount);
2471
- this.fts.upsert(meta);
2802
+ if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
2803
+ this.fts.upsert(fileId, meta, searchDoc);
2804
+ }
2472
2805
  this.checkpoints.remove(filePath);
2473
2806
  this.files.updateCursor(fileId, {
2474
2807
  sizeBytes: stat4.size,
@@ -2494,7 +2827,7 @@ var PersistentEngine = class {
2494
2827
  if (!existing) return;
2495
2828
  const tx = this.db.transaction(() => {
2496
2829
  this.conversations.deleteByFileId(existing.id);
2497
- this.fts.remove(filePath);
2830
+ this.fts.remove(existing.id);
2498
2831
  this.checkpoints.remove(filePath);
2499
2832
  this.files.setStatus(existing.id, "deleted");
2500
2833
  });
@@ -2510,20 +2843,24 @@ var PersistentEngine = class {
2510
2843
  getAllBySessionId(sessionId) {
2511
2844
  return this.conversations.getAllBySessionId(sessionId);
2512
2845
  }
2513
- // Ranked metas matching the FTS query, best first. Empty query returns the
2514
- // most recent conversations (mirroring the in-memory indexer's empty-query
2515
- // behavior). Resolves each FTS hit to its active conversation row.
2516
- searchMetas(query, limit) {
2517
- if (!query.trim()) {
2518
- return this.conversations.recent(limit);
2519
- }
2520
- const paths = this.fts.search(query, limit);
2521
- const metas = [];
2522
- for (const path of paths) {
2523
- const meta = this.conversations.getBySourcePath(path);
2524
- if (meta) metas.push(meta);
2846
+ // Ranked hits matching the FTS query, best first, each already resolved to its
2847
+ // active conversation row and carrying the body excerpt when the match was in
2848
+ // the conversation body.
2849
+ //
2850
+ // Filters are passed down into SQL rather than applied to the result: with a
2851
+ // wide corpus, filtering an already-LIMITed list drops conversations that
2852
+ // would have matched.
2853
+ searchHits(query, limit, filters = {}) {
2854
+ const hits = [];
2855
+ for (const hit of this.fts.search(query, limit, filters)) {
2856
+ const meta = this.conversations.getBySourcePath(hit.sourcePath);
2857
+ if (meta) hits.push({ meta, body: hit.body });
2525
2858
  }
2526
- return metas;
2859
+ return hits;
2860
+ }
2861
+ // Empty-query listing, mirroring the in-memory indexer's behavior.
2862
+ recentMetas(limit) {
2863
+ return this.conversations.recent(limit);
2527
2864
  }
2528
2865
  getProjects() {
2529
2866
  return this.conversations.distinctProjects();
@@ -2684,6 +3021,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
2684
3021
  function defaultDbPath() {
2685
3022
  return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
2686
3023
  }
3024
+ function capForMemory(doc, max) {
3025
+ const combined = combineSearchContent(doc);
3026
+ return combined.length > max ? combined.slice(-max) : combined;
3027
+ }
2687
3028
  var ConversationScanner = class {
2688
3029
  metadataCache = /* @__PURE__ */ new Map();
2689
3030
  // Parsed conversations plus (persistent claude-code entries only) the resume
@@ -2695,6 +3036,10 @@ var ConversationScanner = class {
2695
3036
  sessionIdIndex = /* @__PURE__ */ new Map();
2696
3037
  projects = /* @__PURE__ */ new Set();
2697
3038
  indexer = new SearchIndexer();
3039
+ // Search body per conversation for the in-memory path, already tail-capped to
3040
+ // the content tier. Survives scan() so a statCache hit — which skips the parse
3041
+ // entirely — can still index a body rather than an empty string.
3042
+ searchContents = /* @__PURE__ */ new Map();
2698
3043
  // Tier the most recent scan() ran with, so refreshFile() re-parses a single
2699
3044
  // file at the same content depth. Defaults to the standard tier.
2700
3045
  lastTier = resolveTier("standard");
@@ -2855,34 +3200,42 @@ var ConversationScanner = class {
2855
3200
  try {
2856
3201
  const s = statSync3(filePath);
2857
3202
  if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
2858
- return cached.meta;
3203
+ return {
3204
+ meta: cached.meta,
3205
+ searchContent: this.searchContents.get(cached.meta.id)
3206
+ };
2859
3207
  }
2860
3208
  } catch {
2861
3209
  }
2862
3210
  }
2863
3211
  }
2864
3212
  try {
2865
- const meta = await parseMetaWithProvider(provider, filePath, account, tier);
3213
+ let doc = emptySearchDocument();
3214
+ const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
3215
+ doc = appendSearchDelta(doc, extractSearchDelta(entry));
3216
+ });
2866
3217
  if (meta && meta.gitBranch === null && meta.projectPath) {
2867
3218
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2868
3219
  }
2869
- return meta;
3220
+ return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
2870
3221
  } catch (err) {
2871
3222
  parseFailures++;
2872
3223
  log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
2873
- return null;
3224
+ return { meta: null, searchContent: void 0 };
2874
3225
  }
2875
3226
  })
2876
3227
  );
2877
3228
  const batchMetas = [];
2878
- for (const meta of results) {
3229
+ for (const { meta, searchContent } of results) {
2879
3230
  if (meta && meta.messageCount > 0) {
2880
3231
  this.metadataCache.set(meta.id, meta);
2881
3232
  this.addToSessionIndex(meta);
2882
3233
  this.projects.add(meta.projectPath);
2883
3234
  allMetas.push(meta);
2884
3235
  batchMetas.push(meta);
2885
- this.indexer.addDocument(meta);
3236
+ const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
3237
+ this.searchContents.set(meta.id, content);
3238
+ this.indexer.addDocument(meta, content);
2886
3239
  }
2887
3240
  }
2888
3241
  if (batchMetas.length > 0) {
@@ -2919,12 +3272,29 @@ var ConversationScanner = class {
2919
3272
  const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
2920
3273
  await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
2921
3274
  }
2922
- const metas = engine.searchMetas(query, (options.limit ?? 50) * 2);
2923
- results = query.trim() ? metas.map((meta) => ({ meta, score: 1, matches: generateMatches(meta, query) })) : metas.map((meta) => ({
2924
- meta,
2925
- score: 1,
2926
- matches: [{ field: "timestamp", snippet: meta.preview }]
2927
- }));
3275
+ if (query.trim()) {
3276
+ const want = (options.limit ?? 50) + (options.offset ?? 0);
3277
+ results = engine.searchHits(query, want, {
3278
+ account: options.account,
3279
+ provider: options.provider,
3280
+ project: options.project,
3281
+ since: options.since ? parseSinceCutoff(options.since) : void 0,
3282
+ include: options.include
3283
+ }).map(({ meta, body }) => ({
3284
+ meta,
3285
+ score: 1,
3286
+ matches: body ? [
3287
+ { field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
3288
+ ...generateMatches(meta, query).filter((m) => m.field !== "preview")
3289
+ ] : generateMatches(meta, query)
3290
+ }));
3291
+ } else {
3292
+ results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
3293
+ meta,
3294
+ score: 1,
3295
+ matches: [{ field: "timestamp", snippet: meta.preview }]
3296
+ }));
3297
+ }
2928
3298
  } else {
2929
3299
  if (this.indexer.getDocumentCount() === 0) {
2930
3300
  log.debug("search: index empty, triggering scan");
@@ -3118,8 +3488,11 @@ var ConversationScanner = class {
3118
3488
  const previous = this.metadataCache.get(filePath);
3119
3489
  const resolvedAccount = account ?? previous?.account ?? "default";
3120
3490
  let meta = null;
3491
+ let searchDoc = emptySearchDocument();
3121
3492
  try {
3122
- meta = await parseMeta(filePath, resolvedAccount, this.lastTier);
3493
+ meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
3494
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
3495
+ });
3123
3496
  } catch (err) {
3124
3497
  log.warn({ filePath, err }, "refreshFile: parseMeta threw");
3125
3498
  meta = null;
@@ -3136,6 +3509,7 @@ var ConversationScanner = class {
3136
3509
  this.metadataCache.delete(previous.id);
3137
3510
  this.removeFromSessionIndex(previous);
3138
3511
  this.indexer.removeDocument(previous.id);
3512
+ this.searchContents.delete(previous.id);
3139
3513
  }
3140
3514
  log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
3141
3515
  return null;
@@ -3145,10 +3519,12 @@ var ConversationScanner = class {
3145
3519
  this.metadataCache.set(meta.id, meta);
3146
3520
  this.addToSessionIndex(meta);
3147
3521
  this.projects.add(meta.projectPath);
3522
+ const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
3523
+ this.searchContents.set(meta.id, searchContent);
3148
3524
  if (previous) {
3149
- this.indexer.updateDocument(meta);
3525
+ this.indexer.updateDocument(meta, searchContent);
3150
3526
  } else {
3151
- this.indexer.addDocument(meta);
3527
+ this.indexer.addDocument(meta, searchContent);
3152
3528
  }
3153
3529
  log.debug(
3154
3530
  { filePath, messageCount: meta.messageCount },