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/README.md +260 -79
- package/docs/code-reference.md +130 -42
- package/docs/history-source-handling.md +685 -153
- package/docs/release.md +35 -8
- package/npm-shrinkwrap.json +478 -0
- package/package.json +20 -4
- package/scripts/postinstall.js +156 -0
- package/src/archive.js +1342 -50
- package/src/canonical-events.js +346 -35
- package/src/cli.js +8835 -843
- package/src/collector.js +42 -4
- package/src/config.js +26 -4
- package/src/diffs.js +156 -0
- package/src/doctor.js +48 -5
- package/src/importers/claude.js +51 -4
- package/src/importers/copilot.js +385 -0
- package/src/importers/cursor-recovery.js +22 -0
- package/src/importers/factory.js +396 -0
- package/src/importers/gemini.js +41 -1
- package/src/importers/grok.js +367 -0
- package/src/importers/pi.js +422 -0
- package/src/importers/providers.js +64 -5
- package/src/importers.js +6429 -747
- package/src/mcp.js +1 -0
- package/src/memory-sources.js +671 -0
- package/src/memory-store.js +0 -0
- package/src/parser-versions.js +13 -0
- package/src/pricing.js +84 -0
- package/src/search.js +641 -215
- package/src/session-store.js +405 -0
- package/src/source-watch.js +293 -0
- package/src/sources.js +60 -11
- package/src/supervisor.js +197 -9
- package/src/sync.js +6 -0
- package/src/unavailable-sources.js +358 -0
- package/src/web-export-instructions.js +6 -4
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
// Filesystem events are triggers, not data: an event only marks its source
|
|
8
|
+
// dirty, so watcher memory stays O(sources) no matter how fast agents write.
|
|
9
|
+
// The first event for a source opens a fixed-delay coalesce window and later
|
|
10
|
+
// events fold into it, so a continuously writing session cannot starve its
|
|
11
|
+
// own import (a trailing-edge debounce would).
|
|
12
|
+
const DEFAULT_COALESCE_MS = 3 * 1000;
|
|
13
|
+
// SQLite-backed stores churn their WAL while the app is open, including for
|
|
14
|
+
// state unrelated to conversations, so they get a wider window.
|
|
15
|
+
const SQLITE_COALESCE_MS = 20 * 1000;
|
|
16
|
+
// Roots that don't exist yet (tool not installed) or whose watcher died are
|
|
17
|
+
// re-checked on this cadence.
|
|
18
|
+
const WATCH_RETRY_MS = 5 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
function homeDir(env = process.env) {
|
|
21
|
+
return env.HOME || env.USERPROFILE || os.homedir();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function geminiHome(env, home) {
|
|
25
|
+
return env.AGENTLOG_GEMINI_HOME_DIR || env.GEMINI_HOME || path.join(home, ".gemini");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function claudeAppSupportRoot(env, home) {
|
|
29
|
+
return env.CLAUDE_APP_SUPPORT || path.join(home, "Library", "Application Support", "Claude");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function root(dir, options = {}) {
|
|
33
|
+
return {
|
|
34
|
+
dir: path.resolve(dir),
|
|
35
|
+
recursive: options.recursive !== false,
|
|
36
|
+
filter: options.filter || null,
|
|
37
|
+
coalesceMs: options.coalesceMs || DEFAULT_COALESCE_MS
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sqliteStateDbFilter(name) {
|
|
42
|
+
return path.basename(name).startsWith("state.vscdb");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Watch roots per import source, mirroring the importer path resolution in
|
|
46
|
+
// src/importers.js (including its env overrides) without loading that module:
|
|
47
|
+
// the supervisor process should stay small, and a root that drifts slightly
|
|
48
|
+
// from what the importer reads only costs a no-op import or falls back to the
|
|
49
|
+
// heartbeat poll. Sources with no entry (aider scans whole project trees;
|
|
50
|
+
// watching those would fire on every git operation in every repo) stay on the
|
|
51
|
+
// polling cadence.
|
|
52
|
+
function watchRootsForSource(source, env = process.env) {
|
|
53
|
+
const home = homeDir(env);
|
|
54
|
+
switch (source) {
|
|
55
|
+
case "codex-cli":
|
|
56
|
+
case "codex-desktop":
|
|
57
|
+
case "codex-sdk": {
|
|
58
|
+
const codexHome = env.CODEX_HOME || path.join(home, ".codex");
|
|
59
|
+
return [root(path.join(codexHome, "sessions")), root(path.join(codexHome, "archived_sessions"))];
|
|
60
|
+
}
|
|
61
|
+
case "claude":
|
|
62
|
+
return [root(path.join(home, ".claude", "projects"))];
|
|
63
|
+
case "claude-code-desktop":
|
|
64
|
+
return [root(path.join(claudeAppSupportRoot(env, home), "claude-code-sessions"))];
|
|
65
|
+
case "claude-cowork":
|
|
66
|
+
return [root(path.join(claudeAppSupportRoot(env, home), "local-agent-mode-sessions"))];
|
|
67
|
+
case "gemini-cli":
|
|
68
|
+
// Antigravity homes live inside the Gemini home; exclude them so an
|
|
69
|
+
// Antigravity session doesn't ping the Gemini importer.
|
|
70
|
+
return [root(geminiHome(env, home), { filter: (name) => !name.startsWith("antigravity") })];
|
|
71
|
+
case "antigravity":
|
|
72
|
+
return [root(env.AGENTLOG_ANTIGRAVITY_HOME_DIR || path.join(geminiHome(env, home), "antigravity"))];
|
|
73
|
+
case "antigravity-cli":
|
|
74
|
+
return [root(env.AGENTLOG_ANTIGRAVITY_CLI_HOME_DIR || path.join(geminiHome(env, home), "antigravity-cli"))];
|
|
75
|
+
case "antigravity-ide":
|
|
76
|
+
return [root(env.AGENTLOG_ANTIGRAVITY_IDE_HOME_DIR || path.join(geminiHome(env, home), "antigravity-ide"))];
|
|
77
|
+
case "devin-cli": {
|
|
78
|
+
const db = env.AGENTLOG_DEVIN_SESSIONS_DB || path.join(home, ".local", "share", "devin", "cli", "sessions.db");
|
|
79
|
+
return [root(path.dirname(db), { recursive: false, coalesceMs: SQLITE_COALESCE_MS })];
|
|
80
|
+
}
|
|
81
|
+
case "devin-desktop": {
|
|
82
|
+
const appRoots = [
|
|
83
|
+
path.join(home, "Library", "Application Support", "Devin"),
|
|
84
|
+
path.join(home, ".config", "Devin")
|
|
85
|
+
];
|
|
86
|
+
return appRoots.flatMap((appRoot) => [
|
|
87
|
+
root(path.join(appRoot, "User", "acp-events")),
|
|
88
|
+
root(path.join(appRoot, "User", "globalStorage"), {
|
|
89
|
+
recursive: false,
|
|
90
|
+
filter: sqliteStateDbFilter,
|
|
91
|
+
coalesceMs: SQLITE_COALESCE_MS
|
|
92
|
+
})
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
case "windsurf": {
|
|
96
|
+
const windsurfHome =
|
|
97
|
+
env.AGENTLOG_WINDSURF_HOME_DIR ||
|
|
98
|
+
env.AGENTLOG_CODEIUM_WINDSURF_HOME_DIR ||
|
|
99
|
+
path.join(env.AGENTLOG_CODEIUM_HOME_DIR || path.join(home, ".codeium"), "windsurf");
|
|
100
|
+
return [root(windsurfHome)];
|
|
101
|
+
}
|
|
102
|
+
case "copilot-cli": {
|
|
103
|
+
if (env.AGENTLOG_COPILOT_SESSION_STATE_DIR) return [root(env.AGENTLOG_COPILOT_SESSION_STATE_DIR)];
|
|
104
|
+
const copilotHome = env.AGENTLOG_COPILOT_HOME || env.COPILOT_HOME || path.join(home, ".copilot");
|
|
105
|
+
return [root(path.join(copilotHome, "session-state"))];
|
|
106
|
+
}
|
|
107
|
+
case "factory": {
|
|
108
|
+
if (env.AGENTLOG_FACTORY_SESSIONS_DIR) return [root(env.AGENTLOG_FACTORY_SESSIONS_DIR)];
|
|
109
|
+
return [root(path.join(env.FACTORY_HOME_OVERRIDE || home, ".factory", "sessions"))];
|
|
110
|
+
}
|
|
111
|
+
case "grok-build": {
|
|
112
|
+
if (env.AGENTLOG_GROK_SESSIONS_DIR) return [root(env.AGENTLOG_GROK_SESSIONS_DIR)];
|
|
113
|
+
const grokHome = env.AGENTLOG_GROK_HOME || env.GROK_HOME || path.join(home, ".grok");
|
|
114
|
+
return [root(path.join(grokHome, "sessions"))];
|
|
115
|
+
}
|
|
116
|
+
case "pi": {
|
|
117
|
+
const explicit = env.AGENTLOG_PI_SESSION_DIR || env.PI_CODING_AGENT_SESSION_DIR;
|
|
118
|
+
if (explicit) return [root(explicit)];
|
|
119
|
+
const agentDir = env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent");
|
|
120
|
+
return [root(path.join(agentDir, "sessions"))];
|
|
121
|
+
}
|
|
122
|
+
case "cursor": {
|
|
123
|
+
const globalStorage =
|
|
124
|
+
env.AGENTLOG_CURSOR_GLOBAL_STORAGE_DIR ||
|
|
125
|
+
path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage");
|
|
126
|
+
const workspaceStorage =
|
|
127
|
+
env.AGENTLOG_CURSOR_WORKSPACE_STORAGE_DIR ||
|
|
128
|
+
path.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage");
|
|
129
|
+
return [
|
|
130
|
+
root(globalStorage, { recursive: false, filter: sqliteStateDbFilter, coalesceMs: SQLITE_COALESCE_MS }),
|
|
131
|
+
root(workspaceStorage, { filter: sqliteStateDbFilter, coalesceMs: SQLITE_COALESCE_MS })
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
case "cline": {
|
|
135
|
+
// VS Code variants only; JetBrains installs rely on the heartbeat poll.
|
|
136
|
+
const appData = env.APPDATA || env.AppData;
|
|
137
|
+
return [
|
|
138
|
+
path.join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev"),
|
|
139
|
+
path.join(home, "Library", "Application Support", "Code - Insiders", "User", "globalStorage", "saoudrizwan.claude-dev"),
|
|
140
|
+
path.join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev"),
|
|
141
|
+
appData ? path.join(appData, "Code", "User", "globalStorage", "saoudrizwan.claude-dev") : ""
|
|
142
|
+
]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.map((dir) => root(dir));
|
|
145
|
+
}
|
|
146
|
+
case "opencode-cli":
|
|
147
|
+
case "opencode-desktop":
|
|
148
|
+
case "opencode-web": {
|
|
149
|
+
const configured = env.AGENTLOG_OPENCODE_DATA_DIR || env.OPENCODE_DATA_DIR;
|
|
150
|
+
if (configured) return [root(configured)];
|
|
151
|
+
const cliRoot = path.join(home, ".local", "share", "opencode");
|
|
152
|
+
if (source === "opencode-cli") return [root(cliRoot)];
|
|
153
|
+
const desktopRoots = [
|
|
154
|
+
path.join(home, "Library", "Application Support", "ai.opencode.desktop"),
|
|
155
|
+
path.join(home, "Library", "Application Support", "opencode"),
|
|
156
|
+
path.join(home, ".local", "share", "ai.opencode.app"),
|
|
157
|
+
path.join(home, "Library", "Application Support", "ai.opencode.app")
|
|
158
|
+
];
|
|
159
|
+
const roots = source === "opencode-web" ? [cliRoot, ...desktopRoots] : desktopRoots;
|
|
160
|
+
return roots.map((dir) => root(dir));
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Watches the source roots and reports dirty sources after their coalesce
|
|
168
|
+
// window. Roots are deduped so sources sharing a directory (codex-cli and
|
|
169
|
+
// codex-desktop both read ~/.codex/sessions) share one OS watch. Returns a
|
|
170
|
+
// handle: isWatched(source) tells the supervisor whether the source can rely
|
|
171
|
+
// on events (true while roots are merely missing — nothing to import then
|
|
172
|
+
// either — false once a watch attempt errors), refresh() retries missing or
|
|
173
|
+
// dead watches, close() tears everything down.
|
|
174
|
+
function startSourceWatchers(sources, env, onSourceDirty, options = {}) {
|
|
175
|
+
const coalesceOverrideMs = options.coalesceMs || 0;
|
|
176
|
+
const retryMs = options.retryMs || WATCH_RETRY_MS;
|
|
177
|
+
const onLog = typeof options.onLog === "function" ? options.onLog : () => {};
|
|
178
|
+
|
|
179
|
+
const byDir = new Map();
|
|
180
|
+
const sourcesWithRoots = new Set();
|
|
181
|
+
const failedSources = new Set();
|
|
182
|
+
for (const source of sources || []) {
|
|
183
|
+
for (const entry of watchRootsForSource(source, env)) {
|
|
184
|
+
sourcesWithRoots.add(source);
|
|
185
|
+
const existing = byDir.get(entry.dir);
|
|
186
|
+
if (existing) {
|
|
187
|
+
existing.recursive = existing.recursive || entry.recursive;
|
|
188
|
+
existing.subscribers.push({ source, filter: entry.filter, coalesceMs: entry.coalesceMs });
|
|
189
|
+
} else {
|
|
190
|
+
byDir.set(entry.dir, {
|
|
191
|
+
dir: entry.dir,
|
|
192
|
+
recursive: entry.recursive,
|
|
193
|
+
watcher: null,
|
|
194
|
+
subscribers: [{ source, filter: entry.filter, coalesceMs: entry.coalesceMs }]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const timers = new Map();
|
|
201
|
+
let closed = false;
|
|
202
|
+
|
|
203
|
+
function markDirty(source, coalesceMs) {
|
|
204
|
+
if (closed || timers.has(source)) return;
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
timers.delete(source);
|
|
207
|
+
if (!closed) onSourceDirty(source);
|
|
208
|
+
}, coalesceOverrideMs || coalesceMs);
|
|
209
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
210
|
+
timers.set(source, timer);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function handleEvent(entry, filename) {
|
|
214
|
+
const name = typeof filename === "string" ? filename : "";
|
|
215
|
+
for (const subscriber of entry.subscribers) {
|
|
216
|
+
// A null filename means the platform couldn't say what changed; treat
|
|
217
|
+
// it as a match rather than risk missing activity.
|
|
218
|
+
if (subscriber.filter && name && !subscriber.filter(name)) continue;
|
|
219
|
+
markDirty(subscriber.source, subscriber.coalesceMs);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function markEntryFailed(entry, error) {
|
|
224
|
+
for (const subscriber of entry.subscribers) failedSources.add(subscriber.source);
|
|
225
|
+
onLog(`watch failed for ${entry.dir}: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function attach(entry) {
|
|
229
|
+
if (entry.watcher || closed) return;
|
|
230
|
+
let stat = null;
|
|
231
|
+
try {
|
|
232
|
+
stat = fs.statSync(entry.dir);
|
|
233
|
+
} catch {
|
|
234
|
+
return; // Missing root: nothing to import from it yet; refresh retries.
|
|
235
|
+
}
|
|
236
|
+
if (!stat.isDirectory()) return;
|
|
237
|
+
try {
|
|
238
|
+
const watcher = fs.watch(entry.dir, { recursive: entry.recursive, persistent: false });
|
|
239
|
+
watcher.on("change", (eventType, filename) => handleEvent(entry, filename));
|
|
240
|
+
watcher.on("error", () => {
|
|
241
|
+
// Watches die when the root is replaced; refresh re-attaches.
|
|
242
|
+
try {
|
|
243
|
+
watcher.close();
|
|
244
|
+
} catch {}
|
|
245
|
+
entry.watcher = null;
|
|
246
|
+
});
|
|
247
|
+
entry.watcher = watcher;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
markEntryFailed(entry, error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function refresh() {
|
|
254
|
+
if (closed) return;
|
|
255
|
+
for (const entry of byDir.values()) attach(entry);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
refresh();
|
|
259
|
+
const retryTimer = setInterval(refresh, retryMs);
|
|
260
|
+
if (typeof retryTimer.unref === "function") retryTimer.unref();
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
isWatched(source) {
|
|
264
|
+
return sourcesWithRoots.has(source) && !failedSources.has(source);
|
|
265
|
+
},
|
|
266
|
+
activeWatchCount() {
|
|
267
|
+
let count = 0;
|
|
268
|
+
for (const entry of byDir.values()) if (entry.watcher) count += 1;
|
|
269
|
+
return count;
|
|
270
|
+
},
|
|
271
|
+
refresh,
|
|
272
|
+
close() {
|
|
273
|
+
closed = true;
|
|
274
|
+
clearInterval(retryTimer);
|
|
275
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
276
|
+
timers.clear();
|
|
277
|
+
for (const entry of byDir.values()) {
|
|
278
|
+
if (!entry.watcher) continue;
|
|
279
|
+
try {
|
|
280
|
+
entry.watcher.close();
|
|
281
|
+
} catch {}
|
|
282
|
+
entry.watcher = null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
DEFAULT_COALESCE_MS,
|
|
290
|
+
SQLITE_COALESCE_MS,
|
|
291
|
+
startSourceWatchers,
|
|
292
|
+
watchRootsForSource
|
|
293
|
+
};
|