@threadbase-sh/streamer 1.61.0 → 1.61.1

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.cjs CHANGED
@@ -140828,18 +140828,43 @@ function readGitBranch(projectPath) {
140828
140828
  log15.trace({ projectPath }, "git: no .git found within depth");
140829
140829
  return null;
140830
140830
  }
140831
+ var FTS_HIT_OPEN = "";
140832
+ var FTS_HIT_CLOSE = "";
140833
+ var FTS_SNIPPET_TOKENS = 16;
140834
+ var FTS_ELLIPSIS = "\u2026";
140835
+ var CONTENT_FIELD = "content";
140836
+ function parseFtsSnippet(raw2) {
140837
+ if (!raw2?.includes(FTS_HIT_OPEN)) return null;
140838
+ const highlights = [];
140839
+ let snippet = "";
140840
+ let openAt = -1;
140841
+ for (const ch of raw2) {
140842
+ if (ch === FTS_HIT_OPEN) {
140843
+ openAt = snippet.length;
140844
+ continue;
140845
+ }
140846
+ if (ch === FTS_HIT_CLOSE) {
140847
+ if (openAt >= 0 && snippet.length > openAt) {
140848
+ highlights.push({ start: openAt, end: snippet.length });
140849
+ }
140850
+ openAt = -1;
140851
+ continue;
140852
+ }
140853
+ snippet += ch;
140854
+ }
140855
+ return highlights.length > 0 ? { snippet, highlights } : null;
140856
+ }
140831
140857
  function generateMatches(meta3, query) {
140832
140858
  const matches = [];
140833
140859
  const lowerQuery = query.toLowerCase();
140834
140860
  const fields = [
140835
- ["contentSnippet", meta3.contentSnippet],
140836
- ["projectName", meta3.projectName],
140837
- ["sessionId", meta3.sessionId],
140838
140861
  ["sessionName", meta3.sessionName],
140839
- ["account", meta3.account],
140840
- ["model", meta3.model || ""],
140862
+ ["projectName", meta3.projectName],
140841
140863
  ["gitBranch", meta3.gitBranch || ""],
140842
- ["toolNames", meta3.toolNames.join(" ")]
140864
+ ["toolNames", meta3.toolNames.join(" ")],
140865
+ ["model", meta3.model || ""],
140866
+ ["account", meta3.account],
140867
+ ["sessionId", meta3.sessionId]
140843
140868
  ];
140844
140869
  for (const [field, value] of fields) {
140845
140870
  const idx = value.toLowerCase().indexOf(lowerQuery);
@@ -140854,11 +140879,29 @@ function generateMatches(meta3, query) {
140854
140879
  }
140855
140880
  return matches.length > 0 ? matches : [{ field: "preview", snippet: meta3.preview }];
140856
140881
  }
140882
+ function buildContentMatch(searchContent, query) {
140883
+ if (!searchContent || !query.trim()) return null;
140884
+ const idx = searchContent.toLowerCase().indexOf(query.toLowerCase());
140885
+ if (idx === -1) return null;
140886
+ const start = Math.max(0, idx - 80);
140887
+ const end = Math.min(searchContent.length, idx + query.length + 120);
140888
+ const body = searchContent.slice(start, end).replace(/\s+/g, " ").trim();
140889
+ const hitAt = body.toLowerCase().indexOf(query.toLowerCase());
140890
+ const prefix = start > 0 ? FTS_ELLIPSIS : "";
140891
+ const suffix = end < searchContent.length ? FTS_ELLIPSIS : "";
140892
+ const snippet = `${prefix}${body}${suffix}`;
140893
+ const highlights = hitAt === -1 ? [] : [{ start: hitAt + prefix.length, end: hitAt + prefix.length + query.length }];
140894
+ return { field: CONTENT_FIELD, snippet, highlights };
140895
+ }
140857
140896
  var FlexSearch = flexsearch_bundle_module_min_default.default ?? flexsearch_bundle_module_min_default;
140858
140897
  var SearchIndexer = class {
140859
140898
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
140860
140899
  index;
140861
140900
  documents = /* @__PURE__ */ new Map();
140901
+ // The indexed body per conversation, kept so a hit can produce a real excerpt
140902
+ // instead of falling back to an unrelated preview. Sized by the caller (the
140903
+ // scanner tail-caps it to the content tier) — see the note on addDocument.
140904
+ searchContents = /* @__PURE__ */ new Map();
140862
140905
  constructor() {
140863
140906
  this.index = this.createIndex();
140864
140907
  }
@@ -140884,25 +140927,25 @@ var SearchIndexer = class {
140884
140927
  cache: 100
140885
140928
  });
140886
140929
  }
140887
- addDocument(meta3) {
140930
+ // `searchContent` is the combined search document (text + thinking + tools),
140931
+ // NOT meta.contentSnippet.
140932
+ //
140933
+ // It arrives pre-capped. This index is `tokenize: "forward"` at resolution 9,
140934
+ // which stores every prefix of every token, so it is a resident-memory
140935
+ // structure whose cost is very different from the on-disk FTS index. Feeding
140936
+ // it the full ~128 KB budget across a few hundred conversations would be a
140937
+ // multi-hundred-MB-to-GB index. The scanner therefore caps this to the active
140938
+ // tier's snippetMax and accepts that `persistent: false` has lower recall than
140939
+ // SQLite — an honest, documented divergence rather than a silent one.
140940
+ addDocument(meta3, searchContent = "") {
140888
140941
  this.documents.set(meta3.id, meta3);
140889
- this.index.add({
140890
- id: meta3.id,
140891
- content: meta3.contentSnippet,
140892
- projectName: meta3.projectName,
140893
- projectPath: meta3.projectPath,
140894
- sessionId: meta3.sessionId,
140895
- sessionName: meta3.sessionName,
140896
- account: meta3.account,
140897
- model: meta3.model || "",
140898
- gitBranch: meta3.gitBranch || "",
140899
- toolNames: meta3.toolNames.join(" ")
140900
- });
140942
+ this.searchContents.set(meta3.id, searchContent);
140943
+ this.index.add(toIndexDoc(meta3, searchContent));
140901
140944
  }
140902
- buildIndex(metas) {
140945
+ buildIndex(metas, searchContents) {
140903
140946
  this.clear();
140904
140947
  for (const meta3 of metas) {
140905
- this.addDocument(meta3);
140948
+ this.addDocument(meta3, searchContents?.get(meta3.id) ?? "");
140906
140949
  }
140907
140950
  getLogger2().debug({ docCount: metas.length }, "indexer: built");
140908
140951
  }
@@ -140922,14 +140965,26 @@ var SearchIndexer = class {
140922
140965
  seen.add(id);
140923
140966
  const meta3 = this.documents.get(id);
140924
140967
  if (!meta3) continue;
140925
- const matches = generateMatches(meta3, query);
140926
- searchResults.push({ meta: meta3, score: 1, matches });
140968
+ searchResults.push({
140969
+ meta: meta3,
140970
+ score: 1,
140971
+ matches: this.matchesFor(meta3, query)
140972
+ });
140927
140973
  if (searchResults.length >= limit) break;
140928
140974
  }
140929
140975
  if (searchResults.length >= limit) break;
140930
140976
  }
140931
140977
  return searchResults;
140932
140978
  }
