agentel 0.2.5 → 0.2.8

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
@@ -12,8 +12,7 @@ const { canonicalRepo } = require("./repo");
12
12
 
13
13
  const INDEX_VERSION = 3;
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;
@@ -33,6 +32,42 @@ const _ftsCache = {
33
32
  checkedAtMs: 0,
34
33
  available: false
35
34
  };
35
+ // Read-only better-sqlite3 connection cache for the FTS sidecar. Reopened when
36
+ // the file inode changes (atomic rename during rebuild). Prepared statements
37
+ // are cached on the connection by SQL text.
38
+ const _ftsReadConn = {
39
+ path: "",
40
+ mtimeMs: 0,
41
+ size: 0,
42
+ ino: 0,
43
+ db: null,
44
+ prepared: null
45
+ };
46
+ let _betterSqlite3 = null;
47
+ let _betterSqlite3Loaded = false;
48
+ let _betterSqlite3LoadError = null;
49
+
50
+ /**
51
+ * Lazily load better-sqlite3. Returns the constructor or null when the native
52
+ * binding is unavailable; call sites then fall back to the legacy `sqlite3`
53
+ * subprocess path. The load attempt is cached so missing optional builds do
54
+ * not re-try `require()` on every query.
55
+ */
56
+ function loadBetterSqlite3() {
57
+ if (_betterSqlite3Loaded) return _betterSqlite3;
58
+ _betterSqlite3Loaded = true;
59
+ try {
60
+ _betterSqlite3 = require("better-sqlite3");
61
+ } catch (error) {
62
+ _betterSqlite3 = null;
63
+ _betterSqlite3LoadError = error;
64
+ }
65
+ return _betterSqlite3;
66
+ }
67
+
68
+ function betterSqlite3LoadError() {
69
+ return _betterSqlite3LoadError;
70
+ }
36
71
 
