@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/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
|
}
|
|
@@ -1723,6 +1965,7 @@ function openDatabase(dbPath) {
|
|
|
1723
1965
|
db.pragma("synchronous = NORMAL");
|
|
1724
1966
|
db.pragma("temp_store = MEMORY");
|
|
1725
1967
|
db.pragma("foreign_keys = ON");
|
|
1968
|
+
db.pragma("busy_timeout = 5000");
|
|
1726
1969
|
runMigrations(db);
|
|
1727
1970
|
getLogger().debug({ dbPath }, "db: opened");
|
|
1728
1971
|
return db;
|
|
@@ -2170,22 +2413,31 @@ var ConversationsRepo = class {
|
|
|
2170
2413
|
};
|
|
2171
2414
|
|
|
2172
2415
|
// src/persistent/repositories/fts.repo.ts
|
|
2416
|
+
var BODY_COLUMNS = [
|
|
2417
|
+
{ name: "text", index: 1 },
|
|
2418
|
+
{ name: "thinking", index: 2 },
|
|
2419
|
+
{ name: "tools", index: 3 }
|
|
2420
|
+
];
|
|
2173
2421
|
var FtsRepo = class {
|
|
2174
2422
|
constructor(db) {
|
|
2175
2423
|
this.db = db;
|
|
2176
2424
|
}
|
|
2177
2425
|
db;
|
|
2178
|
-
upsert(meta) {
|
|
2426
|
+
upsert(rowId, meta, doc) {
|
|
2179
2427
|
const sourcePath = canonicalPath(meta.id);
|
|
2180
2428
|
const tx = this.db.transaction(() => {
|
|
2181
|
-
this.db.prepare("DELETE FROM conversation_messages_fts WHERE
|
|
2429
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2182
2430
|
this.db.prepare(
|
|
2183
2431
|
`INSERT INTO conversation_messages_fts
|
|
2184
|
-
(
|
|
2185
|
-
|
|
2432
|
+
(rowid, source_path, text, thinking, tools,
|
|
2433
|
+
project_name, session_id, session_name, account, model, branch, tool_names)
|
|
2434
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2186
2435
|
).run(
|
|
2436
|
+
rowId,
|
|
2187
2437
|
sourcePath,
|
|
2188
|
-
|
|
2438
|
+
doc.text,
|
|
2439
|
+
doc.thinking,
|
|
2440
|
+
doc.tools,
|
|
2189
2441
|
meta.projectName ?? "",
|
|
2190
2442
|
meta.sessionId ?? "",
|
|
2191
2443
|
meta.sessionName ?? "",
|
|
@@ -2197,26 +2449,94 @@ var FtsRepo = class {
|
|
|
2197
2449
|
});
|
|
2198
2450
|
tx();
|
|
2199
2451
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2452
|
+
// Current durable buckets for a conversation, so an append can tail-extend
|
|
2453
|
+
// them without reparsing the file. Returns null when no row exists yet.
|
|
2454
|
+
readDocument(rowId) {
|
|
2455
|
+
const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
|
|
2456
|
+
if (!row) return null;
|
|
2457
|
+
return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
|
|
2202
2458
|
}
|
|
2203
|
-
//
|
|
2204
|
-
//
|
|
2205
|
-
|
|
2459
|
+
// True when the stored row already equals what we would write, so an append
|
|
2460
|
+
// can skip re-tokenizing ~128 KB of body for nothing.
|
|
2461
|
+
//
|
|
2462
|
+
// The check covers the metadata columns too, not just the buckets: a
|
|
2463
|
+
// newly-seen tool name or a late-resolved session_name changes metadata while
|
|
2464
|
+
// the body is byte-identical, and nothing else ever rewrites this row — so
|
|
2465
|
+
// skipping on "body unchanged" alone would strand that stale value forever.
|
|
2466
|
+
isCurrent(rowId, meta, doc) {
|
|
2467
|
+
const row = this.db.prepare(
|
|
2468
|
+
`SELECT text, thinking, tools,
|
|
2469
|
+
project_name, session_id, session_name, account, model, branch, tool_names
|
|
2470
|
+
FROM conversation_messages_fts WHERE rowid = ?`
|
|
2471
|
+
).get(rowId);
|
|
2472
|
+
if (!row) return false;
|
|
2473
|
+
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(" ");
|
|
2474
|
+
}
|
|
2475
|
+
remove(rowId) {
|
|
2476
|
+
this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
|
|
2477
|
+
}
|
|
2478
|
+
// Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
|
|
2479
|
+
// with a wide corpus a popular term matches far more conversations than one
|
|
2480
|
+
// page, so post-filtering an already-truncated list would report "no results"
|
|
2481
|
+
// for queries that do have them.
|
|
2482
|
+
search(query, limit, filters = {}) {
|
|
2206
2483
|
const match = toMatchQuery(query);
|
|
2207
2484
|
if (!match) return [];
|
|
2485
|
+
const snippetSelects = BODY_COLUMNS.map(
|
|
2486
|
+
(c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
|
|
2487
|
+
).join(", ");
|
|
2488
|
+
const params = [];
|
|
2489
|
+
for (const _col of BODY_COLUMNS) {
|
|
2490
|
+
params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
|
|
2491
|
+
}
|
|
2492
|
+
params.push(match);
|
|
2493
|
+
const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
|
|
2494
|
+
if (filters.account) {
|
|
2495
|
+
where.push("c.account = ?");
|
|
2496
|
+
params.push(filters.account);
|
|
2497
|
+
}
|
|
2498
|
+
if (filters.provider) {
|
|
2499
|
+
where.push("c.provider = ?");
|
|
2500
|
+
params.push(filters.provider);
|
|
2501
|
+
}
|
|
2502
|
+
if (filters.project) {
|
|
2503
|
+
where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
|
|
2504
|
+
const like = `%${filters.project.toLowerCase()}%`;
|
|
2505
|
+
params.push(like, like);
|
|
2506
|
+
}
|
|
2507
|
+
if (filters.since) {
|
|
2508
|
+
where.push("c.timestamp >= ?");
|
|
2509
|
+
params.push(filters.since.toISOString());
|
|
2510
|
+
}
|
|
2511
|
+
if (filters.include === "conversations") {
|
|
2512
|
+
where.push("c.is_subagent = 0 AND c.is_teammate = 0");
|
|
2513
|
+
} else if (filters.include === "subagents") {
|
|
2514
|
+
where.push("c.is_subagent = 1");
|
|
2515
|
+
} else if (filters.include === "teammates") {
|
|
2516
|
+
where.push("c.is_teammate = 1");
|
|
2517
|
+
}
|
|
2518
|
+
params.push(limit);
|
|
2208
2519
|
const rows = this.db.prepare(
|
|
2209
|
-
`SELECT source_path
|
|
2210
|
-
|
|
2520
|
+
`SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
|
|
2521
|
+
FROM conversation_messages_fts
|
|
2522
|
+
JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
|
|
2523
|
+
WHERE ${where.join(" AND ")}
|
|
2211
2524
|
ORDER BY rank
|
|
2212
2525
|
LIMIT ?`
|
|
2213
|
-
).all(
|
|
2214
|
-
return rows.map((
|
|
2526
|
+
).all(...params);
|
|
2527
|
+
return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
|
|
2215
2528
|
}
|
|
2216
2529
|
count() {
|
|
2217
2530
|
return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
|
|
2218
2531
|
}
|
|
2219
2532
|
};
|
|
2533
|
+
function pickBodySnippet(row) {
|
|
2534
|
+
for (const col of BODY_COLUMNS) {
|
|
2535
|
+
const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
|
|
2536
|
+
if (parsed) return parsed;
|
|
2537
|
+
}
|
|
2538
|
+
return null;
|
|
2539
|
+
}
|
|
2220
2540
|
function toMatchQuery(query) {
|
|
2221
2541
|
const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
|
|
2222
2542
|
if (terms.length === 0) return "";
|
|
@@ -2396,9 +2716,13 @@ var PersistentEngine = class {
|
|
|
2396
2716
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
2397
2717
|
const startOffset = resume ? existing.last_indexed_offset : 0;
|
|
2398
2718
|
const startLine = resume ? existing.last_indexed_line : 0;
|
|
2719
|
+
let searchDelta = emptySearchDocument();
|
|
2720
|
+
const collectSearch = (entry) => {
|
|
2721
|
+
searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
|
|
2722
|
+
};
|
|
2399
2723
|
let result;
|
|
2400
2724
|
try {
|
|
2401
|
-
result = await tailReduce(filePath, startOffset, startLine, state, tier);
|
|
2725
|
+
result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
|
|
2402
2726
|
} catch (err) {
|
|
2403
2727
|
log.warn({ filePath, err }, "persistent: tail read failed");
|
|
2404
2728
|
return { meta: null, change };
|
|
@@ -2411,9 +2735,13 @@ var PersistentEngine = class {
|
|
|
2411
2735
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2412
2736
|
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
2413
2737
|
const fileId = this.files.ensure(filePath, account);
|
|
2738
|
+
const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
|
|
2739
|
+
const searchDoc = appendSearchDelta(base, searchDelta);
|
|
2414
2740
|
const upsert = this.db.transaction(() => {
|
|
2415
2741
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2416
|
-
this.fts.
|
|
2742
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2743
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2744
|
+
}
|
|
2417
2745
|
if (!resume) this.checkpoints.remove(filePath);
|
|
2418
2746
|
this.files.updateCursor(fileId, {
|
|
2419
2747
|
sizeBytes: stat4.size,
|
|
@@ -2456,7 +2784,10 @@ var PersistentEngine = class {
|
|
|
2456
2784
|
// any change reparses from 0 again. No reducer_state is persisted.
|
|
2457
2785
|
async indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch) {
|
|
2458
2786
|
const log = getLogger();
|
|
2459
|
-
|
|
2787
|
+
let searchDoc = emptySearchDocument();
|
|
2788
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
2789
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
2790
|
+
});
|
|
2460
2791
|
if (!meta) {
|
|
2461
2792
|
this.markDeleted(filePath);
|
|
2462
2793
|
return null;
|
|
@@ -2468,7 +2799,9 @@ var PersistentEngine = class {
|
|
|
2468
2799
|
const fileId = this.files.ensure(filePath, account);
|
|
2469
2800
|
const upsert = this.db.transaction(() => {
|
|
2470
2801
|
this.conversations.upsert(fileId, meta, meta.messageCount);
|
|
2471
|
-
this.fts.
|
|
2802
|
+
if (!this.fts.isCurrent(fileId, meta, searchDoc)) {
|
|
2803
|
+
this.fts.upsert(fileId, meta, searchDoc);
|
|
2804
|
+
}
|
|
2472
2805
|
this.checkpoints.remove(filePath);
|
|
2473
2806
|
this.files.updateCursor(fileId, {
|
|
2474
2807
|
sizeBytes: stat4.size,
|
|
@@ -2494,7 +2827,7 @@ var PersistentEngine = class {
|
|
|
2494
2827
|
if (!existing) return;
|
|
2495
2828
|
const tx = this.db.transaction(() => {
|
|
2496
2829
|
this.conversations.deleteByFileId(existing.id);
|
|
2497
|
-
this.fts.remove(
|
|
2830
|
+
this.fts.remove(existing.id);
|
|
2498
2831
|
this.checkpoints.remove(filePath);
|
|
2499
2832
|
this.files.setStatus(existing.id, "deleted");
|
|
2500
2833
|
});
|
|
@@ -2510,20 +2843,24 @@ var PersistentEngine = class {
|
|
|
2510
2843
|
getAllBySessionId(sessionId) {
|
|
2511
2844
|
return this.conversations.getAllBySessionId(sessionId);
|
|
2512
2845
|
}
|
|
2513
|
-
// Ranked
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
const
|
|
2522
|
-
for (const
|
|
2523
|
-
const meta = this.conversations.getBySourcePath(
|
|
2524
|
-
if (meta)
|
|
2846
|
+
// Ranked hits matching the FTS query, best first, each already resolved to its
|
|
2847
|
+
// active conversation row and carrying the body excerpt when the match was in
|
|
2848
|
+
// the conversation body.
|
|
2849
|
+
//
|
|
2850
|
+
// Filters are passed down into SQL rather than applied to the result: with a
|
|
2851
|
+
// wide corpus, filtering an already-LIMITed list drops conversations that
|
|
2852
|
+
// would have matched.
|
|
2853
|
+
searchHits(query, limit, filters = {}) {
|
|
2854
|
+
const hits = [];
|
|
2855
|
+
for (const hit of this.fts.search(query, limit, filters)) {
|
|
2856
|
+
const meta = this.conversations.getBySourcePath(hit.sourcePath);
|
|
2857
|
+
if (meta) hits.push({ meta, body: hit.body });
|
|
2525
2858
|
}
|
|
2526
|
-
return
|
|
2859
|
+
return hits;
|
|
2860
|
+
}
|
|
2861
|
+
// Empty-query listing, mirroring the in-memory indexer's behavior.
|
|
2862
|
+
recentMetas(limit) {
|
|
2863
|
+
return this.conversations.recent(limit);
|
|
2527
2864
|
}
|
|
2528
2865
|
getProjects() {
|
|
2529
2866
|
return this.conversations.distinctProjects();
|
|
@@ -2684,6 +3021,10 @@ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
|
|
|
2684
3021
|
function defaultDbPath() {
|
|
2685
3022
|
return process.env.TB_SCANNER_DB ?? join5(homedir2(), ".config", "threadbase-scanner", "index.db");
|
|
2686
3023
|
}
|
|
3024
|
+
function capForMemory(doc, max) {
|
|
3025
|
+
const combined = combineSearchContent(doc);
|
|
3026
|
+
return combined.length > max ? combined.slice(-max) : combined;
|
|
3027
|
+
}
|
|
2687
3028
|
var ConversationScanner = class {
|
|
2688
3029
|
metadataCache = /* @__PURE__ */ new Map();
|
|
2689
3030
|
// Parsed conversations plus (persistent claude-code entries only) the resume
|
|
@@ -2695,6 +3036,10 @@ var ConversationScanner = class {
|
|
|
2695
3036
|
sessionIdIndex = /* @__PURE__ */ new Map();
|
|
2696
3037
|
projects = /* @__PURE__ */ new Set();
|
|
2697
3038
|
indexer = new SearchIndexer();
|
|
3039
|
+
// Search body per conversation for the in-memory path, already tail-capped to
|
|
3040
|
+
// the content tier. Survives scan() so a statCache hit — which skips the parse
|
|
3041
|
+
// entirely — can still index a body rather than an empty string.
|
|
3042
|
+
searchContents = /* @__PURE__ */ new Map();
|
|
2698
3043
|
// Tier the most recent scan() ran with, so refreshFile() re-parses a single
|
|
2699
3044
|
// file at the same content depth. Defaults to the standard tier.
|
|
2700
3045
|
lastTier = resolveTier("standard");
|
|
@@ -2855,34 +3200,42 @@ var ConversationScanner = class {
|
|
|
2855
3200
|
try {
|
|
2856
3201
|
const s = statSync3(filePath);
|
|
2857
3202
|
if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
|
|
2858
|
-
return
|
|
3203
|
+
return {
|
|
3204
|
+
meta: cached.meta,
|
|
3205
|
+
searchContent: this.searchContents.get(cached.meta.id)
|
|
3206
|
+
};
|
|
2859
3207
|
}
|
|
2860
3208
|
} catch {
|
|
2861
3209
|
}
|
|
2862
3210
|
}
|
|
2863
3211
|
}
|
|
2864
3212
|
try {
|
|
2865
|
-
|
|
3213
|
+
let doc = emptySearchDocument();
|
|
3214
|
+
const meta = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
|
|
3215
|
+
doc = appendSearchDelta(doc, extractSearchDelta(entry));
|
|
3216
|
+
});
|
|
2866
3217
|
if (meta && meta.gitBranch === null && meta.projectPath) {
|
|
2867
3218
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2868
3219
|
}
|
|
2869
|
-
return meta;
|
|
3220
|
+
return { meta, searchContent: capForMemory(doc, tier.snippetMax) };
|
|
2870
3221
|
} catch (err) {
|
|
2871
3222
|
parseFailures++;
|
|
2872
3223
|
log.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
|
|
2873
|
-
return null;
|
|
3224
|
+
return { meta: null, searchContent: void 0 };
|
|
2874
3225
|
}
|
|
2875
3226
|
})
|
|
2876
3227
|
);
|
|
2877
3228
|
const batchMetas = [];
|
|
2878
|
-
for (const meta of results) {
|
|
3229
|
+
for (const { meta, searchContent } of results) {
|
|
2879
3230
|
if (meta && meta.messageCount > 0) {
|
|
2880
3231
|
this.metadataCache.set(meta.id, meta);
|
|
2881
3232
|
this.addToSessionIndex(meta);
|
|
2882
3233
|
this.projects.add(meta.projectPath);
|
|
2883
3234
|
allMetas.push(meta);
|
|
2884
3235
|
batchMetas.push(meta);
|
|
2885
|
-
this.
|
|
3236
|
+
const content = searchContent ?? this.searchContents.get(meta.id) ?? "";
|
|
3237
|
+
this.searchContents.set(meta.id, content);
|
|
3238
|
+
this.indexer.addDocument(meta, content);
|
|
2886
3239
|
}
|
|
2887
3240
|
}
|
|
2888
3241
|
if (batchMetas.length > 0) {
|
|
@@ -2919,12 +3272,29 @@ var ConversationScanner = class {
|
|
|
2919
3272
|
const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
|
|
2920
3273
|
await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
|
|
2921
3274
|
}
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
3275
|
+
if (query.trim()) {
|
|
3276
|
+
const want = (options.limit ?? 50) + (options.offset ?? 0);
|
|
3277
|
+
results = engine.searchHits(query, want, {
|
|
3278
|
+
account: options.account,
|
|
3279
|
+
provider: options.provider,
|
|
3280
|
+
project: options.project,
|
|
3281
|
+
since: options.since ? parseSinceCutoff(options.since) : void 0,
|
|
3282
|
+
include: options.include
|
|
3283
|
+
}).map(({ meta, body }) => ({
|
|
3284
|
+
meta,
|
|
3285
|
+
score: 1,
|
|
3286
|
+
matches: body ? [
|
|
3287
|
+
{ field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
|
|
3288
|
+
...generateMatches(meta, query).filter((m) => m.field !== "preview")
|
|
3289
|
+
] : generateMatches(meta, query)
|
|
3290
|
+
}));
|
|
3291
|
+
} else {
|
|
3292
|
+
results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta) => ({
|
|
3293
|
+
meta,
|
|
3294
|
+
score: 1,
|
|
3295
|
+
matches: [{ field: "timestamp", snippet: meta.preview }]
|
|
3296
|
+
}));
|
|
3297
|
+
}
|
|
2928
3298
|
} else {
|
|
2929
3299
|
if (this.indexer.getDocumentCount() === 0) {
|
|
2930
3300
|
log.debug("search: index empty, triggering scan");
|
|
@@ -3118,8 +3488,11 @@ var ConversationScanner = class {
|
|
|
3118
3488
|
const previous = this.metadataCache.get(filePath);
|
|
3119
3489
|
const resolvedAccount = account ?? previous?.account ?? "default";
|
|
3120
3490
|
let meta = null;
|
|
3491
|
+
let searchDoc = emptySearchDocument();
|
|
3121
3492
|
try {
|
|
3122
|
-
meta = await parseMeta(filePath, resolvedAccount, this.lastTier)
|
|
3493
|
+
meta = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
|
|
3494
|
+
searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
|
|
3495
|
+
});
|
|
3123
3496
|
} catch (err) {
|
|
3124
3497
|
log.warn({ filePath, err }, "refreshFile: parseMeta threw");
|
|
3125
3498
|
meta = null;
|
|
@@ -3136,6 +3509,7 @@ var ConversationScanner = class {
|
|
|
3136
3509
|
this.metadataCache.delete(previous.id);
|
|
3137
3510
|
this.removeFromSessionIndex(previous);
|
|
3138
3511
|
this.indexer.removeDocument(previous.id);
|
|
3512
|
+
this.searchContents.delete(previous.id);
|
|
3139
3513
|
}
|
|
3140
3514
|
log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
|
|
3141
3515
|
return null;
|
|
@@ -3145,10 +3519,12 @@ var ConversationScanner = class {
|
|
|
3145
3519
|
this.metadataCache.set(meta.id, meta);
|
|
3146
3520
|
this.addToSessionIndex(meta);
|
|
3147
3521
|
this.projects.add(meta.projectPath);
|
|
3522
|
+
const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
|
|
3523
|
+
this.searchContents.set(meta.id, searchContent);
|
|
3148
3524
|
if (previous) {
|
|
3149
|
-
this.indexer.updateDocument(meta);
|
|
3525
|
+
this.indexer.updateDocument(meta, searchContent);
|
|
3150
3526
|
} else {
|
|
3151
|
-
this.indexer.addDocument(meta);
|
|
3527
|
+
this.indexer.addDocument(meta, searchContent);
|
|
3152
3528
|
}
|
|
3153
3529
|
log.debug(
|
|
3154
3530
|
{ filePath, messageCount: meta.messageCount },
|