agentel 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ "use strict";
2
+
3
+ // SQLite-backed session metadata index.
4
+ //
5
+ // Why this exists:
6
+ // listSessions used to walk every *.metadata.json under the archive on each
7
+ // read (~85ms warm for 3,900 sessions on the dev archive). A JSON
8
+ // session-list index later replaced the walk, but parsing the 22MB file
9
+ // still cost ~125ms on every cold list-endpoint hit. SQLite gives us
10
+ // indexed ORDER BY / WHERE / GROUP BY with sub-millisecond reads and
11
+ // incremental upserts on import.
12
+ //
13
+ // Source of truth: still the *.metadata.json files on disk. This DB is a
14
+ // derived cache, rebuildable any time. If it goes missing/corrupt the
15
+ // archive.js fallback rebuilds it by walking the filesystem.
16
+ //
17
+ // Schema is a hybrid: hot columns are extracted so SQL can index/sort/group;
18
+ // everything else is stored as an opaque JSON blob in session_json. This
19
+ // avoids maintaining 30+ ALTER-able columns as session metadata evolves.
20
+
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const { ensureDir } = require("./paths");
24
+
25
+ const SESSION_STORE_VERSION = 2;
26
+
27
+ let _Database = null;
28
+ let _driverLoaded = false;
29
+ let _driverLoadError = null;
30
+ let _driverBroken = false;
31
+
32
+ const _connState = {
33
+ path: "",
34
+ ino: 0,
35
+ db: null,
36
+ prepared: null,
37
+ broken: false
38
+ };
39
+
40
+ function loadDriver() {
41
+ if (_driverLoaded) return _driverBroken ? null : _Database;
42
+ _driverLoaded = true;
43
+ try {
44
+ _Database = require("better-sqlite3");
45
+ } catch (error) {
46
+ _Database = null;
47
+ _driverLoadError = error;
48
+ }
49
+ return _Database;
50
+ }
51
+
52
+ function markDriverBroken() {
53
+ _driverBroken = true;
54
+ _Database = null;
55
+ closeConnection();
56
+ }
57
+
58
+ function closeConnection() {
59
+ if (_connState.db) {
60
+ try { _connState.db.close(); } catch { /* ignore */ }
61
+ }
62
+ _connState.db = null;
63
+ _connState.prepared = null;
64
+ _connState.path = "";
65
+ _connState.ino = 0;
66
+ _connState.broken = false;
67
+ }
68
+
69
+ /**
70
+ * Path to the sessions SQLite file inside an archive root. Kept as its own
71
+ * file so an FTS rebuild (different version, different cadence) does not
72
+ * disturb the session index.
73
+ */
74
+ function sessionStorePath(archiveRoot) {
75
+ return path.join(archiveRoot, "indexes", "sessions", "sessions.sqlite");
76
+ }
77
+
78
+ const SCHEMA_SQL = [
79
+ "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
80
+ "CREATE TABLE IF NOT EXISTS sessions (",
81
+ " session_id TEXT PRIMARY KEY,",
82
+ " metadata_path TEXT NOT NULL,",
83
+ " mtime_ms INTEGER NOT NULL,",
84
+ " size INTEGER NOT NULL,",
85
+ " started_at TEXT,",
86
+ " ended_at TEXT,",
87
+ " provider TEXT,",
88
+ " source_type TEXT,",
89
+ " repo_canonical TEXT,",
90
+ " scope_canonical TEXT,",
91
+ " storage_scope TEXT,",
92
+ " conversation_kind TEXT,",
93
+ " is_subagent INTEGER NOT NULL DEFAULT 0,",
94
+ // source_path is the original importer-side path the session came from
95
+ // (e.g. a Cursor agent-transcripts folder). Used by the Cursor/Devin
96
+ // multi-session-store provider flow to supersede prior snapshots written
97
+ // under the same source.
98
+ " source_path TEXT,",
99
+ " session_json TEXT NOT NULL",
100
+ ");",
101
+ "CREATE INDEX IF NOT EXISTS idx_sessions_started_at_desc ON sessions(started_at DESC);",
102
+ "CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider);",
103
+ "CREATE INDEX IF NOT EXISTS idx_sessions_repo_canonical ON sessions(repo_canonical);",
104
+ "CREATE INDEX IF NOT EXISTS idx_sessions_is_subagent ON sessions(is_subagent);",
105
+ "CREATE INDEX IF NOT EXISTS idx_sessions_metadata_path ON sessions(metadata_path);",
106
+ "CREATE INDEX IF NOT EXISTS idx_sessions_provider_source_path ON sessions(provider, source_path);"
107
+ ].join("\n");
108
+
109
+ /**
110
+ * Open or reuse a connection. Returns null if the driver is unavailable
111
+ * (the caller then falls back to the JSON / filesystem path). A wedged
112
+ * driver (ABI mismatch surfacing at first `new Database()`) latches so we
113
+ * do not re-attempt on every call.
114
+ */
115
+ function openConnection(archiveRoot) {
116
+ if (_driverBroken) return null;
117
+ const Driver = loadDriver();
118
+ if (!Driver) return null;
119
+ const filePath = sessionStorePath(archiveRoot);
120
+ let stat = null;
121
+ try { stat = fs.statSync(filePath); } catch { stat = null; }
122
+ if (_connState.db && _connState.path === filePath && stat && _connState.ino === stat.ino) {
123
+ return _connState;
124
+ }
125
+ closeConnection();
126
+ ensureDir(path.dirname(filePath));
127
+ let db;
128
+ try {
129
+ db = new Driver(filePath);
130
+ } catch (error) {
131
+ if (isAbiMismatch(error)) {
132
+ // Wedged native binding — latch so we don't retry this process. The
133
+ // archive.js fallback path takes over.
134
+ markDriverBroken();
135
+ return null;
136
+ }
137
+ // Any other failure (truncated file, partial write from an OS crash,
138
+ // unreadable bytes, schema mismatch SQLite can't parse) means the file
139
+ // on disk is unusable. Since this DB is a derived cache rebuildable
140
+ // from *.metadata.json files, unlink so the next caller gets a fresh
141
+ // db instead of pinning the broken state for the rest of the process.
142
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
143
+ try { fs.unlinkSync(`${filePath}-wal`); } catch { /* ignore */ }
144
+ try { fs.unlinkSync(`${filePath}-shm`); } catch { /* ignore */ }
145
+ return null;
146
+ }
147
+ try {
148
+ db.pragma("journal_mode = WAL");
149
+ db.pragma("synchronous = NORMAL");
150
+ db.pragma("temp_store = MEMORY");
151
+ // Derived cache: bumping SESSION_STORE_VERSION wipes the sessions table
152
+ // so we can change the schema without writing ALTERs. The on-disk
153
+ // metadata.json files are the source of truth; the next list call
154
+ // repopulates from a filesystem walk.
155
+ let storedVersion = "";
156
+ try {
157
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
158
+ storedVersion = String(row?.value || "");
159
+ } catch {
160
+ // meta table may not exist on a fresh db — fine.
161
+ }
162
+ if (storedVersion && storedVersion !== String(SESSION_STORE_VERSION)) {
163
+ try { db.exec("DROP TABLE IF EXISTS sessions"); } catch { /* ignore */ }
164
+ }
165
+ db.exec(SCHEMA_SQL);
166
+ upsertMeta(db, "version", String(SESSION_STORE_VERSION));
167
+ upsertMeta(db, "archive_root", archiveRoot);
168
+ } catch (error) {
169
+ // Schema setup failed — the file is likely partially populated or has
170
+ // an unreadable on-disk schema header. Unlink so the next caller gets
171
+ // a clean DB instead of pinning this broken state for the process.
172
+ try { db.close(); } catch { /* ignore */ }
173
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
174
+ try { fs.unlinkSync(`${filePath}-wal`); } catch { /* ignore */ }
175
+ try { fs.unlinkSync(`${filePath}-shm`); } catch { /* ignore */ }
176
+ return null;
177
+ }
178
+ let nextStat = null;
179
+ try { nextStat = fs.statSync(filePath); } catch { nextStat = null; }
180
+ _connState.path = filePath;
181
+ _connState.ino = nextStat?.ino || 0;
182
+ _connState.db = db;
183
+ _connState.prepared = new Map();
184
+ _connState.broken = false;
185
+ return _connState;
186
+ }
187
+
188
+ function isAbiMismatch(error) {
189
+ const message = String(error?.message || "");
190
+ return /NODE_MODULE_VERSION|was compiled against a different Node\.js version/i.test(message);
191
+ }
192
+
193
+ function prepareCached(state, sql) {
194
+ let stmt = state.prepared.get(sql);
195
+ if (!stmt) {
196
+ stmt = state.db.prepare(sql);
197
+ state.prepared.set(sql, stmt);
198
+ }
199
+ return stmt;
200
+ }
201
+
202
+ function upsertMeta(db, key, value) {
203
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
204
+ }
205
+
206
+ function entryToRowParams(entry) {
207
+ const session = entry.session || {};
208
+ const isSubagent = /_subagent$/.test(String(session.conversationKind || "")) ? 1 : 0;
209
+ return {
210
+ session_id: session.sessionId || "",
211
+ metadata_path: entry.metadataPath || session.metadataPath || "",
212
+ mtime_ms: Math.trunc(Number(entry.mtimeMs) || 0),
213
+ size: Math.trunc(Number(entry.size) || 0),
214
+ started_at: session.startedAt || "",
215
+ ended_at: session.endedAt || "",
216
+ provider: session.provider || "",
217
+ source_type: session.sourceType || "",
218
+ repo_canonical: session.repoCanonical || "",
219
+ scope_canonical: session.scopeCanonical || "",
220
+ storage_scope: session.storageScope || "",
221
+ conversation_kind: session.conversationKind || "",
222
+ is_subagent: isSubagent,
223
+ source_path: session.sourcePath || "",
224
+ session_json: JSON.stringify(session)
225
+ };
226
+ }
227
+
228
+ function rowToEntry(row) {
229
+ if (!row || !row.session_json) return null;
230
+ let session = null;
231
+ try { session = JSON.parse(row.session_json); } catch { return null; }
232
+ if (!session || typeof session !== "object") return null;
233
+ return {
234
+ metadataPath: row.metadata_path || "",
235
+ mtimeMs: Number(row.mtime_ms) || 0,
236
+ size: Number(row.size) || 0,
237
+ session
238
+ };
239
+ }
240
+
241
+ const ROW_INSERT_SQL =
242
+ "INSERT INTO sessions(" +
243
+ " session_id, metadata_path, mtime_ms, size, started_at, ended_at, provider, source_type, " +
244
+ " repo_canonical, scope_canonical, storage_scope, conversation_kind, is_subagent, source_path, session_json" +
245
+ ") VALUES(" +
246
+ " @session_id, @metadata_path, @mtime_ms, @size, @started_at, @ended_at, @provider, @source_type, " +
247
+ " @repo_canonical, @scope_canonical, @storage_scope, @conversation_kind, @is_subagent, @source_path, @session_json" +
248
+ ") ON CONFLICT(session_id) DO UPDATE SET " +
249
+ " metadata_path = excluded.metadata_path, mtime_ms = excluded.mtime_ms, size = excluded.size, " +
250
+ " started_at = excluded.started_at, ended_at = excluded.ended_at, provider = excluded.provider, " +
251
+ " source_type = excluded.source_type, repo_canonical = excluded.repo_canonical, " +
252
+ " scope_canonical = excluded.scope_canonical, storage_scope = excluded.storage_scope, " +
253
+ " conversation_kind = excluded.conversation_kind, is_subagent = excluded.is_subagent, " +
254
+ " source_path = excluded.source_path, session_json = excluded.session_json";
255
+
256
+ const ROW_SELECT_ALL_SQL =
257
+ "SELECT session_id, metadata_path, mtime_ms, size, session_json FROM sessions";
258
+
259
+ const ROW_DELETE_SQL = "DELETE FROM sessions WHERE session_id = ?";
260
+ const ROW_DELETE_BY_PATH_SQL = "DELETE FROM sessions WHERE metadata_path = ?";
261
+ // Used by the Cursor/Devin provider flow to evict prior snapshots written
262
+ // for the same (provider, source_path) before upserting the fresh one.
263
+ const ROW_DELETE_BY_SOURCE_PATH_SQL =
264
+ "DELETE FROM sessions WHERE provider = ? AND source_path = ? AND source_path != '' AND session_id != ?";
265
+
266
+ /**
267
+ * Bulk-replace the entire sessions table from a rebuilt entry list. Used by
268
+ * the filesystem-walk fallback after it scans the archive. Done in a single
269
+ * transaction so concurrent readers either see the old or new set.
270
+ */
271
+ function replaceAllSessions(entries, archiveRoot) {
272
+ const state = openConnection(archiveRoot);
273
+ if (!state) return false;
274
+ const insertStmt = prepareCached(state, ROW_INSERT_SQL);
275
+ try {
276
+ const replace = state.db.transaction(function (rows) {
277
+ state.db.exec("DELETE FROM sessions");
278
+ // Track actually-inserted rows rather than rows.length so the count
279
+ // meta reflects what's queryable in the sessions table. Entries that
280
+ // lack session_id or metadata_path are skipped above and must not
281
+ // count toward the stored total.
282
+ let inserted = 0;
283
+ for (const entry of rows) {
284
+ if (!entry || !entry.session) continue;
285
+ const params = entryToRowParams(entry);
286
+ if (!params.session_id || !params.metadata_path) continue;
287
+ insertStmt.run(params);
288
+ inserted += 1;
289
+ }
290
+ upsertMeta(state.db, "count", String(inserted));
291
+ upsertMeta(state.db, "updated_at", new Date().toISOString());
292
+ });
293
+ replace(entries);
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Single-row upsert used from writeSession after a session is committed to
302
+ * disk. Cheap path: one INSERT ... ON CONFLICT, no walk.
303
+ */
304
+ function upsertSession(entry, archiveRoot) {
305
+ const state = openConnection(archiveRoot);
306
+ if (!state) return false;
307
+ if (!entry || !entry.session) return false;
308
+ const params = entryToRowParams(entry);
309
+ if (!params.session_id || !params.metadata_path) return false;
310
+ try {
311
+ prepareCached(state, ROW_INSERT_SQL).run(params);
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Delete by session id or by metadata path. Either is sufficient since both
320
+ * are indexed; both are accepted because callers may know only one.
321
+ */
322
+ function deleteSession({ sessionId, metadataPath }, archiveRoot) {
323
+ const state = openConnection(archiveRoot);
324
+ if (!state) return false;
325
+ try {
326
+ if (sessionId) prepareCached(state, ROW_DELETE_SQL).run(sessionId);
327
+ if (metadataPath) prepareCached(state, ROW_DELETE_BY_PATH_SQL).run(metadataPath);
328
+ return true;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Cursor/Devin replace-old-snapshot path: evict every prior session row that
336
+ * shares (provider, source_path) with the incoming session, except the
337
+ * incoming session itself. Returns the count of deleted rows for observability.
338
+ */
339
+ function deleteSameSourceSessions({ provider, sourcePath, keepSessionId }, archiveRoot) {
340
+ const state = openConnection(archiveRoot);
341
+ if (!state) return 0;
342
+ if (!provider || !sourcePath) return 0;
343
+ try {
344
+ const result = prepareCached(state, ROW_DELETE_BY_SOURCE_PATH_SQL).run(provider, sourcePath, keepSessionId || "");
345
+ return Number(result?.changes || 0);
346
+ } catch {
347
+ return 0;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Read all session entries from the store. Returns null when the store is
353
+ * unavailable or empty so the caller can fall back to the JSON file / a
354
+ * filesystem walk and then populate the store.
355
+ */
356
+ function readAllSessions(archiveRoot) {
357
+ const state = openConnection(archiveRoot);
358
+ if (!state) return null;
359
+ let rows;
360
+ try {
361
+ rows = prepareCached(state, ROW_SELECT_ALL_SQL).all();
362
+ } catch {
363
+ return null;
364
+ }
365
+ if (!rows.length) return null;
366
+ const entries = rows.map(rowToEntry).filter(Boolean);
367
+ return entries.length ? entries : null;
368
+ }
369
+
370
+ function countSessions(archiveRoot) {
371
+ const state = openConnection(archiveRoot);
372
+ if (!state) return null;
373
+ try {
374
+ const row = prepareCached(state, "SELECT COUNT(*) AS c FROM sessions").get();
375
+ return row?.c || 0;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ function storeAvailable(archiveRoot) {
382
+ return Boolean(openConnection(archiveRoot));
383
+ }
384
+
385
+ function resetStateForTests() {
386
+ closeConnection();
387
+ _driverBroken = false;
388
+ _driverLoaded = false;
389
+ _Database = null;
390
+ _driverLoadError = null;
391
+ }
392
+
393
+ module.exports = {
394
+ SESSION_STORE_VERSION,
395
+ sessionStorePath,
396
+ replaceAllSessions,
397
+ upsertSession,
398
+ deleteSession,
399
+ deleteSameSourceSessions,
400
+ readAllSessions,
401
+ countSessions,
402
+ storeAvailable,
403
+ closeConnection,
404
+ resetStateForTests
405
+ };