agentel 0.2.8 → 0.3.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/src/search.js CHANGED
@@ -4,13 +4,13 @@ const fs = require("fs");
4
4
  const os = require("os");
5
5
  const path = require("path");
6
6
  const { spawnSync } = require("child_process");
7
- const { ensureConversationMarkdown, listSessions, readEvents, readTranscript, sessionHistoryTime } = require("./archive");
7
+ const { ensureConversationMarkdown, listSessions, listSessionsSnapshot, readEvents, readTranscript, sessionHistoryTime } = require("./archive");
8
8
  const { EVENT_KINDS, renderEventText } = require("./canonical-events");
9
9
  const { loadConfig } = require("./config");
10
10
  const { paths, ensureDir, readJson, writeJson } = require("./paths");
11
11
  const { canonicalRepo } = require("./repo");
12
12
 
13
- const INDEX_VERSION = 3;
13
+ const INDEX_VERSION = 8;
14
14
  const INDEX_STALE_CHECK_TTL_MS = 5000;
15
15
  const SQLITE_BUILD_BATCH_SIZE = 500;
16
16
  const RIPGREP_SEARCH_TIMEOUT_MS = 8000;
@@ -18,6 +18,18 @@ const RIPGREP_BATCH_FILE_COUNT = 200;
18
18
  const MARKDOWN_MATCHES_PER_FILE = 3;
19
19
  const FTS_SEARCH_BATCH_SIZE = 250;
20
20
  const FTS_MAX_SCAN_ROWS = 5000;
