@threadbase-sh/scanner 0.12.4 → 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.cjs CHANGED
@@ -212,18 +212,43 @@ function readGitBranch(projectPath) {
212
212
  var import_flexsearch = __toESM(require("flexsearch"), 1);
213
213
 
214
214
  // src/search-matches.ts
215
+ var FTS_HIT_OPEN = "";
216
+ var FTS_HIT_CLOSE = "";
217
+ var FTS_SNIPPET_TOKENS = 16;
218
+ var FTS_ELLIPSIS = "\u2026";
219
+ var CONTENT_FIELD = "content";
220
+ function parseFtsSnippet(raw) {
221
+ if (!raw?.includes(FTS_HIT_OPEN)) return null;
222
+ const highlights = [];
223
+ let snippet = "";
224
+ let openAt = -1;
225
+ for (const ch of raw) {
226
+ if (ch === FTS_HIT_OPEN) {
227
+ openAt = snippet.length;
228
+ continue;
229
+ }
230
+ if (ch === FTS_HIT_CLOSE) {
231
+ if (openAt >= 0 && snippet.length > openAt) {
232
+ highlights.push({ start: openAt, end: snippet.length });
233
+ }
234
+ openAt = -1;
235
+ continue;
236
+ }
237
+ snippet += ch;
238
+ }
239
+ return highlights.length > 0 ? { snippet, highlights } : null;
240
+ }
215
241
  function generateMatches(meta, query) {
216
242
  const matches = [];
217
243
  const lowerQuery = query.toLowerCase();
218
244
  const fields = [
219
- ["contentSnippet", meta.contentSnippet],
220
- ["projectName", meta.projectName],
221
- ["sessionId", meta.sessionId],
222
245
  ["sessionName", meta.sessionName],
223
- ["account", meta.account],
224
- ["model", meta.model || ""],
246
+ ["projectName", meta.projectName],
225
247
  ["gitBranch", meta.gitBranch || ""],
226
- ["toolNames", meta.toolNames.join(" ")]
248
+ ["toolNames", meta.toolNames.join(" ")],
249
+ ["model", meta.model || ""],
250
+ ["account", meta.account],
251
+ ["sessionId", meta.sessionId]
227
252
  ];
228
253
  for (const [field, value] of fields) {
229
254
  const idx = value.toLowerCase().indexOf(lowerQuery);
@@ -238,6 +263,20 @@ function generateMatches(meta, query) {
238
263
  }
239
264
  return matches.length > 0 ? matches : [{ field: "preview", snippet: meta.preview }];
240
265
  }
266
+ function buildContentMatch(searchContent, query) {
267
+ if (!searchContent || !query.trim()) return null;
268
+ const idx = searchContent.toLowerCase().indexOf(query.toLowerCase());
269
+ if (idx === -1) return null;
270
+ const start = Math.max(0, idx - 80);
271
+ const end = Math.min(searchContent.length, idx + query.length + 120);
272
+ const body = searchContent.slice(start, end).replace(/\s+/g, " ").trim();
273
+ const hitAt = body.toLowerCase().indexOf(query.toLowerCase());
274
+ const prefix = start > 0 ? FTS_ELLIPSIS : "";
275
+ const suffix = end < searchContent.length ? FTS_ELLIPSIS : "";
276
+ const snippet = `${prefix}${body}${suffix}`;
277
+ const highlights = hitAt === -1 ? [] : [{ start: hitAt + prefix.length, end: hitAt + prefix.length + query.length }];
278
+ return { field: CONTENT_FIELD, snippet, highlights };
279
+ }
241
280
 
242
281
  // src/indexer.ts
243
282
  var FlexSearch = import_flexsearch.default.default ?? import_flexsearch.default;
@@ -245,6 +284,10 @@ var SearchIndexer = class {
245
284
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
285
  index;
247
286
  documents = /* @__PURE__ */ new Map();
287
+ // The indexed body per conversation, kept so a hit can produce a real excerpt
288
+ // instead of falling back to an unrelated preview. Sized by the caller (the
289
+ // scanner tail-caps it to the content tier) — see the note on addDocument.
290
+ searchContents = /* @__PURE__ */ new Map();
248
291
  constructor() {
249
292
  this.index = this.createIndex();
250
293
  }
@@ -270,25 +313,25 @@ var SearchIndexer = class {
270
313
  cache: 100
271
314
  });
272
315
  }
273
- addDocument(meta) {
316
+ // `searchContent` is the combined search document (text + thinking + tools),
317
+ // NOT meta.contentSnippet.
318
+ //
319
+ // It arrives pre-capped. This index is `tokenize: "forward"` at resolution 9,
320
+ // which stores every prefix of every token, so it is a resident-memory
321
+ // structure whose cost is very different from the on-disk FTS index. Feeding
322
+ // it the full ~128 KB budget across a few hundred conversations would be a
323
+ // multi-hundred-MB-to-GB index. The scanner therefore caps this to the active
324
+ // tier's snippetMax and accepts that `persistent: false` has lower recall than
325
+ // SQLite — an honest, documented divergence rather than a silent one.
326
+ addDocument(meta, searchContent = "") {
274
327
  this.documents.set(meta.id, meta);
275
- this.index.add({
276
- id: meta.id,
277
- content: meta.contentSnippet,
278
- projectName: meta.projectName,
279
- projectPath: meta.projectPath,
280
- sessionId: meta.sessionId,
281
- sessionName: meta.sessionName,
282
- account: meta.account,
283
- model: meta.model || "",
284
- gitBranch: meta.gitBranch || "",
285
- toolNames: meta.toolNames.join(" ")
286
- });
328
+ this.searchContents.set(meta.id, searchContent);
329
+ this.index.add(toIndexDoc(meta, searchContent));
287
330
  }
288
- buildIndex(metas) {
331
+ buildIndex(metas, searchContents) {
289
332
  this.clear();
290
333
  for (const meta of metas) {
291
- this.addDocument(meta);
334
+ this.addDocument(meta, searchContents?.get(meta.id) ?? "");
292
335
  }
293
336
  getLogger().debug({ docCount: metas.length }, "indexer: built");
294
337
  }
@@ -308,14 +351,26 @@ var SearchIndexer = class {
308
351
  seen.add(id);
309
352
  const meta = this.documents.get(id);
310
353
  if (!meta) continue;
311
- const matches = generateMatches(meta, query);
312
- searchResults.push({ meta, score: 1, matches });
354
+ searchResults.push({
355
+ meta,
356
+ score: 1,
357
+ matches: this.matchesFor(meta, query)
358
+ });
313
359
  if (searchResults.length >= limit) break;
314
360
  }
315
361
  if (searchResults.length >= limit) break;
316
362
  }
317
363
  return searchResults;
318
364
  }
365
+ // Body context first (that is what explains why the result appeared), then any
366
+ // metadata matches. Only when neither hits does generateMatches' preview
367
+ // fallback stand in.
368
+ matchesFor(meta, query) {
369
+ const contentMatch = buildContentMatch(this.searchContents.get(meta.id) ?? "", query);
370
+ const metaMatches = generateMatches(meta, query);
371
+ if (!contentMatch) return metaMatches;
372
+ return [contentMatch, ...metaMatches.filter((m) => m.field !== "preview")];
373
+ }
319
374
  getRecent(limit) {
320
375
  return Array.from(this.documents.values()).sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, limit).map((meta) => ({
321
376
  meta,
@@ -329,31 +384,37 @@ var SearchIndexer = class {
329
384
  // Replace an already-indexed document in place. FlexSearch's `add` does not
330
385
  // overwrite an existing id, so a single-file refresh must go through
331
386
  // `update` to avoid stale matches lingering in the index.
332
- updateDocument(meta) {
387
+ updateDocument(meta, searchContent = "") {
333
388
  this.documents.set(meta.id, meta);
334
- this.index.update({
335
- id: meta.id,
336
- content: meta.contentSnippet,
337
- projectName: meta.projectName,
338
- projectPath: meta.projectPath,
339
- sessionId: meta.sessionId,
340
- sessionName: meta.sessionName,
341
- account: meta.account,
342
- model: meta.model || "",
343
- gitBranch: meta.gitBranch || "",
344
- toolNames: meta.toolNames.join(" ")
345
- });
389
+ this.searchContents.set(meta.id, searchContent);
390
+ this.index.update(toIndexDoc(meta, searchContent));
346
391
  }
347
392
  removeDocument(id) {
348
393
  this.documents.delete(id);
394
+ this.searchContents.delete(id);
349
395
  this.index.remove(id);
350
396
  }
351
397
  clear() {
352
398
  this.documents.clear();
399
+ this.searchContents.clear();
353
400
  this.index = this.createIndex();
354
401
  getLogger().trace("indexer: cleared");
355
402
  }
356
403
  };
404
+ function toIndexDoc(meta, searchContent) {
405
+ return {
406
+ id: meta.id,
407
+ content: searchContent,
408
+ projectName: meta.projectName,
409
+ projectPath: meta.projectPath,
410
+ sessionId: meta.sessionId,
411
+ sessionName: meta.sessionName,
412
+ account: meta.account,
413
+ model: meta.model || "",
414
+ gitBranch: meta.gitBranch || "",
415
+ toolNames: meta.toolNames.join(" ")
416
+ };
417
+ }
357
418
 
358
419
  // src/parser.ts
359
420
  var import_fs2 = require("fs");
@@ -519,7 +580,7 @@ function cleanSystemTags(text) {
519
580
  }
520
581
 
521
582
  // src/parser.ts
522
- async function parseMeta(filePath, account, tier) {
583
+ async function parseMeta(filePath, account, tier, onEntry) {
523
584
  const log = getLogger();
524
585
  log.trace({ filePath, account, tier: tier.name }, "parseMeta: start");
525
586
  const state = initialReducerState();
@@ -536,6 +597,7 @@ async function parseMeta(filePath, account, tier) {
536
597
  continue;
537
598
  }
538
599
  reduceLine(state, entry, tier);
600
+ onEntry?.(entry);
539
601
  }
540
602
  } catch (err) {
541
603
  log.warn({ filePath, err }, "parseMeta: read failed");
@@ -1290,7 +1352,7 @@ var import_promises5 = require("timers/promises");
1290
1352
  var import_fs5 = require("fs");
1291
1353
  var import_promises4 = require("timers/promises");
1292
1354
  var YIELD_EVERY_LINES = 500;
1293
- async function tailReduce(filePath, startOffset, startLine, state, tier) {
1355
+ async function tailReduce(filePath, startOffset, startLine, state, tier, onEntry) {
1294
1356
  const stream = (0, import_fs5.createReadStream)(filePath, { start: startOffset, encoding: "utf8" });
1295
1357
  let buffer = "";
1296
1358
  let offset = startOffset;
@@ -1306,7 +1368,9 @@ async function tailReduce(filePath, startOffset, startLine, state, tier) {
1306
1368
  buffer = buffer.slice(nl + 1);
1307
1369
  if (text.length > 0) {
1308
1370
  try {
1309
- reduceLine(state, JSON.parse(text), tier);
1371
+ const entry = JSON.parse(text);
1372
+ reduceLine(state, entry, tier);
1373
+ onEntry?.(entry);
1310
1374
  } catch {
1311
1375
  state.badJsonLines++;
1312
1376
  }
@@ -1542,7 +1606,7 @@ var import_fs10 = require("fs");
1542
1606
  // src/providers/parse.ts
1543
1607
  var import_fs8 = require("fs");
1544
1608
  var import_readline3 = require("readline");
1545
- async function parseMetaWithProvider(provider, filePath, account, tier) {
1609
+ async function parseMetaWithProvider(provider, filePath, account, tier, onEntry) {
1546
1610
  const log = getLogger();
1547
1611
  const acc = provider.createEmptyAccumulator();
1548
1612
  const rl = (0, import_readline3.createInterface)({
@@ -1560,6 +1624,7 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
1560
1624
  }
1561
1625
  try {
1562
1626
  provider.reduceEntry(acc, entry, tier);
1627
+ onEntry?.(entry);
1563
1628
  } catch (err) {
1564
1629
  log.warn({ filePath, provider: provider.name, err }, "provider reduce threw; line skipped");
1565
1630
  }
@@ -1571,6 +1636,151 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
1571
1636
  return provider.finalize(acc, filePath, account, tier);
1572
1637
  }
1573
1638
 
1639
+ // src/search-document.ts
1640
+ var SEARCH_BUDGET = {
1641
+ textMax: 64 * 1024,
1642
+ thinkingMax: 32 * 1024,
1643
+ toolsMax: 32 * 1024,
1644
+ toolPayloadMax: 4 * 1024
1645
+ };
1646
+ var SEP = "\n\n";
1647
+ function emptySearchDocument() {
1648
+ return { text: "", thinking: "", tools: "" };
1649
+ }
1650
+ function appendSearchDelta(doc, delta) {
1651
+ return {
1652
+ text: tailAppend(doc.text, delta.text, SEARCH_BUDGET.textMax),
1653
+ thinking: tailAppend(doc.thinking, delta.thinking, SEARCH_BUDGET.thinkingMax),
1654
+ tools: tailAppend(doc.tools, delta.tools, SEARCH_BUDGET.toolsMax)
1655
+ };
1656
+ }
1657
+ function tailAppend(current, incoming, max) {
1658
+ if (!incoming) return current;
1659
+ const joined = current ? current + SEP + incoming : incoming;
1660
+ return joined.length <= max ? joined : joined.slice(-max);
1661
+ }
1662
+ function combineSearchContent(doc) {
1663
+ return [doc.text, doc.thinking, doc.tools].filter(Boolean).join(SEP);
1664
+ }
1665
+ function capToolPayload(value) {
1666
+ const raw = stringifyPayload(value);
1667
+ if (!raw) return "";
1668
+ return raw.length > SEARCH_BUDGET.toolPayloadMax ? raw.slice(0, SEARCH_BUDGET.toolPayloadMax) : raw;
1669
+ }
1670
+ function stringifyPayload(value) {
1671
+ if (value === null || value === void 0) return "";
1672
+ if (typeof value === "string") return value;
1673
+ try {
1674
+ return JSON.stringify(value) ?? "";
1675
+ } catch {
1676
+ return "";
1677
+ }
1678
+ }
1679
+ function extractSearchDelta(entry) {
1680
+ if (entry.type === "response_item" || entry.type === "session_meta") {
1681
+ return extractCodexDelta(entry);
1682
+ }
1683
+ return extractClaudeDelta(entry);
1684
+ }
1685
+ function extractClaudeDelta(entry) {
1686
+ const type = entry.type;
1687
+ if (type !== "user" && type !== "assistant") return emptySearchDocument();
1688
+ if (entry.isMeta) return emptySearchDocument();
1689
+ const msg = entry.message;
1690
+ const content = msg?.content;
1691
+ const tools = [
1692
+ extractClaudeToolContent(content),
1693
+ // Claude stores the rich/structured tool result at the JSONL entry's top
1694
+ // level, not inside message.content — indexing only message.content would
1695
+ // miss most real tool output (file reads, command stdout).
1696
+ capToolPayload(entry.toolUseResult)
1697
+ ].filter(Boolean).join(SEP);
1698
+ return {
1699
+ text: extractClaudeText(content),
1700
+ thinking: type === "assistant" ? extractThinking(content).content : "",
1701
+ tools
1702
+ };
1703
+ }
1704
+ function extractClaudeText(content) {
1705
+ if (typeof content === "string") return cleanSystemTags(content);
1706
+ if (!Array.isArray(content)) return "";
1707
+ const parts = [];
1708
+ for (const item of content) {
1709
+ if (typeof item === "string") {
1710
+ const cleaned = cleanSystemTags(item);
1711
+ if (cleaned) parts.push(cleaned);
1712
+ } else if (item?.type === "text" && typeof item.text === "string") {
1713
+ const cleaned = cleanSystemTags(item.text);
1714
+ if (cleaned) parts.push(cleaned);
1715
+ }
1716
+ }
1717
+ return parts.join(SEP);
1718
+ }
1719
+ function extractClaudeToolContent(content) {
1720
+ if (!Array.isArray(content)) return "";
1721
+ const parts = [];
1722
+ for (const item of content) {
1723
+ if (item?.type === "tool_use") {
1724
+ const capped = capToolPayload(item.input);
1725
+ if (capped) parts.push(capped);
1726
+ } else if (item?.type === "tool_result") {
1727
+ const capped = capToolPayload(item.content);
1728
+ if (capped) parts.push(capped);
1729
+ }
1730
+ }
1731
+ return parts.join(SEP);
1732
+ }
1733
+ function extractCodexDelta(entry) {
1734
+ const payload = entry.payload;
1735
+ if (!payload || typeof payload !== "object") return emptySearchDocument();
1736
+ const ptype = payload.type;
1737
+ if (ptype === "function_call" || ptype === "custom_tool_call") {
1738
+ return { text: "", thinking: "", tools: capToolPayload(payload.arguments) };
1739
+ }
1740
+ if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
1741
+ return { text: "", thinking: "", tools: capToolPayload(payload.output) };
1742
+ }
1743
+ if (ptype === "reasoning") {
1744
+ return { text: "", thinking: extractCodexReasoning(payload), tools: "" };
1745
+ }
1746
+ if (ptype === "message") {
1747
+ const role = payload.role;
1748
+ if (role !== "user" && role !== "assistant") return emptySearchDocument();
1749
+ return { text: extractCodexText2(payload.content), thinking: "", tools: "" };
1750
+ }
1751
+ return emptySearchDocument();
1752
+ }
1753
+ function extractCodexReasoning(payload) {
1754
+ const parts = [];
1755
+ for (const key of ["summary", "content"]) {
1756
+ const blocks = payload[key];
1757
+ if (!Array.isArray(blocks)) continue;
1758
+ for (const block of blocks) {
1759
+ if (typeof block === "string") parts.push(block);
1760
+ else if (typeof block?.text === "string") parts.push(block.text);
1761
+ }
1762
+ }
1763
+ return parts.filter(Boolean).join(SEP);
1764
+ }
1765
+ function extractCodexText2(content) {
1766
+ if (typeof content === "string") return cleanSystemTags(content);
1767
+ if (!Array.isArray(content)) return "";
1768
+ const parts = [];
1769
+ for (const item of content) {
1770
+ if (typeof item === "string") {
1771
+ const cleaned = cleanSystemTags(item);
1772
+ if (cleaned) parts.push(cleaned);
1773
+ continue;
1774
+ }
1775
+ const t = item?.type;
1776
+ if ((t === "input_text" || t === "output_text" || t === "text") && typeof item.text === "string") {
1777
+ const cleaned = cleanSystemTags(item.text);
1778
+ if (cleaned) parts.push(cleaned);
1779
+ }
1780
+ }
1781
+ return parts.join(SEP);
1782
+ }
1783
+
1574
1784
  // src/tiers.ts
1575
1785
  var DEFAULT_TIERS = {
1576
1786
  standard: { name: "standard", previewMax: 200, snippetMax: 5e3 },
@@ -1592,7 +1802,7 @@ var import_fs9 = require("fs");
1592
1802
  var import_path8 = require("path");
1593
1803
 
1594
1804
  // src/persistent/schema.ts
1595
- var SCHEMA_VERSION = 4;
1805
+ var SCHEMA_VERSION = 5;
1596
1806
  var SCHEMA_SQL = `
1597
1807
  CREATE TABLE IF NOT EXISTS conversation_files (
1598
1808
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1692,13 +1902,27 @@ CREATE INDEX IF NOT EXISTS idx_conversations_account_recent ON conversations(acc
1692
1902
  CREATE INDEX IF NOT EXISTS idx_conversations_subagent_recent ON conversations(is_subagent, timestamp DESC);
1693
1903
  CREATE INDEX IF NOT EXISTS idx_conversations_team_recent ON conversations(team_name, timestamp DESC);
1694
1904
 
1695
- -- Full-text search index over conversation content + metadata. Kept separate
1696
- -- from the metadata tables so list-screen queries stay small and fast.
1697
- -- source_path is UNINDEXED (stored, not tokenized) and links back to a
1698
- -- conversations row. One FTS row per conversation, replaced on each upsert.
1905
+ -- Full-text search index over conversation body + metadata. Kept separate from
1906
+ -- the metadata tables so list-screen queries stay small and fast. One FTS row
1907
+ -- per conversation, replaced on each upsert.
1908
+ --
1909
+ -- The row's rowid IS conversation_files.id. That is load-bearing, not cosmetic:
1910
+ -- FTS5's query planner only handles MATCH, rowid and rank, so any other
1911
+ -- constraint (including a source_path equality on an UNINDEXED column) has no
1912
+ -- index and linear-scans the whole table. At ~128 KB of body per row that would
1913
+ -- mean scanning the entire corpus on every append. Look rows up by rowid.
1914
+ -- source_path stays stored-but-UNINDEXED so a search hit can resolve back to a
1915
+ -- conversations row.
1916
+ --
1917
+ -- Body is three columns, not one: text > thinking > tools priority has to
1918
+ -- survive an append (a new user message belongs in the text column, not after
1919
+ -- the tool output already written). They are deliberately NOT concatenated into
1920
+ -- a fourth column - that would double-weight body hits and inflate bm25 length.
1699
1921
  CREATE VIRTUAL TABLE IF NOT EXISTS conversation_messages_fts USING fts5(
1700
1922
  source_path UNINDEXED,
1701
- content,
1923
+ text,
1924
+ thinking,
1925
+ tools,
1702
1926
  project_name,
1703
1927
  session_id,
1704
1928
  session_name,
@@ -1768,6 +1992,24 @@ function runMigrations(db) {
1768
1992
  if (current >= 1 && current < 3 && tableExists(db, "conversations")) {
1769
1993
  db.exec("UPDATE conversations SET provider = 'claude-code' WHERE provider = 'threadbase'");
1770
1994
  }
1995
+ if (current >= 1 && current < 5) {
1996
+ db.exec("DROP TABLE IF EXISTS conversation_messages_fts");
1997
+ if (tableExists(db, "conversation_files")) {
1998
+ const assignments = [];
1999
+ if (hasColumn(db, "conversation_files", "last_indexed_offset")) {
2000
+ assignments.push("last_indexed_offset = 0");
2001
+ }
2002
+ if (hasColumn(db, "conversation_files", "last_indexed_line")) {
2003
+ assignments.push("last_indexed_line = 0");
2004
+ }
2005
+ if (hasColumn(db, "conversation_files", "reducer_state")) {
2006
+ assignments.push("reducer_state = NULL");
2007
+ }
2008
+ if (assignments.length > 0) {
2009
+ db.exec(`UPDATE conversation_files SET ${assignments.join(", ")}`);
2010
+ }
2011
+ }
2012
+ }
1771
2013
  db.exec(SCHEMA_SQL);
1772
2014
  db.pragma(`user_version = ${SCHEMA_VERSION}`);
1773
2015
  }
@@ -1789,6 +2031,7 @@ function openDatabase(dbPath) {
1789
2031
  db.pragma("synchronous = NORMAL");
1790
2032
  db.pragma("temp_store = MEMORY");
1791
2033
  db.pragma("foreign_keys = ON");
2034
+ db.pragma("busy_timeout = 5000");
1792
2035
  runMigrations(db);
1793
2036
  getLogger().debug({ dbPath }, "db: opened");
1794
2037
  return db;
@@ -2236,22 +2479,31 @@ var ConversationsRepo = class {
2236
2479
  };
2237
2480
 
2238
2481
  // src/persistent/repositories/fts.repo.ts
2482
+ var BODY_COLUMNS = [
2483
+ { name: "text", index: 1 },
2484
+ { name: "thinking", index: 2 },
2485
+ { name: "tools", index: 3 }
2486
+ ];
2239
2487
  var FtsRepo = class {
2240
2488
  constructor(db) {
2241
2489
  this.db = db;
2242
2490
  }
2243
2491
  db;
2244
- upsert(meta) {
2492
+ upsert(rowId, meta, doc) {
2245
2493
  const sourcePath = canonicalPath(meta.id);
2246
2494
  const tx = this.db.transaction(() => {
2247
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2495
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
2248
2496
  this.db.prepare(
2249
2497
  `INSERT INTO conversation_messages_fts
2250
- (source_path, content, project_name, session_id, session_name, account, model, branch, tool_names)
2251
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
2498
+ (rowid, source_path, text, thinking, tools,
2499
+ project_name, session_id, session_name, account, model, branch, tool_names)
2500
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2252
2501
  ).run(
2502
+ rowId,
2253
2503
  sourcePath,
2254
- meta.contentSnippet ?? "",
2504
+ doc.text,
2505
+ doc.thinking,
2506
+ doc.tools,
2255
2507
  meta.projectName ?? "",
2256
2508
  meta.sessionId ?? "",
2257
2509
  meta.sessionName ?? "",
@@ -2263,26 +2515,94 @@ var FtsRepo = class {
2263
2515
  });
2264
2516
  tx();
2265
2517
  }
2266
- remove(sourcePath) {
2267
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(canonicalPath(sourcePath));
2518
+ // Current durable buckets for a conversation, so an append can tail-extend
2519
+ // them without reparsing the file. Returns null when no row exists yet.
2520
+ readDocument(rowId) {
2521
+ const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
2522
+ if (!row) return null;
2523
+ return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
2268
2524
  }
2269
- // Ranked source_paths matching the query, best first. Returns [] on an empty
2270
- // query (callers fall back to a recency listing).
2271
- search(query, limit) {
2525
+ // True when the stored row already equals what we would write, so an append
2526
+ // can skip re-tokenizing ~128 KB of body for nothing.
2527
+ //
2528
+ // The check covers the metadata columns too, not just the buckets: a
2529
+ // newly-seen tool name or a late-resolved session_name changes metadata while
2530
+ // the body is byte-identical, and nothing else ever rewrites this row — so
2531
+ // skipping on "body unchanged" alone would strand that stale value forever.
2532
+ isCurrent(rowId, meta, doc) {
2533
+ const row = this.db.prepare(
2534
+ `SELECT text, thinking, tools,
2535
+ project_name, session_id, session_name, account, model, branch, tool_names
2536
+ FROM conversation_messages_fts WHERE rowid = ?`
2537
+ ).get(rowId);
2538
+ if (!row) return false;
2539
+ 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(" ");
2540
+ }
2541
+ remove(rowId) {
2542
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
2543
+ }
2544
+ // Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
2545
+ // with a wide corpus a popular term matches far more conversations than one
2546
+ // page, so post-filtering an already-truncated list would report "no results"
2547
+ // for queries that do have them.
2548
+ search(query, limit, filters = {}) {
2272
2549
  const match = toMatchQuery(query);
2273
2550
  if (!match) return [];
2551
+ const snippetSelects = BODY_COLUMNS.map(
2552
+ (c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
2553
+ ).join(", ");
2554
+ const params = [];
2555
+ for (const _col of BODY_COLUMNS) {
2556
+ params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
2557
+ }
2558
+ params.push(match);
2559
+ const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
2560
+ if (filters.account) {
2561
+ where.push("c.account = ?");
2562
+ params.push(filters.account);
2563
+ }
2564
+ if (filters.provider) {
2565
+ where.push("c.provider = ?");
2566
+ params.push(filters.provider);
2567
+ }
2568
+ if (filters.project) {
2569
+ where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
2570
+ const like = `%${filters.project.toLowerCase()}%`;
2571
+ params.push(like, like);
2572
+ }
2573
+ if (filters.since) {
2574
+ where.push("c.timestamp >= ?");
2575
+ params.push(filters.since.toISOString());
2576
+ }
2577
+ if (filters.include === "conversations") {
2578
+ where.push("c.is_subagent = 0 AND c.is_teammate = 0");
2579
+ } else if (filters.include === "subagents") {
2580
+ where.push("c.is_subagent = 1");
2581
+ } else if (filters.include === "teammates") {
2582
+ where.push("c.is_teammate = 1");
2583
+ }
2584
+ params.push(limit);
2274
2585
  const rows = this.db.prepare(
2275
- `SELECT source_path FROM conversation_messages_fts
2276
- WHERE conversation_messages_fts MATCH ?
2586
+ `SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
2587
+ FROM conversation_messages_fts
2588
+ JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
2589
+ WHERE ${where.join(" AND ")}
2277
2590
  ORDER BY rank
2278
2591
  LIMIT ?`
2279
- ).all(match, limit);
2280
- return rows.map((r) => r.source_path);
2592
+ ).all(...params);
2593
+ return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
2281
2594
  }
2282
2595
  count() {
2283
2596
  return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
2284
2597
  }
2285
2598
  };
2599
+ function pickBodySnippet(row) {
2600
+ for (const col of BODY_COLUMNS) {
2601
+ const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
2602
+ if (parsed) return parsed;
2603
+ }
2604
+ return null;
2605
+ }
2286
2606
  function toMatchQuery(query) {
2287
2607
  const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
2288
2608
  if (terms.length === 0) return "";
@@ -2462,9 +2782,13 @@ var PersistentEngine = class {
2462
2782
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
2463
2783
  const startOffset = resume ? existing.last_indexed_offset : 0;
2464
2784
  const startLine = resume ? existing.last_indexed_line : 0;
2785
+ let searchDelta = emptySearchDocument();
2786
+ const collectSearch = (entry) => {
2787
+ searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
2788
+ };
2465
2789
  let result;
2466
2790
  try {
2467
- result = await tailReduce(filePath, startOffset, startLine, state, tier);
2791
+ result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
2468
2792
  } catch (err) {
2469
2793
  log.warn({ filePath, err }, "persistent: tail read failed");
2470
2794
  return { meta: null, change };
@@ -2477,9 +2801,13 @@ var PersistentEngine = class {
2477
2801
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2478
2802
  const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2479
2803
  const fileId = this.files.ensure(filePath, account);
2804
+ const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
2805
+ const searchDoc = appendSearchDelta(base, searchDelta);
2480
2806
  const upsert = this.db.transaction(() => {
2481
2807
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2482
- this.fts.upsert(meta);
2808
+ if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
2809
+ this.fts.upsert(fileId, meta, searchDoc);
2810
+ }
2483
2811
  if (!resume) this.checkpoints.remove(filePath);
2484
2812
  this.files.updateCursor(fileId, {
2485
2813
  sizeBytes: stat4.size,
@@ -2522,7 +2850,10 @@ var PersistentEngine = class {
2522
2850
  // any change reparses from 0 again. No reducer_state is persisted.
2523
2851
  async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
2524
2852
  const log = getLogger();
2525
- const meta = await parseMetaWithProvider(provider, filePath, account, tier);
2853
+ let searchDoc = emptySearchDocument();
2854
+ const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
2855
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
2856
+ });
2526
2857
  if (!meta) {
2527
2858
  this.markDeleted(filePath);
2528
2859
  return null;
@@ -2534,7 +2865,9 @@ var PersistentEngine = class {
2534
2865
  const fileId = this.files.ensure(filePath, account);
2535
2866
  const upsert = this.db.transaction(() => {
2536
2867
  this.conversations.upsert(fileId, meta, meta.messageCount);
2537
- this.fts.upsert(meta);
2868
+ if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
2869
+ this.fts.upsert(fileId, meta, searchDoc);
2870
+ }
2538
2871
  this.checkpoints.remove(filePath);
2539
2872
  this.files.updateCursor(fileId, {
2540
2873
  sizeBytes: stat4.size,
@@ -2560,7 +2893,7 @@ var PersistentEngine = class {
2560
2893
  if (!existing) return;
2561
2894
  const tx = this.db.transaction(() => {
2562
2895
  this.conversations.deleteByFileId(existing.id);
2563
- this.fts.remove(filePath);
2896
+ this.fts.remove(existing.id);
2564
2897
  this.checkpoints.remove(filePath);
2565
2898
  this.files.setStatus(existing.id, "deleted");
2566
2899
  });
@@ -2576,20 +2909,24 @@ var PersistentEngine = class {
2576
2909
  getAllBySessionId(sessionId) {
2577
2910
  return this.conversations.getAllBySessionId(sessionId);
2578
2911
  }
2579
- // Ranked metas matching the FTS query, best first. Empty query returns the
2580
- // most recent conversations (mirroring the in-memory indexer's empty-query
2581
- // behavior). Resolves each FTS hit to its active conversation row.
2582
- searchMetas(query, limit) {
2583
- if (!query.trim()) {
2584
- return this.conversations.recent(limit);
2585
- }
2586
- const paths = this.fts.search(query, limit);
2587
- const metas = [];
2588
- for (const path of paths) {
2589
- const meta = this.conversations.getBySourcePath(path);
2590
- if (meta) metas.push(meta);
2912
+ // Ranked hits matching the FTS query, best first, each already resolved to its
2913
+ // active conversation row and carrying the body excerpt when the match was in
2914
+ // the conversation body.
2915
+ //
2916
+ // Filters are passed down into SQL rather than applied to the result: with a
2917
+ // wide corpus, filtering an already-LIMITed list drops conversations that
2918
+ // would have matched.
2919
+ searchHits(query, limit, filters = {}) {
2920
+ const hits = [];
2921
+ for (const hit of this.fts.search(query, limit, filters)) {
2922
+ const meta = this.conversations.getBySourcePath(hit.sourcePath);
2923
+ if (meta) hits.push({ meta, body: hit.body });
2591
2924
  }
2592
- return metas;
2925
+ return hits;
2926
+ }
2927
+ // Empty-query listing, mirroring the in-memory indexer's behavior.
2928
+ recentMetas(limit) {
2929
+ return this.conversations.recent(limit);
2593
2930
  }
2594
2931
  getProjects() {
2595
2932
  return this.conversations.distinctProjects();
@@ -2750,6 +3087,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
2750
3087
  function defaultDbPath() {
2751
3088
  return process.env.TB_SCANNER_DB ?? (0, import_path11.join)((0, import_os2.homedir)(), ".config", "threadbase-scanner", "index.db");
2752
3089
  }
3090
+ function capForMemory(doc, max) {
3091
+ const combined = combineSearchContent(doc);
3092
+ return combined.length > max ? combined.slice(-max) : combined;
3093
+ }
2753
3094
  var ConversationScanner = class {
2754
3095
  metadataCache = /* @__PURE__ */ new Map();
2755
3096
  // Parsed conversations plus (persistent claude-code entries only) the resume
@@ -2761,6 +3102,10 @@ var ConversationScanner = class {
2761
3102
  sessionIdIndex = /* @__PURE__ */ new Map();
2762
3103
  projects = /* @__PURE__ */ new Set();
2763
3104
  indexer = new SearchIndexer();
3105
+ // Search body per conversation for the in-memory path, already tail-capped to
3106
+ // the content tier. Survives scan() so a statCache hit — which skips the parse
3107
+ // entirely — can still index a body rather than an empty string.
3108
+ searchContents = /* @__PURE__ */ new Map();
2764
3109
  // Tier the most recent scan() ran with, so refreshFile() re-parses a single
2765
3110
  // file at the same content depth. Defaults to the standard tier.
2766
3111
  lastTier = resolveTier("standard");
@@ -2921,34 +3266,42 @@ var ConversationScanner = class {
2921
3266
  try {
2922
3267
  const s = (0, import_fs11.statSync)(filePath);
2923
3268
  if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
2924
- return cached.meta;
3269
+ return {
3270
+ meta: cached.meta,
3271
+ searchContent: this.searchContents.get(cached.meta.id)
3272
+ };
2925
3273
  }
2926
3274
  } catch {
2927
3275
  }
2928
3276
  }
2929
3277
  }
2930
3278
  try {
2931
- const meta = await parseMetaWithProvider(provider, filePath, account, tier);
3279
+ let doc = emptySearchDocument();
3280
+ const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
3281
+ doc = appendSearchDelta(doc, extractSearchDelta(entry));
3282
+ });
2932
3283
  if (meta && meta.gitBranch === null && meta.projectPath) {
2933
3284
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2934
3285
  }
2935
- return meta;
3286
+ return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
2936
3287
  } catch (err) {
2937
3288
  parseFailures++;
2938
3289
  log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
2939
- return null;
3290
+ return { meta: null, searchContent: void 0 };
2940
3291
  }
2941
3292
  })
2942
3293
  );
2943
3294
  const batchMetas = [];
2944
- for (const meta of results) {
3295
+ for (const { meta, searchContent } of results) {
2945
3296
  if (meta && meta.messageCount > 0) {
2946
3297
  this.metadataCache.set(meta.id, meta);
2947
3298
  this.addToSessionIndex(meta);
2948
3299
  this.projects.add(meta.projectPath);
2949
3300
  allMetas.push(meta);
2950
3301
  batchMetas.push(meta);
2951
- this.indexer.addDocument(meta);
3302
+ const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
3303
+ this.searchContents.set(meta.id, content);
3304
+ this.indexer.addDocument(meta, content);
2952
3305
  }
2953
3306
  }
2954
3307
  if (batchMetas.length > 0) {
@@ -2985,12 +3338,29 @@ var ConversationScanner = class {
2985
3338
  const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
2986
3339
  await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
2987
3340
  }
2988
- const metas = engine.searchMetas(query, (options.limit ?? 50) * 2);
2989
- results = query.trim() ? metas.map((meta) => ({ meta, score: 1, matches: generateMatches(meta, query) })) : metas.map((meta) => ({
2990
- meta,
2991
- score: 1,
2992
- matches: [{ field: "timestamp", snippet: meta.preview }]
2993
- }));
3341
+ if (query.trim()) {
3342
+ const want = (options.limit ?? 50) + (options.offset ?? 0);
3343
+ results = engine.searchHits(query, want, {
3344
+ account: options.account,
3345
+ provider: options.provider,
3346
+ project: options.project,
3347
+ since: options.since ? parseSinceCutoff(options.since) : void 0,
3348
+ include: options.include
3349
+ }).map(({ meta, body }) => ({
3350
+ meta,
3351
+ score: 1,
3352
+ matches: body ? [
3353
+ { field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
3354
+ ...generateMatches(meta, query).filter((m) => m.field !== "preview")
3355
+ ] : generateMatches(meta, query)
3356
+ }));
3357
+ } else {
3358
+ results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
3359
+ meta,
3360
+ score: 1,
3361
+ matches: [{ field: "timestamp", snippet: meta.preview }]
3362
+ }));
3363
+ }
2994
3364
  } else {
2995
3365
  if (this.indexer.getDocumentCount() === 0) {
2996
3366
  log.debug("search: index empty, triggering scan");
@@ -3184,8 +3554,11 @@ var ConversationScanner = class {
3184
3554
  const previous = this.metadataCache.get(filePath);
3185
3555
  const resolvedAccount = account ?? previous?.account ?? "default";
3186
3556
  let meta = null;
3557
+ let searchDoc = emptySearchDocument();
3187
3558
  try {
3188
- meta = await parseMeta(filePath, resolvedAccount, this.lastTier);
3559
+ meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
3560
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
3561
+ });
3189
3562
  } catch (err) {
3190
3563
  log.warn({ filePath, err }, "refreshFile: parseMeta threw");
3191
3564
  meta = null;
@@ -3202,6 +3575,7 @@ var ConversationScanner = class {
3202
3575
  this.metadataCache.delete(previous.id);
3203
3576
  this.removeFromSessionIndex(previous);
3204
3577
  this.indexer.removeDocument(previous.id);
3578
+ this.searchContents.delete(previous.id);
3205
3579
  }
3206
3580
  log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
3207
3581
  return null;
@@ -3211,10 +3585,12 @@ var ConversationScanner = class {
3211
3585
  this.metadataCache.set(meta.id, meta);
3212
3586
  this.addToSessionIndex(meta);
3213
3587
  this.projects.add(meta.projectPath);
3588
+ const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
3589
+ this.searchContents.set(meta.id, searchContent);
3214
3590
  if (previous) {
3215
- this.indexer.updateDocument(meta);
3591
+ this.indexer.updateDocument(meta, searchContent);
3216
3592
  } else {
3217
- this.indexer.addDocument(meta);
3593
+ this.indexer.addDocument(meta, searchContent);
3218
3594
  }
3219
3595
  log.debug(
3220
3596
  { filePath, messageCount: meta.messageCount },