37
72
  function buildIndex(env = process.env) {
38
73
  const sessions = listSessions(env);
@@ -147,59 +182,65 @@ function escapeRegExp(value) {
147
182
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
183
  }
149
184
 
185
+ const FTS_SCHEMA_SQL = [
186
+ "CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);",
187
+ "CREATE TABLE docs(",
188
+ " rowid INTEGER PRIMARY KEY,",
189
+ " doc_id TEXT,",
190
+ " session_id TEXT,",
191
+ " provider TEXT,",
192
+ " source_type TEXT,",
193
+ " repo_canonical TEXT,",
194
+ " repo_display TEXT,",
195
+ " scope_canonical TEXT,",
196
+ " cwd TEXT,",
197
+ " title TEXT,",
198
+ " started_at TEXT,",
199
+ " occurred_at TEXT,",
200
+ " role TEXT,",
201
+ " event_id TEXT,",
202
+ " event_kind TEXT,",
203
+ " message_index INTEGER,",
204
+ " path TEXT,",
205
+ " matched_text TEXT",
206
+ ");",
207
+ "CREATE VIRTUAL TABLE docs_fts USING fts5(text, tokenize='unicode61', prefix='2 3 4');"
208
+ ].join("\n");
209
+
210
+ function cleanupFtsTmpFiles(tmpPath) {
211
+ for (const file of [tmpPath, `${tmpPath}-journal`, `${tmpPath}-wal`, `${tmpPath}-shm`]) {
212
+ try {
213
+ fs.rmSync(file, { force: true });
214
+ } catch {
215
+ // Best effort cleanup before rebuilding the sidecar index.
216
+ }
217
+ }
218
+ }
219
+
150
220
  function buildFtsIndex(index, env = process.env) {
151
221
  const ftsPath = paths(env).ftsIndex;
152
222
  const tmpPath = `${ftsPath}.${process.pid}.tmp`;
223
+ let handle = null;
153
224
  try {
154
225
  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');");
226
+ cleanupFtsTmpFiles(tmpPath);
227
+ handle = openFtsBuildDb(tmpPath);
228
+ execFtsBuildSql(handle, FTS_SCHEMA_SQL);
229
+ insertFtsMetaRows(handle, [
230
+ { key: "version", value: String(INDEX_VERSION) },
231
+ { key: "builtAt", value: index.builtAt || "" },
232
+ { key: "docCount", value: String(index.docCount || 0) }
233
+ ]);
234
+ insertFtsDocs(handle, tmpPath, index.docs || [], 1);
235
+ execFtsBuildSql(handle, "INSERT INTO docs_fts(docs_fts) VALUES('optimize');");
236
+ closeFtsBuildDb(handle);
237
+ handle = null;
194
238
  fs.renameSync(tmpPath, ftsPath);
195
239
  rememberFtsCache(ftsPath, true);
196
240
  return true;
197
241
  } catch {
198
- try {
199
- fs.rmSync(tmpPath, { force: true });
200
- } catch {
201
- // Ignore optional FTS cleanup failure.
202
- }
242
+ if (handle) closeFtsBuildDb(handle);
243
+ cleanupFtsTmpFiles(tmpPath);
203
244
  try {
204
245
  fs.rmSync(ftsPath, { force: true });
205
246
  } catch {
@@ -216,43 +257,16 @@ function buildFtsIndexSummary(env = process.env) {
216
257
  const builtAt = new Date().toISOString();
217
258
  let docCount = 0;
218
259
  let totalLength = 0;
260
+ let handle = null;
219
261
  try {
220
262
  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"));
263
+ cleanupFtsTmpFiles(tmpPath);
264
+ handle = openFtsBuildDb(tmpPath);
265
+ execFtsBuildSql(handle, FTS_SCHEMA_SQL);
266
+ insertFtsMetaRows(handle, [
267
+ { key: "version", value: String(INDEX_VERSION) },
268
+ { key: "builtAt", value: builtAt }
269
+ ]);
256
270
 
257
271
  let batch = [];
258
272
  for (const session of listSessions(env)) {
@@ -278,25 +292,22 @@ function buildFtsIndexSummary(env = process.env) {
278
292
  length: tokens.length
279
293
  });
280
294
  if (batch.length >= SQLITE_BUILD_BATCH_SIZE) {
281
- insertFtsDocs(tmpPath, batch, docCount - batch.length + 1);
295
+ insertFtsDocs(handle, tmpPath, batch, docCount - batch.length + 1);
282
296
  batch = [];
283
297
  }
284
298
  }
285
299
  }
286
300
  }
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"));
301
+ if (batch.length) insertFtsDocs(handle, tmpPath, batch, docCount - batch.length + 1);
302
+ insertFtsMetaRows(handle, [{ key: "docCount", value: String(docCount) }]);
303
+ execFtsBuildSql(handle, "INSERT INTO docs_fts(docs_fts) VALUES('optimize');");
304
+ closeFtsBuildDb(handle);
305
+ handle = null;
292
306
  fs.renameSync(tmpPath, ftsPath);
293
307
  rememberFtsCache(ftsPath, true);
294
308
  } catch (error) {
295
- try {
296
- fs.rmSync(tmpPath, { force: true });
297
- } catch {
298
- // Ignore optional FTS cleanup failure.
299
- }
309
+ if (handle) closeFtsBuildDb(handle);
310
+ cleanupFtsTmpFiles(tmpPath);
300
311
  try {
301
312
  fs.rmSync(ftsPath, { force: true });
302
313
  } catch {
@@ -314,62 +325,241 @@ function buildFtsIndexSummary(env = process.env) {
314
325
  };
315
326
  }
316
327
 
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 || "")});`);
328
+ 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)";
331
+ const FTS_FTS_INSERT_SQL = "INSERT INTO docs_fts(rowid, text) VALUES (@rowid, @text)";
332
+
333
+ function docInsertParams(doc, rowid) {
334
+ return {
335
+ rowid,
336
+ doc_id: doc.id || "",
337
+ session_id: doc.sessionId || "",
338
+ provider: doc.provider || "",
339
+ source_type: doc.sourceType || "",
340
+ repo_canonical: doc.repoCanonical || "",
341
+ repo_display: doc.repoDisplay || "",
342
+ scope_canonical: doc.scopeCanonical || "",
343
+ cwd: doc.cwd || "",
344
+ title: doc.title || "",
345
+ started_at: doc.startedAt || "",
346
+ occurred_at: doc.occurredAt || "",
347
+ role: doc.role || "",
348
+ event_id: doc.eventId || "",
349
+ event_kind: doc.eventKind || "",
350
+ message_index: Number.isFinite(Number(doc.messageIndex)) ? Number(doc.messageIndex) : null,
351
+ path: doc.path || "",
352
+ matched_text: doc.matchedText || ""
353
+ };
354
+ }
355
+
356
+ function insertFtsMetaRows(handle, rows) {
357
+ if (handle.kind === "native") {
358
+ const stmt = handle.db.prepare("INSERT INTO meta(key, value) VALUES (?, ?)");
359
+ const insertMany = handle.db.transaction((items) => {
360
+ for (const row of items) stmt.run(row.key, row.value);
361
+ });
362
+ insertMany(rows);
363
+ return;
364
+ }
365
+ const sql = rows
366
+ .map((row) => `INSERT INTO meta(key, value) VALUES ('${String(row.key).replace(/'/g, "''")}', '${String(row.value).replace(/'/g, "''")}');`)
367
+ .join("\n");
368
+ legacyRunSqliteScript(handle.dbPath, sql);
369
+ }
370
+
371
+ function insertFtsDocs(handleOrPath, dbPathHint, docs, rowidStart = 1) {
372
+ // Backwards-compatible call shape: tests/external callers may pass
373
+ // (dbPath, docs, rowidStart). When the first argument looks like a path
374
+ // we open a short-lived build connection just for this batch.
375
+ let handle = handleOrPath && typeof handleOrPath === "object" && handleOrPath.kind ? handleOrPath : null;
376
+ let docsArg = docs;
377
+ let rowidArg = rowidStart;
378
+ let openedHere = false;
379
+ if (!handle) {
380
+ handle = openFtsBuildDb(handleOrPath);
381
+ openedHere = true;
382
+ docsArg = dbPathHint;
383
+ rowidArg = docs ?? 1;
384
+ }
385
+ try {
386
+ if (handle.kind === "native") {
387
+ const docStmt = handle.db.prepare(FTS_DOCS_INSERT_SQL);
388
+ const ftsStmt = handle.db.prepare(FTS_FTS_INSERT_SQL);
389
+ const insertMany = handle.db.transaction((items) => {
390
+ for (let offset = 0; offset < items.length; offset++) {
391
+ const rowid = rowidArg + offset;
392
+ const doc = items[offset];
393
+ docStmt.run(docInsertParams(doc, rowid));
394
+ ftsStmt.run({ rowid, text: doc.text || "" });
395
+ }
396
+ });
397
+ // Chunk to avoid pathological transaction sizes; better-sqlite3 handles
398
+ // ~10k inserts/transaction comfortably but we keep batches bounded for
399
+ // memory and progress observability.
400
+ for (let start = 0; start < docsArg.length; start += SQLITE_BUILD_BATCH_SIZE) {
401
+ const slice = docsArg.slice(start, start + SQLITE_BUILD_BATCH_SIZE);
402
+ insertMany(slice);
403
+ rowidArg += slice.length;
404
+ }
405
+ return;
349
406
  }
350
- statements.push("COMMIT;");
351
- runSqliteScript(dbPath, statements.join("\n"));
407
+ // Legacy spawn path. Same text-script semantics as before, just without
408
+ // the helper rename.
409
+ for (let start = 0; start < docsArg.length; start += SQLITE_BUILD_BATCH_SIZE) {
410
+ const statements = ["BEGIN;"];
411
+ const batch = docsArg.slice(start, start + SQLITE_BUILD_BATCH_SIZE);
412
+ for (let offset = 0; offset < batch.length; offset++) {
413
+ const rowid = rowidArg + start + offset;
414
+ const doc = batch[offset];
415
+ const sqlString = (value) => `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
416
+ 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 (" +
418
+ [
419
+ rowid,
420
+ sqlString(doc.id || ""),
421
+ sqlString(doc.sessionId || ""),
422
+ sqlString(doc.provider || ""),
423
+ sqlString(doc.sourceType || ""),
424
+ sqlString(doc.repoCanonical || ""),
425
+ sqlString(doc.repoDisplay || ""),
426
+ sqlString(doc.scopeCanonical || ""),
427
+ sqlString(doc.cwd || ""),
428
+ sqlString(doc.title || ""),
429
+ sqlString(doc.startedAt || ""),
430
+ sqlString(doc.occurredAt || ""),
431
+ sqlString(doc.role || ""),
432
+ sqlString(doc.eventId || ""),
433
+ sqlString(doc.eventKind || ""),
434
+ Number.isFinite(Number(doc.messageIndex)) ? Number(doc.messageIndex) : "NULL",
435
+ sqlString(doc.path || ""),
436
+ sqlString(doc.matchedText || "")
437
+ ].join(", ") +
438
+ ");"
439
+ );
440
+ statements.push(`INSERT INTO docs_fts(rowid, text) VALUES (${rowid}, '${String(doc.text || "").replace(/'/g, "''")}');`);
441
+ }
442
+ statements.push("COMMIT;");
443
+ legacyRunSqliteScript(handle.dbPath, statements.join("\n"));
444
+ }
445
+ } finally {
446
+ if (openedHere) closeFtsBuildDb(handle);
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Open a fresh write connection for index builds. Build paths run inside
452
+ * spawned child processes (`buildIndexInChild`) so the connection is
453
+ * short-lived and not cached.
454
+ */
455
+ function openFtsBuildDb(dbPath) {
456
+ const Database = loadBetterSqlite3();
457
+ 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 };
464
+ }
465
+ return { kind: "spawn", dbPath };
466
+ }
467
+
468
+ function closeFtsBuildDb(handle) {
469
+ if (handle?.kind === "native") {
470
+ try { handle.db.close(); } catch { /* ignore */ }
471
+ }
472
+ }
473
+
474
+ function execFtsBuildSql(handle, sql) {
475
+ if (handle.kind === "native") {
476
+ handle.db.exec(sql);
477
+ return;
478
+ }
479
+ legacyRunSqliteScript(handle.dbPath, sql);
480
+ }
481
+
482
+ /**
483
+ * Open or reuse a read-only connection for the FTS sidecar. The connection is
484
+ * invalidated when the on-disk file's inode/size/mtime changes — the index
485
+ * rebuild atomically renames a fresh DB into place, so the inode flips and
486
+ * any stale prepared statements are dropped.
487
+ */
488
+ function openFtsReadDb(ftsPath, stat) {
489
+ const Database = loadBetterSqlite3();
490
+ if (!Database) return null;
491
+ if (
492
+ _ftsReadConn.db &&
493
+ _ftsReadConn.path === ftsPath &&
494
+ _ftsReadConn.ino === stat.ino &&
495
+ _ftsReadConn.mtimeMs === stat.mtimeMs &&
496
+ _ftsReadConn.size === stat.size
497
+ ) {
498
+ return _ftsReadConn;
499
+ }
500
+ closeFtsReadDb();
501
+ let db;
502
+ try {
503
+ db = new Database(ftsPath, { readonly: true, fileMustExist: true });
504
+ db.pragma("query_only = ON");
505
+ } catch {
506
+ return null;
507
+ }
508
+ _ftsReadConn.path = ftsPath;
509
+ _ftsReadConn.ino = stat.ino;
510
+ _ftsReadConn.mtimeMs = stat.mtimeMs;
511
+ _ftsReadConn.size = stat.size;
512
+ _ftsReadConn.db = db;
513
+ _ftsReadConn.prepared = new Map();
514
+ return _ftsReadConn;
515
+ }
516
+
517
+ function closeFtsReadDb() {
518
+ if (_ftsReadConn.db) {
519
+ try { _ftsReadConn.db.close(); } catch { /* ignore */ }
520
+ }
521
+ _ftsReadConn.path = "";
522
+ _ftsReadConn.ino = 0;
523
+ _ftsReadConn.mtimeMs = 0;
524
+ _ftsReadConn.size = 0;
525
+ _ftsReadConn.db = null;
526
+ _ftsReadConn.prepared = null;
527
+ }
528
+
529
+ function prepareFtsStatement(conn, sql) {
530
+ let stmt = conn.prepared.get(sql);
531
+ if (!stmt) {
532
+ stmt = conn.db.prepare(sql);
533
+ conn.prepared.set(sql, stmt);
352
534
  }
535
+ return stmt;
353
536
  }
