agentel 0.2.6 → 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,21 +4,32 @@ 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
- const SQLITE_QUERY_TIMEOUT_MS = 5000;
16
- const SQLITE_BUILD_BATCH_SIZE = 100;
15
+ const SQLITE_BUILD_BATCH_SIZE = 500;
17
16
  const RIPGREP_SEARCH_TIMEOUT_MS = 8000;
18
17
  const RIPGREP_BATCH_FILE_COUNT = 200;
19
18
  const MARKDOWN_MATCHES_PER_FILE = 3;
20
19
  const FTS_SEARCH_BATCH_SIZE = 250;
21
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;
22
33
  const _indexCache = {
23
34
  path: "",
24
35
  mtimeMs: 0,
@@ -33,20 +44,83 @@ const _ftsCache = {
33
44
  checkedAtMs: 0,
34
45
  available: false
35
46
  };
47
+ // Read-only better-sqlite3 connection cache for the FTS sidecar. Reopened when
48
+ // the file inode changes (atomic rename during rebuild). Prepared statements
49
+ // are cached on the connection by SQL text.
50
+ const _ftsReadConn = {
51
+ path: "",
52
+ mtimeMs: 0,
53
+ size: 0,
54
+ ino: 0,
55
+ db: null,
56
+ prepared: null
57
+ };
58
+ let _betterSqlite3 = null;
59
+ let _betterSqlite3Loaded = false;
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;
66
+
67
+ /**
68
+ * Lazily load better-sqlite3. Returns the constructor or null when the native
69
+ * binding is unavailable; call sites then fall back to the legacy `sqlite3`
70
+ * subprocess path. The load attempt is cached so missing optional builds do
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.
81
+ */
82
+ function loadBetterSqlite3() {
83
+ if (_betterSqlite3Broken) return null;
84
+ if (_betterSqlite3Loaded) return _betterSqlite3;
85
+ _betterSqlite3Loaded = true;
86
+ try {
87
+ _betterSqlite3 = require("better-sqlite3");
88
+ } catch (error) {
89
+ _betterSqlite3 = null;
90
+ _betterSqlite3LoadError = error;
91
+ }
92
+ return _betterSqlite3;
93
+ }
94
+
95
+ function betterSqlite3LoadError() {
96
+ return _betterSqlite3LoadError;
97
+ }
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
+ }
36
113
 
