@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/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import pino2 from "pino";
6
6
 
7
7
  // package.json
8
- var version = "0.12.3";
8
+ var version = "0.13.0";
9
9
 
10
10
  // src/logger.ts
11
11
  import pino from "pino";
@@ -302,18 +302,43 @@ function readGitBranch(projectPath) {
302
302
  import FlexSearchModule from "flexsearch";
303
303
 
304
304
  // src/search-matches.ts
305
+ var FTS_HIT_OPEN = "";
306
+ var FTS_HIT_CLOSE = "";
307
+ var FTS_SNIPPET_TOKENS = 16;
308
+ var FTS_ELLIPSIS = "\u2026";
309
+ var CONTENT_FIELD = "content";
310
+ function parseFtsSnippet(raw) {
311
+ if (!raw?.includes(FTS_HIT_OPEN)) return null;
312
+ const highlights = [];
313
+ let snippet = "";
314
+ let openAt = -1;
315
+ for (const ch of raw) {
316
+ if (ch === FTS_HIT_OPEN) {
317
+ openAt = snippet.length;
318
+ continue;
319
+ }
320
+ if (ch === FTS_HIT_CLOSE) {
321
+ if (openAt >= 0 && snippet.length > openAt) {
322
+ highlights.push({ start: openAt, end: snippet.length });
323
+ }
324
+ openAt = -1;
325
+ continue;
326
+ }
327
+ snippet += ch;
328
+ }
329
+ return highlights.length > 0 ? { snippet, highlights } : null;
330
+ }
305
331
  function generateMatches(meta, query) {
306
332
  const matches = [];
307
333
  const lowerQuery = query.toLowerCase();
308
334
  const fields = [
309
- ["contentSnippet", meta.contentSnippet],
310
- ["projectName", meta.projectName],
311
- ["sessionId", meta.sessionId],
312
335
  ["sessionName", meta.sessionName],
313
- ["account", meta.account],
314
- ["model", meta.model || ""],
336
+ ["projectName", meta.projectName],
315
337
  ["gitBranch", meta.gitBranch || ""],
316
- ["toolNames", meta.toolNames.join(" ")]
338
+ ["toolNames", meta.toolNames.join(" ")],
339
+ ["model", meta.model || ""],
340
+ ["account", meta.account],
341
+ ["sessionId", meta.sessionId]
317
342
  ];
318
343
  for (const [field, value] of fields) {
319
344
  const idx = value.toLowerCase().indexOf(lowerQuery);
@@ -328,6 +353,20 @@ function generateMatches(meta, query) {
328
353
  }
329
354
  return matches.length > 0 ? matches : [{ field: "preview", snippet: meta.preview }];
330
355
  }
356
+ function buildContentMatch(searchContent, query) {
357
+ if (!searchContent || !query.trim()) return null;
358
+ const idx = searchContent.toLowerCase().indexOf(query.toLowerCase());
359
+ if (idx === -1) return null;
360
+ const start = Math.max(0, idx - 80);
361
+ const end = Math.min(searchContent.length, idx + query.length + 120);
362
+ const body = searchContent.slice(start, end).replace(/\s+/g, " ").trim();
363
+ const hitAt = body.toLowerCase().indexOf(query.toLowerCase());
364
+ const prefix = start > 0 ? FTS_ELLIPSIS : "";
365
+ const suffix = end < searchContent.length ? FTS_ELLIPSIS : "";
366
+ const snippet = `${prefix}${body}${suffix}`;
367
+ const highlights = hitAt === -1 ? [] : [{ start: hitAt + prefix.length, end: hitAt + prefix.length + query.length }];
368
+ return { field: CONTENT_FIELD, snippet, highlights };
369
+ }
331
370
 
332
371
  // src/indexer.ts
333
372
  var FlexSearch = FlexSearchModule.default ?? FlexSearchModule;
@@ -335,6 +374,10 @@ var SearchIndexer = class {
335
374
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
336
375
  index;
337
376
  documents = /* @__PURE__ */ new Map();
377
+ // The indexed body per conversation, kept so a hit can produce a real excerpt
378
+ // instead of falling back to an unrelated preview. Sized by the caller (the
379
+ // scanner tail-caps it to the content tier) — see the note on addDocument.
380
+ searchContents = /* @__PURE__ */ new Map();
338
381
  constructor() {
339
382
  this.index = this.createIndex();
340
383
  }
@@ -360,25 +403,25 @@ var SearchIndexer = class {
360
403
  cache: 100
361
404
  });
362
405
  }
363
- addDocument(meta) {
406
+ // `searchContent` is the combined search document (text + thinking + tools),
407
+ // NOT meta.contentSnippet.
408
+ //
409
+ // It arrives pre-capped. This index is `tokenize: "forward"` at resolution 9,
410
+ // which stores every prefix of every token, so it is a resident-memory
411
+ // structure whose cost is very different from the on-disk FTS index. Feeding
412
+ // it the full ~128 KB budget across a few hundred conversations would be a
413
+ // multi-hundred-MB-to-GB index. The scanner therefore caps this to the active
414
+ // tier's snippetMax and accepts that `persistent: false` has lower recall than
415
+ // SQLite — an honest, documented divergence rather than a silent one.
416
+ addDocument(meta, searchContent = "") {
364
417
  this.documents.set(meta.id, meta);
365
- this.index.add({
366
- id: meta.id,
367
- content: meta.contentSnippet,
368
- projectName: meta.projectName,
369
- projectPath: meta.projectPath,
370
- sessionId: meta.sessionId,
371
- sessionName: meta.sessionName,
372
- account: meta.account,
373
- model: meta.model || "",
374
- gitBranch: meta.gitBranch || "",
375
- toolNames: meta.toolNames.join(" ")
376
- });
418
+ this.searchContents.set(meta.id, searchContent);
419
+ this.index.add(toIndexDoc(meta, searchContent));
377
420
  }
378
- buildIndex(metas) {
421
+ buildIndex(metas, searchContents) {
379
422
  this.clear();
380
423
  for (const meta of metas) {
381
- this.addDocument(meta);
424
+ this.addDocument(meta, searchContents?.get(meta.id) ?? "");
382
425
  }
383
426
  getLogger().debug({ docCount: metas.length }, "indexer: built");
384
427
  }
@@ -398,14 +441,26 @@ var SearchIndexer = class {
398
441
  seen.add(id);
399
442
  const meta = this.documents.get(id);
400
443
  if (!meta) continue;
401
- const matches = generateMatches(meta, query);
402
- searchResults.push({ meta, score: 1, matches });
444
+ searchResults.push({
445
+ meta,
446
+ score: 1,
447
+ matches: this.matchesFor(meta, query)
448
+ });
403
449
  if (searchResults.length >= limit) break;
404
450
  }
405
451
  if (searchResults.length >= limit) break;
406
452
  }
407
453
  return searchResults;
408
454
  }
455
+ // Body context first (that is what explains why the result appeared), then any
456
+ // metadata matches. Only when neither hits does generateMatches' preview
457
+ // fallback stand in.
458
+ matchesFor(meta, query) {
459
+ const contentMatch = buildContentMatch(this.searchContents.get(meta.id) ?? "", query);
460
+ const metaMatches = generateMatches(meta, query);
461
+ if (!contentMatch) return metaMatches;
462
+ return [contentMatch, ...metaMatches.filter((m) => m.field !== "preview")];
463
+ }
409
464
  getRecent(limit) {
410
465
  return Array.from(this.documents.values()).sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, limit).map((meta) => ({
411
466
  meta,
@@ -419,31 +474,37 @@ var SearchIndexer = class {
419
474
  // Replace an already-indexed document in place. FlexSearch's `add` does not
420
475
  // overwrite an existing id, so a single-file refresh must go through
421
476
  // `update` to avoid stale matches lingering in the index.
422
- updateDocument(meta) {
477
+ updateDocument(meta, searchContent = "") {
423
478
  this.documents.set(meta.id, meta);
424
- this.index.update({
425
- id: meta.id,
426
- content: meta.contentSnippet,
427
- projectName: meta.projectName,
428
- projectPath: meta.projectPath,
429
- sessionId: meta.sessionId,
430
- sessionName: meta.sessionName,
431
- account: meta.account,
432
- model: meta.model || "",
433
- gitBranch: meta.gitBranch || "",
434
- toolNames: meta.toolNames.join(" ")
435
- });
479
+ this.searchContents.set(meta.id, searchContent);
480
+ this.index.update(toIndexDoc(meta, searchContent));
436
481
  }
437
482
  removeDocument(id) {
438
483
  this.documents.delete(id);
484
+ this.searchContents.delete(id);
439
485
  this.index.remove(id);
440
486
  }
441
487
  clear() {
442
488
  this.documents.clear();
489
+ this.searchContents.clear();
443
490
  this.index = this.createIndex();
444
491
  getLogger().trace("indexer: cleared");
445
492
  }
446
493
  };
494
+ function toIndexDoc(meta, searchContent) {
495
+ return {
496
+ id: meta.id,
497
+ content: searchContent,
498
+ projectName: meta.projectName,
499
+ projectPath: meta.projectPath,
500
+ sessionId: meta.sessionId,
501
+ sessionName: meta.sessionName,
502
+ account: meta.account,
503
+ model: meta.model || "",
504
+ gitBranch: meta.gitBranch || "",
505
+ toolNames: meta.toolNames.join(" ")
506
+ };
507
+ }
447
508
 
448
509
  // src/parser.ts
449
510
  import { createReadStream } from "fs";
@@ -706,7 +767,7 @@ function cleanSystemTags(text) {
706
767
  }
707
768
 
708
769
  // src/parser.ts
709
- async function parseMeta(filePath, account, tier) {
770
+ async function parseMeta(filePath, account, tier, onEntry) {
710
771
  const log = getLogger();
711
772
  log.trace({ filePath, account, tier: tier.name }, "parseMeta: start");
712
773
  const state = initialReducerState();
@@ -723,6 +784,7 @@ async function parseMeta(filePath, account, tier) {
723
784
  continue;
724
785
  }
725
786
  reduceLine(state, entry, tier);
787
+ onEntry?.(entry);
726
788
  }
727
789
  } catch (err) {
728
790
  log.warn({ filePath, err }, "parseMeta: read failed");
@@ -907,7 +969,7 @@ import { setImmediate as yieldToEventLoop2 } from "timers/promises";
907
969
  import { createReadStream as createReadStream2 } from "fs";
908
970
  import { setImmediate as yieldToEventLoop } from "timers/promises";
909
971
  var YIELD_EVERY_LINES = 500;
910
- async function tailReduce(filePath, startOffset, startLine, state, tier) {
972
+ async function tailReduce(filePath, startOffset, startLine, state, tier, onEntry) {
911
973
  const stream = createReadStream2(filePath, { start: startOffset, encoding: "utf8" });
912
974
  let buffer = "";
913
975
  let offset = startOffset;
@@ -923,7 +985,9 @@ async function tailReduce(filePath, startOffset, startLine, state, tier) {
923
985
  buffer = buffer.slice(nl + 1);
924
986
  if (text.length > 0) {
925
987
  try {
926
- reduceLine(state, JSON.parse(text), tier);
988
+ const entry = JSON.parse(text);
989
+ reduceLine(state, entry, tier);
990
+ onEntry?.(entry);
927
991
  } catch {
928
992
  state.badJsonLines++;
929
993
  }
@@ -1388,7 +1452,7 @@ async function parseCodexConversation(filePath, account) {
1388
1452
  // src/providers/parse.ts
1389
1453
  import { createReadStream as createReadStream5 } from "fs";
1390
1454
  import { createInterface as createInterface3 } from "readline";
1391
- async function parseMetaWithProvider(provider, filePath, account, tier) {
1455
+ async function parseMetaWithProvider(provider, filePath, account, tier, onEntry) {
1392
1456
  const log = getLogger();
1393
1457
  const acc = provider.createEmptyAccumulator();
1394
1458
  const rl = createInterface3({
@@ -1406,6 +1470,7 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
1406
1470
  }
1407
1471
  try {
1408
1472
  provider.reduceEntry(acc, entry, tier);
1473
+ onEntry?.(entry);
1409
1474
  } catch (err) {
1410
1475
  log.warn({ filePath, provider: provider.name, err }, "provider reduce threw; line skipped");
1411
1476
  }
@@ -1417,6 +1482,151 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
1417
1482
  return provider.finalize(acc, filePath, account, tier);
1418
1483
  }
1419
1484
 
1485
+ // src/search-document.ts
1486
+ var SEARCH_BUDGET = {
1487
+ textMax: 64 * 1024,
1488
+ thinkingMax: 32 * 1024,
1489
+ toolsMax: 32 * 1024,
1490
+ toolPayloadMax: 4 * 1024
1491
+ };
1492
+ var SEP = "\n\n";
1493
+ function emptySearchDocument() {
1494
+ return { text: "", thinking: "", tools: "" };
1495
+ }
1496
+ function appendSearchDelta(doc, delta) {
1497
+ return {
1498
+ text: tailAppend(doc.text, delta.text, SEARCH_BUDGET.textMax),
1499
+ thinking: tailAppend(doc.thinking, delta.thinking, SEARCH_BUDGET.thinkingMax),
1500
+ tools: tailAppend(doc.tools, delta.tools, SEARCH_BUDGET.toolsMax)
1501
+ };
1502
+ }
1503
+ function tailAppend(current, incoming, max) {
1504
+ if (!incoming) return current;
1505
+ const joined = current ? current + SEP + incoming : incoming;
1506
+ return joined.length <= max ? joined : joined.slice(-max);
1507
+ }
1508
+ function combineSearchContent(doc) {
1509
+ return [doc.text, doc.thinking, doc.tools].filter(Boolean).join(SEP);
1510
+ }
1511
+ function capToolPayload(value) {
1512
+ const raw = stringifyPayload(value);
1513
+ if (!raw) return "";
1514
+ return raw.length > SEARCH_BUDGET.toolPayloadMax ? raw.slice(0, SEARCH_BUDGET.toolPayloadMax) : raw;
1515
+ }
1516
+ function stringifyPayload(value) {
1517
+ if (value === null || value === void 0) return "";
1518
+ if (typeof value === "string") return value;
1519
+ try {
1520
+ return JSON.stringify(value) ?? "";
1521
+ } catch {
1522
+ return "";
1523
+ }
1524
+ }
1525
+ function extractSearchDelta(entry) {
1526
+ if (entry.type === "response_item" || entry.type === "session_meta") {
1527
+ return extractCodexDelta(entry);
1528
+ }
1529
+ return extractClaudeDelta(entry);
1530
+ }
1531
+ function extractClaudeDelta(entry) {
1532
+ const type = entry.type;
1533
+ if (type !== "user" && type !== "assistant") return emptySearchDocument();
1534
+ if (entry.isMeta) return emptySearchDocument();
1535
+ const msg = entry.message;
1536
+ const content = msg?.content;
1537
+ const tools = [
1538
+ extractClaudeToolContent(content),
1539
+ // Claude stores the rich/structured tool result at the JSONL entry's top
1540
+ // level, not inside message.content — indexing only message.content would
1541
+ // miss most real tool output (file reads, command stdout).
1542
+ capToolPayload(entry.toolUseResult)
1543
+ ].filter(Boolean).join(SEP);
1544
+ return {
1545
+ text: extractClaudeText(content),
1546
+ thinking: type === "assistant" ? extractThinking(content).content : "",
1547
+ tools
1548
+ };
1549
+ }
1550
+ function extractClaudeText(content) {
1551
+ if (typeof content === "string") return cleanSystemTags(content);
1552
+ if (!Array.isArray(content)) return "";
1553
+ const parts = [];
1554
+ for (const item of content) {
1555
+ if (typeof item === "string") {
1556
+ const cleaned = cleanSystemTags(item);
1557
+ if (cleaned) parts.push(cleaned);
1558
+ } else if (item?.type === "text" && typeof item.text === "string") {
1559
+ const cleaned = cleanSystemTags(item.text);
1560
+ if (cleaned) parts.push(cleaned);
1561
+ }
1562
+ }
1563
+ return parts.join(SEP);
1564
+ }
1565
+ function extractClaudeToolContent(content) {
1566
+ if (!Array.isArray(content)) return "";
1567
+ const parts = [];
1568
+ for (const item of content) {
1569
+ if (item?.type === "tool_use") {
1570
+ const capped = capToolPayload(item.input);
1571
+ if (capped) parts.push(capped);
1572
+ } else if (item?.type === "tool_result") {
1573
+ const capped = capToolPayload(item.content);
1574
+ if (capped) parts.push(capped);
1575
+ }
1576
+ }
1577
+ return parts.join(SEP);
1578
+ }
1579
+ function extractCodexDelta(entry) {
1580
+ const payload = entry.payload;
1581
+ if (!payload || typeof payload !== "object") return emptySearchDocument();
1582
+ const ptype = payload.type;
1583
+ if (ptype === "function_call" || ptype === "custom_tool_call") {
1584
+ return { text: "", thinking: "", tools: capToolPayload(payload.arguments) };
1585
+ }
1586
+ if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
1587
+ return { text: "", thinking: "", tools: capToolPayload(payload.output) };
1588
+ }
1589
+ if (ptype === "reasoning") {
1590
+ return { text: "", thinking: extractCodexReasoning(payload), tools: "" };
1591
+ }
1592
+ if (ptype === "message") {
1593
+ const role = payload.role;
1594
+ if (role !== "user" && role !== "assistant") return emptySearchDocument();
1595
+ return { text: extractCodexText2(payload.content), thinking: "", tools: "" };
1596
+ }
1597
+ return emptySearchDocument();
1598
+ }
1599
+ function extractCodexReasoning(payload) {
1600
+ const parts = [];
1601
+ for (const key of ["summary", "content"]) {
1602
+ const blocks = payload[key];
1603
+ if (!Array.isArray(blocks)) continue;
1604
+ for (const block of blocks) {
1605
+ if (typeof block === "string") parts.push(block);
1606
+ else if (typeof block?.text === "string") parts.push(block.text);
1607
+ }
1608
+ }
1609
+ return parts.filter(Boolean).join(SEP);
1610
+ }
1611
+ function extractCodexText2(content) {
1612
+ if (typeof content === "string") return cleanSystemTags(content);
1613
+ if (!Array.isArray(content)) return "";
1614
+ const parts = [];
1615
+ for (const item of content) {
1616
+ if (typeof item === "string") {
1617
+ const cleaned = cleanSystemTags(item);
1618
+ if (cleaned) parts.push(cleaned);
1619
+ continue;
1620
+ }
1621
+ const t = item?.type;
1622
+ if ((t === "input_text" || t === "output_text" || t === "text") && typeof item.text === "string") {
1623
+ const cleaned = cleanSystemTags(item.text);
1624
+ if (cleaned) parts.push(cleaned);
1625
+ }
1626
+ }
1627
+ return parts.join(SEP);
1628
+ }
1629
+
1420
1630
  // src/tiers.ts
1421
1631
  var DEFAULT_TIERS = {
1422
1632
  standard: { name: "standard", previewMax: 200, snippetMax: 5e3 },
@@ -1438,7 +1648,7 @@ import { mkdirSync } from "fs";
1438
1648
  import { dirname as dirname3 } from "path";
1439
1649
 
1440
1650
  // src/persistent/schema.ts
1441
- var SCHEMA_VERSION = 4;
1651
+ var SCHEMA_VERSION = 5;
1442
1652
  var SCHEMA_SQL = `
1443
1653
  CREATE TABLE IF NOT EXISTS conversation_files (
1444
1654
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1538,13 +1748,27 @@ CREATE INDEX IF NOT EXISTS idx_conversations_account_recent ON conversations(acc
1538
1748
  CREATE INDEX IF NOT EXISTS idx_conversations_subagent_recent ON conversations(is_subagent, timestamp DESC);
1539
1749
  CREATE INDEX IF NOT EXISTS idx_conversations_team_recent ON conversations(team_name, timestamp DESC);
1540
1750
 
1541
- -- Full-text search index over conversation content + metadata. Kept separate
1542
- -- from the metadata tables so list-screen queries stay small and fast.
1543
- -- source_path is UNINDEXED (stored, not tokenized) and links back to a
1544
- -- conversations row. One FTS row per conversation, replaced on each upsert.
1751
+ -- Full-text search index over conversation body + metadata. Kept separate from
1752
+ -- the metadata tables so list-screen queries stay small and fast. One FTS row
1753
+ -- per conversation, replaced on each upsert.
1754
+ --
1755
+ -- The row's rowid IS conversation_files.id. That is load-bearing, not cosmetic:
1756
+ -- FTS5's query planner only handles MATCH, rowid and rank, so any other
1757
+ -- constraint (including a source_path equality on an UNINDEXED column) has no
1758
+ -- index and linear-scans the whole table. At ~128 KB of body per row that would
1759
+ -- mean scanning the entire corpus on every append. Look rows up by rowid.
1760
+ -- source_path stays stored-but-UNINDEXED so a search hit can resolve back to a
1761
+ -- conversations row.
1762
+ --
1763
+ -- Body is three columns, not one: text > thinking > tools priority has to
1764
+ -- survive an append (a new user message belongs in the text column, not after
1765
+ -- the tool output already written). They are deliberately NOT concatenated into
1766
+ -- a fourth column - that would double-weight body hits and inflate bm25 length.
1545
1767
  CREATE VIRTUAL TABLE IF NOT EXISTS conversation_messages_fts USING fts5(
1546
1768
  source_path UNINDEXED,
1547
- content,
1769
+ text,
1770
+ thinking,
1771
+ tools,
1548
1772
  project_name,
1549
1773
  session_id,
1550
1774
  session_name,
@@ -1614,6 +1838,24 @@ function runMigrations(db) {
1614
1838
  if (current >= 1 && current < 3 && tableExists(db, "conversations")) {
1615
1839
  db.exec("UPDATE conversations SET provider = 'claude-code' WHERE provider = 'threadbase'");
1616
1840
  }
1841
+ if (current >= 1 && current < 5) {
1842
+ db.exec("DROP TABLE IF EXISTS conversation_messages_fts");
1843
+ if (tableExists(db, "conversation_files")) {
1844
+ const assignments = [];
1845
+ if (hasColumn(db, "conversation_files", "last_indexed_offset")) {
1846
+ assignments.push("last_indexed_offset = 0");
1847
+ }
1848
+ if (hasColumn(db, "conversation_files", "last_indexed_line")) {
1849
+ assignments.push("last_indexed_line = 0");
1850
+ }
1851
+ if (hasColumn(db, "conversation_files", "reducer_state")) {
1852
+ assignments.push("reducer_state = NULL");
1853
+ }
1854
+ if (assignments.length > 0) {
1855
+ db.exec(`UPDATE conversation_files SET ${assignments.join(", ")}`);
1856
+ }
1857
+ }
1858
+ }
1617
1859
  db.exec(SCHEMA_SQL);
1618
1860
  db.pragma(`user_version = ${SCHEMA_VERSION}`);
1619
1861
  }
@@ -1635,6 +1877,7 @@ function openDatabase(dbPath) {
1635
1877
  db.pragma("synchronous = NORMAL");
1636
1878
  db.pragma("temp_store = MEMORY");
1637
1879
  db.pragma("foreign_keys = ON");
1880
+ db.pragma("busy_timeout = 5000");
1638
1881
  runMigrations(db);
1639
1882
  getLogger().debug({ dbPath }, "db: opened");
1640
1883
  return db;
@@ -2082,22 +2325,31 @@ var ConversationsRepo = class {
2082
2325
  };
2083
2326
 
2084
2327
  // src/persistent/repositories/fts.repo.ts
2328
+ var BODY_COLUMNS = [
2329
+ { name: "text", index: 1 },
2330
+ { name: "thinking", index: 2 },
2331
+ { name: "tools", index: 3 }
2332
+ ];
2085
2333
  var FtsRepo = class {
2086
2334
  constructor(db) {
2087
2335
  this.db = db;
2088
2336
  }
2089
2337
  db;
2090
- upsert(meta) {
2338
+ upsert(rowId, meta, doc) {
2091
2339
  const sourcePath = canonicalPath(meta.id);
2092
2340
  const tx = this.db.transaction(() => {
2093
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
2341
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
2094
2342
  this.db.prepare(
2095
2343
  `INSERT INTO conversation_messages_fts
2096
- (source_path, content, project_name, session_id, session_name, account, model, branch, tool_names)
2097
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
2344
+ (rowid, source_path, text, thinking, tools,
2345
+ project_name, session_id, session_name, account, model, branch, tool_names)
2346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2098
2347
  ).run(
2348
+ rowId,
2099
2349
  sourcePath,
2100
- meta.contentSnippet ?? "",
2350
+ doc.text,
2351
+ doc.thinking,
2352
+ doc.tools,
2101
2353
  meta.projectName ?? "",
2102
2354
  meta.sessionId ?? "",
2103
2355
  meta.sessionName ?? "",
@@ -2109,26 +2361,94 @@ var FtsRepo = class {
2109
2361
  });
2110
2362
  tx();
2111
2363
  }
2112
- remove(sourcePath) {
2113
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(canonicalPath(sourcePath));
2364
+ // Current durable buckets for a conversation, so an append can tail-extend
2365
+ // them without reparsing the file. Returns null when no row exists yet.
2366
+ readDocument(rowId) {
2367
+ const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
2368
+ if (!row) return null;
2369
+ return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
2114
2370
  }
2115
- // Ranked source_paths matching the query, best first. Returns [] on an empty
2116
- // query (callers fall back to a recency listing).
2117
- search(query, limit) {
2371
+ // True when the stored row already equals what we would write, so an append
2372
+ // can skip re-tokenizing ~128 KB of body for nothing.
2373
+ //
2374
+ // The check covers the metadata columns too, not just the buckets: a
2375
+ // newly-seen tool name or a late-resolved session_name changes metadata while
2376
+ // the body is byte-identical, and nothing else ever rewrites this row — so
2377
+ // skipping on "body unchanged" alone would strand that stale value forever.
2378
+ isCurrent(rowId, meta, doc) {
2379
+ const row = this.db.prepare(
2380
+ `SELECT text, thinking, tools,
2381
+ project_name, session_id, session_name, account, model, branch, tool_names
2382
+ FROM conversation_messages_fts WHERE rowid = ?`
2383
+ ).get(rowId);
2384
+ if (!row) return false;
2385
+ 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(" ");
2386
+ }
2387
+ remove(rowId) {
2388
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
2389
+ }
2390
+ // Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
2391
+ // with a wide corpus a popular term matches far more conversations than one
2392
+ // page, so post-filtering an already-truncated list would report "no results"
2393
+ // for queries that do have them.
2394
+ search(query, limit, filters = {}) {
2118
2395
  const match = toMatchQuery(query);
2119
2396
  if (!match) return [];
2397
+ const snippetSelects = BODY_COLUMNS.map(
2398
+ (c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
2399
+ ).join(", ");
2400
+ const params = [];
2401
+ for (const _col of BODY_COLUMNS) {
2402
+ params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
2403
+ }
2404
+ params.push(match);
2405
+ const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
2406
+ if (filters.account) {
2407
+ where.push("c.account = ?");
2408
+ params.push(filters.account);
2409
+ }
2410
+ if (filters.provider) {
2411
+ where.push("c.provider = ?");
2412
+ params.push(filters.provider);
2413
+ }
2414
+ if (filters.project) {
2415
+ where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
2416
+ const like = `%${filters.project.toLowerCase()}%`;
2417
+ params.push(like, like);
2418
+ }
2419
+ if (filters.since) {
2420
+ where.push("c.timestamp >= ?");
2421
+ params.push(filters.since.toISOString());
2422
+ }
2423
+ if (filters.include === "conversations") {
2424
+ where.push("c.is_subagent = 0 AND c.is_teammate = 0");
2425
+ } else if (filters.include === "subagents") {
2426
+ where.push("c.is_subagent = 1");
2427
+ } else if (filters.include === "teammates") {
2428
+ where.push("c.is_teammate = 1");
2429
+ }
2430
+ params.push(limit);
2120
2431
  const rows = this.db.prepare(
2121
- `SELECT source_path FROM conversation_messages_fts
2122
- WHERE conversation_messages_fts MATCH ?
2432
+ `SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
2433
+ FROM conversation_messages_fts
2434
+ JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
2435
+ WHERE ${where.join(" AND ")}
2123
2436
  ORDER BY rank
2124
2437
  LIMIT ?`
2125
- ).all(match, limit);
2126
- return rows.map((r) => r.source_path);
2438
+ ).all(...params);
2439
+ return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
2127
2440
  }
2128
2441
  count() {
2129
2442
  return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
2130
2443
  }
2131
2444
  };
2445
+ function pickBodySnippet(row) {
2446
+ for (const col of BODY_COLUMNS) {
2447
+ const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
2448
+ if (parsed) return parsed;
2449
+ }
2450
+ return null;
2451
+ }
2132
2452
  function toMatchQuery(query) {
2133
2453
  const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
2134
2454
  if (terms.length === 0) return "";
@@ -2341,9 +2661,13 @@ var PersistentEngine = class {
2341
2661
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
2342
2662
  const startOffset = resume ? existing.last_indexed_offset : 0;
2343
2663
  const startLine = resume ? existing.last_indexed_line : 0;
2664
+ let searchDelta = emptySearchDocument();
2665
+ const collectSearch = (entry) => {
2666
+ searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
2667
+ };
2344
2668
  let result;
2345
2669
  try {
2346
- result = await tailReduce(filePath, startOffset, startLine, state, tier);
2670
+ result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
2347
2671
  } catch (err) {
2348
2672
  log.warn({ filePath, err }, "persistent: tail read failed");
2349
2673
  return { meta: null, change };
@@ -2356,9 +2680,13 @@ var PersistentEngine = class {
2356
2680
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2357
2681
  const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
2358
2682
  const fileId = this.files.ensure(filePath, account);
2683
+ const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
2684
+ const searchDoc = appendSearchDelta(base, searchDelta);
2359
2685
  const upsert = this.db.transaction(() => {
2360
2686
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2361
- this.fts.upsert(meta);
2687
+ if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
2688
+ this.fts.upsert(fileId, meta, searchDoc);
2689
+ }
2362
2690
  if (!resume) this.checkpoints.remove(filePath);
2363
2691
  this.files.updateCursor(fileId, {
2364
2692
  sizeBytes: stat4.size,
@@ -2401,7 +2729,10 @@ var PersistentEngine = class {
2401
2729
  // any change reparses from 0 again. No reducer_state is persisted.
2402
2730
  async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
2403
2731
  const log = getLogger();
2404
- const meta = await parseMetaWithProvider(provider, filePath, account, tier);
2732
+ let searchDoc = emptySearchDocument();
2733
+ const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
2734
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
2735
+ });
2405
2736
  if (!meta) {
2406
2737
  this.markDeleted(filePath);
2407
2738
  return null;
@@ -2413,7 +2744,9 @@ var PersistentEngine = class {
2413
2744
  const fileId = this.files.ensure(filePath, account);
2414
2745
  const upsert = this.db.transaction(() => {
2415
2746
  this.conversations.upsert(fileId, meta, meta.messageCount);
2416
- this.fts.upsert(meta);
2747
+ if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
2748
+ this.fts.upsert(fileId, meta, searchDoc);
2749
+ }
2417
2750
  this.checkpoints.remove(filePath);
2418
2751
  this.files.updateCursor(fileId, {
2419
2752
  sizeBytes: stat4.size,
@@ -2439,7 +2772,7 @@ var PersistentEngine = class {
2439
2772
  if (!existing) return;
2440
2773
  const tx = this.db.transaction(() => {
2441
2774
  this.conversations.deleteByFileId(existing.id);
2442
- this.fts.remove(filePath);
2775
+ this.fts.remove(existing.id);
2443
2776
  this.checkpoints.remove(filePath);
2444
2777
  this.files.setStatus(existing.id, "deleted");
2445
2778
  });
@@ -2455,20 +2788,24 @@ var PersistentEngine = class {
2455
2788
  getAllBySessionId(sessionId) {
2456
2789
  return this.conversations.getAllBySessionId(sessionId);
2457
2790
  }
2458
- // Ranked metas matching the FTS query, best first. Empty query returns the
2459
- // most recent conversations (mirroring the in-memory indexer's empty-query
2460
- // behavior). Resolves each FTS hit to its active conversation row.
2461
- searchMetas(query, limit) {
2462
- if (!query.trim()) {
2463
- return this.conversations.recent(limit);
2464
- }
2465
- const paths = this.fts.search(query, limit);
2466
- const metas = [];
2467
- for (const path of paths) {
2468
- const meta = this.conversations.getBySourcePath(path);
2469
- if (meta) metas.push(meta);
2791
+ // Ranked hits matching the FTS query, best first, each already resolved to its
2792
+ // active conversation row and carrying the body excerpt when the match was in
2793
+ // the conversation body.
2794
+ //
2795
+ // Filters are passed down into SQL rather than applied to the result: with a
2796
+ // wide corpus, filtering an already-LIMITed list drops conversations that
2797
+ // would have matched.
2798
+ searchHits(query, limit, filters = {}) {
2799
+ const hits = [];
2800
+ for (const hit of this.fts.search(query, limit, filters)) {
2801
+ const meta = this.conversations.getBySourcePath(hit.sourcePath);
2802
+ if (meta) hits.push({ meta, body: hit.body });
2470
2803
  }
2471
- return metas;
2804
+ return hits;
2805
+ }
2806
+ // Empty-query listing, mirroring the in-memory indexer's behavior.
2807
+ recentMetas(limit) {
2808
+ return this.conversations.recent(limit);
2472
2809
  }
2473
2810
  getProjects() {
2474
2811
  return this.conversations.distinctProjects();
@@ -2665,6 +3002,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
2665
3002
  function defaultDbPath() {
2666
3003
  return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
2667
3004
  }
3005
+ function capForMemory(doc, max) {
3006
+ const combined = combineSearchContent(doc);
3007
+ return combined.length > max ? combined.slice(-max) : combined;
3008
+ }
2668
3009
  var ConversationScanner = class {
2669
3010
  metadataCache = /* @__PURE__ */ new Map();
2670
3011
  // Parsed conversations plus (persistent claude-code entries only) the resume
@@ -2676,6 +3017,10 @@ var ConversationScanner = class {
2676
3017
  sessionIdIndex = /* @__PURE__ */ new Map();
2677
3018
  projects = /* @__PURE__ */ new Set();
2678
3019
  indexer = new SearchIndexer();
3020
+ // Search body per conversation for the in-memory path, already tail-capped to
3021
+ // the content tier. Survives scan() so a statCache hit — which skips the parse
3022
+ // entirely — can still index a body rather than an empty string.
3023
+ searchContents = /* @__PURE__ */ new Map();
2679
3024
  // Tier the most recent scan() ran with, so refreshFile() re-parses a single
2680
3025
  // file at the same content depth. Defaults to the standard tier.
2681
3026
  lastTier = resolveTier("standard");
@@ -2836,34 +3181,42 @@ var ConversationScanner = class {
2836
3181
  try {
2837
3182
  const s = statSync3(filePath);
2838
3183
  if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
2839
- return cached.meta;
3184
+ return {
3185
+ meta: cached.meta,
3186
+ searchContent: this.searchContents.get(cached.meta.id)
3187
+ };
2840
3188
  }
2841
3189
  } catch {
2842
3190
  }
2843
3191
  }
2844
3192
  }
2845
3193
  try {
2846
- const meta = await parseMetaWithProvider(provider, filePath, account, tier);
3194
+ let doc = emptySearchDocument();
3195
+ const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
3196
+ doc = appendSearchDelta(doc, extractSearchDelta(entry));
3197
+ });
2847
3198
  if (meta && meta.gitBranch === null && meta.projectPath) {
2848
3199
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2849
3200
  }
2850
- return meta;
3201
+ return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
2851
3202
  } catch (err) {
2852
3203
  parseFailures++;
2853
3204
  log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
2854
- return null;
3205
+ return { meta: null, searchContent: void 0 };
2855
3206
  }
2856
3207
  })
2857
3208
  );
2858
3209
  const batchMetas = [];
2859
- for (const meta of results) {
3210
+ for (const { meta, searchContent } of results) {
2860
3211
  if (meta && meta.messageCount > 0) {
2861
3212
  this.metadataCache.set(meta.id, meta);
2862
3213
  this.addToSessionIndex(meta);
2863
3214
  this.projects.add(meta.projectPath);
2864
3215
  allMetas.push(meta);
2865
3216
  batchMetas.push(meta);
2866
- this.indexer.addDocument(meta);
3217
+ const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
3218
+ this.searchContents.set(meta.id, content);
3219
+ this.indexer.addDocument(meta, content);
2867
3220
  }
2868
3221
  }
2869
3222
  if (batchMetas.length > 0) {
@@ -2900,12 +3253,29 @@ var ConversationScanner = class {
2900
3253
  const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
2901
3254
  await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
2902
3255
  }
2903
- const metas = engine.searchMetas(query, (options.limit ?? 50) * 2);
2904
- results = query.trim() ? metas.map((meta) => ({ meta, score: 1, matches: generateMatches(meta, query) })) : metas.map((meta) => ({
2905
- meta,
2906
- score: 1,
2907
- matches: [{ field: "timestamp", snippet: meta.preview }]
2908
- }));
3256
+ if (query.trim()) {
3257
+ const want = (options.limit ?? 50) + (options.offset ?? 0);
3258
+ results = engine.searchHits(query, want, {
3259
+ account: options.account,
3260
+ provider: options.provider,
3261
+ project: options.project,
3262
+ since: options.since ? parseSinceCutoff(options.since) : void 0,
3263
+ include: options.include
3264
+ }).map(({ meta, body }) => ({
3265
+ meta,
3266
+ score: 1,
3267
+ matches: body ? [
3268
+ { field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
3269
+ ...generateMatches(meta, query).filter((m) => m.field !== "preview")
3270
+ ] : generateMatches(meta, query)
3271
+ }));
3272
+ } else {
3273
+ results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
3274
+ meta,
3275
+ score: 1,
3276
+ matches: [{ field: "timestamp", snippet: meta.preview }]
3277
+ }));
3278
+ }
2909
3279
  } else {
2910
3280
  if (this.indexer.getDocumentCount() === 0) {
2911
3281
  log.debug("search: index empty, triggering scan");
@@ -3099,8 +3469,11 @@ var ConversationScanner = class {
3099
3469
  const previous = this.metadataCache.get(filePath);
3100
3470
  const resolvedAccount = account ?? previous?.account ?? "default";
3101
3471
  let meta = null;
3472
+ let searchDoc = emptySearchDocument();
3102
3473
  try {
3103
- meta = await parseMeta(filePath, resolvedAccount, this.lastTier);
3474
+ meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
3475
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
3476
+ });
3104
3477
  } catch (err) {
3105
3478
  log.warn({ filePath, err }, "refreshFile: parseMeta threw");
3106
3479
  meta = null;
@@ -3117,6 +3490,7 @@ var ConversationScanner = class {
3117
3490
  this.metadataCache.delete(previous.id);
3118
3491
  this.removeFromSessionIndex(previous);
3119
3492
  this.indexer.removeDocument(previous.id);
3493
+ this.searchContents.delete(previous.id);
3120
3494
  }
3121
3495
  log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
3122
3496
  return null;
@@ -3126,10 +3500,12 @@ var ConversationScanner = class {
3126
3500
  this.metadataCache.set(meta.id, meta);
3127
3501
  this.addToSessionIndex(meta);
3128
3502
  this.projects.add(meta.projectPath);
3503
+ const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
3504
+ this.searchContents.set(meta.id, searchContent);
3129
3505
  if (previous) {
3130
- this.indexer.updateDocument(meta);
3506
+ this.indexer.updateDocument(meta, searchContent);
3131
3507
  } else {
3132
- this.indexer.addDocument(meta);
3508
+ this.indexer.addDocument(meta, searchContent);
3133
3509
  }
3134
3510
  log.debug(
3135
3511
  { filePath, messageCount: meta.messageCount },