354
537
 
355
- function runSqliteScript(dbPath, script) {
538
+ /**
539
+ * Last-resort fallback: spawn the system `sqlite3` binary. Only used when the
540
+ * better-sqlite3 native binding could not be loaded (older Node, missing build
541
+ * toolchain). Pricing this path stays in line with the pre-PR-1 behavior so
542
+ * users without a toolchain are not regressed.
543
+ */
544
+ function legacyRunSqliteScript(dbPath, script) {
356
545
  const result = spawnSync("sqlite3", [dbPath], {
357
546
  argv0: "agentlog-sqlite",
358
547
  input: script,
359
548
  encoding: "utf8",
360
549
  maxBuffer: 1024 * 1024 * 20,
361
- timeout: SQLITE_QUERY_TIMEOUT_MS
550
+ timeout: 5000
362
551
  });
363
552
  if (result.error) throw result.error;
364
553
  if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || "sqlite3 failed").trim());
365
554
  }
366
555
 
367
- function sqliteJson(dbPath, query) {
368
- const result = spawnSync("sqlite3", [dbPath, "-json", query], {
556
+ function legacySqliteJson(dbPath, query, params = []) {
557
+ const inlined = inlineSqlParams(query, params);
558
+ const result = spawnSync("sqlite3", [dbPath, "-json", inlined], {
369
559
  argv0: "agentlog-sqlite",
370
560
  encoding: "utf8",
371
561
  maxBuffer: 1024 * 1024 * 20,
372
- timeout: SQLITE_QUERY_TIMEOUT_MS
562
+ timeout: 5000
373
563
  });
374
564
  if (result.error || result.status !== 0) return null;
375
565
  try {
@@ -379,8 +569,21 @@ function sqliteJson(dbPath, query) {
379
569
  }
380
570
  }
381
571
 
382
- function sqliteString(value) {
383
- return `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
572
+ /**
573
+ * Substitute `?` placeholders for the legacy spawn fallback. Not used by the
574
+ * better-sqlite3 path — that path passes params directly to prepared
575
+ * statements. Quoting matches the prior `sqliteString` semantics.
576
+ */
577
+ function inlineSqlParams(query, params) {
578
+ if (!params || !params.length) return query;
579
+ let index = 0;
580
+ return query.replace(/\?/g, () => {
581
+ if (index >= params.length) return "?";
582
+ const value = params[index++];
583
+ if (value == null) return "NULL";
584
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
585
+ return `'${String(value).replace(/'/g, "''")}'`;
586
+ });
384
587
  }
