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/README.md +77 -37
- package/docs/code-reference.md +26 -13
- package/docs/history-source-handling.md +247 -82
- package/docs/release.md +1 -1
- package/package.json +5 -2
- package/src/archive.js +200 -17
- package/src/canonical-events.js +74 -25
- package/src/cli.js +2561 -204
- package/src/config.js +11 -0
- package/src/doctor.js +2 -0
- package/src/importers/claude.js +309 -11
- package/src/importers/gemini.js +2 -1
- package/src/importers/providers.js +22 -0
- package/src/importers.js +2142 -212
- package/src/parser-versions.js +1 -0
- package/src/search.js +417 -176
- package/src/sources.js +1 -0
- package/src/web-export-instructions.js +79 -0
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
368
|
-
const
|
|
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:
|
|
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
|
-
|
|
383
|
-
|
|
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 =
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
729
|
-
clauses.push(
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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"] },
|