@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/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
|
-
["
|
|
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
|
-
|
|
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.
|
|
276
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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.
|
|
335
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1696
|
-
--
|
|
1697
|
-
--
|
|
1698
|
-
--
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1784,15 +2026,46 @@ function openDatabase(dbPath) {
|
|
|
1784
2026
|
if (dbPath !== ":memory:") {
|
|
1785
2027
|
(0, import_fs9.mkdirSync)((0, import_path8.dirname)(dbPath), { recursive: true });
|
|
1786
2028
|
}
|
|
1787
|
-
|
|
2029
|
+
let db;
|
|
2030
|
+
try {
|
|
2031
|
+
db = new import_better_sqlite3.default(dbPath);
|
|
2032
|
+
} catch (err) {
|
|
2033
|
+
if (isNativeBindingFailure(err)) throw nativeBindingError(err, dbPath);
|
|
2034
|
+
throw err;
|
|
2035
|
+
}
|
|
1788
2036
|
db.pragma("journal_mode = WAL");
|
|
1789
2037
|
db.pragma("synchronous = NORMAL");
|
|
1790
2038
|
db.pragma("temp_store = MEMORY");
|
|
1791
2039
|
db.pragma("foreign_keys = ON");
|
|
2040
|
+
db.pragma("busy_timeout = 5000");
|
|
1792
2041
|
runMigrations(db);
|
|
1793
2042
|
getLogger().debug({ dbPath }, "db: opened");
|
|
1794
2043
|
return db;
|
|
1795
2044
|
}
|
|
2045
|
+
function isNativeBindingFailure(err) {
|
|
2046
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2047
|
+
return /could not locate the bindings file|NODE_MODULE_VERSION|was compiled against|\.node['"\s]/i.test(
|
|
2048
|
+
message
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
function nativeBindingError(err, dbPath) {
|
|
2052
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2053
|
+
return new Error(
|
|
2054
|
+
[
|
|
2055
|
+
`Could not load better-sqlite3's native binary, so the persistent index at ${dbPath} cannot be opened.`,
|
|
2056
|
+
"This usually means the binary was never built or downloaded \u2014 not that your Node version is wrong.",
|
|
2057
|
+
"",
|
|
2058
|
+
"Try, in order:",
|
|
2059
|
+
" 1. npm rebuild better-sqlite3",
|
|
2060
|
+
" 2. rm -rf node_modules && npm install",
|
|
2061
|
+
" (npm 12 blocks package install scripts by default; approve better-sqlite3 if asked)",
|
|
2062
|
+
" 3. Run without SQLite entirely: new ConversationScanner({ persistent: false })",
|
|
2063
|
+
" or pass --no-persist on the CLI. Search falls back to the in-memory index.",
|
|
2064
|
+
"",
|
|
2065
|
+
`Original error: ${detail}`
|
|
2066
|
+
].join("\n")
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
1796
2069
|
|
|
1797
2070
|
// src/persistent/dir-watermark.ts
|
|
1798
2071
|
var import_promises6 = require("fs/promises");
|
|
@@ -2236,22 +2509,31 @@ var ConversationsRepo = class {
|
|
|
2236
2509
|
};
|
|
2237
2510
|
|
|
2238
2511
|
// src/persistent/repositories/fts.repo.ts
|
|
2512
|
+
var BODY_COLUMNS = [
|
|
2513
|
+
{ name: "text", index: 1 },
|
|
2514
|
+
{ name: "thinking", index: 2 },
|
|
2515
|
+
{ name: "tools", index: 3 }
|
|
2516
|
+
];
|
|
2239
2517
|
var FtsRepo = class {
|
|
2240
2518
|
constructor(db) {
|
|
2241
2519
|
this.db = db;
|
|
2242
2520
|
}
|
|
2243
2521
|
db;
|
|
2244
|
-
upsert(meta) {
|
|
2522
|
+
upsert(rowId, meta, doc) {
|
|
2245
2523
|
const sourcePath = canonicalPath(meta.id);
|
|
2246
2524
|
const tx = this.db.transaction(() => {
|
|
2247
|
-
this.db.prepare("DELETE FROM conversation_messages_fts WHERE
|
|
2525
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2248
2526
|
this.db.prepare(
|
|
2249
2527
|
`INSERT INTO conversation_messages_fts
|
|
2250
|
-
(
|
|
2251
|
-
|
|
2528
|
+
(rowid, source_path, text, thinking, tools,
|
|
2529
|
+
project_name, session_id, session_name, account, model, branch, tool_names)
|
|
2530
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2252
2531
|
).run(
|
|
2532
|
+
rowId,
|
|
2253
2533
|
sourcePath,
|
|
2254
|
-
|
|
2534
|
+
doc.text,
|
|
2535
|
+
doc.thinking,
|
|
2536
|
+
doc.tools,
|
|
2255
2537
|
meta.projectName ?? "",
|
|
2256
2538
|
meta.sessionId ?? "",
|
|
2257
2539
|
meta.sessionName ?? "",
|
|
@@ -2263,26 +2545,94 @@ var FtsRepo = class {
|
|
|
2263
2545
|
});
|
|
2264
2546
|
tx();
|
|
2265
2547
|
}
|
|
2266
|
-
|
|
2267
|
-
|
|
2548
|
+
// Current durable buckets for a conversation, so an append can tail-extend
|
|
2549
|
+
// them without reparsing the file. Returns null when no row exists yet.
|
|
2550
|
+
readDocument(rowId) {
|
|
2551
|
+
const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
|
|
2552
|
+
if (!row) return null;
|
|
2553
|
+
return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
|
|
2268
2554
|
}
|
|
2269
|
-
//
|
|
2270
|
-
//
|
|
2271
|
-
|
|
2555
|
+
// True when the stored row already equals what we would write, so an append
|
|
2556
|
+
// can skip re-tokenizing ~128 KB of body for nothing.
|
|
2557
|
+
//
|
|
2558
|
+
// The check covers the metadata columns too, not just the buckets: a
|
|
2559
|
+
// newly-seen tool name or a late-resolved session_name changes metadata while
|
|
2560
|
+
// the body is byte-identical, and nothing else ever rewrites this row — so
|
|
2561
|
+
// skipping on "body unchanged" alone would strand that stale value forever.
|
|
2562
|
+
isCurrent(rowId, meta, doc) {
|
|
2563
|
+
const row = this.db.prepare(
|
|
2564
|
+
`SELECT text, thinking, tools,
|
|
2565
|
+
project_name, session_id, session_name, account, model, branch, tool_names
|
|
2566
|
+
FROM conversation_messages_fts WHERE rowid = ?`
|
|
2567
|
+
).get(rowId);
|
|
2568
|
+
if (!row) return false;
|
|
2569
|
+
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(" ");
|
|
2570
|
+
}
|
|
2571
|
+
remove(rowId) {
|
|
2572
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2573
|
+
}
|
|
2574
|
+
// Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
|
|
2575
|
+
// with a wide corpus a popular term matches far more conversations than one
|
|
2576
|
+
// page, so post-filtering an already-truncated list would report "no results"
|
|
2577
|
+
// for queries that do have them.
|
|
2578
|
+
search(query, limit, filters = {}) {
|
|
2272
2579
|
const match = toMatchQuery(query);
|
|
2273
2580
|
if (!match) return [];
|
|
2581
|
+
const snippetSelects = BODY_COLUMNS.map(
|
|
2582
|
+
(c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
|
|
2583
|
+
).join(", ");
|
|
2584
|
+
const params = [];
|
|
2585
|
+
for (const _col of BODY_COLUMNS) {
|
|
2586
|
+
params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
|
|
2587
|
+
}
|
|
2588
|
+
params.push(match);
|
|
2589
|
+
const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
|
|
2590
|
+
if (filters.account) {
|
|
2591
|
+
where.push("c.account = ?");
|
|
2592
|
+
params.push(filters.account);
|
|
2593
|
+
}
|
|
2594
|
+
if (filters.provider) {
|
|
2595
|
+
where.push("c.provider = ?");
|
|
2596
|
+
params.push(filters.provider);
|
|
2597
|
+
}
|
|
2598
|
+
if (filters.project) {
|
|
2599
|
+
where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
|
|
2600
|
+
const like = `%${filters.project.toLowerCase()}%`;
|
|
2601
|
+
params.push(like, like);
|
|
2602
|
+
}
|
|
2603
|
+
if (filters.since) {
|
|
2604
|
+
where.push("c.timestamp >= ?");
|
|
2605
|
+
params.push(filters.since.toISOString());
|
|
2606
|
+
}
|
|
2607
|
+
if (filters.include === "conversations") {
|
|
2608
|
+
where.push("c.is_subagent = 0 AND c.is_teammate = 0");
|
|
2609
|
+
} else if (filters.include === "subagents") {
|
|
2610
|
+
where.push("c.is_subagent = 1");
|
|
2611
|
+
} else if (filters.include === "teammates") {
|
|
2612
|
+
where.push("c.is_teammate = 1");
|
|
2613
|
+
}
|
|
2614
|
+
params.push(limit);
|
|
2274
2615
|
const rows = this.db.prepare(
|
|
2275
|
-
`SELECT source_path
|
|
2276
|
-
|
|
2616
|
+
`SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
|
|
2617
|
+
FROM conversation_messages_fts
|
|
2618
|
+
JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
|
|
2619
|
+
WHERE ${where.join(" AND ")}
|
|
2277
2620
|
ORDER BY rank
|
|
2278
2621
|
LIMIT ?`
|
|
2279
|
-
).all(
|
|
2280
|
-
return rows.map((
|
|
2622
|
+
).all(...params);
|
|
2623
|
+
return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
|
|
2281
2624
|
}
|
|
2282
2625
|
count() {
|
|
2283
2626
|
return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
|
|
2284
2627
|
}
|
|
2285
2628
|
};
|
|
2629
|
+
function pickBodySnippet(row) {
|
|
2630
|
+
for (const col of BODY_COLUMNS) {
|
|
2631
|
+
const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
|
|
2632
|
+
if (parsed) return parsed;
|
|
2633
|
+
}
|
|
2634
|
+
return null;
|
|
2635
|
+
}
|
|
2286
2636
|
function toMatchQuery(query) {
|
|
2287
2637
|
const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
|
|
2288
2638
|
if (terms.length === 0) return "";
|
|
@@ -2462,9 +2812,13 @@ var PersistentEngine = class {
|
|
|
2462
2812
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
2463
2813
|
const startOffset = resume ? existing.last_indexed_offset : 0;
|
|
2464
2814
|
const startLine = resume ? existing.last_indexed_line : 0;
|
|
2815
|
+
let searchDelta = emptySearchDocument();
|
|
2816
|
+
const collectSearch = (entry) => {
|
|
2817
|
+
searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
|
|
2818
|
+
};
|
|
2465
2819
|
let result;
|
|
2466
2820
|
try {
|
|
2467
|
-
result = await tailReduce(filePath, startOffset, startLine, state, tier);
|
|
2821
|
+
result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
|
|
2468
2822
|
} catch (err) {
|
|
2469
2823
|
log.warn({ filePath, err }, "persistent: tail read failed");
|
|
2470
2824
|
return { meta: null, change };
|
|
@@ -2477,9 +2831,13 @@ var PersistentEngine = class {
|
|
|
2477
2831
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2478
2832
|
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2479
2833
|
const fileId = this.files.ensure(filePath, account);
|
|
2834
|
+
const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
|
|
2835
|
+
const searchDoc = appendSearchDelta(base, searchDelta);
|
|
2480
2836
|
const upsert = this.db.transaction(() => {
|
|
2481
2837
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2482
|
-
this.fts.
|
|
2838
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2839
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2840
|
+
}
|
|
2483
2841
|
if (!resume) this.checkpoints.remove(filePath);
|
|
2484
2842
|
this.files.updateCursor(fileId, {
|
|
2485
2843
|
sizeBytes: stat4.size,
|
|
@@ -2522,7 +2880,10 @@ var PersistentEngine = class {
|
|
|
2522
2880
|
// any change reparses from 0 again. No reducer_state is persisted.
|
|
2523
2881
|
async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
|
|
2524
2882
|
const log = getLogger();
|
|
2525
|
-
|
|
2883
|
+
let searchDoc = emptySearchDocument();
|
|
2884
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
2885
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
2886
|
+
});
|
|
2526
2887
|
if (!meta) {
|
|
2527
2888
|
this.markDeleted(filePath);
|
|
2528
2889
|
return null;
|
|
@@ -2534,7 +2895,9 @@ var PersistentEngine = class {
|
|
|
2534
2895
|
const fileId = this.files.ensure(filePath, account);
|
|
2535
2896
|
const upsert = this.db.transaction(() => {
|
|
2536
2897
|
this.conversations.upsert(fileId, meta, meta.messageCount);
|
|
2537
|
-
this.fts.
|
|
2898
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2899
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2900
|
+
}
|
|
2538
2901
|
this.checkpoints.remove(filePath);
|
|
2539
2902
|
this.files.updateCursor(fileId, {
|
|
2540
2903
|
sizeBytes: stat4.size,
|
|
@@ -2560,7 +2923,7 @@ var PersistentEngine = class {
|
|
|
2560
2923
|
if (!existing) return;
|
|
2561
2924
|
const tx = this.db.transaction(() => {
|
|
2562
2925
|
this.conversations.deleteByFileId(existing.id);
|
|
2563
|
-
this.fts.remove(
|
|
2926
|
+
this.fts.remove(existing.id);
|
|
2564
2927
|
this.checkpoints.remove(filePath);
|
|
2565
2928
|
this.files.setStatus(existing.id, "deleted");
|
|
2566
2929
|
});
|
|
@@ -2576,20 +2939,24 @@ var PersistentEngine = class {
|
|
|
2576
2939
|
getAllBySessionId(sessionId) {
|
|
2577
2940
|
return this.conversations.getAllBySessionId(sessionId);
|
|
2578
2941
|
}
|
|
2579
|
-
// Ranked
|
|
2580
|
-
//
|
|
2581
|
-
//
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
const
|
|
2588
|
-
for (const
|
|
2589
|
-
const meta = this.conversations.getBySourcePath(
|
|
2590
|
-
if (meta)
|
|
2942
|
+
// Ranked hits matching the FTS query, best first, each already resolved to its
|
|
2943
|
+
// active conversation row and carrying the body excerpt when the match was in
|
|
2944
|
+
// the conversation body.
|
|
2945
|
+
//
|
|
2946
|
+
// Filters are passed down into SQL rather than applied to the result: with a
|
|
2947
|
+
// wide corpus, filtering an already-LIMITed list drops conversations that
|
|
2948
|
+
// would have matched.
|
|
2949
|
+
searchHits(query, limit, filters = {}) {
|
|
2950
|
+
const hits = [];
|
|
2951
|
+
for (const hit of this.fts.search(query, limit, filters)) {
|
|
2952
|
+
const meta = this.conversations.getBySourcePath(hit.sourcePath);
|
|
2953
|
+
if (meta) hits.push({ meta, body: hit.body });
|
|
2591
2954
|
}
|
|
2592
|
-
return
|
|
2955
|
+
return hits;
|
|
2956
|
+
}
|
|
2957
|
+
// Empty-query listing, mirroring the in-memory indexer's behavior.
|
|
2958
|
+
recentMetas(limit) {
|
|
2959
|
+
return this.conversations.recent(limit);
|
|
2593
2960
|
}
|
|
2594
2961
|
getProjects() {
|
|
2595
2962
|
return this.conversations.distinctProjects();
|
|
@@ -2750,6 +3117,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
|
|
|
2750
3117
|
function defaultDbPath() {
|
|
2751
3118
|
return process.env.TB_SCANNER_DB ?? (0, import_path11.join)((0, import_os2.homedir)(), ".config", "threadbase-scanner", "index.db");
|
|
2752
3119
|
}
|
|
3120
|
+
function capForMemory(doc, max) {
|
|
3121
|
+
const combined = combineSearchContent(doc);
|
|
3122
|
+
return combined.length > max ? combined.slice(-max) : combined;
|
|
3123
|
+
}
|
|
2753
3124
|
var ConversationScanner = class {
|
|
2754
3125
|
metadataCache = /* @__PURE__ */ new Map();
|
|
2755
3126
|
// Parsed conversations plus (persistent claude-code entries only) the resume
|
|
@@ -2761,6 +3132,10 @@ var ConversationScanner = class {
|
|
|
2761
3132
|
sessionIdIndex = /* @__PURE__ */ new Map();
|
|
2762
3133
|
projects = /* @__PURE__ */ new Set();
|
|
2763
3134
|
indexer = new SearchIndexer();
|
|
3135
|
+
// Search body per conversation for the in-memory path, already tail-capped to
|
|
3136
|
+
// the content tier. Survives scan() so a statCache hit — which skips the parse
|
|
3137
|
+
// entirely — can still index a body rather than an empty string.
|
|
3138
|
+
searchContents = /* @__PURE__ */ new Map();
|
|
2764
3139
|
// Tier the most recent scan() ran with, so refreshFile() re-parses a single
|
|
2765
3140
|
// file at the same content depth. Defaults to the standard tier.
|
|
2766
3141
|
lastTier = resolveTier("standard");
|
|
@@ -2921,34 +3296,42 @@ var ConversationScanner = class {
|
|
|
2921
3296
|
try {
|
|
2922
3297
|
const s = (0, import_fs11.statSync)(filePath);
|
|
2923
3298
|
if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
|
|
2924
|
-
return
|
|
3299
|
+
return {
|
|
3300
|
+
meta: cached.meta,
|
|
3301
|
+
searchContent: this.searchContents.get(cached.meta.id)
|
|
3302
|
+
};
|
|
2925
3303
|
}
|
|
2926
3304
|
} catch {
|
|
2927
3305
|
}
|
|
2928
3306
|
}
|
|
2929
3307
|
}
|
|
2930
3308
|
try {
|
|
2931
|
-
|
|
3309
|
+
let doc = emptySearchDocument();
|
|
3310
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
3311
|
+
doc = appendSearchDelta(doc, extractSearchDelta(entry));
|
|
3312
|
+
});
|
|
2932
3313
|
if (meta && meta.gitBranch === null && meta.projectPath) {
|
|
2933
3314
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2934
3315
|
}
|
|
2935
|
-
return meta;
|
|
3316
|
+
return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
|
|
2936
3317
|
} catch (err) {
|
|
2937
3318
|
parseFailures++;
|
|
2938
3319
|
log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
|
|
2939
|
-
return null;
|
|
3320
|
+
return { meta: null, searchContent: void 0 };
|
|
2940
3321
|
}
|
|
2941
3322
|
})
|
|
2942
3323
|
);
|
|
2943
3324
|
const batchMetas = [];
|
|
2944
|
-
for (const meta of results) {
|
|
3325
|
+
for (const { meta, searchContent } of results) {
|
|
2945
3326
|
if (meta && meta.messageCount > 0) {
|
|
2946
3327
|
this.metadataCache.set(meta.id, meta);
|
|
2947
3328
|
this.addToSessionIndex(meta);
|
|
2948
3329
|
this.projects.add(meta.projectPath);
|
|
2949
3330
|
allMetas.push(meta);
|
|
2950
3331
|
batchMetas.push(meta);
|
|
2951
|
-
this.
|
|
3332
|
+
const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
|
|
3333
|
+
this.searchContents.set(meta.id, content);
|
|
3334
|
+
this.indexer.addDocument(meta, content);
|
|
2952
3335
|
}
|
|
2953
3336
|
}
|
|
2954
3337
|
if (batchMetas.length > 0) {
|
|
@@ -2985,12 +3368,29 @@ var ConversationScanner = class {
|
|
|
2985
3368
|
const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
|
|
2986
3369
|
await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
|
|
2987
3370
|
}
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3371
|
+
if (query.trim()) {
|
|
3372
|
+
const want = (options.limit ?? 50) + (options.offset ?? 0);
|
|
3373
|
+
results = engine.searchHits(query, want, {
|
|
3374
|
+
account: options.account,
|
|
3375
|
+
provider: options.provider,
|
|
3376
|
+
project: options.project,
|
|
3377
|
+
since: options.since ? parseSinceCutoff(options.since) : void 0,
|
|
3378
|
+
include: options.include
|
|
3379
|
+
}).map(({ meta, body }) => ({
|
|
3380
|
+
meta,
|
|
3381
|
+
score: 1,
|
|
3382
|
+
matches: body ? [
|
|
3383
|
+
{ field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
|
|
3384
|
+
...generateMatches(meta, query).filter((m) => m.field !== "preview")
|
|
3385
|
+
] : generateMatches(meta, query)
|
|
3386
|
+
}));
|
|
3387
|
+
} else {
|
|
3388
|
+
results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
|
|
3389
|
+
meta,
|
|
3390
|
+
score: 1,
|
|
3391
|
+
matches: [{ field: "timestamp", snippet: meta.preview }]
|
|
3392
|
+
}));
|
|
3393
|
+
}
|
|
2994
3394
|
} else {
|
|
2995
3395
|
if (this.indexer.getDocumentCount() === 0) {
|
|
2996
3396
|
log.debug("search: index empty, triggering scan");
|
|
@@ -3184,8 +3584,11 @@ var ConversationScanner = class {
|
|
|
3184
3584
|
const previous = this.metadataCache.get(filePath);
|
|
3185
3585
|
const resolvedAccount = account ?? previous?.account ?? "default";
|
|
3186
3586
|
let meta = null;
|
|
3587
|
+
let searchDoc = emptySearchDocument();
|
|
3187
3588
|
try {
|
|
3188
|
-
meta = await parseMeta(filePath, resolvedAccount, this.lastTier)
|
|
3589
|
+
meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
|
|
3590
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
3591
|
+
});
|
|
3189
3592
|
} catch (err) {
|
|
3190
3593
|
log.warn({ filePath, err }, "refreshFile: parseMeta threw");
|
|
3191
3594
|
meta = null;
|
|
@@ -3202,6 +3605,7 @@ var ConversationScanner = class {
|
|
|
3202
3605
|
this.metadataCache.delete(previous.id);
|
|
3203
3606
|
this.removeFromSessionIndex(previous);
|
|
3204
3607
|
this.indexer.removeDocument(previous.id);
|
|
3608
|
+
this.searchContents.delete(previous.id);
|
|
3205
3609
|
}
|
|
3206
3610
|
log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
|
|
3207
3611
|
return null;
|
|
@@ -3211,10 +3615,12 @@ var ConversationScanner = class {
|
|
|
3211
3615
|
this.metadataCache.set(meta.id, meta);
|
|
3212
3616
|
this.addToSessionIndex(meta);
|
|
3213
3617
|
this.projects.add(meta.projectPath);
|
|
3618
|
+
const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
|
|
3619
|
+
this.searchContents.set(meta.id, searchContent);
|
|
3214
3620
|
if (previous) {
|
|
3215
|
-
this.indexer.updateDocument(meta);
|
|
3621
|
+
this.indexer.updateDocument(meta, searchContent);
|
|
3216
3622
|
} else {
|
|
3217
|
-
this.indexer.addDocument(meta);
|
|
3623
|
+
this.indexer.addDocument(meta, searchContent);
|
|
3218
3624
|
}
|
|
3219
3625
|
log.debug(
|
|
3220
3626
|
{ filePath, messageCount: meta.messageCount },
|