21
+ // Most queries find `limit` distinct sessions within the first few hundred
22
+ // ranked rows — broader terms used to hit FTS_MAX_SCAN_ROWS unconditionally,
23
+ // which forced FTS5 to read ~5000 rows from the 469MB index even when the
24
+ // answer was in the first 200. Try a cheap primary scan first and only
25
+ // re-query at the wide cap if dedupe leaves fewer than `limit` sessions.
26
+ const FTS_PRIMARY_SCAN_MULTIPLIER = 100;
27
+ // Tool *output* (Bash stdout, file dumps, test logs) is high-volume and
28
+ // low-signal: the useful part (error strings, the head of a diff, the start
29
+ // of a stack trace) is front-loaded. We still index tool.completed events so
30
+ // those head matches are findable, but cap the indexed/excerpt text so a
31
+ // 50k-line log doesn't add dozens of chunk docs and inflate every BM25 scan.
32
+ const TOOL_COMPLETED_INDEX_MAX_CHARS = 600;
21
33
  const _indexCache = {
22
34
  path: "",
23
35
  mtimeMs: 0,
@@ -46,14 +58,29 @@ const _ftsReadConn = {
46
58
  let _betterSqlite3 = null;
47
59
  let _betterSqlite3Loaded = false;
48
60
  let _betterSqlite3LoadError = null;
61
+ // Latched true once `new Database()` throws at runtime (e.g. ABI mismatch
62
+ // after Node version change). Once latched, loadBetterSqlite3 returns null
63
+ // permanently so callers take the spawn fallback instead of repeatedly
64
+ // crashing in the constructor.
65
+ let _betterSqlite3Broken = false;
49
66
 
50
67
  /**
51
68
  * Lazily load better-sqlite3. Returns the constructor or null when the native
52
69
  * binding is unavailable; call sites then fall back to the legacy `sqlite3`
53
70
  * subprocess path. The load attempt is cached so missing optional builds do
54
71
  * not re-try `require()` on every query.
72
+ *
73
+ * Two failure modes are handled here:
74
+ * 1. `require("better-sqlite3")` itself throws (no native binding shipped).
75
+ * 2. The require succeeds but `new Database()` later throws because the
76
+ * native .node file was built against a different Node ABI (e.g. nvm
77
+ * flipped versions). The lower-level openFts*Db helpers call
78
+ * `markBetterSqlite3Broken()` when that happens, after which we
79
+ * permanently return null here so callers consistently take the spawn
80
+ * fallback instead of repeatedly hitting the same constructor crash.
55
81
  */
56
82
  function loadBetterSqlite3() {
83
+ if (_betterSqlite3Broken) return null;
57
84
  if (_betterSqlite3Loaded) return _betterSqlite3;
58
85
  _betterSqlite3Loaded = true;
59
86
  try {
@@ -69,19 +96,31 @@ function betterSqlite3LoadError() {
69
96
  return _betterSqlite3LoadError;
70
97
  }
71
98
 
99
+ function markBetterSqlite3Broken(error) {
100
+ _betterSqlite3Broken = true;
101
+ if (error && !_betterSqlite3LoadError) _betterSqlite3LoadError = error;
102
+ }
103
+
104
+ function resetBetterSqlite3State() {
105
+ // Test-only: clear the load+broken latches so a test can simulate fresh
106
+ // module load with different `require` behavior. Not part of any public
107
+ // API contract.
108
+ _betterSqlite3 = null;
109
+ _betterSqlite3Loaded = false;
110
+ _betterSqlite3LoadError = null;
111
+ _betterSqlite3Broken = false;
112
+ }
113
+
72
114
  function buildIndex(env = process.env) {
73
- const sessions = listSessions(env);
115
+ const snapshot = listSessionsSnapshot(env);
116
+ const sessions = snapshot.sessions;
74
117
  const docs = [];
75
118
  const postings = Object.create(null);
76
119
  const df = Object.create(null);
77
120
  let totalLength = 0;
78
121
 
79
122
  for (const session of sessions) {
80
- if (session.conversationPath && !fs.existsSync(session.conversationPath)) ensureConversationMarkdown(session, env);
81
- const events = readEvents(session);
82
- const eventDocs = events.length ? docsForEvents(session, events) : [];
83
- if (!eventDocs.length) ensureConversationMarkdown(session, env);
84
- const sourceDocs = eventDocs.length ? eventDocs : docsForTranscript(session, readTranscript(session.transcriptPath));
123
+ const sourceDocs = canonicalDocsForSession(session);
85
124
  for (const sourceDoc of sourceDocs) {
86
125
  const indexText = normalizeIndexText(sourceDoc.text);
87
126
  if (!indexText) continue;
@@ -111,6 +150,7 @@ function buildIndex(env = process.env) {
111
150
  const index = {
112
151
  version: INDEX_VERSION,
113
152
  builtAt: new Date().toISOString(),
153
+ sessionFingerprint: snapshot.fingerprint,
114
154
  docCount: docs.length,
115
155
  avgDocLength: docs.length ? totalLength / docs.length : 0,
116
156
  df,
@@ -139,6 +179,7 @@ function summarizeIndex(index) {
139
179
  return {
140
180
  version: index.version,
141
181
  builtAt: index.builtAt,
182
+ sessionFingerprint: index.sessionFingerprint,
142
183
  docCount: index.docCount,
143
184
  avgDocLength: index.avgDocLength,
144
185
  summaryOnly: true
@@ -155,6 +196,7 @@ function readIndexSummary(indexPath) {
155
196
  const summary = {
156
197
  version: readJsonHeaderNumber(header, "version"),
157
198
  builtAt: readJsonHeaderString(header, "builtAt"),
199
+ sessionFingerprint: readJsonHeaderString(header, "sessionFingerprint"),
158
200
  docCount: readJsonHeaderNumber(header, "docCount"),
159
201
  avgDocLength: readJsonHeaderNumber(header, "avgDocLength"),
160
202
  summaryOnly: true
@@ -190,9 +232,12 @@ const FTS_SCHEMA_SQL = [
190
232
  " session_id TEXT,",
191
233
  " provider TEXT,",
192
234
  " source_type TEXT,",
235
+ " conversation_kind TEXT,",
236
+ " is_subagent INTEGER,",
193
237
  " repo_canonical TEXT,",
194
238
  " repo_display TEXT,",
195
239
  " scope_canonical TEXT,",
240
+ " storage_scope TEXT,",
196
241
  " cwd TEXT,",
197
242
  " title TEXT,",
198
243
  " started_at TEXT,",
@@ -229,6 +274,7 @@ function buildFtsIndex(index, env = process.env) {
229
274
  insertFtsMetaRows(handle, [
230
275
  { key: "version", value: String(INDEX_VERSION) },
231
276
  { key: "builtAt", value: index.builtAt || "" },
277
+ { key: "sessionFingerprint", value: index.sessionFingerprint || "" },
232
278
  { key: "docCount", value: String(index.docCount || 0) }
233
279
  ]);
234
280
  insertFtsDocs(handle, tmpPath, index.docs || [], 1);
@@ -252,6 +298,7 @@ function buildFtsIndex(index, env = process.env) {
252
298
  }
253
299
 
254
300
  function buildFtsIndexSummary(env = process.env) {
301
+ const snapshot = listSessionsSnapshot(env);
255
302
  const ftsPath = paths(env).ftsIndex;
256
303
  const tmpPath = `${ftsPath}.${process.pid}.tmp`;
257
304
  const builtAt = new Date().toISOString();
@@ -265,16 +312,13 @@ function buildFtsIndexSummary(env = process.env) {
265
312
  execFtsBuildSql(handle, FTS_SCHEMA_SQL);
266
313
  insertFtsMetaRows(handle, [
267
314
  { key: "version", value: String(INDEX_VERSION) },
268
- { key: "builtAt", value: builtAt }
315
+ { key: "builtAt", value: builtAt },
316
+ { key: "sessionFingerprint", value: snapshot.fingerprint || "" }
269
317
  ]);
270
318
 
271
319
  let batch = [];
272
- for (const session of listSessions(env)) {
273
- if (session.conversationPath && !fs.existsSync(session.conversationPath)) ensureConversationMarkdown(session, env);
274
- const events = readEvents(session);
275
- const eventDocs = events.length ? docsForEvents(session, events) : [];
276
- if (!eventDocs.length) ensureConversationMarkdown(session, env);
277
- const sourceDocs = eventDocs.length ? eventDocs : docsForTranscript(session, readTranscript(session.transcriptPath));
320
+ for (const session of snapshot.sessions) {
321
+ const sourceDocs = canonicalDocsForSession(session);
278
322
  for (const sourceDoc of sourceDocs) {
279
323
  const indexText = normalizeIndexText(sourceDoc.text);
280
324
  if (!indexText) continue;
@@ -319,6 +363,7 @@ function buildFtsIndexSummary(env = process.env) {
319
363
  return {
320
364
  version: INDEX_VERSION,
321
365
  builtAt,
366
+ sessionFingerprint: snapshot.fingerprint,
322
367
  docCount,
323
368
  avgDocLength: docCount ? totalLength / docCount : 0,
324
369
  summaryOnly: true
@@ -326,8 +371,8 @@ function buildFtsIndexSummary(env = process.env) {
326
371
  }
327
372
 
328
373
  const FTS_DOCS_INSERT_SQL =
329
- "INSERT INTO docs(rowid, doc_id, session_id, provider, source_type, repo_canonical, repo_display, scope_canonical, cwd, title, started_at, occurred_at, role, event_id, event_kind, message_index, path, matched_text) " +
330
- "VALUES (@rowid, @doc_id, @session_id, @provider, @source_type, @repo_canonical, @repo_display, @scope_canonical, @cwd, @title, @started_at, @occurred_at, @role, @event_id, @event_kind, @message_index, @path, @matched_text)";
374
+ "INSERT INTO docs(rowid, doc_id, session_id, provider, source_type, conversation_kind, is_subagent, repo_canonical, repo_display, scope_canonical, storage_scope, cwd, title, started_at, occurred_at, role, event_id, event_kind, message_index, path, matched_text) " +
375
+ "VALUES (@rowid, @doc_id, @session_id, @provider, @source_type, @conversation_kind, @is_subagent, @repo_canonical, @repo_display, @scope_canonical, @storage_scope, @cwd, @title, @started_at, @occurred_at, @role, @event_id, @event_kind, @message_index, @path, @matched_text)";
331
376
  const FTS_FTS_INSERT_SQL = "INSERT INTO docs_fts(rowid, text) VALUES (@rowid, @text)";
332
377
 
333
378
  function docInsertParams(doc, rowid) {
@@ -337,9 +382,16 @@ function docInsertParams(doc, rowid) {
337
382
  session_id: doc.sessionId || "",
338
383
  provider: doc.provider || "",
339
384
  source_type: doc.sourceType || "",
385
+ conversation_kind: doc.conversationKind || "",
386
+ // Precompute the subagent flag once at index time using the same JS
387
+ // predicate the rest of the codebase uses. Querying an integer column is
388
+ // ~50x faster than `NOT GLOB '*_subagent'`, which scans every row's
389
+ // string char-by-char because of the leading wildcard.
390
+ is_subagent: isSubagentSession(doc) ? 1 : 0,
340
391
  repo_canonical: doc.repoCanonical || "",
341
392
  repo_display: doc.repoDisplay || "",
342
393
  scope_canonical: doc.scopeCanonical || "",
394
+ storage_scope: doc.storageScope || "",
343
395
  cwd: doc.cwd || "",
344
396
  title: doc.title || "",
345
397
  started_at: doc.startedAt || "",
@@ -414,16 +466,19 @@ function insertFtsDocs(handleOrPath, dbPathHint, docs, rowidStart = 1) {
414
466
  const doc = batch[offset];
415
467
  const sqlString = (value) => `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
416
468
  statements.push(
417
- "INSERT INTO docs(rowid, doc_id, session_id, provider, source_type, repo_canonical, repo_display, scope_canonical, cwd, title, started_at, occurred_at, role, event_id, event_kind, message_index, path, matched_text) VALUES (" +
469
+ "INSERT INTO docs(rowid, doc_id, session_id, provider, source_type, conversation_kind, is_subagent, repo_canonical, repo_display, scope_canonical, storage_scope, cwd, title, started_at, occurred_at, role, event_id, event_kind, message_index, path, matched_text) VALUES (" +
418
470
  [
419
471
  rowid,
420
472
  sqlString(doc.id || ""),
421
473
  sqlString(doc.sessionId || ""),
422
474
  sqlString(doc.provider || ""),
423
475
  sqlString(doc.sourceType || ""),
476
+ sqlString(doc.conversationKind || ""),
477
+ isSubagentSession(doc) ? 1 : 0,
424
478
  sqlString(doc.repoCanonical || ""),
425
479
  sqlString(doc.repoDisplay || ""),
426
480
  sqlString(doc.scopeCanonical || ""),
481
+ sqlString(doc.storageScope || ""),
427
482
  sqlString(doc.cwd || ""),
428
483
  sqlString(doc.title || ""),
429
484
  sqlString(doc.startedAt || ""),
@@ -455,12 +510,22 @@ function insertFtsDocs(handleOrPath, dbPathHint, docs, rowidStart = 1) {
455
510
  function openFtsBuildDb(dbPath) {
456
511
  const Database = loadBetterSqlite3();
457
512
  if (Database) {
458
- const db = new Database(dbPath);
459
- db.pragma("journal_mode = OFF");
460
- db.pragma("synchronous = OFF");
461
- db.pragma("temp_store = MEMORY");
462
- db.pragma("locking_mode = EXCLUSIVE");
463
- return { kind: "native", db };
513
+ // `new Database()` can throw on ABI mismatch (different Node version
514
+ // than the one better-sqlite3 was compiled for). Without this guard the
515
+ // exception propagates up to buildFtsIndex's catch block, which then
516
+ // deletes the FTS index file — hard-breaking search until next reindex.
517
+ // Falling back to the spawn path preserves search instead.
518
+ try {
519
+ const db = new Database(dbPath);
520
+ db.pragma("journal_mode = OFF");
521
+ db.pragma("synchronous = OFF");
522
+ db.pragma("temp_store = MEMORY");
523
+ db.pragma("locking_mode = EXCLUSIVE");
524
+ return { kind: "native", db };
525
+ } catch (error) {
526
+ markBetterSqlite3Broken(error);
527
+ // Fall through to spawn handle.
528
+ }
464
529
  }
465
530
  return { kind: "spawn", dbPath };
466
531
  }
@@ -502,7 +567,11 @@ function openFtsReadDb(ftsPath, stat) {
502
567
  try {
503
568
  db = new Database(ftsPath, { readonly: true, fileMustExist: true });
504
569
  db.pragma("query_only = ON");
505
- } catch {
570
+ } catch (error) {
571
+ // ABI-mismatch crashes look like ordinary throws here. Latch the module
572
+ // as broken so other readers/builders take the spawn path immediately
573
+ // instead of repeatedly hitting the same crash.
574
+ markBetterSqlite3Broken(error);
506
575
  return null;
507
576
  }
508
577
  _ftsReadConn.path = ftsPath;
@@ -586,6 +655,10 @@ function inlineSqlParams(query, params) {
586
655
  });
587
656
  }
588
657
 
658
+ function escapeSqlLike(value) {
659
+ return String(value).replace(/[\\%_]/g, (match) => `\\${match}`);
660
+ }
661
+
589
662
  function rememberFtsCache(ftsPath, available) {
590
663
  let stat = null;
591
664
  try {
@@ -605,14 +678,14 @@ function readFtsMeta(ftsPath, stat) {
605
678
  const conn = openFtsReadDb(ftsPath, stat);
606
679
  if (conn) {
607
680
  try {
608
- const stmt = prepareFtsStatement(conn, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount')");
681
+ const stmt = prepareFtsStatement(conn, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount', 'sessionFingerprint')");
609
682
  return stmt.all();
610
683
  } catch {
611
684
  closeFtsReadDb();
612
685
  return null;
613
686
  }
614
687
  }
615
- return legacySqliteJson(ftsPath, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount');");
688
+ return legacySqliteJson(ftsPath, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount', 'sessionFingerprint');");
616
689
  }
617
690
 
618
691
  function ftsIndexAvailable(env = process.env, options = {}) {
@@ -651,13 +724,20 @@ function docsForEvents(session, events) {
651
724
  const indexedKinds = new Set([
652
725
  EVENT_KINDS.PROMPT_SUBMITTED,
653
726
  EVENT_KINDS.RESPONSE_GENERATED,
654
- EVENT_KINDS.TOOL_CALLED
727
+ EVENT_KINDS.TOOL_CALLED,
728
+ EVENT_KINDS.TOOL_COMPLETED
655
729
  ]);
656
730
  return events
657
731
  .filter((event) => indexedKinds.has(event.kind))
658
732
  .map((event) => {
659
- const renderedText = renderEventText(event);
733
+ let renderedText = renderEventText(event);
660
734
  if (!String(renderedText || "").trim()) return null;
735
+ // Cap tool output to its head only. The first ~600 chars hold the
736
+ // error message / first lines of a diff / start of a stack trace;
737
+ // the tail is verbose machine output with near-zero recall value.
738
+ if (event.kind === EVENT_KINDS.TOOL_COMPLETED && renderedText.length > TOOL_COMPLETED_INDEX_MAX_CHARS) {
739
+ renderedText = `${renderedText.slice(0, TOOL_COMPLETED_INDEX_MAX_CHARS)} [truncated]`;
740
+ }
661
741
  return {
662
742
  id: event.eventId,
663
743
  eventId: event.eventId,
@@ -666,9 +746,11 @@ function docsForEvents(session, events) {
666
746
  sessionId: session.sessionId,
667
747
  provider: event.provider || session.provider,
668
748
  sourceType: event.sourceType || session.sourceType || "",
749
+ conversationKind: session.conversationKind || "",
669
750
  repoCanonical: event.repoCanonical || session.repoCanonical || "",
670
751
  repoDisplay: displayRepoLabel(session),
671
752
  scopeCanonical: event.scopeCanonical || session.scopeCanonical || "",
753
+ storageScope: session.storageScope || "",
672
754
  cwd: session.cwd || "",
673
755
  title: session.title || event.indexed?.title || "",
674
756
  eventTitle: event.indexed?.title || "",
@@ -684,6 +766,11 @@ function docsForEvents(session, events) {
684
766
  .filter(Boolean);
685
767
  }
686
768
 
769
+ function canonicalDocsForSession(session) {
770
+ const events = readEvents(session);
771
+ return events.length ? docsForEvents(session, events) : [];
772
+ }
773
+
687
774
  function docsForTranscript(session, messages) {
688
775
  const docs = [];
689
776
  for (const message of messages) {
@@ -692,9 +779,11 @@ function docsForTranscript(session, messages) {
692
779
  sessionId: session.sessionId,
693
780
  provider: session.provider,
694
781
  sourceType: session.sourceType || "",
782
+ conversationKind: session.conversationKind || "",
695
783
  repoCanonical: session.repoCanonical || "",
696
784
  repoDisplay: displayRepoLabel(session),
697
785
  scopeCanonical: session.scopeCanonical || "",
786
+ storageScope: session.storageScope || "",
698
787
  cwd: session.cwd || "",
699
788
  title: session.title || "",
700
789
  startedAt: session.startedAt,
@@ -758,11 +847,12 @@ function rememberIndexCache(indexPath, index) {
758
847
  }
759
848
 
760
849
  function searchPastSessions(query, options = {}, env = process.env) {
850
+ const allowMarkdownFallback = Boolean(options.markdownFallback || options.legacyMarkdownFallback || options.allowMarkdownFallback);
761
851
  try {
762
852
  const eventResults = searchIndexedSessions(query, options, env);
763
- if (eventResults.length || options.skipMarkdownFallback) return eventResults;
853
+ if (eventResults.length || !allowMarkdownFallback || options.skipMarkdownFallback) return eventResults;
764
854
  } catch {
765
- if (options.skipMarkdownFallback) return [];
855
+ if (!allowMarkdownFallback || options.skipMarkdownFallback) return [];
766
856
  // Fall through to the legacy markdown path below.
767
857
  }
768
858
  return searchMarkdownSessions(query, options, env);
@@ -772,6 +862,7 @@ function searchMarkdownSessions(query, options = {}, env = process.env) {
772
862
  const limit = Math.max(1, Math.min(Number(options.limit || 10), 50));
773
863
  const maxMatches = Math.max(limit * 8, 40);
774
864
  const includeWebChats = Boolean(options.includeWebChats);
865
+ const includeSubagents = Boolean(options.includeSubagents || options.include_subagents);
775
866
  const filter = normalizeSessionFilter(options);
776
867
  const repo = filter.repo || inferCallingRepo(options.cwd || process.cwd());
777
868
  const since = parseSinceFilter(options.since);
@@ -780,7 +871,7 @@ function searchMarkdownSessions(query, options = {}, env = process.env) {
780
871
  if (!queryTokens.length && !phrase) return [];
781
872
 
782
873
  const sessions = listSessions(env).filter((session) => {
783
- if (!matchesSessionFilter(session, { ...filter, includeWebChats, since })) return false;
874
+ if (!matchesSessionFilter(session, { ...filter, includeWebChats, includeSubagents, since })) return false;
784
875
  const conversationPath = ensureConversationMarkdown(session, env);
785
876
  const searchPath = conversationPath || session.transcriptPath;
786
877
  session._searchPath = searchPath;
@@ -838,22 +929,23 @@ function searchMarkdownSessions(query, options = {}, env = process.env) {
838
929
  function searchIndexedSessions(query, options = {}, env = process.env) {
839
930
  const limit = Math.max(1, Math.min(Number(options.limit || 10), 50));
840
931
  const includeWebChats = Boolean(options.includeWebChats);
932
+ const includeSubagents = Boolean(options.includeSubagents || options.include_subagents);
841
933
  const filter = normalizeSessionFilter(options);
842
934
  const repo = filter.repo || inferCallingRepo(options.cwd || process.cwd());
843
935
  const since = parseSinceFilter(options.since);
844
936
  const queryTokens = tokenize(query);
845
937
  const phrase = String(query || "").trim().toLowerCase();
846
938
  if (!queryTokens.length && !phrase) return [];
847
- const ftsResults = searchFtsSessions(query, queryTokens, { limit, includeWebChats, filter, repo, since, options }, env);
939
+ const ftsResults = searchFtsSessions(query, queryTokens, { limit, includeWebChats, includeSubagents, filter, repo, since, options }, env);
848
940
  if (ftsResults) return ftsResults;
849
- if (options.skipJsonIndex) return [];
941
+ if (options.skipJsonIndex || !(options.legacyJsonIndex || options.allowJsonIndex)) return [];
850
942
  const index = loadIndex(env, { noRebuild: Boolean(options.noRebuild) });
851
943
  if (!index) return [];
852
944
 
853
945
  const scored = [];
854
946
  const candidates = candidateDocsForQuery(index, queryTokens, phrase);
855
947
  for (const { doc, docIndex } of candidates) {
856
- if (!matchesSessionFilter(doc, { ...filter, includeWebChats, since })) continue;
948
+ if (!matchesSessionFilter(doc, { ...filter, includeWebChats, includeSubagents, since })) continue;
857
949
 
858
950
  let score = bm25Score(doc, queryTokens, index, docIndex, candidates.termFrequencies);
859
951
  if (phrase && doc.text.toLowerCase().includes(phrase)) score += 2.5;
@@ -887,32 +979,44 @@ function searchIndexedSessions(query, options = {}, env = process.env) {
887
979
  }));
888
980
  }
889
981
 
890
- function searchFtsSessions(query, queryTokens, context, env = process.env) {
891
- const ftsPath = paths(env).ftsIndex;
892
- if (!ftsIndexAvailable(env, { noStaleCheck: Boolean(context.options.noRebuild || context.options.allowStaleFts) })) return null;
893
- const matchQuery = ftsMatchQuery(query);
894
- if (!matchQuery) return [];
895
- // Pull a single bounded prefix in rank order rather than paging with growing
896
- // OFFSET. FTS5 with bm25 ORDER BY recomputes the entire ranked set per
897
- // OFFSET query, which is quadratic for broad terms (e.g. "react*" matching
898
- // 1700+ docs across 7 sessions). One query + JS dedupe is O(N) and stays
899
- // under FTS_MAX_SCAN_ROWS to bound memory for catastrophically broad terms.
900
- const scanLimit = Math.max(
901
- Math.max(context.limit * 20, FTS_SEARCH_BATCH_SIZE),
902
- Math.min(FTS_MAX_SCAN_ROWS, context.limit * 500)
903
- );
904
- const rows = ftsSearchRows(ftsPath, matchQuery, { limit: scanLimit, offset: 0, context });
905
- if (!rows) return null;
982
+ function dedupeFtsRowsBySession(rows, context) {
906
983
  const bySession = new Map();
907
984
  for (const row of rows) {
908
985
  const doc = ftsRowToDoc(row);
909
- if (!matchesSessionFilter(doc, { ...context.filter, includeWebChats: context.includeWebChats, since: context.since })) continue;
986
+ if (!matchesSessionFilter(doc, { ...context.filter, includeWebChats: context.includeWebChats, includeSubagents: context.includeSubagents, since: context.since })) continue;
910
987
  if (!context.options.repo && context.repo && doc.repoCanonical === context.repo) {
911
988
  row.rank = Number(row.rank || 0) - 0.05;
912
989
  }
913
990
  if (!bySession.has(doc.sessionId)) bySession.set(doc.sessionId, { doc, row });
914
991
  if (bySession.size >= context.limit) break;
915
992
  }
993
+ return bySession;
994
+ }
995
+
996
+ function searchFtsSessions(query, queryTokens, context, env = process.env) {
997
+ const ftsPath = paths(env).ftsIndex;
998
+ if (!ftsIndexAvailable(env, { noStaleCheck: Boolean(context.options.noRebuild || context.options.allowStaleFts) })) return null;
999
+ const matchQuery = ftsMatchQuery(query);
1000
+ if (!matchQuery) return [];
1001
+ // Single bounded prefix in rank order — FTS5 with bm25 + OFFSET is quadratic,
1002
+ // so we pull one shot and dedupe in JS. Two-tier scan: primary is small and
1003
+ // fast (limit*100, typically 1000), wide cap is only used if dedupe couldn't
1004
+ // fill `limit` distinct sessions from the primary.
1005
+ const primaryScanLimit = Math.max(context.limit * FTS_PRIMARY_SCAN_MULTIPLIER, FTS_SEARCH_BATCH_SIZE);
1006
+ const wideScanLimit = Math.max(primaryScanLimit, Math.min(FTS_MAX_SCAN_ROWS, context.limit * 500));
1007
+
1008
+ let rows = ftsSearchRows(ftsPath, matchQuery, { limit: primaryScanLimit, offset: 0, context });
1009
+ if (!rows) return null;
1010
+ let bySession = dedupeFtsRowsBySession(rows, context);
1011
+ if (bySession.size < context.limit && primaryScanLimit < wideScanLimit) {
1012
+ // Under-filled (e.g. broad term where many top-ranked docs collapse onto
1013
+ // the same session, or filters reject most of the primary set). Re-query
1014
+ // at the wide cap. SQLite re-evaluates the MATCH but the surrounding
1015
+ // prepared statement + OS page cache make the second pass cheap.
1016
+ rows = ftsSearchRows(ftsPath, matchQuery, { limit: wideScanLimit, offset: 0, context });
1017
+ if (!rows) return null;
1018
+ bySession = dedupeFtsRowsBySession(rows, context);
1019
+ }
916
1020
  return [...bySession.values()].slice(0, context.limit).map(({ doc, row }) => ({
917
1021
  session_id: doc.sessionId,
918
1022
  provider: doc.provider,
@@ -947,11 +1051,31 @@ function ftsSearchRows(ftsPath, matchQuery, options) {
947
1051
  clauses.push(`d.source_type IN (${sourceTypes.map(() => "?").join(", ")})`);
948
1052
  for (const value of sourceTypes) params.push(value);
949
1053
  }
1054
+ if (!options.context?.includeSubagents) {
1055
+ // Precomputed integer flag (set in docInsertParams). Equivalent to the
1056
+ // old `NOT GLOB '*_subagent'` but a 1-byte compare instead of a
1057
+ // leading-wildcard string scan over every FTS-matched row.
1058
+ clauses.push("(d.is_subagent IS NULL OR d.is_subagent = 0)");
1059
+ }
950
1060
  if (options.context?.since) {
951
1061
  const since = options.context.since.toISOString();
952
1062
  clauses.push("(d.started_at >= ? OR (d.started_at = '' AND d.occurred_at >= ?))");
953
1063
  params.push(since, since);
954
1064
  }
1065
+ if (filter.repo) {
1066
+ const repo = String(filter.repo).toLowerCase();
1067
+ const repoLike = `%${escapeSqlLike(repo)}%`;
1068
+ const repoColumns = ["d.repo_canonical", "d.repo_display", "d.scope_canonical", "d.cwd"];
1069
+ clauses.push(
1070
+ "(" +
1071
+ repoColumns.map((column) => `(LOWER(${column}) = ? OR LOWER(${column}) LIKE ? ESCAPE '\\')`).join(" OR ") +
1072
+ ")"
1073
+ );
1074
+ for (const column of repoColumns) {
1075
+ void column;
1076
+ params.push(repo, repoLike);
1077
+ }
1078
+ }
955
1079
  const limit = Math.max(1, Number(options.limit) || FTS_SEARCH_BATCH_SIZE);
956
1080
  const offset = Math.max(0, Number(options.offset) || 0);
957
1081
  // LIMIT/OFFSET stay parameterized so a single prepared-statement shape
@@ -959,8 +1083,8 @@ function ftsSearchRows(ftsPath, matchQuery, options) {
959
1083
  // per call but the resulting prepared statement is cached on the connection.
960
1084
  const sql = [
961
1085
  "SELECT",
962
- " d.doc_id, d.session_id, d.provider, d.source_type, d.repo_canonical, d.repo_display,",
963
- " d.scope_canonical, d.cwd, d.title, d.started_at, d.occurred_at, d.role,",
1086
+ " d.doc_id, d.session_id, d.provider, d.source_type, d.conversation_kind, d.is_subagent, d.repo_canonical, d.repo_display,",
1087
+ " d.scope_canonical, d.storage_scope, d.cwd, d.title, d.started_at, d.occurred_at, d.role,",
964
1088
  " d.event_id, d.event_kind, d.message_index, d.path, d.matched_text,",
965
1089
  " snippet(docs_fts, 0, '', '', '...', 32) AS excerpt,",
966
1090
  " bm25(docs_fts) AS rank",
@@ -992,9 +1116,12 @@ function ftsRowToDoc(row) {
992
1116
  sessionId: row.session_id || "",
993
1117
  provider: row.provider || "",
994
1118
  sourceType: row.source_type || "",
1119
+ conversationKind: row.conversation_kind || "",
1120
+ isSubagent: row.is_subagent == null ? undefined : Number(row.is_subagent),
995
1121
  repoCanonical: row.repo_canonical || "",
996
1122
  repoDisplay: row.repo_display || "",
997
1123
  scopeCanonical: row.scope_canonical || "",
1124
+ storageScope: row.storage_scope || "",
998
1125
  cwd: row.cwd || "",
999
1126
  title: row.title || "",
1000
1127
  startedAt: row.started_at || "",
@@ -1220,11 +1347,16 @@ function inferCallingRepo(cwd) {
1220
1347
  }
1221
1348
 
1222
1349
  function listHistorySessions(options = {}, env = process.env) {
1350
+ return listHistorySessionsFromSessions(listSessions(env), options);
1351
+ }
1352
+
1353
+ function listHistorySessionsFromSessions(sessions, options = {}) {
1223
1354
  const since = parseSinceFilter(options.since);
1224
1355
  const includeWebChats = options.includeWebChats !== false;
1356
+ const includeSubagents = Boolean(options.includeSubagents || options.include_subagents);
1225
1357
  const filter = normalizeSessionFilter(options);
1226
- return listSessions(env)
1227
- .filter((session) => matchesSessionFilter(session, { ...filter, includeWebChats, since }))
1358
+ return (Array.isArray(sessions) ? sessions : [])
1359
+ .filter((session) => matchesSessionFilter(session, { ...filter, includeWebChats, includeSubagents, since }))
1228
1360
  .map(historySessionSummary)
1229
1361
  .sort((a, b) => String(historySessionSortTime(b)).localeCompare(String(historySessionSortTime(a))));
1230
1362
  }
@@ -1245,6 +1377,7 @@ function historySessionSummary(session) {
1245
1377
  chat_display_name: session.chatDisplayName || undefined,
1246
1378
  chat_project_path: session.chatProjectPath || undefined,
1247
1379
  conversation_kind: session.conversationKind || undefined,
1380
+ parent_composer_id: session.parentComposerId || undefined,
1248
1381
  pinned: Boolean(session.pinned) || undefined,
1249
1382
  cwd: session.cwd || undefined,
1250
1383
  title: session.title || undefined,
@@ -1257,9 +1390,25 @@ function historySessionSummary(session) {
1257
1390
  usage: session.usage || undefined,
1258
1391
  estimatedUsage: session.estimatedUsage || undefined,
1259
1392
  models: session.models || undefined,
1393
+ toolUsage: session.toolUsage || undefined,
1394
+ outputTokenWork: session.outputTokenWork || undefined,
1395
+ outcomes: session.outcomes || undefined,
1260
1396
  cursorCommandTypeCounts: session.cursorCommandTypeCounts || undefined,
1397
+ codexTurnContext: session.sessionSummary?.codexTurnContext || undefined,
1398
+ sidecarEffort: session.sessionSummary?.claudeCodeSidecar?.effort || undefined,
1399
+ claudeTasks: session.sessionSummary?.claudeTasks
1400
+ ? { count: session.sessionSummary.claudeTasks.count, completed: session.sessionSummary.claudeTasks.completed }
1401
+ : undefined,
1402
+ cursorFileHistory: session.sessionSummary?.cursorFileHistory
1403
+ ? { snapshots: session.sessionSummary.cursorFileHistory.snapshots, bytes: session.sessionSummary.cursorFileHistory.bytes }
1404
+ : undefined,
1405
+ artifactCount: Array.isArray(session.artifacts) && session.artifacts.length ? session.artifacts.length : undefined,
1406
+ artifactBytes: Array.isArray(session.artifacts) && session.artifacts.length
1407
+ ? session.artifacts.reduce((sum, item) => sum + (Number(item?.bytes) || 0), 0)
1408
+ : undefined,
1261
1409
  conversation: session.conversationPath,
1262
- transcript: session.transcriptPath
1410
+ transcript: session.transcriptPath,
1411
+ events: session.eventPath
1263
1412
  };
1264
1413
  }
1265
1414
 
@@ -1282,6 +1431,7 @@ function displayRepoLabel(session) {
1282
1431
  function displayScopeLabel(scope) {
1283
1432
  return {
1284
1433
  "claude-desktop/uncategorized": "Claude Desktop (uncategorized)",
1434
+ "claude-cowork/uncategorized": "Claude Cowork (uncategorized)",
1285
1435
  "claude-code-desktop/uncategorized": "Claude Code Desktop (uncategorized)"
1286
1436
  }[scope] || scope;
1287
1437
  }
@@ -1317,6 +1467,10 @@ function listRecentSessions(limit = 20, options = {}, env = process.env) {
1317
1467
  return listHistorySessions(options, env).slice(0, limit);
1318
1468
  }
1319
1469
 
1470
+ function listRecentSessionsFromSessions(sessions, limit = 20, options = {}) {
1471
+ return listHistorySessionsFromSessions(sessions, options).slice(0, limit);
1472
+ }
1473
+
1320
1474
  function normalizeSessionFilter(options = {}) {
1321
1475
  const providerInput = options.provider || options.source || "";
1322
1476
  const normalizedProvider = normalizeProviderFilter(providerInput);
@@ -1337,6 +1491,8 @@ function normalizeProviderFilter(value) {
1337
1491
  claude_cli: { provider: "claude_code" },
1338
1492
  claude_desktop: { provider: "claude_desktop" },
1339
1493
  claude_code_desktop: { provider: "claude_desktop", sourceType: "claude-code-desktop-metadata", sourceTypes: ["claude-code-desktop-metadata"] },
1494
+ claude_cowork: { provider: "claude_desktop", sourceType: "claude-workspace-desktop", sourceTypes: ["claude-workspace-desktop"] },
1495
+ claude_cowork_desktop: { provider: "claude_desktop", sourceType: "claude-workspace-desktop", sourceTypes: ["claude-workspace-desktop"] },
1340
1496
  claude_workspace: { provider: "claude_desktop", sourceType: "claude-workspace-desktop", sourceTypes: ["claude-workspace-desktop"] },
1341
1497
  claude_workspace_desktop: { provider: "claude_desktop", sourceType: "claude-workspace-desktop", sourceTypes: ["claude-workspace-desktop"] },
1342
1498
  claude_sdk: { provider: "claude_sdk" },
@@ -1353,10 +1509,21 @@ function normalizeProviderFilter(value) {
1353
1509
  aider: { provider: "aider", sourceType: "aider-chat-history", sourceTypes: ["aider-chat-history"] },
1354
1510
  devin: { provider: "devin" },
1355
1511
  devin_cli: { provider: "devin", sourceType: "devin-cli-history", sourceTypes: ["devin-cli-history"] },
1512
+ devin_desktop: { provider: "devin", sourceType: "devin-desktop-acp-events", sourceTypes: ["devin-desktop-acp-events"] },
1356
1513
  gemini: { provider: "gemini_cli" },
1357
1514
  gemini_cli: { provider: "gemini_cli" },
1358
1515
  windsurf: { provider: "windsurf" },
1359
1516
  antigravity: { provider: "antigravity" },
1517
+ antigravity_cli: { provider: "antigravity_cli" },
1518
+ antigravity_ide: { provider: "antigravity_ide" },
1519
+ copilot: { provider: "copilot" },
1520
+ copilot_cli: { provider: "copilot" },
1521
+ factory: { provider: "factory" },
1522
+ factory_droid: { provider: "factory" },
1523
+ droid: { provider: "factory" },
1524
+ grok: { provider: "grok" },
1525
+ grok_build: { provider: "grok" },
1526
+ pi: { provider: "pi" },
1360
1527
  chatgpt: { provider: "chatgpt" },
1361
1528
  claude_web: { provider: "claude_web" },
1362
1529
  claude_ai: { provider: "claude_web" }
@@ -1368,6 +1535,7 @@ function matchesSessionFilter(session, filter) {
1368
1535
  if (filter.provider && session.provider !== filter.provider) return false;
1369
1536
  const sourceTypes = filter.sourceTypes?.length ? filter.sourceTypes : filter.sourceType ? [filter.sourceType] : [];
1370
1537
  if (sourceTypes.length && !sourceTypes.includes(session.sourceType)) return false;
1538
+ if (!filter.includeSubagents && isSubagentSession(session)) return false;
1371
1539
  if (!filter.includeWebChats && session.scopeCanonical && session.storageScope !== "local") return false;
1372
1540
  if (filter.repo && !matchesRepoFilter(session, filter.repo)) return false;
1373
1541
  if (filter.since) {
@@ -1378,6 +1546,10 @@ function matchesSessionFilter(session, filter) {
1378
1546
  return true;
1379
1547
  }
1380
1548
 
1549
+ function isSubagentSession(session) {
1550
+ return /_subagent$/.test(String(session?.conversationKind || session?.conversation_kind || ""));
1551
+ }
1552
+
1381
1553
  function matchesRepoFilter(session, repoFilter) {
1382
1554
  const wanted = String(repoFilter || "").toLowerCase();
1383
1555
  if (!wanted) return true;
@@ -1442,22 +1614,24 @@ function rebuildIndexSummary(env = process.env, options = {}) {
1442
1614
  }
1443
1615
 
1444
1616
  function indexIsStale(indexPath, env = process.env) {
1445
- let indexStat;
1446
- try {
1447
- indexStat = fs.statSync(indexPath);
1448
- } catch {
1449
- return true;
1450
- }
1451
- for (const session of listSessions(env)) {
1617
+ const expected = listSessionsSnapshot(env).fingerprint;
1618
+ const actual = indexSessionFingerprint(indexPath, env);
1619
+ return !actual || actual !== expected;
1620
+ }
1621
+
1622
+ function indexSessionFingerprint(indexPath, env = process.env) {
1623
+ const ftsPath = paths(env).ftsIndex;
1624
+ if (path.resolve(indexPath) === path.resolve(ftsPath)) {
1625
+ let stat;
1452
1626
  try {
1453
- if (fs.statSync(session.transcriptPath).mtimeMs > indexStat.mtimeMs) return true;
1454
- if (fs.statSync(session.metadataPath).mtimeMs > indexStat.mtimeMs) return true;
1455
- if (session.eventPath && fs.existsSync(session.eventPath) && fs.statSync(session.eventPath).mtimeMs > indexStat.mtimeMs) return true;
1627
+ stat = fs.statSync(ftsPath);
1456
1628
  } catch {
1457
- return true;
1629
+ return "";
1458
1630
  }
1631
+ const rows = readFtsMeta(ftsPath, stat);
1632
+ return rows?.find((row) => row.key === "sessionFingerprint")?.value || "";
1459
1633
  }
1460
- return false;
1634
+ return readIndexSummary(indexPath)?.sessionFingerprint || "";
1461
1635
  }
1462
1636
 
1463
1637
  function parseSinceFilter(value) {
@@ -1498,11 +1672,23 @@ module.exports = {
1498
1672
  buildIndexSummary,
1499
1673
  chunkText,
1500
1674
  listHistorySessions,
1675
+ listHistorySessionsFromSessions,
1501
1676
  listRecentSessions,
1677
+ listRecentSessionsFromSessions,
1502
1678
  loadIndex,
1503
1679
  readIndexSummary,
1680
+ rebuildIndexSummary,
1504
1681
  reindexIfNeeded,
1505
1682
  sessionHistoryTime,
1506
1683
  searchPastSessions,
1507
- tokenize
1684
+ tokenize,
1685
+ // Test-only handles so we can simulate native-binding failures without
1686
+ // touching the on-disk .node file. Not part of any public contract.
1687
+ _searchInternals: {
1688
+ openFtsBuildDb,
1689
+ openFtsReadDb,
1690
+ loadBetterSqlite3,
1691
+ markBetterSqlite3Broken,
1692
+ resetBetterSqlite3State
1693
+ }
1508
1694
  };