37
114
  function buildIndex(env = process.env) {
38
- const sessions = listSessions(env);
115
+ const snapshot = listSessionsSnapshot(env);
116
+ const sessions = snapshot.sessions;
39
117
  const docs = [];
40
118
  const postings = Object.create(null);
41
119
  const df = Object.create(null);
42
120
  let totalLength = 0;
43
121
 
44
122
  for (const session of sessions) {
45
- if (session.conversationPath && !fs.existsSync(session.conversationPath)) ensureConversationMarkdown(session, env);
46
- const events = readEvents(session);
47
- const eventDocs = events.length ? docsForEvents(session, events) : [];
48
- if (!eventDocs.length) ensureConversationMarkdown(session, env);
49
- const sourceDocs = eventDocs.length ? eventDocs : docsForTranscript(session, readTranscript(session.transcriptPath));
123
+ const sourceDocs = canonicalDocsForSession(session);
50
124
  for (const sourceDoc of sourceDocs) {
51
125
  const indexText = normalizeIndexText(sourceDoc.text);
52
126
  if (!indexText) continue;
@@ -76,6 +150,7 @@ function buildIndex(env = process.env) {
76
150
  const index = {
77
151
  version: INDEX_VERSION,
78
152
  builtAt: new Date().toISOString(),
153
+ sessionFingerprint: snapshot.fingerprint,
79
154
  docCount: docs.length,
80
155
  avgDocLength: docs.length ? totalLength / docs.length : 0,
81
156
  df,
@@ -104,6 +179,7 @@ function summarizeIndex(index) {
104
179
  return {
105
180
  version: index.version,
106
181
  builtAt: index.builtAt,
182
+ sessionFingerprint: index.sessionFingerprint,
107
183
  docCount: index.docCount,
108
184
  avgDocLength: index.avgDocLength,
109
185
  summaryOnly: true
@@ -120,6 +196,7 @@ function readIndexSummary(indexPath) {
120
196
  const summary = {
121
197
  version: readJsonHeaderNumber(header, "version"),
122
198
  builtAt: readJsonHeaderString(header, "builtAt"),
199
+ sessionFingerprint: readJsonHeaderString(header, "sessionFingerprint"),
123
200
  docCount: readJsonHeaderNumber(header, "docCount"),
124
201
  avgDocLength: readJsonHeaderNumber(header, "avgDocLength"),
125
202
  summaryOnly: true
@@ -147,59 +224,69 @@ function escapeRegExp(value) {
147
224
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
225
  }
149
226
 
227
+ const FTS_SCHEMA_SQL = [
228
+ "CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);",
229
+ "CREATE TABLE docs(",
230
+ " rowid INTEGER PRIMARY KEY,",
231
+ " doc_id TEXT,",
232
+ " session_id TEXT,",
233
+ " provider TEXT,",
234
+ " source_type TEXT,",
235
+ " conversation_kind TEXT,",
236
+ " is_subagent INTEGER,",
237
+ " repo_canonical TEXT,",
238
+ " repo_display TEXT,",
239
+ " scope_canonical TEXT,",
240
+ " storage_scope TEXT,",
241
+ " cwd TEXT,",
242
+ " title TEXT,",
243
+ " started_at TEXT,",
244
+ " occurred_at TEXT,",
245
+ " role TEXT,",
246
+ " event_id TEXT,",
247
+ " event_kind TEXT,",
248
+ " message_index INTEGER,",
249
+ " path TEXT,",
250
+ " matched_text TEXT",
251
+ ");",
252
+ "CREATE VIRTUAL TABLE docs_fts USING fts5(text, tokenize='unicode61', prefix='2 3 4');"
253
+ ].join("\n");
254
+
255
+ function cleanupFtsTmpFiles(tmpPath) {
256
+ for (const file of [tmpPath, `${tmpPath}-journal`, `${tmpPath}-wal`, `${tmpPath}-shm`]) {
257
+ try {
258
+ fs.rmSync(file, { force: true });
259
+ } catch {
260
+ // Best effort cleanup before rebuilding the sidecar index.
261
+ }
262
+ }
263
+ }
264
+
150
265
  function buildFtsIndex(index, env = process.env) {
151
266
  const ftsPath = paths(env).ftsIndex;
152
267
  const tmpPath = `${ftsPath}.${process.pid}.tmp`;
268
+ let handle = null;
153
269
  try {
154
270
  ensureDir(path.dirname(ftsPath));
155
- for (const file of [tmpPath, `${tmpPath}-journal`, `${tmpPath}-wal`, `${tmpPath}-shm`]) {
156
- try {
157
- fs.rmSync(file, { force: true });
158
- } catch {
159
- // Best effort cleanup before rebuilding the sidecar index.
160
- }
161
- }
162
- runSqliteScript(tmpPath, [
163
- "PRAGMA journal_mode=OFF;",
164
- "PRAGMA synchronous=OFF;",
165
- "CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);",
166
- "CREATE TABLE docs(",
167
- " rowid INTEGER PRIMARY KEY,",
168
- " doc_id TEXT,",
169
- " session_id TEXT,",
170
- " provider TEXT,",
171
- " source_type TEXT,",
172
- " repo_canonical TEXT,",
173
- " repo_display TEXT,",
174
- " scope_canonical TEXT,",
175
- " cwd TEXT,",
176
- " title TEXT,",
177
- " started_at TEXT,",
178
- " occurred_at TEXT,",
179
- " role TEXT,",
180
- " event_id TEXT,",
181
- " event_kind TEXT,",
182
- " message_index INTEGER,",
183
- " path TEXT,",
184
- " matched_text TEXT",
185
- ");",
186
- "CREATE VIRTUAL TABLE docs_fts USING fts5(text, tokenize='unicode61', prefix='2 3 4');",
187
- `INSERT INTO meta(key, value) VALUES ('version', ${sqliteString(String(INDEX_VERSION))});`,
188
- `INSERT INTO meta(key, value) VALUES ('builtAt', ${sqliteString(index.builtAt || "")});`,
189
- `INSERT INTO meta(key, value) VALUES ('docCount', ${sqliteString(String(index.docCount || 0))});`
190
- ].join("\n"));
191
-
192
- insertFtsDocs(tmpPath, index.docs || [], 1);
193
- runSqliteScript(tmpPath, "INSERT INTO docs_fts(docs_fts) VALUES('optimize');");
271
+ cleanupFtsTmpFiles(tmpPath);
272
+ handle = openFtsBuildDb(tmpPath);
273
+ execFtsBuildSql(handle, FTS_SCHEMA_SQL);
274
+ insertFtsMetaRows(handle, [
275
+ { key: "version", value: String(INDEX_VERSION) },
276
+ { key: "builtAt", value: index.builtAt || "" },
277
+ { key: "sessionFingerprint", value: index.sessionFingerprint || "" },
278
+ { key: "docCount", value: String(index.docCount || 0) }
279
+ ]);
280
+ insertFtsDocs(handle, tmpPath, index.docs || [], 1);
281
+ execFtsBuildSql(handle, "INSERT INTO docs_fts(docs_fts) VALUES('optimize');");
282
+ closeFtsBuildDb(handle);
283
+ handle = null;
194
284
  fs.renameSync(tmpPath, ftsPath);
195
285
  rememberFtsCache(ftsPath, true);
196
286
  return true;
197
287
  } catch {
198
- try {
199
- fs.rmSync(tmpPath, { force: true });
200
- } catch {
201
- // Ignore optional FTS cleanup failure.
202
- }
288
+ if (handle) closeFtsBuildDb(handle);
289
+ cleanupFtsTmpFiles(tmpPath);
203
290
  try {
204
291
  fs.rmSync(ftsPath, { force: true });
205
292
  } catch {
@@ -211,56 +298,27 @@ function buildFtsIndex(index, env = process.env) {
211
298
  }
212
299
 
213
300
  function buildFtsIndexSummary(env = process.env) {
301
+ const snapshot = listSessionsSnapshot(env);
214
302
  const ftsPath = paths(env).ftsIndex;
215
303
  const tmpPath = `${ftsPath}.${process.pid}.tmp`;
216
304
  const builtAt = new Date().toISOString();
217
305
  let docCount = 0;
218
306
  let totalLength = 0;
307
+ let handle = null;
219
308
  try {
220
309
  ensureDir(path.dirname(ftsPath));
221
- for (const file of [tmpPath, `${tmpPath}-journal`, `${tmpPath}-wal`, `${tmpPath}-shm`]) {
222
- try {
223
- fs.rmSync(file, { force: true });
224
- } catch {
225
- // Best effort cleanup before rebuilding the sidecar index.
226
- }
227
- }
228
- runSqliteScript(tmpPath, [
229
- "PRAGMA journal_mode=OFF;",
230
- "PRAGMA synchronous=OFF;",
231
- "CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);",
232
- "CREATE TABLE docs(",
233
- " rowid INTEGER PRIMARY KEY,",
234
- " doc_id TEXT,",
235
- " session_id TEXT,",
236
- " provider TEXT,",
237
- " source_type TEXT,",
238
- " repo_canonical TEXT,",
239
- " repo_display TEXT,",
240
- " scope_canonical TEXT,",
241
- " cwd TEXT,",
242
- " title TEXT,",
243
- " started_at TEXT,",
244
- " occurred_at TEXT,",
245
- " role TEXT,",
246
- " event_id TEXT,",
247
- " event_kind TEXT,",
248
- " message_index INTEGER,",
249
- " path TEXT,",
250
- " matched_text TEXT",
251
- ");",
252
- "CREATE VIRTUAL TABLE docs_fts USING fts5(text, tokenize='unicode61', prefix='2 3 4');",
253
- `INSERT INTO meta(key, value) VALUES ('version', ${sqliteString(String(INDEX_VERSION))});`,
254
- `INSERT INTO meta(key, value) VALUES ('builtAt', ${sqliteString(builtAt)});`
255
- ].join("\n"));
310
+ cleanupFtsTmpFiles(tmpPath);
311
+ handle = openFtsBuildDb(tmpPath);
312
+ execFtsBuildSql(handle, FTS_SCHEMA_SQL);
313
+ insertFtsMetaRows(handle, [
314
+ { key: "version", value: String(INDEX_VERSION) },
315
+ { key: "builtAt", value: builtAt },
316
+ { key: "sessionFingerprint", value: snapshot.fingerprint || "" }
317
+ ]);
256
318
 
257
319
  let batch = [];
258
- for (const session of listSessions(env)) {
259
- if (session.conversationPath && !fs.existsSync(session.conversationPath)) ensureConversationMarkdown(session, env);
260
- const events = readEvents(session);
261
- const eventDocs = events.length ? docsForEvents(session, events) : [];
262
- if (!eventDocs.length) ensureConversationMarkdown(session, env);
263
- const sourceDocs = eventDocs.length ? eventDocs : docsForTranscript(session, readTranscript(session.transcriptPath));
320
+ for (const session of snapshot.sessions) {
321
+ const sourceDocs = canonicalDocsForSession(session);
264
322
  for (const sourceDoc of sourceDocs) {
265
323
  const indexText = normalizeIndexText(sourceDoc.text);
266
324
  if (!indexText) continue;
@@ -278,25 +336,22 @@ function buildFtsIndexSummary(env = process.env) {
278
336
  length: tokens.length
279
337
  });
280
338
  if (batch.length >= SQLITE_BUILD_BATCH_SIZE) {
281
- insertFtsDocs(tmpPath, batch, docCount - batch.length + 1);
339
+ insertFtsDocs(handle, tmpPath, batch, docCount - batch.length + 1);
282
340
  batch = [];
283
341
  }
284
342
  }
285
343
  }
286
344
  }
287
- if (batch.length) insertFtsDocs(tmpPath, batch, docCount - batch.length + 1);
288
- runSqliteScript(tmpPath, [
289
- `INSERT INTO meta(key, value) VALUES ('docCount', ${sqliteString(String(docCount))});`,
290
- "INSERT INTO docs_fts(docs_fts) VALUES('optimize');"
291
- ].join("\n"));
345
+ if (batch.length) insertFtsDocs(handle, tmpPath, batch, docCount - batch.length + 1);
346
+ insertFtsMetaRows(handle, [{ key: "docCount", value: String(docCount) }]);
347
+ execFtsBuildSql(handle, "INSERT INTO docs_fts(docs_fts) VALUES('optimize');");
348
+ closeFtsBuildDb(handle);
349
+ handle = null;
292
350
  fs.renameSync(tmpPath, ftsPath);
293
351
  rememberFtsCache(ftsPath, true);
294
352
  } catch (error) {
295
- try {
296
- fs.rmSync(tmpPath, { force: true });
297
- } catch {
298
- // Ignore optional FTS cleanup failure.
299
- }
353
+ if (handle) closeFtsBuildDb(handle);
354
+ cleanupFtsTmpFiles(tmpPath);
300
355
  try {
301
356
  fs.rmSync(ftsPath, { force: true });
302
357
  } catch {
@@ -308,68 +363,272 @@ function buildFtsIndexSummary(env = process.env) {
308
363
  return {
309
364
  version: INDEX_VERSION,
310
365
  builtAt,
366
+ sessionFingerprint: snapshot.fingerprint,
311
367
  docCount,
312
368
  avgDocLength: docCount ? totalLength / docCount : 0,
313
369
  summaryOnly: true
314
370
  };
315
371
  }
316
372
 
317
- function insertFtsDocs(dbPath, docs, rowidStart = 1) {
318
- for (let start = 0; start < docs.length; start += SQLITE_BUILD_BATCH_SIZE) {
319
- const statements = ["BEGIN;"];
320
- const batch = docs.slice(start, start + SQLITE_BUILD_BATCH_SIZE);
321
- for (let offset = 0; offset < batch.length; offset++) {
322
- const rowid = rowidStart + start + offset;
323
- const doc = batch[offset];
324
- statements.push(
325
- `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 (` +
326
- [
327
- rowid,
328
- sqliteString(doc.id || ""),
329
- sqliteString(doc.sessionId || ""),
330
- sqliteString(doc.provider || ""),
331
- sqliteString(doc.sourceType || ""),
332
- sqliteString(doc.repoCanonical || ""),
333
- sqliteString(doc.repoDisplay || ""),
334
- sqliteString(doc.scopeCanonical || ""),
335
- sqliteString(doc.cwd || ""),
336
- sqliteString(doc.title || ""),
337
- sqliteString(doc.startedAt || ""),
338
- sqliteString(doc.occurredAt || ""),
339
- sqliteString(doc.role || ""),
340
- sqliteString(doc.eventId || ""),
341
- sqliteString(doc.eventKind || ""),
342
- Number.isFinite(Number(doc.messageIndex)) ? Number(doc.messageIndex) : "NULL",
343
- sqliteString(doc.path || ""),
344
- sqliteString(doc.matchedText || "")
345
- ].join(", ") +
346
- ");"
347
- );
348
- statements.push(`INSERT INTO docs_fts(rowid, text) VALUES (${rowid}, ${sqliteString(doc.text || "")});`);
373
+ const FTS_DOCS_INSERT_SQL =
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)";
376
+ const FTS_FTS_INSERT_SQL = "INSERT INTO docs_fts(rowid, text) VALUES (@rowid, @text)";
377
+
378
+ function docInsertParams(doc, rowid) {
379
+ return {
380
+ rowid,
381
+ doc_id: doc.id || "",
382
+ session_id: doc.sessionId || "",
383
+ provider: doc.provider || "",
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,
391
+ repo_canonical: doc.repoCanonical || "",
392
+ repo_display: doc.repoDisplay || "",
393
+ scope_canonical: doc.scopeCanonical || "",
394
+ storage_scope: doc.storageScope || "",
395
+ cwd: doc.cwd || "",
396
+ title: doc.title || "",
397
+ started_at: doc.startedAt || "",
398
+ occurred_at: doc.occurredAt || "",
399
+ role: doc.role || "",
400
+ event_id: doc.eventId || "",
401
+ event_kind: doc.eventKind || "",
402
+ message_index: Number.isFinite(Number(doc.messageIndex)) ? Number(doc.messageIndex) : null,
403
+ path: doc.path || "",
404
+ matched_text: doc.matchedText || ""
405
+ };
406
+ }
407
+
408
+ function insertFtsMetaRows(handle, rows) {
409
+ if (handle.kind === "native") {
410
+ const stmt = handle.db.prepare("INSERT INTO meta(key, value) VALUES (?, ?)");
411
+ const insertMany = handle.db.transaction((items) => {
412
+ for (const row of items) stmt.run(row.key, row.value);
413
+ });
414
+ insertMany(rows);
415
+ return;
416
+ }
417
+ const sql = rows
418
+ .map((row) => `INSERT INTO meta(key, value) VALUES ('${String(row.key).replace(/'/g, "''")}', '${String(row.value).replace(/'/g, "''")}');`)
419
+ .join("\n");
420
+ legacyRunSqliteScript(handle.dbPath, sql);
421
+ }
422
+
423
+ function insertFtsDocs(handleOrPath, dbPathHint, docs, rowidStart = 1) {
424
+ // Backwards-compatible call shape: tests/external callers may pass
425
+ // (dbPath, docs, rowidStart). When the first argument looks like a path
426
+ // we open a short-lived build connection just for this batch.
427
+ let handle = handleOrPath && typeof handleOrPath === "object" && handleOrPath.kind ? handleOrPath : null;
428
+ let docsArg = docs;
429
+ let rowidArg = rowidStart;
430
+ let openedHere = false;
431
+ if (!handle) {
432
+ handle = openFtsBuildDb(handleOrPath);
433
+ openedHere = true;
434
+ docsArg = dbPathHint;
435
+ rowidArg = docs ?? 1;
436
+ }
437
+ try {
438
+ if (handle.kind === "native") {
439
+ const docStmt = handle.db.prepare(FTS_DOCS_INSERT_SQL);
440
+ const ftsStmt = handle.db.prepare(FTS_FTS_INSERT_SQL);
441
+ const insertMany = handle.db.transaction((items) => {
442
+ for (let offset = 0; offset < items.length; offset++) {
443
+ const rowid = rowidArg + offset;
444
+ const doc = items[offset];
445
+ docStmt.run(docInsertParams(doc, rowid));
446
+ ftsStmt.run({ rowid, text: doc.text || "" });
447
+ }
448
+ });
449
+ // Chunk to avoid pathological transaction sizes; better-sqlite3 handles
450
+ // ~10k inserts/transaction comfortably but we keep batches bounded for
451
+ // memory and progress observability.
452
+ for (let start = 0; start < docsArg.length; start += SQLITE_BUILD_BATCH_SIZE) {
453
+ const slice = docsArg.slice(start, start + SQLITE_BUILD_BATCH_SIZE);
454
+ insertMany(slice);
455
+ rowidArg += slice.length;
456
+ }
457
+ return;
458
+ }
459
+ // Legacy spawn path. Same text-script semantics as before, just without
460
+ // the helper rename.
461
+ for (let start = 0; start < docsArg.length; start += SQLITE_BUILD_BATCH_SIZE) {
462
+ const statements = ["BEGIN;"];
463
+ const batch = docsArg.slice(start, start + SQLITE_BUILD_BATCH_SIZE);
464
+ for (let offset = 0; offset < batch.length; offset++) {
465
+ const rowid = rowidArg + start + offset;
466
+ const doc = batch[offset];
467
+ const sqlString = (value) => `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
468
+ statements.push(
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 (" +
470
+ [
471
+ rowid,
472
+ sqlString(doc.id || ""),
473
+ sqlString(doc.sessionId || ""),
474
+ sqlString(doc.provider || ""),
475
+ sqlString(doc.sourceType || ""),
476
+ sqlString(doc.conversationKind || ""),
477
+ isSubagentSession(doc) ? 1 : 0,
478
+ sqlString(doc.repoCanonical || ""),
479
+ sqlString(doc.repoDisplay || ""),
480
+ sqlString(doc.scopeCanonical || ""),
481
+ sqlString(doc.storageScope || ""),
482
+ sqlString(doc.cwd || ""),
483
+ sqlString(doc.title || ""),
484
+ sqlString(doc.startedAt || ""),
485
+ sqlString(doc.occurredAt || ""),
486
+ sqlString(doc.role || ""),
487
+ sqlString(doc.eventId || ""),
488
+ sqlString(doc.eventKind || ""),
489
+ Number.isFinite(Number(doc.messageIndex)) ? Number(doc.messageIndex) : "NULL",
490
+ sqlString(doc.path || ""),
491
+ sqlString(doc.matchedText || "")
492
+ ].join(", ") +
493
+ ");"
494
+ );
495
+ statements.push(`INSERT INTO docs_fts(rowid, text) VALUES (${rowid}, '${String(doc.text || "").replace(/'/g, "''")}');`);
496
+ }
497
+ statements.push("COMMIT;");
498
+ legacyRunSqliteScript(handle.dbPath, statements.join("\n"));
349
499
  }
350
- statements.push("COMMIT;");
351
- runSqliteScript(dbPath, statements.join("\n"));
500
+ } finally {
501
+ if (openedHere) closeFtsBuildDb(handle);
352
502
  }
353
503
  }
354
504
 
355
- function runSqliteScript(dbPath, script) {
505
+ /**
506
+ * Open a fresh write connection for index builds. Build paths run inside
507
+ * spawned child processes (`buildIndexInChild`) so the connection is
508
+ * short-lived and not cached.
509
+ */
510
+ function openFtsBuildDb(dbPath) {
511
+ const Database = loadBetterSqlite3();
512
+ if (Database) {
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
+ }
529
+ }
530
+ return { kind: "spawn", dbPath };
531
+ }
532
+
533
+ function closeFtsBuildDb(handle) {
534
+ if (handle?.kind === "native") {
535
+ try { handle.db.close(); } catch { /* ignore */ }
536
+ }
537
+ }
538
+
539
+ function execFtsBuildSql(handle, sql) {
540
+ if (handle.kind === "native") {
541
+ handle.db.exec(sql);
542
+ return;
543
+ }
544
+ legacyRunSqliteScript(handle.dbPath, sql);
545
+ }
546
+
547
+ /**
548
+ * Open or reuse a read-only connection for the FTS sidecar. The connection is
549
+ * invalidated when the on-disk file's inode/size/mtime changes — the index
550
+ * rebuild atomically renames a fresh DB into place, so the inode flips and
551
+ * any stale prepared statements are dropped.
552
+ */
553
+ function openFtsReadDb(ftsPath, stat) {
554
+ const Database = loadBetterSqlite3();
555
+ if (!Database) return null;
556
+ if (
557
+ _ftsReadConn.db &&
558
+ _ftsReadConn.path === ftsPath &&
559
+ _ftsReadConn.ino === stat.ino &&
560
+ _ftsReadConn.mtimeMs === stat.mtimeMs &&
561
+ _ftsReadConn.size === stat.size
562
+ ) {
563
+ return _ftsReadConn;
564
+ }
565
+ closeFtsReadDb();
566
+ let db;
567
+ try {
568
+ db = new Database(ftsPath, { readonly: true, fileMustExist: true });
569
+ db.pragma("query_only = ON");
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);
575
+ return null;
576
+ }
577
+ _ftsReadConn.path = ftsPath;
578
+ _ftsReadConn.ino = stat.ino;
579
+ _ftsReadConn.mtimeMs = stat.mtimeMs;
580
+ _ftsReadConn.size = stat.size;
581
+ _ftsReadConn.db = db;
582
+ _ftsReadConn.prepared = new Map();
583
+ return _ftsReadConn;
584
+ }
585
+
586
+ function closeFtsReadDb() {
587
+ if (_ftsReadConn.db) {
588
+ try { _ftsReadConn.db.close(); } catch { /* ignore */ }
589
+ }
590
+ _ftsReadConn.path = "";
591
+ _ftsReadConn.ino = 0;
592
+ _ftsReadConn.mtimeMs = 0;
593
+ _ftsReadConn.size = 0;
594
+ _ftsReadConn.db = null;
595
+ _ftsReadConn.prepared = null;
596
+ }
597
+
598
+ function prepareFtsStatement(conn, sql) {
599
+ let stmt = conn.prepared.get(sql);
600
+ if (!stmt) {
601
+ stmt = conn.db.prepare(sql);
602
+ conn.prepared.set(sql, stmt);
603
+ }
604
+ return stmt;
605
+ }
606
+
607
+ /**
608
+ * Last-resort fallback: spawn the system `sqlite3` binary. Only used when the
609
+ * better-sqlite3 native binding could not be loaded (older Node, missing build
610
+ * toolchain). Pricing this path stays in line with the pre-PR-1 behavior so
611
+ * users without a toolchain are not regressed.
612
+ */
613
+ function legacyRunSqliteScript(dbPath, script) {
356
614
  const result = spawnSync("sqlite3", [dbPath], {
357
615
  argv0: "agentlog-sqlite",
358
616
  input: script,
359
617
  encoding: "utf8",
360
618
  maxBuffer: 1024 * 1024 * 20,
361
- timeout: SQLITE_QUERY_TIMEOUT_MS
619
+ timeout: 5000
362
620
  });
363
621
  if (result.error) throw result.error;
364
622
  if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || "sqlite3 failed").trim());
365
623
  }
366
624
 
367
- function sqliteJson(dbPath, query) {
368
- const result = spawnSync("sqlite3", [dbPath, "-json", query], {
625
+ function legacySqliteJson(dbPath, query, params = []) {
626
+ const inlined = inlineSqlParams(query, params);
627
+ const result = spawnSync("sqlite3", [dbPath, "-json", inlined], {
369
628
  argv0: "agentlog-sqlite",
370
629
  encoding: "utf8",
371
630
  maxBuffer: 1024 * 1024 * 20,
372
- timeout: SQLITE_QUERY_TIMEOUT_MS
631
+ timeout: 5000
373
632
  });
374
633
  if (result.error || result.status !== 0) return null;
375
634
  try {
@@ -379,8 +638,25 @@ function sqliteJson(dbPath, query) {
379
638
  }
380
639
  }
381
640
 
382
- function sqliteString(value) {
383
- return `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
641
+ /**
642
+ * Substitute `?` placeholders for the legacy spawn fallback. Not used by the
643
+ * better-sqlite3 path — that path passes params directly to prepared
644
+ * statements. Quoting matches the prior `sqliteString` semantics.
645
+ */
646
+ function inlineSqlParams(query, params) {
647
+ if (!params || !params.length) return query;
648
+ let index = 0;
649
+ return query.replace(/\?/g, () => {
650
+ if (index >= params.length) return "?";
651
+ const value = params[index++];
652
+ if (value == null) return "NULL";
653
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
654
+ return `'${String(value).replace(/'/g, "''")}'`;
655
+ });
656
+ }
657
+
658
+ function escapeSqlLike(value) {
659
+ return String(value).replace(/[\\%_]/g, (match) => `\\${match}`);
384
660
  }
385
661
 
386
662
  function rememberFtsCache(ftsPath, available) {
@@ -395,6 +671,21 @@ function rememberFtsCache(ftsPath, available) {
395
671
  _ftsCache.size = stat?.size || 0;
396
672
  _ftsCache.checkedAtMs = Date.now();
397
673
  _ftsCache.available = Boolean(available && stat);
674
+ if (!available || !stat) closeFtsReadDb();
675
+ }
676
+
677
+ function readFtsMeta(ftsPath, stat) {
678
+ const conn = openFtsReadDb(ftsPath, stat);
679
+ if (conn) {
680
+ try {
681
+ const stmt = prepareFtsStatement(conn, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount', 'sessionFingerprint')");
682
+ return stmt.all();
683
+ } catch {
684
+ closeFtsReadDb();
685
+ return null;
686
+ }
687
+ }
688
+ return legacySqliteJson(ftsPath, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount', 'sessionFingerprint');");
398
689
  }
399
690
 
400
691
  function ftsIndexAvailable(env = process.env, options = {}) {
@@ -404,6 +695,7 @@ function ftsIndexAvailable(env = process.env, options = {}) {
404
695
  stat = fs.statSync(ftsPath);
405
696
  } catch (error) {
406
697
  if (error.code !== "ENOENT") throw error;
698
+ closeFtsReadDb();
407
699
  return false;
408
700
  }
409
701
  if (
@@ -415,7 +707,7 @@ function ftsIndexAvailable(env = process.env, options = {}) {
415
707
  ) {
416
708
  return true;
417
709
  }
418
- const rows = sqliteJson(ftsPath, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount');");
710
+ const rows = readFtsMeta(ftsPath, stat);
419
711
  if (!rows || !rows.some((row) => row.key === "version" && Number(row.value) === INDEX_VERSION)) {
420
712
  rememberFtsCache(ftsPath, false);
421
713
  return false;
@@ -432,13 +724,20 @@ function docsForEvents(session, events) {
432
724
  const indexedKinds = new Set([
433
725
  EVENT_KINDS.PROMPT_SUBMITTED,
434
726
  EVENT_KINDS.RESPONSE_GENERATED,
435
- EVENT_KINDS.TOOL_CALLED
727
+ EVENT_KINDS.TOOL_CALLED,
728
+ EVENT_KINDS.TOOL_COMPLETED
436
729
  ]);
437
730
  return events
438
731
  .filter((event) => indexedKinds.has(event.kind))
439
732
  .map((event) => {
440
- const renderedText = renderEventText(event);
733
+ let renderedText = renderEventText(event);
441
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
+ }
442
741
  return {
443
742
  id: event.eventId,
444
743
  eventId: event.eventId,
@@ -447,9 +746,11 @@ function docsForEvents(session, events) {
447
746
  sessionId: session.sessionId,
448
747
  provider: event.provider || session.provider,
449
748
  sourceType: event.sourceType || session.sourceType || "",
749
+ conversationKind: session.conversationKind || "",
450
750
  repoCanonical: event.repoCanonical || session.repoCanonical || "",
451
751
  repoDisplay: displayRepoLabel(session),
452
752
  scopeCanonical: event.scopeCanonical || session.scopeCanonical || "",
753
+ storageScope: session.storageScope || "",
453
754
  cwd: session.cwd || "",
454
755
  title: session.title || event.indexed?.title || "",
455
756
  eventTitle: event.indexed?.title || "",
@@ -465,6 +766,11 @@ function docsForEvents(session, events) {
465
766
  .filter(Boolean);
466
767
  }
467
768
 
769
+ function canonicalDocsForSession(session) {
770
+ const events = readEvents(session);
771
+ return events.length ? docsForEvents(session, events) : [];
772
+ }
773
+
468
774
  function docsForTranscript(session, messages) {
469
775
  const docs = [];
470
776
  for (const message of messages) {
@@ -473,9 +779,11 @@ function docsForTranscript(session, messages) {
473
779
  sessionId: session.sessionId,
474
780
  provider: session.provider,
475
781
  sourceType: session.sourceType || "",
782
+ conversationKind: session.conversationKind || "",
476
783
  repoCanonical: session.repoCanonical || "",
477
784
  repoDisplay: displayRepoLabel(session),
478
785
  scopeCanonical: session.scopeCanonical || "",
786
+ storageScope: session.storageScope || "",
479
787
  cwd: session.cwd || "",
480
788
  title: session.title || "",
481
789
  startedAt: session.startedAt,
@@ -539,11 +847,12 @@ function rememberIndexCache(indexPath, index) {
539
847
  }
540
848
 
541
849
  function searchPastSessions(query, options = {}, env = process.env) {
850
+ const allowMarkdownFallback = Boolean(options.markdownFallback || options.legacyMarkdownFallback || options.allowMarkdownFallback);
542
851
  try {
543
852
  const eventResults = searchIndexedSessions(query, options, env);
544
- if (eventResults.length || options.skipMarkdownFallback) return eventResults;
853
+ if (eventResults.length || !allowMarkdownFallback || options.skipMarkdownFallback) return eventResults;
545
854
  } catch {
546
- if (options.skipMarkdownFallback) return [];
855
+ if (!allowMarkdownFallback || options.skipMarkdownFallback) return [];
547
856
  // Fall through to the legacy markdown path below.
548
857
  }
549
858
  return searchMarkdownSessions(query, options, env);
@@ -553,6 +862,7 @@ function searchMarkdownSessions(query, options = {}, env = process.env) {
553
862
  const limit = Math.max(1, Math.min(Number(options.limit || 10), 50));
554
863
  const maxMatches = Math.max(limit * 8, 40);
555
864
  const includeWebChats = Boolean(options.includeWebChats);
865
+ const includeSubagents = Boolean(options.includeSubagents || options.include_subagents);
556
866
  const filter = normalizeSessionFilter(options);
557
867
  const repo = filter.repo || inferCallingRepo(options.cwd || process.cwd());
558
868
  const since = parseSinceFilter(options.since);
@@ -561,7 +871,7 @@ function searchMarkdownSessions(query, options = {}, env = process.env) {
561
871
  if (!queryTokens.length && !phrase) return [];
562
872
 
563
873
  const sessions = listSessions(env).filter((session) => {
564
- if (!matchesSessionFilter(session, { ...filter, includeWebChats, since })) return false;
874
+ if (!matchesSessionFilter(session, { ...filter, includeWebChats, includeSubagents, since })) return false;
565
875
  const conversationPath = ensureConversationMarkdown(session, env);
566
876
  const searchPath = conversationPath || session.transcriptPath;
567
877
  session._searchPath = searchPath;
@@ -619,22 +929,23 @@ function searchMarkdownSessions(query, options = {}, env = process.env) {
619
929
  function searchIndexedSessions(query, options = {}, env = process.env) {
620
930
  const limit = Math.max(1, Math.min(Number(options.limit || 10), 50));
621
931
  const includeWebChats = Boolean(options.includeWebChats);
932
+ const includeSubagents = Boolean(options.includeSubagents || options.include_subagents);
622
933
  const filter = normalizeSessionFilter(options);
623
934
  const repo = filter.repo || inferCallingRepo(options.cwd || process.cwd());
624
935
  const since = parseSinceFilter(options.since);
625
936
  const queryTokens = tokenize(query);
626
937
  const phrase = String(query || "").trim().toLowerCase();
627
938
  if (!queryTokens.length && !phrase) return [];
628
- 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);
629
940
  if (ftsResults) return ftsResults;
630
- if (options.skipJsonIndex) return [];
941
+ if (options.skipJsonIndex || !(options.legacyJsonIndex || options.allowJsonIndex)) return [];
631
942
  const index = loadIndex(env, { noRebuild: Boolean(options.noRebuild) });
632
943
  if (!index) return [];
633
944
 
634
945
  const scored = [];
635
946
  const candidates = candidateDocsForQuery(index, queryTokens, phrase);
636
947
  for (const { doc, docIndex } of candidates) {
637
- if (!matchesSessionFilter(doc, { ...filter, includeWebChats, since })) continue;
948
+ if (!matchesSessionFilter(doc, { ...filter, includeWebChats, includeSubagents, since })) continue;
638
949
 
639
950
  let score = bm25Score(doc, queryTokens, index, docIndex, candidates.termFrequencies);
640
951
  if (phrase && doc.text.toLowerCase().includes(phrase)) score += 2.5;
@@ -668,34 +979,43 @@ function searchIndexedSessions(query, options = {}, env = process.env) {
668
979
  }));
669
980
  }
670
981
 
982
+ function dedupeFtsRowsBySession(rows, context) {
983
+ const bySession = new Map();
984
+ for (const row of rows) {
985
+ const doc = ftsRowToDoc(row);
986
+ if (!matchesSessionFilter(doc, { ...context.filter, includeWebChats: context.includeWebChats, includeSubagents: context.includeSubagents, since: context.since })) continue;
987
+ if (!context.options.repo && context.repo && doc.repoCanonical === context.repo) {
988
+ row.rank = Number(row.rank || 0) - 0.05;
989
+ }
990
+ if (!bySession.has(doc.sessionId)) bySession.set(doc.sessionId, { doc, row });
991
+ if (bySession.size >= context.limit) break;
992
+ }
993
+ return bySession;
994
+ }
995
+
671
996
  function searchFtsSessions(query, queryTokens, context, env = process.env) {
672
997
  const ftsPath = paths(env).ftsIndex;
673
998
  if (!ftsIndexAvailable(env, { noStaleCheck: Boolean(context.options.noRebuild || context.options.allowStaleFts) })) return null;
674
999
  const matchQuery = ftsMatchQuery(query);
675
1000
  if (!matchQuery) return [];
676
- const bySession = new Map();
677
- const batchSize = Math.max(context.limit * 20, FTS_SEARCH_BATCH_SIZE);
678
- const maxScanRows = Math.max(batchSize, Math.min(FTS_MAX_SCAN_ROWS, context.limit * 500));
679
- let offset = 0;
680
- while (offset < maxScanRows && bySession.size < context.limit) {
681
- const rows = ftsSearchRows(ftsPath, matchQuery, {
682
- limit: Math.min(batchSize, maxScanRows - offset),
683
- offset,
684
- context
685
- });
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 });
686
1017
  if (!rows) return null;
687
- if (!rows.length) break;
688
- offset += rows.length;
689
- for (const row of rows) {
690
- const doc = ftsRowToDoc(row);
691
- if (!matchesSessionFilter(doc, { ...context.filter, includeWebChats: context.includeWebChats, since: context.since })) continue;
692
- if (!context.options.repo && context.repo && doc.repoCanonical === context.repo) {
693
- row.rank = Number(row.rank || 0) - 0.05;
694
- }
695
- if (!bySession.has(doc.sessionId)) bySession.set(doc.sessionId, { doc, row });
696
- if (bySession.size >= context.limit) break;
697
- }
698
- if (rows.length < batchSize) break;
1018
+ bySession = dedupeFtsRowsBySession(rows, context);
699
1019
  }
700
1020
  return [...bySession.values()].slice(0, context.limit).map(({ doc, row }) => ({
701
1021
  session_id: doc.sessionId,
@@ -719,31 +1039,75 @@ function searchFtsSessions(query, queryTokens, context, env = process.env) {
719
1039
  }
720
1040
 
721
1041
  function ftsSearchRows(ftsPath, matchQuery, options) {
722
- const clauses = [`docs_fts MATCH ${sqliteString(matchQuery)}`];
723
1042
  const filter = options.context?.filter || {};
724
- if (filter.provider) clauses.push(`d.provider = ${sqliteString(filter.provider)}`);
725
1043
  const sourceTypes = filter.sourceTypes?.length ? filter.sourceTypes : filter.sourceType ? [filter.sourceType] : [];
726
- if (sourceTypes.length) clauses.push(`d.source_type IN (${sourceTypes.map(sqliteString).join(", ")})`);
1044
+ const params = [matchQuery];
1045
+ const clauses = ["docs_fts MATCH ?"];
1046
+ if (filter.provider) {
1047
+ clauses.push("d.provider = ?");
1048
+ params.push(filter.provider);
1049
+ }
1050
+ if (sourceTypes.length) {
1051
+ clauses.push(`d.source_type IN (${sourceTypes.map(() => "?").join(", ")})`);
1052
+ for (const value of sourceTypes) params.push(value);
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
+ }
727
1060
  if (options.context?.since) {
728
- const since = sqliteString(options.context.since.toISOString());
729
- clauses.push(`(d.started_at >= ${since} OR (d.started_at = '' AND d.occurred_at >= ${since}))`);
1061
+ const since = options.context.since.toISOString();
1062
+ clauses.push("(d.started_at >= ? OR (d.started_at = '' AND d.occurred_at >= ?))");
1063
+ params.push(since, since);
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
+ }
1079
+ const limit = Math.max(1, Number(options.limit) || FTS_SEARCH_BATCH_SIZE);
1080
+ const offset = Math.max(0, Number(options.offset) || 0);
1081
+ // LIMIT/OFFSET stay parameterized so a single prepared-statement shape
1082
+ // covers every page; the filter shape varies by query so its SQL is built
1083
+ // per call but the resulting prepared statement is cached on the connection.
1084
+ const sql = [
1085
+ "SELECT",
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,",
1088
+ " d.event_id, d.event_kind, d.message_index, d.path, d.matched_text,",
1089
+ " snippet(docs_fts, 0, '', '', '...', 32) AS excerpt,",
1090
+ " bm25(docs_fts) AS rank",
1091
+ "FROM docs_fts",
1092
+ "JOIN docs d ON d.rowid = docs_fts.rowid",
1093
+ `WHERE ${clauses.join(" AND ")}`,
1094
+ "ORDER BY rank ASC, d.occurred_at DESC, d.started_at DESC",
1095
+ "LIMIT ? OFFSET ?"
1096
+ ].join("\n");
1097
+ params.push(limit, offset);
1098
+
1099
+ let stat;
1100
+ try { stat = fs.statSync(ftsPath); } catch { return null; }
1101
+ const conn = openFtsReadDb(ftsPath, stat);
1102
+ if (conn) {
1103
+ try {
1104
+ return prepareFtsStatement(conn, sql).all(...params);
1105
+ } catch {
1106
+ closeFtsReadDb();
1107
+ return null;
1108
+ }
730
1109
  }
731
- return sqliteJson(
732
- ftsPath,
733
- [
734
- "SELECT",
735
- " d.doc_id, d.session_id, d.provider, d.source_type, d.repo_canonical, d.repo_display,",
736
- " d.scope_canonical, d.cwd, d.title, d.started_at, d.occurred_at, d.role,",
737
- " d.event_id, d.event_kind, d.message_index, d.path, d.matched_text,",
738
- " snippet(docs_fts, 0, '', '', '...', 32) AS excerpt,",
739
- " bm25(docs_fts) AS rank",
740
- "FROM docs_fts",
741
- "JOIN docs d ON d.rowid = docs_fts.rowid",
742
- `WHERE ${clauses.join(" AND ")}`,
743
- "ORDER BY rank ASC, d.occurred_at DESC, d.started_at DESC",
744
- `LIMIT ${Math.max(1, Number(options.limit) || FTS_SEARCH_BATCH_SIZE)} OFFSET ${Math.max(0, Number(options.offset) || 0)};`
745
- ].join("\n")
746
- );
1110
+ return legacySqliteJson(ftsPath, sql, params);
747
1111
  }
748
1112
 
749
1113
  function ftsRowToDoc(row) {
@@ -752,9 +1116,12 @@ function ftsRowToDoc(row) {
752
1116
  sessionId: row.session_id || "",
753
1117
  provider: row.provider || "",
754
1118
  sourceType: row.source_type || "",
1119
+ conversationKind: row.conversation_kind || "",
1120
+ isSubagent: row.is_subagent == null ? undefined : Number(row.is_subagent),
755
1121
  repoCanonical: row.repo_canonical || "",
756
1122
  repoDisplay: row.repo_display || "",
757
1123
  scopeCanonical: row.scope_canonical || "",
1124
+ storageScope: row.storage_scope || "",
758
1125
  cwd: row.cwd || "",
759
1126
  title: row.title || "",
760
1127
  startedAt: row.started_at || "",
@@ -980,11 +1347,16 @@ function inferCallingRepo(cwd) {
980
1347
  }
981
1348
 
982
1349
  function listHistorySessions(options = {}, env = process.env) {
1350
+ return listHistorySessionsFromSessions(listSessions(env), options);
1351
+ }
1352
+
1353
+ function listHistorySessionsFromSessions(sessions, options = {}) {
983
1354
  const since = parseSinceFilter(options.since);
984
1355
  const includeWebChats = options.includeWebChats !== false;
1356
+ const includeSubagents = Boolean(options.includeSubagents || options.include_subagents);
985
1357
  const filter = normalizeSessionFilter(options);
986
- return listSessions(env)
987
- .filter((session) => matchesSessionFilter(session, { ...filter, includeWebChats, since }))
1358
+ return (Array.isArray(sessions) ? sessions : [])
1359
+ .filter((session) => matchesSessionFilter(session, { ...filter, includeWebChats, includeSubagents, since }))
988
1360
  .map(historySessionSummary)
989
1361
  .sort((a, b) => String(historySessionSortTime(b)).localeCompare(String(historySessionSortTime(a))));
990
1362
  }
@@ -1005,6 +1377,7 @@ function historySessionSummary(session) {
1005
1377
  chat_display_name: session.chatDisplayName || undefined,
1006
1378
  chat_project_path: session.chatProjectPath || undefined,
1007
1379
  conversation_kind: session.conversationKind || undefined,
1380
+ parent_composer_id: session.parentComposerId || undefined,
1008
1381
  pinned: Boolean(session.pinned) || undefined,
1009
1382
  cwd: session.cwd || undefined,
1010
1383
  title: session.title || undefined,
@@ -1017,9 +1390,25 @@ function historySessionSummary(session) {
1017
1390
  usage: session.usage || undefined,
1018
1391
  estimatedUsage: session.estimatedUsage || undefined,
1019
1392
  models: session.models || undefined,
1393
+ toolUsage: session.toolUsage || undefined,
1394
+ outputTokenWork: session.outputTokenWork || undefined,
1395
+ outcomes: session.outcomes || undefined,
1020
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,
1021
1409
  conversation: session.conversationPath,
1022
- transcript: session.transcriptPath
1410
+ transcript: session.transcriptPath,
1411
+ events: session.eventPath
1023
1412
  };
1024
1413
  }
1025
1414
 
@@ -1042,6 +1431,7 @@ function displayRepoLabel(session) {
1042
1431
  function displayScopeLabel(scope) {
1043
1432
  return {
1044
1433
  "claude-desktop/uncategorized": "Claude Desktop (uncategorized)",
1434
+ "claude-cowork/uncategorized": "Claude Cowork (uncategorized)",
1045
1435
  "claude-code-desktop/uncategorized": "Claude Code Desktop (uncategorized)"
1046
1436
  }[scope] || scope;
1047
1437
  }
@@ -1077,6 +1467,10 @@ function listRecentSessions(limit = 20, options = {}, env = process.env) {
1077
1467
  return listHistorySessions(options, env).slice(0, limit);
1078
1468
  }
1079
1469
 
1470
+ function listRecentSessionsFromSessions(sessions, limit = 20, options = {}) {
1471
+ return listHistorySessionsFromSessions(sessions, options).slice(0, limit);
1472
+ }
1473
+
1080
1474
  function normalizeSessionFilter(options = {}) {
1081
1475
  const providerInput = options.provider || options.source || "";
1082
1476
  const normalizedProvider = normalizeProviderFilter(providerInput);
@@ -1097,6 +1491,8 @@ function normalizeProviderFilter(value) {
1097
1491
  claude_cli: { provider: "claude_code" },
1098
1492
  claude_desktop: { provider: "claude_desktop" },
1099
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"] },
1100
1496
  claude_workspace: { provider: "claude_desktop", sourceType: "claude-workspace-desktop", sourceTypes: ["claude-workspace-desktop"] },
1101
1497
  claude_workspace_desktop: { provider: "claude_desktop", sourceType: "claude-workspace-desktop", sourceTypes: ["claude-workspace-desktop"] },
1102
1498
  claude_sdk: { provider: "claude_sdk" },
@@ -1113,10 +1509,21 @@ function normalizeProviderFilter(value) {
1113
1509
  aider: { provider: "aider", sourceType: "aider-chat-history", sourceTypes: ["aider-chat-history"] },
1114
1510
  devin: { provider: "devin" },
1115
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"] },
1116
1513
  gemini: { provider: "gemini_cli" },
1117
1514
  gemini_cli: { provider: "gemini_cli" },
1118
1515
  windsurf: { provider: "windsurf" },
1119
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" },
1120
1527
  chatgpt: { provider: "chatgpt" },
1121
1528
  claude_web: { provider: "claude_web" },
1122
1529
  claude_ai: { provider: "claude_web" }
@@ -1128,6 +1535,7 @@ function matchesSessionFilter(session, filter) {
1128
1535
  if (filter.provider && session.provider !== filter.provider) return false;
1129
1536
  const sourceTypes = filter.sourceTypes?.length ? filter.sourceTypes : filter.sourceType ? [filter.sourceType] : [];
1130
1537
  if (sourceTypes.length && !sourceTypes.includes(session.sourceType)) return false;
1538
+ if (!filter.includeSubagents && isSubagentSession(session)) return false;
1131
1539
  if (!filter.includeWebChats && session.scopeCanonical && session.storageScope !== "local") return false;
1132
1540
  if (filter.repo && !matchesRepoFilter(session, filter.repo)) return false;
1133
1541
  if (filter.since) {
@@ -1138,6 +1546,10 @@ function matchesSessionFilter(session, filter) {
1138
1546
  return true;
1139
1547
  }
1140
1548
 
1549
+ function isSubagentSession(session) {
1550
+ return /_subagent$/.test(String(session?.conversationKind || session?.conversation_kind || ""));
1551
+ }
1552
+
1141
1553
  function matchesRepoFilter(session, repoFilter) {
1142
1554
  const wanted = String(repoFilter || "").toLowerCase();
1143
1555
  if (!wanted) return true;
@@ -1202,22 +1614,24 @@ function rebuildIndexSummary(env = process.env, options = {}) {
1202
1614
  }
1203
1615
 
1204
1616
  function indexIsStale(indexPath, env = process.env) {
1205
- let indexStat;
1206
- try {
1207
- indexStat = fs.statSync(indexPath);
1208
- } catch {
1209
- return true;
1210
- }
1211
- 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;
1212
1626
  try {
1213
- if (fs.statSync(session.transcriptPath).mtimeMs > indexStat.mtimeMs) return true;
1214
- if (fs.statSync(session.metadataPath).mtimeMs > indexStat.mtimeMs) return true;
1215
- if (session.eventPath && fs.existsSync(session.eventPath) && fs.statSync(session.eventPath).mtimeMs > indexStat.mtimeMs) return true;
1627
+ stat = fs.statSync(ftsPath);
1216
1628
  } catch {
1217
- return true;
1629
+ return "";
1218
1630
  }
1631
+ const rows = readFtsMeta(ftsPath, stat);
1632
+ return rows?.find((row) => row.key === "sessionFingerprint")?.value || "";
1219
1633
  }
1220
- return false;
1634
+ return readIndexSummary(indexPath)?.sessionFingerprint || "";
1221
1635
  }
1222
1636
 
1223
1637
  function parseSinceFilter(value) {
@@ -1258,11 +1672,23 @@ module.exports = {
1258
1672
  buildIndexSummary,
1259
1673
  chunkText,
1260
1674
  listHistorySessions,
1675
+ listHistorySessionsFromSessions,
1261
1676
  listRecentSessions,
1677
+ listRecentSessionsFromSessions,
1262
1678
  loadIndex,
1263
1679
  readIndexSummary,
1680
+ rebuildIndexSummary,
1264
1681
  reindexIfNeeded,
1265
1682
  sessionHistoryTime,
1266
1683
  searchPastSessions,
1267
- 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
+ }
1268
1694
  };