385
588
 
386
589
  function rememberFtsCache(ftsPath, available) {
@@ -395,6 +598,21 @@ function rememberFtsCache(ftsPath, available) {
395
598
  _ftsCache.size = stat?.size || 0;
396
599
  _ftsCache.checkedAtMs = Date.now();
397
600
  _ftsCache.available = Boolean(available && stat);
601
+ if (!available || !stat) closeFtsReadDb();
602
+ }
603
+
604
+ function readFtsMeta(ftsPath, stat) {
605
+ const conn = openFtsReadDb(ftsPath, stat);
606
+ if (conn) {
607
+ try {
608
+ const stmt = prepareFtsStatement(conn, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount')");
609
+ return stmt.all();
610
+ } catch {
611
+ closeFtsReadDb();
612
+ return null;
613
+ }
614
+ }
615
+ return legacySqliteJson(ftsPath, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount');");
398
616
  }
399
617
 
400
618
  function ftsIndexAvailable(env = process.env, options = {}) {
@@ -404,6 +622,7 @@ function ftsIndexAvailable(env = process.env, options = {}) {
404
622
  stat = fs.statSync(ftsPath);
405
623
  } catch (error) {
406
624
  if (error.code !== "ENOENT") throw error;
625
+ closeFtsReadDb();
407
626
  return false;
408
627
  }
409
628
  if (
@@ -415,7 +634,7 @@ function ftsIndexAvailable(env = process.env, options = {}) {
415
634
  ) {
416
635
  return true;
417
636
  }
418
- const rows = sqliteJson(ftsPath, "SELECT key, value FROM meta WHERE key IN ('version', 'docCount');");
637
+ const rows = readFtsMeta(ftsPath, stat);
419
638
  if (!rows || !rows.some((row) => row.key === "version" && Number(row.value) === INDEX_VERSION)) {
420
639
  rememberFtsCache(ftsPath, false);
421
640
  return false;
@@ -673,29 +892,26 @@ function searchFtsSessions(query, queryTokens, context, env = process.env) {
673
892
  if (!ftsIndexAvailable(env, { noStaleCheck: Boolean(context.options.noRebuild || context.options.allowStaleFts) })) return null;
674
893
  const matchQuery = ftsMatchQuery(query);
675
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;
676
906
  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
- });
686
- 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;
907
+ for (const row of rows) {
908
+ const doc = ftsRowToDoc(row);
909
+ if (!matchesSessionFilter(doc, { ...context.filter, includeWebChats: context.includeWebChats, since: context.since })) continue;
910
+ if (!context.options.repo && context.repo && doc.repoCanonical === context.repo) {
911
+ row.rank = Number(row.rank || 0) - 0.05;
697
912
  }
698
- if (rows.length < batchSize) break;
913
+ if (!bySession.has(doc.sessionId)) bySession.set(doc.sessionId, { doc, row });
914
+ if (bySession.size >= context.limit) break;
699
915
  }
700
916
  return [...bySession.values()].slice(0, context.limit).map(({ doc, row }) => ({
701
917
  session_id: doc.sessionId,
@@ -719,31 +935,55 @@ function searchFtsSessions(query, queryTokens, context, env = process.env) {
719
935
  }
720
936
 
721
937
  function ftsSearchRows(ftsPath, matchQuery, options) {
722
- const clauses = [`docs_fts MATCH ${sqliteString(matchQuery)}`];
723
938
  const filter = options.context?.filter || {};
724
- if (filter.provider) clauses.push(`d.provider = ${sqliteString(filter.provider)}`);
725
939
  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(", ")})`);
940
+ const params = [matchQuery];
941
+ const clauses = ["docs_fts MATCH ?"];
942
+ if (filter.provider) {
943
+ clauses.push("d.provider = ?");
944
+ params.push(filter.provider);
945
+ }
946
+ if (sourceTypes.length) {
947
+ clauses.push(`d.source_type IN (${sourceTypes.map(() => "?").join(", ")})`);
948
+ for (const value of sourceTypes) params.push(value);
949
+ }
727
950
  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}))`);
951
+ const since = options.context.since.toISOString();
952
+ clauses.push("(d.started_at >= ? OR (d.started_at = '' AND d.occurred_at >= ?))");
953
+ params.push(since, since);
730
954
  }
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
- );
955
+ const limit = Math.max(1, Number(options.limit) || FTS_SEARCH_BATCH_SIZE);
956
+ const offset = Math.max(0, Number(options.offset) || 0);
957
+ // LIMIT/OFFSET stay parameterized so a single prepared-statement shape
958
+ // covers every page; the filter shape varies by query so its SQL is built
959
+ // per call but the resulting prepared statement is cached on the connection.
960
+ const sql = [
961
+ "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,",
964
+ " d.event_id, d.event_kind, d.message_index, d.path, d.matched_text,",
965
+ " snippet(docs_fts, 0, '', '', '...', 32) AS excerpt,",
966
+ " bm25(docs_fts) AS rank",
967
+ "FROM docs_fts",
968
+ "JOIN docs d ON d.rowid = docs_fts.rowid",
969
+ `WHERE ${clauses.join(" AND ")}`,
970
+ "ORDER BY rank ASC, d.occurred_at DESC, d.started_at DESC",
971
+ "LIMIT ? OFFSET ?"
972
+ ].join("\n");
973
+ params.push(limit, offset);
974
+
975
+ let stat;
976
+ try { stat = fs.statSync(ftsPath); } catch { return null; }
977
+ const conn = openFtsReadDb(ftsPath, stat);
978
+ if (conn) {
979
+ try {
980
+ return prepareFtsStatement(conn, sql).all(...params);
981
+ } catch {
982
+ closeFtsReadDb();
983
+ return null;
984
+ }
985
+ }
986
+ return legacySqliteJson(ftsPath, sql, params);
747
987
  }
748
988
 
749
989
  function ftsRowToDoc(row) {
@@ -1103,6 +1343,7 @@ function normalizeProviderFilter(value) {
1103
1343
  codex: { provider: "codex" },
1104
1344
  codex_cli: { provider: "codex", sourceType: "codex-cli-history", sourceTypes: ["codex-cli-history", "cli-history"] },
1105
1345
  codex_desktop: { provider: "codex", sourceType: "codex-desktop-history", sourceTypes: ["codex-desktop-history"] },
1346
+ codex_sdk: { provider: "codex", sourceType: "codex-sdk-history", sourceTypes: ["codex-sdk-history"] },
1106
1347
  cursor: { provider: "cursor" },
1107
1348
  cline: { provider: "cline", sourceType: "cline-task-history", sourceTypes: ["cline-task-history"] },
1108
1349
  opencode: { provider: "opencode", sourceTypes: ["opencode-cli-history", "opencode-cli-sqlite-history", "opencode-desktop-history", "opencode-desktop-sqlite-history", "opencode-web-sqlite-history", "opencode-history", "opencode-sqlite-history"] },