@threadbase-sh/scanner 0.12.4 → 0.14.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/README.md +2 -2
- package/dist/cli.js +501 -95
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +500 -94
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -4
- package/dist/index.d.ts +12 -4
- package/dist/index.js +500 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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.
|
|
8
|
+
var version = "0.14.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
|
-
["
|
|
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
|
-
|
|
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.
|
|
366
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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.
|
|
425
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1542
|
-
--
|
|
1543
|
-
--
|
|
1544
|
-
--
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1630,15 +1872,46 @@ function openDatabase(dbPath) {
|
|
|
1630
1872
|
if (dbPath !== ":memory:") {
|
|
1631
1873
|
mkdirSync(dirname3(dbPath), { recursive: true });
|
|
1632
1874
|
}
|
|
1633
|
-
|
|
1875
|
+
let db;
|
|
1876
|
+
try {
|
|
1877
|
+
db = new Database(dbPath);
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
if (isNativeBindingFailure(err)) throw nativeBindingError(err, dbPath);
|
|
1880
|
+
throw err;
|
|
1881
|
+
}
|
|
1634
1882
|
db.pragma("journal_mode = WAL");
|
|
1635
1883
|
db.pragma("synchronous = NORMAL");
|
|
1636
1884
|
db.pragma("temp_store = MEMORY");
|
|
1637
1885
|
db.pragma("foreign_keys = ON");
|
|
1886
|
+
db.pragma("busy_timeout = 5000");
|
|
1638
1887
|
runMigrations(db);
|
|
1639
1888
|
getLogger().debug({ dbPath }, "db: opened");
|
|
1640
1889
|
return db;
|
|
1641
1890
|
}
|
|
1891
|
+
function isNativeBindingFailure(err) {
|
|
1892
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1893
|
+
return /could not locate the bindings file|NODE_MODULE_VERSION|was compiled against|\.node['"\s]/i.test(
|
|
1894
|
+
message
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
function nativeBindingError(err, dbPath) {
|
|
1898
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1899
|
+
return new Error(
|
|
1900
|
+
[
|
|
1901
|
+
`Could not load better-sqlite3's native binary, so the persistent index at ${dbPath} cannot be opened.`,
|
|
1902
|
+
"This usually means the binary was never built or downloaded \u2014 not that your Node version is wrong.",
|
|
1903
|
+
"",
|
|
1904
|
+
"Try, in order:",
|
|
1905
|
+
" 1. npm rebuild better-sqlite3",
|
|
1906
|
+
" 2. rm -rf node_modules && npm install",
|
|
1907
|
+
" (npm 12 blocks package install scripts by default; approve better-sqlite3 if asked)",
|
|
1908
|
+
" 3. Run without SQLite entirely: new ConversationScanner({ persistent: false })",
|
|
1909
|
+
" or pass --no-persist on the CLI. Search falls back to the in-memory index.",
|
|
1910
|
+
"",
|
|
1911
|
+
`Original error: ${detail}`
|
|
1912
|
+
].join("\n")
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1642
1915
|
|
|
1643
1916
|
// src/persistent/dir-watermark.ts
|
|
1644
1917
|
import { readdir, stat as stat3 } from "fs/promises";
|
|
@@ -2082,22 +2355,31 @@ var ConversationsRepo = class {
|
|
|
2082
2355
|
};
|
|
2083
2356
|
|
|
2084
2357
|
// src/persistent/repositories/fts.repo.ts
|
|
2358
|
+
var BODY_COLUMNS = [
|
|
2359
|
+
{ name: "text", index: 1 },
|
|
2360
|
+
{ name: "thinking", index: 2 },
|
|
2361
|
+
{ name: "tools", index: 3 }
|
|
2362
|
+
];
|
|
2085
2363
|
var FtsRepo = class {
|
|
2086
2364
|
constructor(db) {
|
|
2087
2365
|
this.db = db;
|
|
2088
2366
|
}
|
|
2089
2367
|
db;
|
|
2090
|
-
upsert(meta) {
|
|
2368
|
+
upsert(rowId, meta, doc) {
|
|
2091
2369
|
const sourcePath = canonicalPath(meta.id);
|
|
2092
2370
|
const tx = this.db.transaction(() => {
|
|
2093
|
-
this.db.prepare("DELETE FROM conversation_messages_fts WHERE
|
|
2371
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2094
2372
|
this.db.prepare(
|
|
2095
2373
|
`INSERT INTO conversation_messages_fts
|
|
2096
|
-
(
|
|
2097
|
-
|
|
2374
|
+
(rowid, source_path, text, thinking, tools,
|
|
2375
|
+
project_name, session_id, session_name, account, model, branch, tool_names)
|
|
2376
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2098
2377
|
).run(
|
|
2378
|
+
rowId,
|
|
2099
2379
|
sourcePath,
|
|
2100
|
-
|
|
2380
|
+
doc.text,
|
|
2381
|
+
doc.thinking,
|
|
2382
|
+
doc.tools,
|
|
2101
2383
|
meta.projectName ?? "",
|
|
2102
2384
|
meta.sessionId ?? "",
|
|
2103
2385
|
meta.sessionName ?? "",
|
|
@@ -2109,26 +2391,94 @@ var FtsRepo = class {
|
|
|
2109
2391
|
});
|
|
2110
2392
|
tx();
|
|
2111
2393
|
}
|
|
2112
|
-
|
|
2113
|
-
|
|
2394
|
+
// Current durable buckets for a conversation, so an append can tail-extend
|
|
2395
|
+
// them without reparsing the file. Returns null when no row exists yet.
|
|
2396
|
+
readDocument(rowId) {
|
|
2397
|
+
const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
|
|
2398
|
+
if (!row) return null;
|
|
2399
|
+
return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
|
|
2114
2400
|
}
|
|
2115
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
|
|
2401
|
+
// True when the stored row already equals what we would write, so an append
|
|
2402
|
+
// can skip re-tokenizing ~128 KB of body for nothing.
|
|
2403
|
+
//
|
|
2404
|
+
// The check covers the metadata columns too, not just the buckets: a
|
|
2405
|
+
// newly-seen tool name or a late-resolved session_name changes metadata while
|
|
2406
|
+
// the body is byte-identical, and nothing else ever rewrites this row — so
|
|
2407
|
+
// skipping on "body unchanged" alone would strand that stale value forever.
|
|
2408
|
+
isCurrent(rowId, meta, doc) {
|
|
2409
|
+
const row = this.db.prepare(
|
|
2410
|
+
`SELECT text, thinking, tools,
|
|
2411
|
+
project_name, session_id, session_name, account, model, branch, tool_names
|
|
2412
|
+
FROM conversation_messages_fts WHERE rowid = ?`
|
|
2413
|
+
).get(rowId);
|
|
2414
|
+
if (!row) return false;
|
|
2415
|
+
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(" ");
|
|
2416
|
+
}
|
|
2417
|
+
remove(rowId) {
|
|
2418
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2419
|
+
}
|
|
2420
|
+
// Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
|
|
2421
|
+
// with a wide corpus a popular term matches far more conversations than one
|
|
2422
|
+
// page, so post-filtering an already-truncated list would report "no results"
|
|
2423
|
+
// for queries that do have them.
|
|
2424
|
+
search(query, limit, filters = {}) {
|
|
2118
2425
|
const match = toMatchQuery(query);
|
|
2119
2426
|
if (!match) return [];
|
|
2427
|
+
const snippetSelects = BODY_COLUMNS.map(
|
|
2428
|
+
(c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
|
|
2429
|
+
).join(", ");
|
|
2430
|
+
const params = [];
|
|
2431
|
+
for (const _col of BODY_COLUMNS) {
|
|
2432
|
+
params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
|
|
2433
|
+
}
|
|
2434
|
+
params.push(match);
|
|
2435
|
+
const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
|
|
2436
|
+
if (filters.account) {
|
|
2437
|
+
where.push("c.account = ?");
|
|
2438
|
+
params.push(filters.account);
|
|
2439
|
+
}
|
|
2440
|
+
if (filters.provider) {
|
|
2441
|
+
where.push("c.provider = ?");
|
|
2442
|
+
params.push(filters.provider);
|
|
2443
|
+
}
|
|
2444
|
+
if (filters.project) {
|
|
2445
|
+
where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
|
|
2446
|
+
const like = `%${filters.project.toLowerCase()}%`;
|
|
2447
|
+
params.push(like, like);
|
|
2448
|
+
}
|
|
2449
|
+
if (filters.since) {
|
|
2450
|
+
where.push("c.timestamp >= ?");
|
|
2451
|
+
params.push(filters.since.toISOString());
|
|
2452
|
+
}
|
|
2453
|
+
if (filters.include === "conversations") {
|
|
2454
|
+
where.push("c.is_subagent = 0 AND c.is_teammate = 0");
|
|
2455
|
+
} else if (filters.include === "subagents") {
|
|
2456
|
+
where.push("c.is_subagent = 1");
|
|
2457
|
+
} else if (filters.include === "teammates") {
|
|
2458
|
+
where.push("c.is_teammate = 1");
|
|
2459
|
+
}
|
|
2460
|
+
params.push(limit);
|
|
2120
2461
|
const rows = this.db.prepare(
|
|
2121
|
-
`SELECT source_path
|
|
2122
|
-
|
|
2462
|
+
`SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
|
|
2463
|
+
FROM conversation_messages_fts
|
|
2464
|
+
JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
|
|
2465
|
+
WHERE ${where.join(" AND ")}
|
|
2123
2466
|
ORDER BY rank
|
|
2124
2467
|
LIMIT ?`
|
|
2125
|
-
).all(
|
|
2126
|
-
return rows.map((
|
|
2468
|
+
).all(...params);
|
|
2469
|
+
return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
|
|
2127
2470
|
}
|
|
2128
2471
|
count() {
|
|
2129
2472
|
return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
|
|
2130
2473
|
}
|
|
2131
2474
|
};
|
|
2475
|
+
function pickBodySnippet(row) {
|
|
2476
|
+
for (const col of BODY_COLUMNS) {
|
|
2477
|
+
const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
|
|
2478
|
+
if (parsed) return parsed;
|
|
2479
|
+
}
|
|
2480
|
+
return null;
|
|
2481
|
+
}
|
|
2132
2482
|
function toMatchQuery(query) {
|
|
2133
2483
|
const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
|
|
2134
2484
|
if (terms.length === 0) return "";
|
|
@@ -2341,9 +2691,13 @@ var PersistentEngine = class {
|
|
|
2341
2691
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
2342
2692
|
const startOffset = resume ? existing.last_indexed_offset : 0;
|
|
2343
2693
|
const startLine = resume ? existing.last_indexed_line : 0;
|
|
2694
|
+
let searchDelta = emptySearchDocument();
|
|
2695
|
+
const collectSearch = (entry) => {
|
|
2696
|
+
searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
|
|
2697
|
+
};
|
|
2344
2698
|
let result;
|
|
2345
2699
|
try {
|
|
2346
|
-
result = await tailReduce(filePath, startOffset, startLine, state, tier);
|
|
2700
|
+
result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
|
|
2347
2701
|
} catch (err) {
|
|
2348
2702
|
log.warn({ filePath, err }, "persistent: tail read failed");
|
|
2349
2703
|
return { meta: null, change };
|
|
@@ -2356,9 +2710,13 @@ var PersistentEngine = class {
|
|
|
2356
2710
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2357
2711
|
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2358
2712
|
const fileId = this.files.ensure(filePath, account);
|
|
2713
|
+
const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
|
|
2714
|
+
const searchDoc = appendSearchDelta(base, searchDelta);
|
|
2359
2715
|
const upsert = this.db.transaction(() => {
|
|
2360
2716
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2361
|
-
this.fts.
|
|
2717
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2718
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2719
|
+
}
|
|
2362
2720
|
if (!resume) this.checkpoints.remove(filePath);
|
|
2363
2721
|
this.files.updateCursor(fileId, {
|
|
2364
2722
|
sizeBytes: stat4.size,
|
|
@@ -2401,7 +2759,10 @@ var PersistentEngine = class {
|
|
|
2401
2759
|
// any change reparses from 0 again. No reducer_state is persisted.
|
|
2402
2760
|
async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
|
|
2403
2761
|
const log = getLogger();
|
|
2404
|
-
|
|
2762
|
+
let searchDoc = emptySearchDocument();
|
|
2763
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
2764
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
2765
|
+
});
|
|
2405
2766
|
if (!meta) {
|
|
2406
2767
|
this.markDeleted(filePath);
|
|
2407
2768
|
return null;
|
|
@@ -2413,7 +2774,9 @@ var PersistentEngine = class {
|
|
|
2413
2774
|
const fileId = this.files.ensure(filePath, account);
|
|
2414
2775
|
const upsert = this.db.transaction(() => {
|
|
2415
2776
|
this.conversations.upsert(fileId, meta, meta.messageCount);
|
|
2416
|
-
this.fts.
|
|
2777
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2778
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2779
|
+
}
|
|
2417
2780
|
this.checkpoints.remove(filePath);
|
|
2418
2781
|
this.files.updateCursor(fileId, {
|
|
2419
2782
|
sizeBytes: stat4.size,
|
|
@@ -2439,7 +2802,7 @@ var PersistentEngine = class {
|
|
|
2439
2802
|
if (!existing) return;
|
|
2440
2803
|
const tx = this.db.transaction(() => {
|
|
2441
2804
|
this.conversations.deleteByFileId(existing.id);
|
|
2442
|
-
this.fts.remove(
|
|
2805
|
+
this.fts.remove(existing.id);
|
|
2443
2806
|
this.checkpoints.remove(filePath);
|
|
2444
2807
|
this.files.setStatus(existing.id, "deleted");
|
|
2445
2808
|
});
|
|
@@ -2455,20 +2818,24 @@ var PersistentEngine = class {
|
|
|
2455
2818
|
getAllBySessionId(sessionId) {
|
|
2456
2819
|
return this.conversations.getAllBySessionId(sessionId);
|
|
2457
2820
|
}
|
|
2458
|
-
// Ranked
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
const
|
|
2467
|
-
for (const
|
|
2468
|
-
const meta = this.conversations.getBySourcePath(
|
|
2469
|
-
if (meta)
|
|
2821
|
+
// Ranked hits matching the FTS query, best first, each already resolved to its
|
|
2822
|
+
// active conversation row and carrying the body excerpt when the match was in
|
|
2823
|
+
// the conversation body.
|
|
2824
|
+
//
|
|
2825
|
+
// Filters are passed down into SQL rather than applied to the result: with a
|
|
2826
|
+
// wide corpus, filtering an already-LIMITed list drops conversations that
|
|
2827
|
+
// would have matched.
|
|
2828
|
+
searchHits(query, limit, filters = {}) {
|
|
2829
|
+
const hits = [];
|
|
2830
|
+
for (const hit of this.fts.search(query, limit, filters)) {
|
|
2831
|
+
const meta = this.conversations.getBySourcePath(hit.sourcePath);
|
|
2832
|
+
if (meta) hits.push({ meta, body: hit.body });
|
|
2470
2833
|
}
|
|
2471
|
-
return
|
|
2834
|
+
return hits;
|
|
2835
|
+
}
|
|
2836
|
+
// Empty-query listing, mirroring the in-memory indexer's behavior.
|
|
2837
|
+
recentMetas(limit) {
|
|
2838
|
+
return this.conversations.recent(limit);
|
|
2472
2839
|
}
|
|
2473
2840
|
getProjects() {
|
|
2474
2841
|
return this.conversations.distinctProjects();
|
|
@@ -2665,6 +3032,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
|
|
|
2665
3032
|
function defaultDbPath() {
|
|
2666
3033
|
return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
|
|
2667
3034
|
}
|
|
3035
|
+
function capForMemory(doc, max) {
|
|
3036
|
+
const combined = combineSearchContent(doc);
|
|
3037
|
+
return combined.length > max ? combined.slice(-max) : combined;
|
|
3038
|
+
}
|
|
2668
3039
|
var ConversationScanner = class {
|
|
2669
3040
|
metadataCache = /* @__PURE__ */ new Map();
|
|
2670
3041
|
// Parsed conversations plus (persistent claude-code entries only) the resume
|
|
@@ -2676,6 +3047,10 @@ var ConversationScanner = class {
|
|
|
2676
3047
|
sessionIdIndex = /* @__PURE__ */ new Map();
|
|
2677
3048
|
projects = /* @__PURE__ */ new Set();
|
|
2678
3049
|
indexer = new SearchIndexer();
|
|
3050
|
+
// Search body per conversation for the in-memory path, already tail-capped to
|
|
3051
|
+
// the content tier. Survives scan() so a statCache hit — which skips the parse
|
|
3052
|
+
// entirely — can still index a body rather than an empty string.
|
|
3053
|
+
searchContents = /* @__PURE__ */ new Map();
|
|
2679
3054
|
// Tier the most recent scan() ran with, so refreshFile() re-parses a single
|
|
2680
3055
|
// file at the same content depth. Defaults to the standard tier.
|
|
2681
3056
|
lastTier = resolveTier("standard");
|
|
@@ -2836,34 +3211,42 @@ var ConversationScanner = class {
|
|
|
2836
3211
|
try {
|
|
2837
3212
|
const s = statSync3(filePath);
|
|
2838
3213
|
if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
|
|
2839
|
-
return
|
|
3214
|
+
return {
|
|
3215
|
+
meta: cached.meta,
|
|
3216
|
+
searchContent: this.searchContents.get(cached.meta.id)
|
|
3217
|
+
};
|
|
2840
3218
|
}
|
|
2841
3219
|
} catch {
|
|
2842
3220
|
}
|
|
2843
3221
|
}
|
|
2844
3222
|
}
|
|
2845
3223
|
try {
|
|
2846
|
-
|
|
3224
|
+
let doc = emptySearchDocument();
|
|
3225
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
3226
|
+
doc = appendSearchDelta(doc, extractSearchDelta(entry));
|
|
3227
|
+
});
|
|
2847
3228
|
if (meta && meta.gitBranch === null && meta.projectPath) {
|
|
2848
3229
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2849
3230
|
}
|
|
2850
|
-
return meta;
|
|
3231
|
+
return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
|
|
2851
3232
|
} catch (err) {
|
|
2852
3233
|
parseFailures++;
|
|
2853
3234
|
log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
|
|
2854
|
-
return null;
|
|
3235
|
+
return { meta: null, searchContent: void 0 };
|
|
2855
3236
|
}
|
|
2856
3237
|
})
|
|
2857
3238
|
);
|
|
2858
3239
|
const batchMetas = [];
|
|
2859
|
-
for (const meta of results) {
|
|
3240
|
+
for (const { meta, searchContent } of results) {
|
|
2860
3241
|
if (meta && meta.messageCount > 0) {
|
|
2861
3242
|
this.metadataCache.set(meta.id, meta);
|
|
2862
3243
|
this.addToSessionIndex(meta);
|
|
2863
3244
|
this.projects.add(meta.projectPath);
|
|
2864
3245
|
allMetas.push(meta);
|
|
2865
3246
|
batchMetas.push(meta);
|
|
2866
|
-
this.
|
|
3247
|
+
const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
|
|
3248
|
+
this.searchContents.set(meta.id, content);
|
|
3249
|
+
this.indexer.addDocument(meta, content);
|
|
2867
3250
|
}
|
|
2868
3251
|
}
|
|
2869
3252
|
if (batchMetas.length > 0) {
|
|
@@ -2900,12 +3283,29 @@ var ConversationScanner = class {
|
|
|
2900
3283
|
const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
|
|
2901
3284
|
await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
|
|
2902
3285
|
}
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
3286
|
+
if (query.trim()) {
|
|
3287
|
+
const want = (options.limit ?? 50) + (options.offset ?? 0);
|
|
3288
|
+
results = engine.searchHits(query, want, {
|
|
3289
|
+
account: options.account,
|
|
3290
|
+
provider: options.provider,
|
|
3291
|
+
project: options.project,
|
|
3292
|
+
since: options.since ? parseSinceCutoff(options.since) : void 0,
|
|
3293
|
+
include: options.include
|
|
3294
|
+
}).map(({ meta, body }) => ({
|
|
3295
|
+
meta,
|
|
3296
|
+
score: 1,
|
|
3297
|
+
matches: body ? [
|
|
3298
|
+
{ field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
|
|
3299
|
+
...generateMatches(meta, query).filter((m) => m.field !== "preview")
|
|
3300
|
+
] : generateMatches(meta, query)
|
|
3301
|
+
}));
|
|
3302
|
+
} else {
|
|
3303
|
+
results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
|
|
3304
|
+
meta,
|
|
3305
|
+
score: 1,
|
|
3306
|
+
matches: [{ field: "timestamp", snippet: meta.preview }]
|
|
3307
|
+
}));
|
|
3308
|
+
}
|
|
2909
3309
|
} else {
|
|
2910
3310
|
if (this.indexer.getDocumentCount() === 0) {
|
|
2911
3311
|
log.debug("search: index empty, triggering scan");
|
|
@@ -3099,8 +3499,11 @@ var ConversationScanner = class {
|
|
|
3099
3499
|
const previous = this.metadataCache.get(filePath);
|
|
3100
3500
|
const resolvedAccount = account ?? previous?.account ?? "default";
|
|
3101
3501
|
let meta = null;
|
|
3502
|
+
let searchDoc = emptySearchDocument();
|
|
3102
3503
|
try {
|
|
3103
|
-
meta = await parseMeta(filePath, resolvedAccount, this.lastTier)
|
|
3504
|
+
meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
|
|
3505
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
3506
|
+
});
|
|
3104
3507
|
} catch (err) {
|
|
3105
3508
|
log.warn({ filePath, err }, "refreshFile: parseMeta threw");
|
|
3106
3509
|
meta = null;
|
|
@@ -3117,6 +3520,7 @@ var ConversationScanner = class {
|
|
|
3117
3520
|
this.metadataCache.delete(previous.id);
|
|
3118
3521
|
this.removeFromSessionIndex(previous);
|
|
3119
3522
|
this.indexer.removeDocument(previous.id);
|
|
3523
|
+
this.searchContents.delete(previous.id);
|
|
3120
3524
|
}
|
|
3121
3525
|
log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
|
|
3122
3526
|
return null;
|
|
@@ -3126,10 +3530,12 @@ var ConversationScanner = class {
|
|
|
3126
3530
|
this.metadataCache.set(meta.id, meta);
|
|
3127
3531
|
this.addToSessionIndex(meta);
|
|
3128
3532
|
this.projects.add(meta.projectPath);
|
|
3533
|
+
const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
|
|
3534
|
+
this.searchContents.set(meta.id, searchContent);
|
|
3129
3535
|
if (previous) {
|
|
3130
|
-
this.indexer.updateDocument(meta);
|
|
3536
|
+
this.indexer.updateDocument(meta, searchContent);
|
|
3131
3537
|
} else {
|
|
3132
|
-
this.indexer.addDocument(meta);
|
|
3538
|
+
this.indexer.addDocument(meta, searchContent);
|
|
3133
3539
|
}
|
|
3134
3540
|
log.debug(
|
|
3135
3541
|
{ filePath, messageCount: meta.messageCount },
|