@threadbase-sh/scanner 0.12.4 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +470 -94
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +469 -93
- 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 +469 -93
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.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
|
-
["
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
(
|
|
2097
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2113
|
-
|
|
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
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
|
|
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
|
|
2122
|
-
|
|
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(
|
|
2126
|
-
return rows.map((
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
const
|
|
2467
|
-
for (const
|
|
2468
|
-
const meta = this.conversations.getBySourcePath(
|
|
2469
|
-
if (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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
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 },
|