140979
+ // Body context first (that is what explains why the result appeared), then any
140980
+ // metadata matches. Only when neither hits does generateMatches' preview
140981
+ // fallback stand in.
140982
+ matchesFor(meta3, query) {
140983
+ const contentMatch = buildContentMatch(this.searchContents.get(meta3.id) ?? "", query);
140984
+ const metaMatches = generateMatches(meta3, query);
140985
+ if (!contentMatch) return metaMatches;
140986
+ return [contentMatch, ...metaMatches.filter((m2) => m2.field !== "preview")];
140987
+ }
140933
140988
  getRecent(limit) {
140934
140989
  return Array.from(this.documents.values()).sort((a, b2) => b2.timestamp.localeCompare(a.timestamp)).slice(0, limit).map((meta3) => ({
140935
140990
  meta: meta3,
@@ -140943,31 +140998,37 @@ var SearchIndexer = class {
140943
140998
  // Replace an already-indexed document in place. FlexSearch's `add` does not
140944
140999
  // overwrite an existing id, so a single-file refresh must go through
140945
141000
  // `update` to avoid stale matches lingering in the index.
140946
- updateDocument(meta3) {
141001
+ updateDocument(meta3, searchContent = "") {
140947
141002
  this.documents.set(meta3.id, meta3);
140948
- this.index.update({
140949
- id: meta3.id,
140950
- content: meta3.contentSnippet,
140951
- projectName: meta3.projectName,
140952
- projectPath: meta3.projectPath,
140953
- sessionId: meta3.sessionId,
140954
- sessionName: meta3.sessionName,
140955
- account: meta3.account,
140956
- model: meta3.model || "",
140957
- gitBranch: meta3.gitBranch || "",
140958
- toolNames: meta3.toolNames.join(" ")
140959
- });
141003
+ this.searchContents.set(meta3.id, searchContent);
141004
+ this.index.update(toIndexDoc(meta3, searchContent));
140960
141005
  }
140961
141006
  removeDocument(id) {
140962
141007
  this.documents.delete(id);
141008
+ this.searchContents.delete(id);
140963
141009
  this.index.remove(id);
140964
141010
  }
140965
141011
  clear() {
140966
141012
  this.documents.clear();
141013
+ this.searchContents.clear();
140967
141014
  this.index = this.createIndex();
140968
141015
  getLogger2().trace("indexer: cleared");
140969
141016
  }
140970
141017
  };
141018
+ function toIndexDoc(meta3, searchContent) {
141019
+ return {
141020
+ id: meta3.id,
141021
+ content: searchContent,
141022
+ projectName: meta3.projectName,
141023
+ projectPath: meta3.projectPath,
141024
+ sessionId: meta3.sessionId,
141025
+ sessionName: meta3.sessionName,
141026
+ account: meta3.account,
141027
+ model: meta3.model || "",
141028
+ gitBranch: meta3.gitBranch || "",
141029
+ toolNames: meta3.toolNames.join(" ")
141030
+ };
141031
+ }
140971
141032
  var CLAUDE_CODE_PROVIDER2 = "claude-code";
140972
141033
  var CODEX_CLI_PROVIDER2 = "codex-cli";
140973
141034
  function initialReducerState() {
@@ -141117,7 +141178,7 @@ var SYSTEM_TAG_RE = new RegExp(`<(${SYSTEM_TAGS.join("|")})[^>]*>[\\s\\S]*?<\\/\
141117
141178
  function cleanSystemTags(text) {
141118
141179
  return text.replace(SYSTEM_TAG_RE, "").replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
141119
141180
  }
141120
- async function parseMeta(filePath, account, tier) {
141181
+ async function parseMeta(filePath, account, tier, onEntry) {
141121
141182
  const log15 = getLogger2();
141122
141183
  log15.trace({ filePath, account, tier: tier.name }, "parseMeta: start");
141123
141184
  const state = initialReducerState();
@@ -141134,6 +141195,7 @@ async function parseMeta(filePath, account, tier) {
141134
141195
  continue;
141135
141196
  }
141136
141197
  reduceLine(state, entry, tier);
141198
+ onEntry?.(entry);
141137
141199
  }
141138
141200
  } catch (err) {
141139
141201
  log15.warn({ filePath, err }, "parseMeta: read failed");
@@ -141828,7 +141890,7 @@ var LRUCache = class {
141828
141890
  }
141829
141891
  };
141830
141892
  var YIELD_EVERY_LINES = 500;
141831
- async function tailReduce(filePath, startOffset, startLine, state, tier) {
141893
+ async function tailReduce(filePath, startOffset, startLine, state, tier, onEntry) {
141832
141894
  const stream = (0, import_fs11.createReadStream)(filePath, { start: startOffset, encoding: "utf8" });
141833
141895
  let buffer = "";
141834
141896
  let offset = startOffset;
@@ -141844,7 +141906,9 @@ async function tailReduce(filePath, startOffset, startLine, state, tier) {
141844
141906
  buffer = buffer.slice(nl + 1);
141845
141907
  if (text.length > 0) {
141846
141908
  try {
141847
- reduceLine(state, JSON.parse(text), tier);
141909
+ const entry = JSON.parse(text);
141910
+ reduceLine(state, entry, tier);
141911
+ onEntry?.(entry);
141848
141912
  } catch {
141849
141913
  state.badJsonLines++;
141850
141914
  }
@@ -142065,7 +142129,7 @@ function classify(filePath, existing) {
142065
142129
  }
142066
142130
  return { change: "reindex", stat: stat42 };
142067
142131
  }
142068
- async function parseMetaWithProvider(provider, filePath, account, tier) {
142132
+ async function parseMetaWithProvider(provider, filePath, account, tier, onEntry) {
142069
142133
  const log15 = getLogger2();
142070
142134
  const acc = provider.createEmptyAccumulator();
142071
142135
  const rl = (0, import_readline3.createInterface)({
@@ -142083,6 +142147,7 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
142083
142147
  }
142084
142148
  try {
142085
142149
  provider.reduceEntry(acc, entry, tier);
142150
+ onEntry?.(entry);
142086
142151
  } catch (err) {
142087
142152
  log15.warn({ filePath, provider: provider.name, err }, "provider reduce threw; line skipped");
142088
142153
  }
@@ -142093,6 +142158,149 @@ async function parseMetaWithProvider(provider, filePath, account, tier) {
142093
142158
  }
142094
142159
  return provider.finalize(acc, filePath, account, tier);
142095
142160
  }
142161
+ var SEARCH_BUDGET = {
142162
+ textMax: 64 * 1024,
142163
+ thinkingMax: 32 * 1024,
142164
+ toolsMax: 32 * 1024,
142165
+ toolPayloadMax: 4 * 1024
142166
+ };
142167
+ var SEP = "\n\n";
142168
+ function emptySearchDocument() {
142169
+ return { text: "", thinking: "", tools: "" };
142170
+ }
142171
+ function appendSearchDelta(doc, delta) {
142172
+ return {
142173
+ text: tailAppend(doc.text, delta.text, SEARCH_BUDGET.textMax),
142174
+ thinking: tailAppend(doc.thinking, delta.thinking, SEARCH_BUDGET.thinkingMax),
142175
+ tools: tailAppend(doc.tools, delta.tools, SEARCH_BUDGET.toolsMax)
142176
+ };
142177
+ }
142178
+ function tailAppend(current, incoming, max) {
142179
+ if (!incoming) return current;
142180
+ const joined = current ? current + SEP + incoming : incoming;
142181
+ return joined.length <= max ? joined : joined.slice(-max);
142182
+ }
142183
+ function combineSearchContent(doc) {
142184
+ return [doc.text, doc.thinking, doc.tools].filter(Boolean).join(SEP);
142185
+ }
142186
+ function capToolPayload(value) {
142187
+ const raw2 = stringifyPayload(value);
142188
+ if (!raw2) return "";
142189
+ return raw2.length > SEARCH_BUDGET.toolPayloadMax ? raw2.slice(0, SEARCH_BUDGET.toolPayloadMax) : raw2;
142190
+ }
142191
+ function stringifyPayload(value) {
142192
+ if (value === null || value === void 0) return "";
142193
+ if (typeof value === "string") return value;
142194
+ try {
142195
+ return JSON.stringify(value) ?? "";
142196
+ } catch {
142197
+ return "";
142198
+ }
142199
+ }
142200
+ function extractSearchDelta(entry) {
142201
+ if (entry.type === "response_item" || entry.type === "session_meta") {
142202
+ return extractCodexDelta(entry);
142203
+ }
142204
+ return extractClaudeDelta(entry);
142205
+ }
142206
+ function extractClaudeDelta(entry) {
142207
+ const type = entry.type;
142208
+ if (type !== "user" && type !== "assistant") return emptySearchDocument();
142209
+ if (entry.isMeta) return emptySearchDocument();
142210
+ const msg = entry.message;
142211
+ const content = msg?.content;
142212
+ const tools = [
142213
+ extractClaudeToolContent(content),
142214
+ // Claude stores the rich/structured tool result at the JSONL entry's top
142215
+ // level, not inside message.content — indexing only message.content would
142216
+ // miss most real tool output (file reads, command stdout).
142217
+ capToolPayload(entry.toolUseResult)
142218
+ ].filter(Boolean).join(SEP);
142219
+ return {
142220
+ text: extractClaudeText(content),
142221
+ thinking: type === "assistant" ? extractThinking(content).content : "",
142222
+ tools
142223
+ };
142224
+ }
142225
+ function extractClaudeText(content) {
142226
+ if (typeof content === "string") return cleanSystemTags(content);
142227
+ if (!Array.isArray(content)) return "";
142228
+ const parts = [];
142229
+ for (const item of content) {
142230
+ if (typeof item === "string") {
142231
+ const cleaned = cleanSystemTags(item);
142232
+ if (cleaned) parts.push(cleaned);
142233
+ } else if (item?.type === "text" && typeof item.text === "string") {
142234
+ const cleaned = cleanSystemTags(item.text);
142235
+ if (cleaned) parts.push(cleaned);
142236
+ }
142237
+ }
142238
+ return parts.join(SEP);
142239
+ }
142240
+ function extractClaudeToolContent(content) {
142241
+ if (!Array.isArray(content)) return "";
142242
+ const parts = [];
142243
+ for (const item of content) {
142244
+ if (item?.type === "tool_use") {
142245
+ const capped = capToolPayload(item.input);
142246
+ if (capped) parts.push(capped);
142247
+ } else if (item?.type === "tool_result") {
142248
+ const capped = capToolPayload(item.content);
142249
+ if (capped) parts.push(capped);
142250
+ }
142251
+ }
142252
+ return parts.join(SEP);
142253
+ }
142254
+ function extractCodexDelta(entry) {
142255
+ const payload = entry.payload;
142256
+ if (!payload || typeof payload !== "object") return emptySearchDocument();
142257
+ const ptype = payload.type;
142258
+ if (ptype === "function_call" || ptype === "custom_tool_call") {
142259
+ return { text: "", thinking: "", tools: capToolPayload(payload.arguments) };
142260
+ }
142261
+ if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
142262
+ return { text: "", thinking: "", tools: capToolPayload(payload.output) };
142263
+ }
142264
+ if (ptype === "reasoning") {
142265
+ return { text: "", thinking: extractCodexReasoning(payload), tools: "" };
142266
+ }
142267
+ if (ptype === "message") {
142268
+ const role = payload.role;
142269
+ if (role !== "user" && role !== "assistant") return emptySearchDocument();
142270
+ return { text: extractCodexText2(payload.content), thinking: "", tools: "" };
142271
+ }
142272
+ return emptySearchDocument();
142273
+ }
142274
+ function extractCodexReasoning(payload) {
142275
+ const parts = [];
142276
+ for (const key of ["summary", "content"]) {
142277
+ const blocks = payload[key];
142278
+ if (!Array.isArray(blocks)) continue;
142279
+ for (const block of blocks) {
142280
+ if (typeof block === "string") parts.push(block);
142281
+ else if (typeof block?.text === "string") parts.push(block.text);
142282
+ }
142283
+ }
142284
+ return parts.filter(Boolean).join(SEP);
142285
+ }
142286
+ function extractCodexText2(content) {
142287
+ if (typeof content === "string") return cleanSystemTags(content);
142288
+ if (!Array.isArray(content)) return "";
142289
+ const parts = [];
142290
+ for (const item of content) {
142291
+ if (typeof item === "string") {
142292
+ const cleaned = cleanSystemTags(item);
142293
+ if (cleaned) parts.push(cleaned);
142294
+ continue;
142295
+ }
142296
+ const t = item?.type;
142297
+ if ((t === "input_text" || t === "output_text" || t === "text") && typeof item.text === "string") {
142298
+ const cleaned = cleanSystemTags(item.text);
142299
+ if (cleaned) parts.push(cleaned);
142300
+ }
142301
+ }
142302
+ return parts.join(SEP);
142303
+ }
142096
142304
  var DEFAULT_TIERS = {
142097
142305
  standard: { name: "standard", previewMax: 200, snippetMax: 5e3 },
142098
142306
  full: { name: "full", previewMax: 1200, snippetMax: 5e4 }
@@ -142106,7 +142314,7 @@ function resolveTier(tierName, customTiers) {
142106
142314
  }
142107
142315
  return tier;
142108
142316
  }
142109
- var SCHEMA_VERSION = 4;
142317
+ var SCHEMA_VERSION = 5;
142110
142318
  var SCHEMA_SQL = `
142111
142319
  CREATE TABLE IF NOT EXISTS conversation_files (
142112
142320
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -142206,13 +142414,27 @@ CREATE INDEX IF NOT EXISTS idx_conversations_account_recent ON conversations(acc
142206
142414
  CREATE INDEX IF NOT EXISTS idx_conversations_subagent_recent ON conversations(is_subagent, timestamp DESC);
142207
142415
  CREATE INDEX IF NOT EXISTS idx_conversations_team_recent ON conversations(team_name, timestamp DESC);
142208
142416
 
142209
- -- Full-text search index over conversation content + metadata. Kept separate
142210
- -- from the metadata tables so list-screen queries stay small and fast.
142211
- -- source_path is UNINDEXED (stored, not tokenized) and links back to a
142212
- -- conversations row. One FTS row per conversation, replaced on each upsert.
142417
+ -- Full-text search index over conversation body + metadata. Kept separate from
142418
+ -- the metadata tables so list-screen queries stay small and fast. One FTS row
142419
+ -- per conversation, replaced on each upsert.
142420
+ --
142421
+ -- The row's rowid IS conversation_files.id. That is load-bearing, not cosmetic:
142422
+ -- FTS5's query planner only handles MATCH, rowid and rank, so any other
142423
+ -- constraint (including a source_path equality on an UNINDEXED column) has no
142424
+ -- index and linear-scans the whole table. At ~128 KB of body per row that would
142425
+ -- mean scanning the entire corpus on every append. Look rows up by rowid.
142426
+ -- source_path stays stored-but-UNINDEXED so a search hit can resolve back to a
142427
+ -- conversations row.
142428
+ --
142429
+ -- Body is three columns, not one: text > thinking > tools priority has to
142430
+ -- survive an append (a new user message belongs in the text column, not after
142431
+ -- the tool output already written). They are deliberately NOT concatenated into
142432
+ -- a fourth column - that would double-weight body hits and inflate bm25 length.
142213
142433
  CREATE VIRTUAL TABLE IF NOT EXISTS conversation_messages_fts USING fts5(
142214
142434
  source_path UNINDEXED,
142215
- content,
142435
+ text,
142436
+ thinking,
142437
+ tools,
142216
142438
  project_name,
142217
142439
  session_id,
142218
142440
  session_name,
@@ -142280,6 +142502,24 @@ function runMigrations(db) {
142280
142502
  if (current >= 1 && current < 3 && tableExists(db, "conversations")) {
142281
142503
  db.exec("UPDATE conversations SET provider = 'claude-code' WHERE provider = 'threadbase'");
142282
142504
  }
142505
+ if (current >= 1 && current < 5) {
142506
+ db.exec("DROP TABLE IF EXISTS conversation_messages_fts");
142507
+ if (tableExists(db, "conversation_files")) {
142508
+ const assignments = [];
142509
+ if (hasColumn(db, "conversation_files", "last_indexed_offset")) {
142510
+ assignments.push("last_indexed_offset = 0");
142511
+ }
142512
+ if (hasColumn(db, "conversation_files", "last_indexed_line")) {
142513
+ assignments.push("last_indexed_line = 0");
142514
+ }
142515
+ if (hasColumn(db, "conversation_files", "reducer_state")) {
142516
+ assignments.push("reducer_state = NULL");
142517
+ }
142518
+ if (assignments.length > 0) {
142519
+ db.exec(`UPDATE conversation_files SET ${assignments.join(", ")}`);
142520
+ }
142521
+ }
142522
+ }
142283
142523
  db.exec(SCHEMA_SQL);
142284
142524
  db.pragma(`user_version = ${SCHEMA_VERSION}`);
142285
142525
  }
@@ -142299,6 +142539,7 @@ function openDatabase(dbPath) {
142299
142539
  db.pragma("synchronous = NORMAL");
142300
142540
  db.pragma("temp_store = MEMORY");
142301
142541
  db.pragma("foreign_keys = ON");
142542
+ db.pragma("busy_timeout = 5000");
142302
142543
  runMigrations(db);
142303
142544
  getLogger2().debug({ dbPath }, "db: opened");
142304
142545
  return db;
@@ -142733,22 +142974,31 @@ var ConversationsRepo = class {
142733
142974
  return this.db.prepare("SELECT COUNT(*) AS n FROM conversations WHERE status = 'active'").get().n;
142734
142975
  }
142735
142976
  };
142977
+ var BODY_COLUMNS = [
142978
+ { name: "text", index: 1 },
142979
+ { name: "thinking", index: 2 },
142980
+ { name: "tools", index: 3 }
142981
+ ];
142736
142982
  var FtsRepo = class {
142737
142983
  constructor(db) {
142738
142984
  this.db = db;
142739
142985
  }
142740
142986
  db;
142741
- upsert(meta3) {
142987
+ upsert(rowId, meta3, doc) {
142742
142988
  const sourcePath = canonicalPath(meta3.id);
142743
142989
  const tx = this.db.transaction(() => {
142744
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(sourcePath);
142990
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
142745
142991
  this.db.prepare(
142746
142992
  `INSERT INTO conversation_messages_fts
142747
- (source_path, content, project_name, session_id, session_name, account, model, branch, tool_names)
142748
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
142993
+ (rowid, source_path, text, thinking, tools,
142994
+ project_name, session_id, session_name, account, model, branch, tool_names)
142995
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
142749
142996
  ).run(
142997
+ rowId,
142750
142998
  sourcePath,
142751
- meta3.contentSnippet ?? "",
142999
+ doc.text,
143000
+ doc.thinking,
143001
+ doc.tools,
142752
143002
  meta3.projectName ?? "",
142753
143003
  meta3.sessionId ?? "",
142754
143004
  meta3.sessionName ?? "",
@@ -142760,26 +143010,94 @@ var FtsRepo = class {
142760
143010
  });
142761
143011
  tx();
142762
143012
  }
142763
- remove(sourcePath) {
142764
- this.db.prepare("DELETE FROM conversation_messages_fts WHERE source_path = ?").run(canonicalPath(sourcePath));
143013
+ // Current durable buckets for a conversation, so an append can tail-extend
143014
+ // them without reparsing the file. Returns null when no row exists yet.
143015
+ readDocument(rowId) {
143016
+ const row = this.db.prepare("SELECT text, thinking, tools FROM conversation_messages_fts WHERE rowid = ?").get(rowId);
143017
+ if (!row) return null;
143018
+ return { text: row.text ?? "", thinking: row.thinking ?? "", tools: row.tools ?? "" };
143019
+ }
143020
+ // True when the stored row already equals what we would write, so an append
143021
+ // can skip re-tokenizing ~128 KB of body for nothing.
143022
+ //
143023
+ // The check covers the metadata columns too, not just the buckets: a
143024
+ // newly-seen tool name or a late-resolved session_name changes metadata while
143025
+ // the body is byte-identical, and nothing else ever rewrites this row — so
143026
+ // skipping on "body unchanged" alone would strand that stale value forever.
143027
+ isCurrent(rowId, meta3, doc) {
143028
+ const row = this.db.prepare(
143029
+ `SELECT text, thinking, tools,
143030
+ project_name, session_id, session_name, account, model, branch, tool_names
143031
+ FROM conversation_messages_fts WHERE rowid = ?`
143032
+ ).get(rowId);
143033
+ if (!row) return false;
143034
+ return row.text === doc.text && row.thinking === doc.thinking && row.tools === doc.tools && row.project_name === (meta3.projectName ?? "") && row.session_id === (meta3.sessionId ?? "") && row.session_name === (meta3.sessionName ?? "") && row.account === (meta3.account ?? "") && row.model === (meta3.model ?? "") && row.branch === (meta3.gitBranch ?? "") && row.tool_names === meta3.toolNames.join(" ");
142765
143035
  }
142766
- // Ranked source_paths matching the query, best first. Returns [] on an empty
142767
- // query (callers fall back to a recency listing).
142768
- search(query, limit) {
143036
+ remove(rowId) {
143037
+ this.db.prepare("DELETE FROM conversation_messages_fts WHERE rowid = ?").run(rowId);
143038
+ }
143039
+ // Ranked hits matching the query, best first. Filters run in SQL BEFORE LIMIT:
143040
+ // with a wide corpus a popular term matches far more conversations than one
143041
+ // page, so post-filtering an already-truncated list would report "no results"
143042
+ // for queries that do have them.
143043
+ search(query, limit, filters = {}) {
142769
143044
  const match2 = toMatchQuery(query);
142770
143045
  if (!match2) return [];
143046
+ const snippetSelects = BODY_COLUMNS.map(
143047
+ (c) => `snippet(conversation_messages_fts, ${c.index}, ?, ?, ?, ?) AS snippet_${c.name}`
143048
+ ).join(", ");
143049
+ const params = [];
143050
+ for (const _col of BODY_COLUMNS) {
143051
+ params.push(FTS_HIT_OPEN, FTS_HIT_CLOSE, FTS_ELLIPSIS, FTS_SNIPPET_TOKENS);
143052
+ }
143053
+ params.push(match2);
143054
+ const where = ["conversation_messages_fts MATCH ?", "c.status = 'active'"];
143055
+ if (filters.account) {
143056
+ where.push("c.account = ?");
143057
+ params.push(filters.account);
143058
+ }
143059
+ if (filters.provider) {
143060
+ where.push("c.provider = ?");
143061
+ params.push(filters.provider);
143062
+ }
143063
+ if (filters.project) {
143064
+ where.push("(lower(c.project_path) LIKE ? OR lower(c.project_name) LIKE ?)");
143065
+ const like = `%${filters.project.toLowerCase()}%`;
143066
+ params.push(like, like);
143067
+ }
143068
+ if (filters.since) {
143069
+ where.push("c.timestamp >= ?");
143070
+ params.push(filters.since.toISOString());
143071
+ }
143072
+ if (filters.include === "conversations") {
143073
+ where.push("c.is_subagent = 0 AND c.is_teammate = 0");
143074
+ } else if (filters.include === "subagents") {
143075
+ where.push("c.is_subagent = 1");
143076
+ } else if (filters.include === "teammates") {
143077
+ where.push("c.is_teammate = 1");
143078
+ }
143079
+ params.push(limit);
142771
143080
  const rows = this.db.prepare(
142772
- `SELECT source_path FROM conversation_messages_fts
142773
- WHERE conversation_messages_fts MATCH ?
143081
+ `SELECT conversation_messages_fts.source_path AS source_path, ${snippetSelects}
143082
+ FROM conversation_messages_fts
143083
+ JOIN conversations c ON c.source_path = conversation_messages_fts.source_path
143084
+ WHERE ${where.join(" AND ")}
142774
143085
  ORDER BY rank
142775
143086
  LIMIT ?`
142776
- ).all(match2, limit);
142777
- return rows.map((r) => r.source_path);
143087
+ ).all(...params);
143088
+ return rows.map((row) => ({ sourcePath: row.source_path, body: pickBodySnippet(row) }));
142778
143089
  }
142779
143090
  count() {
142780
143091
  return this.db.prepare("SELECT COUNT(*) AS n FROM conversation_messages_fts").get().n;
142781
143092
  }
142782
143093
  };
143094
+ function pickBodySnippet(row) {
143095
+ for (const col of BODY_COLUMNS) {
143096
+ const parsed = parseFtsSnippet(row[`snippet_${col.name}`]);
143097
+ if (parsed) return parsed;
143098
+ }
143099
+ return null;
143100
+ }
142783
143101
  function toMatchQuery(query) {
142784
143102
  const terms = query.trim().split(/\s+/).map((t) => t.replace(/"/g, "").trim()).filter(Boolean);
142785
143103
  if (terms.length === 0) return "";
@@ -142955,9 +143273,13 @@ var PersistentEngine = class {
142955
143273
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
142956
143274
  const startOffset = resume ? existing.last_indexed_offset : 0;
142957
143275
  const startLine = resume ? existing.last_indexed_line : 0;
143276
+ let searchDelta = emptySearchDocument();
143277
+ const collectSearch = (entry) => {
143278
+ searchDelta = appendSearchDelta(searchDelta, extractSearchDelta(entry));
143279
+ };
142958
143280
  let result;
142959
143281
  try {
142960
- result = await tailReduce(filePath, startOffset, startLine, state, tier);
143282
+ result = await tailReduce(filePath, startOffset, startLine, state, tier, collectSearch);
142961
143283
  } catch (err) {
142962
143284
  log15.warn({ filePath, err }, "persistent: tail read failed");
142963
143285
  return { meta: null, change };
@@ -142970,9 +143292,13 @@ var PersistentEngine = class {
142970
143292
  meta3.gitBranch = resolveGitBranch(meta3.projectPath);
142971
143293
  const fp = stat42.size > 0 ? fingerprint(filePath, stat42.size) : null;
142972
143294
  const fileId = this.files.ensure(filePath, account);
143295
+ const base = resume ? this.fts.readDocument(fileId) ?? emptySearchDocument() : emptySearchDocument();
143296
+ const searchDoc = appendSearchDelta(base, searchDelta);
142973
143297
  const upsert = this.db.transaction(() => {
142974
143298
  this.conversations.upsert(fileId, meta3, state.pageMessageCount);
142975
- this.fts.upsert(meta3);
143299
+ if (!this.fts.isCurrent(fileId, meta3, searchDoc)) {
143300
+ this.fts.upsert(fileId, meta3, searchDoc);
143301
+ }
142976
143302
  if (!resume) this.checkpoints.remove(filePath);
142977
143303
  this.files.updateCursor(fileId, {
142978
143304
  sizeBytes: stat42.size,
@@ -143015,7 +143341,10 @@ var PersistentEngine = class {
143015
143341
  // any change reparses from 0 again. No reducer_state is persisted.
143016
143342
  async indexFileWithProvider(provider, filePath, account, tier, stat42, resolveGitBranch) {
143017
143343
  const log15 = getLogger2();
143018
- const meta3 = await parseMetaWithProvider(provider, filePath, account, tier);
143344
+ let searchDoc = emptySearchDocument();
143345
+ const meta3 = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
143346
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
143347
+ });
143019
143348
  if (!meta3) {
143020
143349
  this.markDeleted(filePath);
143021
143350
  return null;
@@ -143027,7 +143356,9 @@ var PersistentEngine = class {
143027
143356
  const fileId = this.files.ensure(filePath, account);
143028
143357
  const upsert = this.db.transaction(() => {
143029
143358
  this.conversations.upsert(fileId, meta3, meta3.messageCount);
143030
- this.fts.upsert(meta3);
143359
+ if (!this.fts.isCurrent(fileId, meta3, searchDoc)) {
143360
+ this.fts.upsert(fileId, meta3, searchDoc);
143361
+ }
143031
143362
  this.checkpoints.remove(filePath);
143032
143363
  this.files.updateCursor(fileId, {
143033
143364
  sizeBytes: stat42.size,
@@ -143053,7 +143384,7 @@ var PersistentEngine = class {
143053
143384
  if (!existing) return;
143054
143385
  const tx = this.db.transaction(() => {
143055
143386
  this.conversations.deleteByFileId(existing.id);
143056
- this.fts.remove(filePath);
143387
+ this.fts.remove(existing.id);
143057
143388
  this.checkpoints.remove(filePath);
143058
143389
  this.files.setStatus(existing.id, "deleted");
143059
143390
  });
@@ -143069,20 +143400,24 @@ var PersistentEngine = class {
143069
143400
  getAllBySessionId(sessionId) {
143070
143401
  return this.conversations.getAllBySessionId(sessionId);
143071
143402
  }
143072
- // Ranked metas matching the FTS query, best first. Empty query returns the
143073
- // most recent conversations (mirroring the in-memory indexer's empty-query
143074
- // behavior). Resolves each FTS hit to its active conversation row.
143075
- searchMetas(query, limit) {
143076
- if (!query.trim()) {
143077
- return this.conversations.recent(limit);
143078
- }
143079
- const paths = this.fts.search(query, limit);
143080
- const metas = [];
143081
- for (const path2 of paths) {
143082
- const meta3 = this.conversations.getBySourcePath(path2);
143083
- if (meta3) metas.push(meta3);
143403
+ // Ranked hits matching the FTS query, best first, each already resolved to its
143404
+ // active conversation row and carrying the body excerpt when the match was in
143405
+ // the conversation body.
143406
+ //
143407
+ // Filters are passed down into SQL rather than applied to the result: with a
143408
+ // wide corpus, filtering an already-LIMITed list drops conversations that
143409
+ // would have matched.
143410
+ searchHits(query, limit, filters = {}) {
143411
+ const hits = [];
143412
+ for (const hit of this.fts.search(query, limit, filters)) {
143413
+ const meta3 = this.conversations.getBySourcePath(hit.sourcePath);
143414
+ if (meta3) hits.push({ meta: meta3, body: hit.body });
143084
143415
  }
143085
- return metas;
143416
+ return hits;
143417
+ }
143418
+ // Empty-query listing, mirroring the in-memory indexer's behavior.
143419
+ recentMetas(limit) {
143420
+ return this.conversations.recent(limit);
143086
143421
  }
143087
143422
  getProjects() {
143088
143423
  return this.conversations.distinctProjects();
@@ -143236,6 +143571,10 @@ var DEFAULT_CONFIG_PATH2 = "~/.config/threadbase-scanner";
143236
143571
  function defaultDbPath() {
143237
143572
  return process.env.TB_SCANNER_DB ?? (0, import_path10.join)((0, import_os8.homedir)(), ".config", "threadbase-scanner", "index.db");
143238
143573
  }
143574
+ function capForMemory(doc, max) {
143575
+ const combined = combineSearchContent(doc);
143576
+ return combined.length > max ? combined.slice(-max) : combined;
143577
+ }
143239
143578
  var ConversationScanner = class {
143240
143579
  metadataCache = /* @__PURE__ */ new Map();
143241
143580
  // Parsed conversations plus (persistent claude-code entries only) the resume
@@ -143247,6 +143586,10 @@ var ConversationScanner = class {
143247
143586
  sessionIdIndex = /* @__PURE__ */ new Map();
143248
143587
  projects = /* @__PURE__ */ new Set();
143249
143588
  indexer = new SearchIndexer();
143589
+ // Search body per conversation for the in-memory path, already tail-capped to
143590
+ // the content tier. Survives scan() so a statCache hit — which skips the parse
143591
+ // entirely — can still index a body rather than an empty string.
143592
+ searchContents = /* @__PURE__ */ new Map();
143250
143593
  // Tier the most recent scan() ran with, so refreshFile() re-parses a single
143251
143594
  // file at the same content depth. Defaults to the standard tier.
143252
143595
  lastTier = resolveTier("standard");
@@ -143407,34 +143750,42 @@ var ConversationScanner = class {
143407
143750
  try {
143408
143751
  const s3 = (0, import_fs9.statSync)(filePath);
143409
143752
  if (s3.mtimeMs === cached4.stat.mtimeMs && s3.size === cached4.stat.size) {
143410
- return cached4.meta;
143753
+ return {
143754
+ meta: cached4.meta,
143755
+ searchContent: this.searchContents.get(cached4.meta.id)
143756
+ };
143411
143757
  }
143412
143758
  } catch {
143413
143759
  }
143414
143760
  }
143415
143761
  }
143416
143762
  try {
143417
- const meta3 = await parseMetaWithProvider(provider, filePath, account, tier);
143763
+ let doc = emptySearchDocument();
143764
+ const meta3 = await parseMetaWithProvider(provider, filePath, account, tier, (entry) => {
143765
+ doc = appendSearchDelta(doc, extractSearchDelta(entry));
143766
+ });
143418
143767
  if (meta3 && meta3.gitBranch === null && meta3.projectPath) {
143419
143768
  meta3.gitBranch = resolveGitBranch(meta3.projectPath);
143420
143769
  }
143421
- return meta3;
143770
+ return { meta: meta3, searchContent: capForMemory(doc, tier.snippetMax) };
143422
143771
  } catch (err) {
143423
143772
  parseFailures++;
143424
143773
  log15.warn({ filePath, account, provider: provider.name, err }, "scan: parse threw");
143425
- return null;
143774
+ return { meta: null, searchContent: void 0 };
143426
143775
  }
143427
143776
  })
143428
143777
  );
143429
143778
  const batchMetas = [];
143430
- for (const meta3 of results) {
143779
+ for (const { meta: meta3, searchContent } of results) {
143431
143780
  if (meta3 && meta3.messageCount > 0) {
143432
143781
  this.metadataCache.set(meta3.id, meta3);
143433
143782
  this.addToSessionIndex(meta3);
143434
143783
  this.projects.add(meta3.projectPath);
143435
143784
  allMetas.push(meta3);
143436
143785
  batchMetas.push(meta3);
143437
- this.indexer.addDocument(meta3);
143786
+ const content = searchContent ?? this.searchContents.get(meta3.id) ?? "";
143787
+ this.searchContents.set(meta3.id, content);
143788
+ this.indexer.addDocument(meta3, content);
143438
143789
  }
143439
143790
  }
143440
143791
  if (batchMetas.length > 0) {
@@ -143471,12 +143822,29 @@ var ConversationScanner = class {
143471
143822
  const activeProfiles = profiles.filter((p2) => p2.enabled && p2.scanHistory !== false);
143472
143823
  await engine.indexAll(activeProfiles, { ...options, limit: void 0, offset: void 0 });
143473
143824
  }
143474
- const metas = engine.searchMetas(query, (options.limit ?? 50) * 2);
143475
- results = query.trim() ? metas.map((meta3) => ({ meta: meta3, score: 1, matches: generateMatches(meta3, query) })) : metas.map((meta3) => ({
143476
- meta: meta3,
143477
- score: 1,
143478
- matches: [{ field: "timestamp", snippet: meta3.preview }]
143479
- }));
143825
+ if (query.trim()) {
143826
+ const want = (options.limit ?? 50) + (options.offset ?? 0);
143827
+ results = engine.searchHits(query, want, {
143828
+ account: options.account,
143829
+ provider: options.provider,
143830
+ project: options.project,
143831
+ since: options.since ? parseSinceCutoff(options.since) : void 0,
143832
+ include: options.include
143833
+ }).map(({ meta: meta3, body }) => ({
143834
+ meta: meta3,
143835
+ score: 1,
143836
+ matches: body ? [
143837
+ { field: CONTENT_FIELD, snippet: body.snippet, highlights: body.highlights },
143838
+ ...generateMatches(meta3, query).filter((m2) => m2.field !== "preview")
143839
+ ] : generateMatches(meta3, query)
143840
+ }));
143841
+ } else {
143842
+ results = engine.recentMetas((options.limit ?? 50) + (options.offset ?? 0)).map((meta3) => ({
143843
+ meta: meta3,
143844
+ score: 1,
143845
+ matches: [{ field: "timestamp", snippet: meta3.preview }]
143846
+ }));
143847
+ }
143480
143848
  } else {
143481
143849
  if (this.indexer.getDocumentCount() === 0) {
143482
143850
  log15.debug("search: index empty, triggering scan");
@@ -143670,8 +144038,11 @@ var ConversationScanner = class {
143670
144038
  const previous = this.metadataCache.get(filePath);
143671
144039
  const resolvedAccount = account ?? previous?.account ?? "default";
143672
144040
  let meta3 = null;
144041
+ let searchDoc = emptySearchDocument();
143673
144042
  try {
143674
- meta3 = await parseMeta(filePath, resolvedAccount, this.lastTier);
144043
+ meta3 = await parseMeta(filePath, resolvedAccount, this.lastTier, (entry) => {
144044
+ searchDoc = appendSearchDelta(searchDoc, extractSearchDelta(entry));
144045
+ });
143675
144046
  } catch (err) {
143676
144047
  log15.warn({ filePath, err }, "refreshFile: parseMeta threw");
143677
144048
  meta3 = null;
@@ -143688,6 +144059,7 @@ var ConversationScanner = class {
143688
144059
  this.metadataCache.delete(previous.id);
143689
144060
  this.removeFromSessionIndex(previous);
143690
144061
  this.indexer.removeDocument(previous.id);
144062
+ this.searchContents.delete(previous.id);
143691
144063
  }
143692
144064
  log15.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
143693
144065
  return null;
@@ -143697,10 +144069,12 @@ var ConversationScanner = class {
143697
144069
  this.metadataCache.set(meta3.id, meta3);
143698
144070
  this.addToSessionIndex(meta3);
143699
144071
  this.projects.add(meta3.projectPath);
144072
+ const searchContent = capForMemory(searchDoc, this.lastTier.snippetMax);
144073
+ this.searchContents.set(meta3.id, searchContent);
143700
144074
  if (previous) {
143701
- this.indexer.updateDocument(meta3);
144075
+ this.indexer.updateDocument(meta3, searchContent);
143702
144076
  } else {
143703
- this.indexer.addDocument(meta3);
144077
+ this.indexer.addDocument(meta3, searchContent);
143704
144078
  }
143705
144079
  log15.debug(
143706
144080
  { filePath, messageCount: meta3.messageCount },
@@ -145550,7 +145924,7 @@ function paginate(results, offset, limit) {
145550
145924
  }
145551
145925
 
145552
145926
  // src/utils/codexConversationLine.ts
145553
- function extractCodexText2(content) {
145927
+ function extractCodexText3(content) {
145554
145928
  if (typeof content === "string") return content.trim();
145555
145929
  if (!Array.isArray(content)) return "";
145556
145930
  return content.map((item) => {
@@ -145589,7 +145963,7 @@ function classifyCodexLine(line) {
145589
145963
  if (role !== "user" && role !== "assistant") {
145590
145964
  return { kind: "ignored", reason: `role ${String(role)} is not rendered` };
145591
145965
  }
145592
- const text = extractCodexText2(payload.content);
145966
+ const text = extractCodexText3(payload.content);
145593
145967
  if (!text) {
145594
145968
  return { kind: "ignored", reason: "message has no extractable text" };
145595
145969
  }
@@ -154529,13 +154903,16 @@ var StreamerServer = class {
154529
154903
  json2(res, 400, { error: "Missing token or clientPublicKey" });
154530
154904
  return;
154531
154905
  }
154532
- let e2eeRequest;
154533
- try {
154534
- e2eeRequest = parseE2eeRequest(body?.e2ee);
154535
- } catch (err) {
154536
- const e = err;
154537
- json2(res, 400, { error: e.message, code: e.code });
154538
- return;
154906
+ const e2eeEnabled = describeE2eeCapability(this.featureFlags.e2ee).enabled;
154907
+ let e2eeRequest = null;
154908
+ if (e2eeEnabled) {
154909
+ try {
154910
+ e2eeRequest = parseE2eeRequest(body?.e2ee);
154911
+ } catch (err) {
154912
+ const e = err;
154913
+ json2(res, 400, { error: e.message, code: e.code });
154914
+ return;
154915
+ }
154539
154916
  }
154540
154917
  const precheck = this.pairTokens.wouldConsume(token);
154541
154918
  if (!precheck.ok) {