@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.js
CHANGED
|
@@ -146,18 +146,43 @@ function readGitBranch(projectPath) {
|
|
|
146
146
|
import FlexSearchModule from "flexsearch";
|
|
147
147
|
|
|
148
148
|
// src/search-matches.ts
|
|
149
|
+
var FTS_HIT_OPEN = "";
|
|
150
|
+
var FTS_HIT_CLOSE = "";
|
|
151
|
+
var FTS_SNIPPET_TOKENS = 16;
|
|
152
|
+
var FTS_ELLIPSIS = "\u2026";
|
|
153
|
+
var CONTENT_FIELD = "content";
|
|
154
|
+
function parseFtsSnippet(raw) {
|
|
155
|
+
if (!raw?.includes(FTS_HIT_OPEN)) return null;
|
|
156
|
+
const highlights = [];
|
|
157
|
+
let snippet = "";
|
|
158
|
+
let openAt = -1;
|
|
159
|
+
for (const ch of raw) {
|
|
160
|
+
if (ch === FTS_HIT_OPEN) {
|
|
161
|
+
openAt = snippet.length;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ch === FTS_HIT_CLOSE) {
|
|
165
|
+
if (openAt >= 0 && snippet.length > openAt) {
|
|
166
|
+
highlights.push({ start: openAt, end: snippet.length });
|
|
167
|
+
}
|
|
168
|
+
openAt = -1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
snippet += ch;
|
|
172
|
+
}
|
|
173
|
+
return highlights.length > 0 ? { snippet, highlights } : null;
|
|
174
|
+
}
|
|
149
175
|
function generateMatches(meta, query) {
|
|
150
176
|
const matches = [];
|
|
151
177
|
const lowerQuery = query.toLowerCase();
|
|
152
178
|
const fields = [
|
|
153
|
-
["contentSnippet", meta.contentSnippet],
|
|
154
|
-
["projectName", meta.projectName],
|
|
155
|
-
["sessionId", meta.sessionId],
|
|
156
179
|
["sessionName", meta.sessionName],
|
|
157
|
-
["
|
|
158
|
-
["model", meta.model || ""],
|
|
180
|
+
["projectName", meta.projectName],
|
|
159
181
|
["gitBranch", meta.gitBranch || ""],
|
|
160
|
-
["toolNames", meta.toolNames.join(" ")]
|
|
182
|
+
["toolNames", meta.toolNames.join(" ")],
|
|
183
|
+
["model", meta.model || ""],
|
|
184
|
+
["account", meta.account],
|
|
185
|
+
["sessionId", meta.sessionId]
|
|
161
186
|
];
|
|
162
187
|
for (const [field, value] of fields) {
|
|
163
188
|
const idx = value.toLowerCase().indexOf(lowerQuery);
|
|
@@ -172,6 +197,20 @@ function generateMatches(meta, query) {
|
|
|
172
197
|
}
|
|
173
198
|
return matches.length > 0 ? matches : [{ field: "preview", snippet: meta.preview }];
|
|
174
199
|
}
|
|
200
|
+
function buildContentMatch(searchContent, query) {
|
|
201
|
+
if (!searchContent || !query.trim()) return null;
|
|
202
|
+
const idx = searchContent.toLowerCase().indexOf(query.toLowerCase());
|
|
203
|
+
if (idx === -1) return null;
|
|
204
|
+
const start = Math.max(0, idx - 80);
|
|
205
|
+
const end = Math.min(searchContent.length, idx + query.length + 120);
|
|
206
|
+
const body = searchContent.slice(start, end).replace(/\s+/g, " ").trim();
|
|
207
|
+
const hitAt = body.toLowerCase().indexOf(query.toLowerCase());
|
|
208
|
+
const prefix = start > 0 ? FTS_ELLIPSIS : "";
|
|
209
|
+
const suffix = end < searchContent.length ? FTS_ELLIPSIS : "";
|
|
210
|
+
const snippet = `${prefix}${body}${suffix}`;
|
|
211
|
+
const highlights = hitAt === -1 ? [] : [{ start: hitAt + prefix.length, end: hitAt + prefix.length + query.length }];
|
|
212
|
+
return { field: CONTENT_FIELD, snippet, highlights };
|
|
213
|
+
}
|
|
175
214
|
|
|
176
215
|
// src/indexer.ts
|
|
177
216
|
var FlexSearch = FlexSearchModule.default ?? FlexSearchModule;
|
|
@@ -179,6 +218,10 @@ var SearchIndexer = class {
|
|
|
179
218
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
180
219
|
index;
|
|
181
220
|
documents = /* @__PURE__ */ new Map();
|
|
221
|
+
// The indexed body per conversation, kept so a hit can produce a real excerpt
|
|
222
|
+
// instead of falling back to an unrelated preview. Sized by the caller (the
|
|
223
|
+
// scanner tail-caps it to the content tier) — see the note on addDocument.
|
|
224
|
+
searchContents = /* @__PURE__ */ new Map();
|
|
182
225
|
constructor() {
|
|
183
226
|
this.index = this.createIndex();
|
|
184
227
|
}
|
|
@@ -204,25 +247,25 @@ var SearchIndexer = class {
|
|
|
204
247
|
cache: 100
|
|
205
248
|
});
|
|
206
249
|
}
|
|
207
|
-
|
|
250
|
+
// `searchContent` is the combined search document (text + thinking + tools),
|
|
251
|
+
// NOT meta.contentSnippet.
|
|
252
|
+
//
|
|
253
|
+
// It arrives pre-capped. This index is `tokenize: "forward"` at resolution 9,
|
|
254
|
+
// which stores every prefix of every token, so it is a resident-memory
|
|
255
|
+
// structure whose cost is very different from the on-disk FTS index. Feeding
|
|
256
|
+
// it the full ~128 KB budget across a few hundred conversations would be a
|
|
257
|
+
// multi-hundred-MB-to-GB index. The scanner therefore caps this to the active
|
|
258
|
+
// tier's snippetMax and accepts that `persistent: false` has lower recall than
|
|
259
|
+
// SQLite — an honest, documented divergence rather than a silent one.
|
|
260
|
+
addDocument(meta, searchContent = "") {
|
|
208
261
|
this.documents.set(meta.id, meta);
|
|
209
|
-
this.
|
|
210
|
-
|
|
211
|
-
content: meta.contentSnippet,
|
|
212
|
-
projectName: meta.projectName,
|
|
213
|
-
projectPath: meta.projectPath,
|
|
214
|
-
sessionId: meta.sessionId,
|
|
215
|
-
sessionName: meta.sessionName,
|
|
216
|
-
account: meta.account,
|
|
217
|
-
model: meta.model || "",
|
|
218
|
-
gitBranch: meta.gitBranch || "",
|
|
219
|
-
toolNames: meta.toolNames.join(" ")
|
|
220
|
-
});
|
|
262
|
+
this.searchContents.set(meta.id, searchContent);
|
|
263
|
+
this.index.add(toIndexDoc(meta, searchContent));
|
|
221
264
|
}
|
|
222
|
-
buildIndex(metas) {
|
|
265
|
+
buildIndex(metas, searchContents) {
|
|
223
266
|
this.clear();
|
|
224
267
|
for (const meta of metas) {
|
|
225
|
-
this.addDocument(meta);
|
|
268
|
+
this.addDocument(meta, searchContents?.get(meta.id) ?? "");
|
|
226
269
|
}
|
|
227
270
|
getLogger().debug({ docCount: metas.length }, "indexer: built");
|
|
228
271
|
}
|
|
@@ -242,14 +285,26 @@ var SearchIndexer = class {
|
|
|
242
285
|
seen.add(id);
|
|
243
286
|
const meta = this.documents.get(id);
|
|
244
287
|
if (!meta) continue;
|
|
245
|
-
|
|
246
|
-
|
|
288
|
+
searchResults.push({
|
|
289
|
+
meta,
|
|
290
|
+
score: 1,
|
|
291
|
+
matches: this.matchesFor(meta, query)
|
|
292
|
+
});
|
|
247
293
|
if (searchResults.length >= limit) break;
|
|
248
294
|
}
|
|
249
295
|
if (searchResults.length >= limit) break;
|
|
250
296
|
}
|
|
251
297
|
return searchResults;
|
|
252
298
|
}
|
|
299
|
+
// Body context first (that is what explains why the result appeared), then any
|
|
300
|
+
// metadata matches. Only when neither hits does generateMatches' preview
|
|
301
|
+
// fallback stand in.
|
|
302
|
+
matchesFor(meta, query) {
|
|
303
|
+
const contentMatch = buildContentMatch(this.searchContents.get(meta.id) ?? "", query);
|
|
304
|
+
const metaMatches = generateMatches(meta, query);
|
|
305
|
+
if (!contentMatch) return metaMatches;
|
|
306
|
+
return [contentMatch, ...metaMatches.filter((m) => m.field !== "preview")];
|
|
307
|
+
}
|
|
253
308
|
getRecent(limit) {
|
|
254
309
|
return Array.from(this.documents.values()).sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, limit).map((meta) => ({
|
|
255
310
|
meta,
|
|
@@ -263,31 +318,37 @@ var SearchIndexer = class {
|
|
|
263
318
|
// Replace an already-indexed document in place. FlexSearch's `add` does not
|
|
264
319
|
// overwrite an existing id, so a single-file refresh must go through
|
|
265
320
|
// `update` to avoid stale matches lingering in the index.
|
|
266
|
-
updateDocument(meta) {
|
|
321
|
+
updateDocument(meta, searchContent = "") {
|
|
267
322
|
this.documents.set(meta.id, meta);
|
|
268
|
-
this.
|
|
269
|
-
|
|
270
|
-
content: meta.contentSnippet,
|
|
271
|
-
projectName: meta.projectName,
|
|
272
|
-
projectPath: meta.projectPath,
|
|
273
|
-
sessionId: meta.sessionId,
|
|
274
|
-
sessionName: meta.sessionName,
|
|
275
|
-
account: meta.account,
|
|
276
|
-
model: meta.model || "",
|
|
277
|
-
gitBranch: meta.gitBranch || "",
|
|
278
|
-
toolNames: meta.toolNames.join(" ")
|
|
279
|
-
});
|
|
323
|
+
this.searchContents.set(meta.id, searchContent);
|
|
324
|
+
this.index.update(toIndexDoc(meta, searchContent));
|
|
280
325
|
}
|
|
281
326
|
removeDocument(id) {
|
|
282
327
|
this.documents.delete(id);
|
|
328
|
+
this.searchContents.delete(id);
|
|
283
329
|
this.index.remove(id);
|
|
284
330
|
}
|
|
285
331
|
clear() {
|
|
286
332
|
this.documents.clear();
|
|
333
|
+
this.searchContents.clear();
|
|
287
334
|
this.index = this.createIndex();
|
|
288
335
|
getLogger().trace("indexer: cleared");
|
|
289
336
|
}
|
|
290
337
|
};
|
|
338
|
+
function toIndexDoc(meta, searchContent) {
|
|
339
|
+
return {
|
|
340
|
+
id: meta.id,
|
|
341
|
+
content: searchContent,
|
|
342
|
+
projectName: meta.projectName,
|
|
343
|
+
projectPath: meta.projectPath,
|
|
344
|
+
sessionId: meta.sessionId,
|
|
345
|
+
sessionName: meta.sessionName,
|
|
346
|
+
account: meta.account,
|
|
347
|
+
model: meta.model || "",
|
|
348
|
+
gitBranch: meta.gitBranch || "",
|
|
349
|
+
toolNames: meta.toolNames.join(" ")
|
|
350
|
+
};
|
|
351
|
+
}
|
|
291
352
|
|
|
292
353
|
// src/parser.ts
|
|
293
354
|
import { createReadStream } from "fs";
|
|
@@ -453,7 +514,7 @@ function cleanSystemTags(text) {
|
|
|
453
514
|
}
|
|
454
515
|
|
|
455
516
|
// src/parser.ts
|
|
456
|
-
async function parseMeta(filePath, account, tier) {
|
|
517
|
+
async function parseMeta(filePath, account, tier, onEntry) {
|
|
457
518
|
const log = getLogger();
|
|
458
519
|
log.trace({ filePath, account, tier: tier.name }, "parseMeta: start");
|
|
459
520
|
const state = initialReducerState();
|
|
@@ -470,6 +531,7 @@ async function parseMeta(filePath, account, tier) {
|
|
|
470
531
|
continue;
|
|
471
532
|
}
|
|
472
533
|
reduceLine(state, entry, tier);
|
|
534
|
+
onEntry?.(entry);
|
|
473
535
|
}
|
|
474
536
|
} catch (err) {
|
|
475
537
|
log.warn({ filePath, err }, "parseMeta: read failed");
|
|
@@ -1224,7 +1286,7 @@ import { setImmediate as yieldToEventLoop2 } from "timers/promises";
|
|
|
1224
1286
|
import { createReadStream as createReadStream3 } from "fs";
|
|
1225
1287
|
import { setImmediate as yieldToEventLoop } from "timers/promises";
|
|
1226
1288
|
var YIELD_EVERY_LINES = 500;
|
|
1227
|
-
async function tailReduce(filePath, startOffset, startLine, state, tier) {
|
|
1289
|
+
async function tailReduce(filePath, startOffset, startLine, state, tier, onEntry) {
|
|
1228
1290
|
const stream = createReadStream3(filePath, { start: startOffset, encoding: "utf8" });
|
|
1229
1291
|
let buffer = "";
|
|
1230
1292
|
let offset = startOffset;
|
|
@@ -1240,7 +1302,9 @@ async function tailReduce(filePath, startOffset, startLine, state, tier) {
|
|
|
1240
1302
|
buffer = buffer.slice(nl + 1);
|
|
1241
1303
|
if (text.length > 0) {
|
|
1242
1304
|
try {
|
|
1243
|
-
|
|
1305
|
+
const entry = JSON.parse(text);
|
|
1306
|
+
reduceLine(state, entry, tier);
|
|
1307
|
+
onEntry?.(entry);
|
|
1244
1308
|
} catch {
|
|
1245
1309
|
state.badJsonLines++;
|
|
1246
1310
|
}
|
|
@@ -1476,7 +1540,7 @@ import { statSync as statSync2 } from "fs";
|
|
|
1476
1540
|
// src/providers/parse.ts
|
|
1477
1541
|
import { createReadStream as createReadStream5 } from "fs";
|
|
1478
1542
|
import { createInterface as createInterface3 } from "readline";
|
|
1479
|
-
async function parseMetaWithProvider(provider, filePath, account, tier) {
|
|
1543
|
+
async function parseMetaWithProvider(provider, filePath, account, tier, onEntry) {
|
|
1480
1544
|
const log = getLogger();
|
|
1481
1545
|
const acc = provider.createEmptyAccumulator();
|
|
1482
1546
|
const rl = createInterface3({
|
|
@@ -1494,6 +1558,7 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
|
|
|
1494
1558
|
}
|
|
1495
1559
|
try {
|
|
1496
1560
|
provider.reduceEntry(acc, entry, tier);
|
|
1561
|
+
onEntry?.(entry);
|
|
1497
1562
|
} catch (err) {
|
|
1498
1563
|
log.warn({ filePath, provider: provider.name, err }, "provider reduce threw; line skipped");
|
|
1499
1564
|
}
|
|
@@ -1505,6 +1570,151 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
|
|
|
1505
1570
|
return provider.finalize(acc, filePath, account, tier);
|
|
1506
1571
|
}
|
|
1507
1572
|
|
|
1573
|
+
// src/search-document.ts
|
|
1574
|
+
var SEARCH_BUDGET = {
|
|
1575
|
+
textMax: 64 * 1024,
|
|
1576
|
+
thinkingMax: 32 * 1024,
|
|
1577
|
+
toolsMax: 32 * 1024,
|
|
1578
|
+
toolPayloadMax: 4 * 1024
|
|
1579
|
+
};
|
|
1580
|
+
var SEP = "\n\n";
|
|
1581
|
+
function emptySearchDocument() {
|
|
1582
|
+
return { text: "", thinking: "", tools: "" };
|
|
1583
|
+
}
|
|
1584
|
+
function appendSearchDelta(doc, delta) {
|
|
1585
|
+
return {
|
|
1586
|
+
text: tailAppend(doc.text, delta.text, SEARCH_BUDGET.textMax),
|
|
1587
|
+
thinking: tailAppend(doc.thinking, delta.thinking, SEARCH_BUDGET.thinkingMax),
|
|
1588
|
+
tools: tailAppend(doc.tools, delta.tools, SEARCH_BUDGET.toolsMax)
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function tailAppend(current, incoming, max) {
|
|
1592
|
+
if (!incoming) return current;
|
|
1593
|
+
const joined = current ? current + SEP + incoming : incoming;
|
|
1594
|
+
return joined.length <= max ? joined : joined.slice(-max);
|
|
1595
|
+
}
|
|
1596
|
+
function combineSearchContent(doc) {
|
|
1597
|
+
return [doc.text, doc.thinking, doc.tools].filter(Boolean).join(SEP);
|
|
1598
|
+
}
|
|
1599
|
+
function capToolPayload(value) {
|
|
1600
|
+
const raw = stringifyPayload(value);
|
|
1601
|
+
if (!raw) return "";
|
|
1602
|
+
return raw.length > SEARCH_BUDGET.toolPayloadMax ? raw.slice(0, SEARCH_BUDGET.toolPayloadMax) : raw;
|
|
1603
|
+
}
|
|
1604
|
+
function stringifyPayload(value) {
|
|
1605
|
+
if (value === null || value === void 0) return "";
|
|
1606
|
+
if (typeof value === "string") return value;
|
|
1607
|
+
try {
|
|
1608
|
+
return JSON.stringify(value) ?? "";
|
|
1609
|
+
} catch {
|
|
1610
|
+
return "";
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
function extractSearchDelta(entry) {
|
|
1614
|
+
if (entry.type === "response_item" || entry.type === "session_meta") {
|
|
1615
|
+
return extractCodexDelta(entry);
|
|
1616
|
+
}
|
|
1617
|
+
return extractClaudeDelta(entry);
|
|
1618
|
+
}
|
|
1619
|
+
function extractClaudeDelta(entry) {
|
|
1620
|
+
const type = entry.type;
|
|
1621
|
+
if (type !== "user" && type !== "assistant") return emptySearchDocument();
|
|
1622
|
+
if (entry.isMeta) return emptySearchDocument();
|
|
1623
|
+
const msg = entry.message;
|
|
1624
|
+
const content = msg?.content;
|
|
1625
|
+
const tools = [
|
|
1626
|
+
extractClaudeToolContent(content),
|
|
1627
|
+
// Claude stores the rich/structured tool result at the JSONL entry's top
|
|
1628
|
+
// level, not inside message.content — indexing only message.content would
|
|
1629
|
+
// miss most real tool output (file reads, command stdout).
|
|
1630
|
+
capToolPayload(entry.toolUseResult)
|
|
1631
|
+
].filter(Boolean).join(SEP);
|
|
1632
|
+
return {
|
|
1633
|
+
text: extractClaudeText(content),
|
|
1634
|
+
thinking: type === "assistant" ? extractThinking(content).content : "",
|
|
1635
|
+
tools
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
function extractClaudeText(content) {
|
|
1639
|
+
if (typeof content === "string") return cleanSystemTags(content);
|
|
1640
|
+
if (!Array.isArray(content)) return "";
|
|
1641
|
+
const parts = [];
|
|
1642
|
+
for (const item of content) {
|
|
1643
|
+
if (typeof item === "string") {
|
|
1644
|
+
const cleaned = cleanSystemTags(item);
|
|
1645
|
+
if (cleaned) parts.push(cleaned);
|
|
1646
|
+
} else if (item?.type === "text" && typeof item.text === "string") {
|
|
1647
|
+
const cleaned = cleanSystemTags(item.text);
|
|
1648
|
+
if (cleaned) parts.push(cleaned);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return parts.join(SEP);
|
|
1652
|
+
}
|
|
1653
|
+
function extractClaudeToolContent(content) {
|
|
1654
|
+
if (!Array.isArray(content)) return "";
|
|
1655
|
+
const parts = [];
|
|
1656
|
+
for (const item of content) {
|
|
1657
|
+
if (item?.type === "tool_use") {
|
|
1658
|
+
const capped = capToolPayload(item.input);
|
|
1659
|
+
if (capped) parts.push(capped);
|
|
1660
|
+
} else if (item?.type === "tool_result") {
|
|
1661
|
+
const capped = capToolPayload(item.content);
|
|
1662
|
+
if (capped) parts.push(capped);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return parts.join(SEP);
|
|
1666
|
+
}
|
|
1667
|
+
function extractCodexDelta(entry) {
|
|
1668
|
+
const payload = entry.payload;
|
|
1669
|
+
if (!payload || typeof payload !== "object") return emptySearchDocument();
|
|
1670
|
+
const ptype = payload.type;
|
|
1671
|
+
if (ptype === "function_call" || ptype === "custom_tool_call") {
|
|
1672
|
+
return { text: "", thinking: "", tools: capToolPayload(payload.arguments) };
|
|
1673
|
+
}
|
|
1674
|
+
if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
|
|
1675
|
+
return { text: "", thinking: "", tools: capToolPayload(payload.output) };
|
|
1676
|
+
}
|
|
1677
|
+
if (ptype === "reasoning") {
|
|
1678
|
+
return { text: "", thinking: extractCodexReasoning(payload), tools: "" };
|
|
1679
|
+
}
|
|
1680
|
+
if (ptype === "message") {
|
|
1681
|
+
const role = payload.role;
|
|
1682
|
+
if (role !== "user" && role !== "assistant") return emptySearchDocument();
|
|
1683
|
+
return { text: extractCodexText2(payload.content), thinking: "", tools: "" };
|
|
1684
|
+
}
|
|
1685
|
+
return emptySearchDocument();
|
|
1686
|
+
}
|
|
1687
|
+
function extractCodexReasoning(payload) {
|
|
1688
|
+
const parts = [];
|
|
1689
|
+
for (const key of ["summary", "content"]) {
|
|
1690
|
+
const blocks = payload[key];
|
|
1691
|
+
if (!Array.isArray(blocks)) continue;
|
|
1692
|
+
for (const block of blocks) {
|
|
1693
|
+
if (typeof block === "string") parts.push(block);
|
|
1694
|
+
else if (typeof block?.text === "string") parts.push(block.text);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return parts.filter(Boolean).join(SEP);
|
|
1698
|
+
}
|
|
1699
|
+
function extractCodexText2(content) {
|
|
1700
|
+
if (typeof content === "string") return cleanSystemTags(content);
|
|
1701
|
+
if (!Array.isArray(content)) return "";
|
|
1702
|
+
const parts = [];
|
|
1703
|
+
for (const item of content) {
|
|
1704
|
+
if (typeof item === "string") {
|
|
1705
|
+
const cleaned = cleanSystemTags(item);
|
|
1706
|
+
if (cleaned) parts.push(cleaned);
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
const t = item?.type;
|
|
1710
|
+
if ((t === "input_text" || t === "output_text" || t === "text") && typeof item.text === "string") {
|
|
1711
|
+
const cleaned = cleanSystemTags(item.text);
|
|
1712
|
+
if (cleaned) parts.push(cleaned);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
return parts.join(SEP);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1508
1718
|
// src/tiers.ts
|
|
1509
1719
|
var DEFAULT_TIERS = {
|
|
1510
1720
|
standard: { name: "standard", previewMax: 200, snippetMax: 5e3 },
|
|
@@ -1526,7 +1736,7 @@ import { mkdirSync } from "fs";
|
|
|
1526
1736
|
import { dirname as dirname3 } from "path";
|
|
1527
1737
|
|
|
1528
1738
|
// src/persistent/schema.ts
|
|
1529
|
-
var SCHEMA_VERSION =
|
|
1739
|
+
var SCHEMA_VERSION = 5;
|
|
1530
1740
|
var SCHEMA_SQL = `
|
|
1531
1741
|
CREATE TABLE IF NOT EXISTS conversation_files (
|
|
1532
1742
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1626,13 +1836,27 @@ CREATE INDEX IF NOT EXISTS idx_conversations_account_recent ON conversations(acc
|
|
|
1626
1836
|
CREATE INDEX IF NOT EXISTS idx_conversations_subagent_recent ON conversations(is_subagent, timestamp DESC);
|
|
1627
1837
|
CREATE INDEX IF NOT EXISTS idx_conversations_team_recent ON conversations(team_name, timestamp DESC);
|
|
1628
1838
|
|
|
1629
|
-
-- Full-text search index over conversation
|
|
1630
|
-
--
|
|
1631
|
-
--
|
|
1632
|
-
--
|
|
1839
|
+
-- Full-text search index over conversation body + metadata. Kept separate from
|
|
1840
|
+
-- the metadata tables so list-screen queries stay small and fast. One FTS row
|
|
1841
|
+
-- per conversation, replaced on each upsert.
|
|
1842
|
+
--
|
|
1843
|
+
-- The row's rowid IS conversation_files.id. That is load-bearing, not cosmetic:
|
|
1844
|
+
-- FTS5's query planner only handles MATCH, rowid and rank, so any other
|
|
1845
|
+
-- constraint (including a source_path equality on an UNINDEXED column) has no
|
|
1846
|
+
-- index and linear-scans the whole table. At ~128 KB of body per row that would
|
|
1847
|
+
-- mean scanning the entire corpus on every append. Look rows up by rowid.
|
|
1848
|
+
-- source_path stays stored-but-UNINDEXED so a search hit can resolve back to a
|
|
1849
|
+
-- conversations row.
|
|
1850
|
+
--
|
|
1851
|
+
-- Body is three columns, not one: text > thinking > tools priority has to
|
|
1852
|
+
-- survive an append (a new user message belongs in the text column, not after
|
|
1853
|
+
-- the tool output already written). They are deliberately NOT concatenated into
|
|
1854
|
+
-- a fourth column - that would double-weight body hits and inflate bm25 length.
|
|
1633
1855
|
CREATE VIRTUAL TABLE IF NOT EXISTS conversation_messages_fts USING fts5(
|
|
1634
1856
|
source_path UNINDEXED,
|
|
1635
|
-
|
|
1857
|
+
text,
|
|
1858
|
+
thinking,
|
|
1859
|
+
tools,
|
|
1636
1860
|
project_name,
|
|
1637
1861
|
session_id,
|
|
1638
1862
|
session_name,
|
|
@@ -1702,6 +1926,24 @@ function runMigrations(db) {
|
|
|
1702
1926
|
if (current >= 1 && current < 3 && tableExists(db, "conversations")) {
|
|
1703
1927
|
db.exec("UPDATE conversations SET provider = 'claude-code' WHERE provider = 'threadbase'");
|
|
1704
1928
|
}
|
|
1929
|
+
if (current >= 1 && current < 5) {
|
|
1930
|
+
db.exec("DROP TABLE IF EXISTS conversation_messages_fts");
|
|
1931
|
+
if (tableExists(db, "conversation_files")) {
|
|
1932
|
+
const assignments = [];
|
|
1933
|
+
if (hasColumn(db, "conversation_files", "last_indexed_offset")) {
|
|
1934
|
+
assignments.push("last_indexed_offset = 0");
|
|
1935
|
+
}
|
|
1936
|
+
if (hasColumn(db, "conversation_files", "last_indexed_line")) {
|
|
1937
|
+
assignments.push("last_indexed_line = 0");
|
|
1938
|
+
}
|
|
1939
|
+
if (hasColumn(db, "conversation_files", "reducer_state")) {
|
|
1940
|
+
assignments.push("reducer_state = NULL");
|
|
1941
|
+
}
|
|
1942
|
+
if (assignments.length > 0) {
|
|
1943
|
+
db.exec(`UPDATE conversation_files SET ${assignments.join(", ")}`);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1705
1947
|
db.exec(SCHEMA_SQL);
|
|
1706
1948
|
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
1707
1949
|
}
|
|
@@ -1718,15 +1960,46 @@ function openDatabase(dbPath) {
|
|
|
1718
1960
|
if (dbPath !== ":memory:") {
|
|
1719
1961
|
mkdirSync(dirname3(dbPath), { recursive: true });
|
|
1720
1962
|
}
|
|
1721
|
-
|
|
1963
|
+
let db;
|
|
1964
|
+
try {
|
|
1965
|
+
db = new Database(dbPath);
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
if (isNativeBindingFailure(err)) throw nativeBindingError(err, dbPath);
|
|
1968
|
+
throw err;
|
|
1969
|
+
}
|
|
1722
1970
|
db.pragma("journal_mode = WAL");
|
|
1723
1971
|
db.pragma("synchronous = NORMAL");
|
|
1724
1972
|
db.pragma("temp_store = MEMORY");
|
|
1725
1973
|
db.pragma("foreign_keys = ON");
|
|
1974
|
+
db.pragma("busy_timeout = 5000");
|
|
1726
1975
|
runMigrations(db);
|
|
1727
1976
|
getLogger().debug({ dbPath }, "db: opened");
|
|
1728
1977
|
return db;
|
|
1729
1978
|
}
|
|
1979
|
+
function isNativeBindingFailure(err) {
|
|
1980
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1981
|
+
return /could not locate the bindings file|NODE_MODULE_VERSION|was compiled against|\.node['"\s]/i.test(
|
|
1982
|
+
message
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
function nativeBindingError(err, dbPath) {
|
|
1986
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1987
|
+
return new Error(
|
|
1988
|
+
[
|
|
1989
|
+
`Could not load better-sqlite3's native binary, so the persistent index at ${dbPath} cannot be opened.`,
|
|
1990
|
+
"This usually means the binary was never built or downloaded \u2014 not that your Node version is wrong.",
|
|
1991
|
+
"",
|
|
1992
|
+
"Try, in order:",
|
|
1993
|
+
" 1. npm rebuild better-sqlite3",
|
|
1994
|
+
" 2. rm -rf node_modules && npm install",
|
|
1995
|
+
" (npm 12 blocks package install scripts by default; approve better-sqlite3 if asked)",
|
|
1996
|
+
" 3. Run without SQLite entirely: new ConversationScanner({ persistent: false })",
|
|
1997
|
+
" or pass --no-persist on the CLI. Search falls back to the in-memory index.",
|
|
1998
|
+
"",
|
|
1999
|
+
`Original error: ${detail}`
|
|
2000
|
+
].join("\n")
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
1730
2003
|
|
|
1731
2004
|
// src/persistent/dir-watermark.ts
|
|
1732
2005
|
import { readdir, stat as stat3 } from "fs/promises";
|
|
@@ -2170,22 +2443,31 @@ var ConversationsRepo = class {
|
|
|
2170
2443
|
};
|
|
2171
2444
|
|
|
2172
2445
|
// src/persistent/repositories/fts.repo.ts
|
|
2446
|
+
var BODY_COLUMNS = [
|
|
2447
|
+
{ name: "text", index: 1 },
|
|
2448
|
+
{ name: "thinking", index: 2 },
|
|
2449
|
+
{ name: "tools", index: 3 }
|
|
2450
|
+
];
|
|
2173
2451
|
var FtsRepo = class {
|
|
2174
2452
|
constructor(db) {
|
|
2175
2453
|
this.db = db;
|
|
2176
2454
|
}
|
|
2177
2455
|
db;
|
|
2178
|
-
upsert(meta) {
|
|
2456
|
+
upsert(rowId, meta, doc) {
|
|
2179
2457
|
const sourcePath = canonicalPath(meta.id);
|
|
2180
2458
|
const tx = this.db.transaction(() => {
|
|
2181
|
-
this.db.prepare("DELETE FROM conversation_messages_fts WHERE
|
|
2459
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2182
2460
|
this.db.prepare(
|
|
2183
2461
|
`INSERT INTO conversation_messages_fts
|
|
2184
|
-
(
|
|
2185
|
-
|
|
2462
|
+
(rowid, source_path, text, thinking, tools,
|
|
2463
|
+
project_name, session_id, session_name, account, model, branch, tool_names)
|
|
2464
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2186
2465
|
).run(
|
|
2466
|
+
rowId,
|
|
2187
2467
|
sourcePath,
|
|
2188
|
-
|
|
2468
|
+
doc.text,
|
|
2469
|
+
doc.thinking,
|
|
2470
|
+
doc.tools,
|
|
2189
2471
|
meta.projectName ?? "",
|
|
2190
2472
|
meta.sessionId ?? "",
|
|
2191
2473
|
meta.sessionName ?? "",
|
|
@@ -2197,26 +2479,94 @@ var FtsRepo = class {
|
|
|
2197
2479
|
});
|
|
2198
2480
|
tx();
|
|
2199
2481
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2482
|
+
// Current durable buckets for a conversation, so an append can tail-extend
|
|
2483
|
+
// them without reparsing the file. Returns null when no row exists yet.
|
|
2484
|
+
readDocument(rowId) {
|
|
2485
|
+
const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
|
|
2486
|
+
if (!row) return null;
|
|
2487
|
+
return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
|
|
2202
2488
|
}
|
|
2203
|
-
//
|
|
2204
|
-
//
|
|
2205
|
-
|
|
2489
|
+
// True when the stored row already equals what we would write, so an append
|
|
2490
|
+
// can skip re-tokenizing ~128 KB of body for nothing.
|
|
2491
|
+
//
|
|
2492
|
+
// The check covers the metadata columns too, not just the buckets: a
|
|
2493
|
+
// newly-seen tool name or a late-resolved session_name changes metadata while
|
|
2494
|
+
// the body is byte-identical, and nothing else ever rewrites this row — so
|
|
2495
|
+
// skipping on "body unchanged" alone would strand that stale value forever.
|
|
2496
|
+
isCurrent(rowId, meta, doc) {
|
|
2497
|
+
const row = this.db.prepare(
|
|
2498
|
+
`SELECT text, thinking, tools,
|
|
2499
|
+
project_name, session_id, session_name, account, model, branch, tool_names
|
|
2500
|
+
FROM conversation_messages_fts WHERE rowid = ?`
|
|
2501
|
+
).get(rowId);
|
|
2502
|
+
if (!row) return false;
|
|
2503
|
+
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(" ");
|
|
2504
|
+
}
|
|
2505
|
+
remove(rowId) {
|
|
2506
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2507
|
+
}
|
|
2508
|
+
// Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
|
|
2509
|
+
// with a wide corpus a popular term matches far more conversations than one
|
|
2510
|
+
// page, so post-filtering an already-truncated list would report "no results"
|
|
2511
|
+
// for queries that do have them.
|
|
2512
|
+
search(query, limit, filters = {}) {
|
|
2206
2513
|
const match = toMatchQuery(query);
|
|
2207
2514
|
if (!match) return [];
|
|
2515
|
+
const snippetSelects = BODY_COLUMNS.map(
|
|
2516
|
+
(c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
|
|
2517
|
+
).join(", ");
|
|
2518
|
+
const params = [];
|
|
2519
|
+
for (const _col of BODY_COLUMNS) {
|
|
2520
|
+
params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
|
|
2521
|
+
}
|
|
2522
|
+
params.push(match);
|
|
2523
|
+
const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
|
|
2524
|
+
if (filters.account) {
|
|
2525
|
+
where.push("c.account = ?");
|
|
2526
|
+
params.push(filters.account);
|
|
2527
|
+
}
|
|
2528
|
+
if (filters.provider) {
|
|
2529
|
+
where.push("c.provider = ?");
|
|
2530
|
+
params.push(filters.provider);
|
|
2531
|
+
}
|
|
2532
|
+
if (filters.project) {
|
|
2533
|
+
where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
|
|
2534
|
+
const like = `%${filters.project.toLowerCase()}%`;
|
|
2535
|
+
params.push(like, like);
|
|
2536
|
+
}
|
|
2537
|
+
if (filters.since) {
|
|
2538
|
+
where.push("c.timestamp >= ?");
|
|
2539
|
+
params.push(filters.since.toISOString());
|
|
2540
|
+
}
|
|
2541
|
+
if (filters.include === "conversations") {
|
|
2542
|
+
where.push("c.is_subagent = 0 AND c.is_teammate = 0");
|
|
2543
|
+
} else if (filters.include === "subagents") {
|
|
2544
|
+
where.push("c.is_subagent = 1");
|
|
2545
|
+
} else if (filters.include === "teammates") {
|
|
2546
|
+
where.push("c.is_teammate = 1");
|
|
2547
|
+
}
|
|
2548
|
+
params.push(limit);
|
|
2208
2549
|
const rows = this.db.prepare(
|
|
2209
|
-
`SELECT source_path
|
|
2210
|
-
|
|
2550
|
+
`SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
|
|
2551
|
+
FROM conversation_messages_fts
|
|
2552
|
+
JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
|
|
2553
|
+
WHERE ${where.join(" AND ")}
|
|
2211
2554
|
ORDER BY rank
|
|
2212
2555
|
LIMIT ?`
|
|
2213
|
-
).all(
|
|
2214
|
-
return rows.map((
|
|
2556
|
+
).all(...params);
|
|
2557
|
+
return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
|
|
2215
2558
|
}
|
|
2216
2559
|
count() {
|
|
2217
2560
|
return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
|
|
2218
2561
|
}
|
|
2219
2562
|
};
|
|
2563
|
+
function pickBodySnippet(row) {
|
|
2564
|
+
for (const col of BODY_COLUMNS) {
|
|
2565
|
+
const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
|
|
2566
|
+
if (parsed) return parsed;
|
|
2567
|
+
}
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2220
2570
|
function toMatchQuery(query) {
|
|
2221
2571
|
const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
|
|
2222
2572
|
if (terms.length === 0) return "";
|
|
@@ -2396,9 +2746,13 @@ var PersistentEngine = class {
|
|
|
2396
2746
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
2397
2747
|
const startOffset = resume ? existing.last_indexed_offset : 0;
|
|
2398
2748
|
const startLine = resume ? existing.last_indexed_line : 0;
|
|
2749
|
+
let searchDelta = emptySearchDocument();
|
|
2750
|
+
const collectSearch = (entry) => {
|
|
2751
|
+
searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
|
|
2752
|
+
};
|
|
2399
2753
|
let result;
|
|
2400
2754
|
try {
|
|
2401
|
-
result = await tailReduce(filePath, startOffset, startLine, state, tier);
|
|
2755
|
+
result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
|
|
2402
2756
|
} catch (err) {
|
|
2403
2757
|
log.warn({ filePath, err }, "persistent: tail read failed");
|
|
2404
2758
|
return { meta: null, change };
|
|
@@ -2411,9 +2765,13 @@ var PersistentEngine = class {
|
|
|
2411
2765
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2412
2766
|
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2413
2767
|
const fileId = this.files.ensure(filePath, account);
|
|
2768
|
+
const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
|
|
2769
|
+
const searchDoc = appendSearchDelta(base, searchDelta);
|
|
2414
2770
|
const upsert = this.db.transaction(() => {
|
|
2415
2771
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2416
|
-
this.fts.
|
|
2772
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2773
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2774
|
+
}
|
|
2417
2775
|
if (!resume) this.checkpoints.remove(filePath);
|
|
2418
2776
|
this.files.updateCursor(fileId, {
|
|
2419
2777
|
sizeBytes: stat4.size,
|
|
@@ -2456,7 +2814,10 @@ var PersistentEngine = class {
|
|
|
2456
2814
|
// any change reparses from 0 again. No reducer_state is persisted.
|
|
2457
2815
|
async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
|
|
2458
2816
|
const log = getLogger();
|
|
2459
|
-
|
|
2817
|
+
let searchDoc = emptySearchDocument();
|
|
2818
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
2819
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
2820
|
+
});
|
|
2460
2821
|
if (!meta) {
|
|
2461
2822
|
this.markDeleted(filePath);
|
|
2462
2823
|
return null;
|
|
@@ -2468,7 +2829,9 @@ var PersistentEngine = class {
|
|
|
2468
2829
|
const fileId = this.files.ensure(filePath, account);
|
|
2469
2830
|
const upsert = this.db.transaction(() => {
|
|
2470
2831
|
this.conversations.upsert(fileId, meta, meta.messageCount);
|
|
2471
|
-
this.fts.
|
|
2832
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2833
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2834
|
+
}
|
|
2472
2835
|
this.checkpoints.remove(filePath);
|
|
2473
2836
|
this.files.updateCursor(fileId, {
|
|
2474
2837
|
sizeBytes: stat4.size,
|
|
@@ -2494,7 +2857,7 @@ var PersistentEngine = class {
|
|
|
2494
2857
|
if (!existing) return;
|
|
2495
2858
|
const tx = this.db.transaction(() => {
|
|
2496
2859
|
this.conversations.deleteByFileId(existing.id);
|
|
2497
|
-
this.fts.remove(
|
|
2860
|
+
this.fts.remove(existing.id);
|
|
2498
2861
|
this.checkpoints.remove(filePath);
|
|
2499
2862
|
this.files.setStatus(existing.id, "deleted");
|
|
2500
2863
|
});
|
|
@@ -2510,20 +2873,24 @@ var PersistentEngine = class {
|
|
|
2510
2873
|
getAllBySessionId(sessionId) {
|
|
2511
2874
|
return this.conversations.getAllBySessionId(sessionId);
|
|
2512
2875
|
}
|
|
2513
|
-
// Ranked
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
const
|
|
2522
|
-
for (const
|
|
2523
|
-
const meta = this.conversations.getBySourcePath(
|
|
2524
|
-
if (meta)
|
|
2876
|
+
// Ranked hits matching the FTS query, best first, each already resolved to its
|
|
2877
|
+
// active conversation row and carrying the body excerpt when the match was in
|
|
2878
|
+
// the conversation body.
|
|
2879
|
+
//
|
|
2880
|
+
// Filters are passed down into SQL rather than applied to the result: with a
|
|
2881
|
+
// wide corpus, filtering an already-LIMITed list drops conversations that
|
|
2882
|
+
// would have matched.
|
|
2883
|
+
searchHits(query, limit, filters = {}) {
|
|
2884
|
+
const hits = [];
|
|
2885
|
+
for (const hit of this.fts.search(query, limit, filters)) {
|
|
2886
|
+
const meta = this.conversations.getBySourcePath(hit.sourcePath);
|
|
2887
|
+
if (meta) hits.push({ meta, body: hit.body });
|
|
2525
2888
|
}
|
|
2526
|
-
return
|
|
2889
|
+
return hits;
|
|
2890
|
+
}
|
|
2891
|
+
// Empty-query listing, mirroring the in-memory indexer's behavior.
|
|
2892
|
+
recentMetas(limit) {
|
|
2893
|
+
return this.conversations.recent(limit);
|
|
2527
2894
|
}
|
|
2528
2895
|
getProjects() {
|
|
2529
2896
|
return this.conversations.distinctProjects();
|
|
@@ -2684,6 +3051,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
|
|
|
2684
3051
|
function defaultDbPath() {
|
|
2685
3052
|
return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
|
|
2686
3053
|
}
|
|
3054
|
+
function capForMemory(doc, max) {
|
|
3055
|
+
const combined = combineSearchContent(doc);
|
|
3056
|
+
return combined.length > max ? combined.slice(-max) : combined;
|
|
3057
|
+
}
|
|
2687
3058
|
var ConversationScanner = class {
|
|
2688
3059
|
metadataCache = /* @__PURE__ */ new Map();
|
|
2689
3060
|
// Parsed conversations plus (persistent claude-code entries only) the resume
|
|
@@ -2695,6 +3066,10 @@ var ConversationScanner = class {
|
|
|
2695
3066
|
sessionIdIndex = /* @__PURE__ */ new Map();
|
|
2696
3067
|
projects = /* @__PURE__ */ new Set();
|
|
2697
3068
|
indexer = new SearchIndexer();
|
|
3069
|
+
// Search body per conversation for the in-memory path, already tail-capped to
|
|
3070
|
+
// the content tier. Survives scan() so a statCache hit — which skips the parse
|
|
3071
|
+
// entirely — can still index a body rather than an empty string.
|
|
3072
|
+
searchContents = /* @__PURE__ */ new Map();
|
|
2698
3073
|
// Tier the most recent scan() ran with, so refreshFile() re-parses a single
|
|
2699
3074
|
// file at the same content depth. Defaults to the standard tier.
|
|
2700
3075
|
lastTier = resolveTier("standard");
|
|
@@ -2855,34 +3230,42 @@ var ConversationScanner = class {
|
|
|
2855
3230
|
try {
|
|
2856
3231
|
const s = statSync3(filePath);
|
|
2857
3232
|
if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
|
|
2858
|
-
return
|
|
3233
|
+
return {
|
|
3234
|
+
meta: cached.meta,
|
|
3235
|
+
searchContent: this.searchContents.get(cached.meta.id)
|
|
3236
|
+
};
|
|
2859
3237
|
}
|
|
2860
3238
|
} catch {
|
|
2861
3239
|
}
|
|
2862
3240
|
}
|
|
2863
3241
|
}
|
|
2864
3242
|
try {
|
|
2865
|
-
|
|
3243
|
+
let doc = emptySearchDocument();
|
|
3244
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
3245
|
+
doc = appendSearchDelta(doc, extractSearchDelta(entry));
|
|
3246
|
+
});
|
|
2866
3247
|
if (meta && meta.gitBranch === null && meta.projectPath) {
|
|
2867
3248
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2868
3249
|
}
|
|
2869
|
-
return meta;
|
|
3250
|
+
return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
|
|
2870
3251
|
} catch (err) {
|
|
2871
3252
|
parseFailures++;
|
|
2872
3253
|
log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
|
|
2873
|
-
return null;
|
|
3254
|
+
return { meta: null, searchContent: void 0 };
|
|
2874
3255
|
}
|
|
2875
3256
|
})
|
|
2876
3257
|
);
|
|
2877
3258
|
const batchMetas = [];
|
|
2878
|
-
for (const meta of results) {
|
|
3259
|
+
for (const { meta, searchContent } of results) {
|
|
2879
3260
|
if (meta && meta.messageCount > 0) {
|
|
2880
3261
|
this.metadataCache.set(meta.id, meta);
|
|
2881
3262
|
this.addToSessionIndex(meta);
|
|
2882
3263
|
this.projects.add(meta.projectPath);
|
|
2883
3264
|
allMetas.push(meta);
|
|
2884
3265
|
batchMetas.push(meta);
|
|
2885
|
-
this.
|
|
3266
|
+
const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
|
|
3267
|
+
this.searchContents.set(meta.id, content);
|
|
3268
|
+
this.indexer.addDocument(meta, content);
|
|
2886
3269
|
}
|
|
2887
3270
|
}
|
|
2888
3271
|
if (batchMetas.length > 0) {
|
|
@@ -2919,12 +3302,29 @@ var ConversationScanner = class {
|
|
|
2919
3302
|
const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
|
|
2920
3303
|
await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
|
|
2921
3304
|
}
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
3305
|
+
if (query.trim()) {
|
|
3306
|
+
const want = (options.limit ?? 50) + (options.offset ?? 0);
|
|
3307
|
+
results = engine.searchHits(query, want, {
|
|
3308
|
+
account: options.account,
|
|
3309
|
+
provider: options.provider,
|
|
3310
|
+
project: options.project,
|
|
3311
|
+
since: options.since ? parseSinceCutoff(options.since) : void 0,
|
|
3312
|
+
include: options.include
|
|
3313
|
+
}).map(({ meta, body }) => ({
|
|
3314
|
+
meta,
|
|
3315
|
+
score: 1,
|
|
3316
|
+
matches: body ? [
|
|
3317
|
+
{ field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
|
|
3318
|
+
...generateMatches(meta, query).filter((m) => m.field !== "preview")
|
|
3319
|
+
] : generateMatches(meta, query)
|
|
3320
|
+
}));
|
|
3321
|
+
} else {
|
|
3322
|
+
results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
|
|
3323
|
+
meta,
|
|
3324
|
+
score: 1,
|
|
3325
|
+
matches: [{ field: "timestamp", snippet: meta.preview }]
|
|
3326
|
+
}));
|
|
3327
|
+
}
|
|
2928
3328
|
} else {
|
|
2929
3329
|
if (this.indexer.getDocumentCount() === 0) {
|
|
2930
3330
|
log.debug("search: index empty, triggering scan");
|
|
@@ -3118,8 +3518,11 @@ var ConversationScanner = class {
|
|
|
3118
3518
|
const previous = this.metadataCache.get(filePath);
|
|
3119
3519
|
const resolvedAccount = account ?? previous?.account ?? "default";
|
|
3120
3520
|
let meta = null;
|
|
3521
|
+
let searchDoc = emptySearchDocument();
|
|
3121
3522
|
try {
|
|
3122
|
-
meta = await parseMeta(filePath, resolvedAccount, this.lastTier)
|
|
3523
|
+
meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
|
|
3524
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
3525
|
+
});
|
|
3123
3526
|
} catch (err) {
|
|
3124
3527
|
log.warn({ filePath, err }, "refreshFile: parseMeta threw");
|
|
3125
3528
|
meta = null;
|
|
@@ -3136,6 +3539,7 @@ var ConversationScanner = class {
|
|
|
3136
3539
|
this.metadataCache.delete(previous.id);
|
|
3137
3540
|
this.removeFromSessionIndex(previous);
|
|
3138
3541
|
this.indexer.removeDocument(previous.id);
|
|
3542
|
+
this.searchContents.delete(previous.id);
|
|
3139
3543
|
}
|
|
3140
3544
|
log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
|
|
3141
3545
|
return null;
|
|
@@ -3145,10 +3549,12 @@ var ConversationScanner = class {
|
|
|
3145
3549
|
this.metadataCache.set(meta.id, meta);
|
|
3146
3550
|
this.addToSessionIndex(meta);
|
|
3147
3551
|
this.projects.add(meta.projectPath);
|
|
3552
|
+
const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
|
|
3553
|
+
this.searchContents.set(meta.id, searchContent);
|
|
3148
3554
|
if (previous) {
|
|
3149
|
-
this.indexer.updateDocument(meta);
|
|
3555
|
+
this.indexer.updateDocument(meta, searchContent);
|
|
3150
3556
|
} else {
|
|
3151
|
-
this.indexer.addDocument(meta);
|
|
3557
|
+
this.indexer.addDocument(meta, searchContent);
|
|
3152
3558
|
}
|
|
3153
3559
|
log.debug(
|
|
3154
3560
|
{ filePath, messageCount: meta.messageCount },
|