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.
- package/README.md +238 -68
- package/docs/code-reference.md +165 -37
- package/docs/history-source-handling.md +555 -124
- package/docs/release.md +35 -8
- package/npm-shrinkwrap.json +478 -0
- package/package.json +18 -5
- package/scripts/postinstall.js +156 -0
- package/src/archive.js +1176 -65
- package/src/canonical-events.js +346 -35
- package/src/cli.js +7801 -874
- package/src/collector.js +42 -4
- package/src/config.js +51 -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 +39 -0
- 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 +4524 -383
- 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 +256 -70
- package/src/session-store.js +405 -0
- package/src/slack-notify.js +732 -0
- package/src/source-watch.js +293 -0
- package/src/sources.js +60 -11
- package/src/supervisor.js +231 -7
- package/src/sync.js +6 -0
- package/src/unavailable-sources.js +358 -0
package/src/supervisor.js
CHANGED
|
@@ -6,12 +6,102 @@ const { spawn } = require("child_process");
|
|
|
6
6
|
const { ensureBaseDirs, paths, readJson, writeJson } = require("./paths");
|
|
7
7
|
const { effectiveImportSources, loadConfig } = require("./config");
|
|
8
8
|
const { startCollector } = require("./collector");
|
|
9
|
+
const { startSourceWatchers } = require("./source-watch");
|
|
9
10
|
const { hasRemoteTarget } = require("./sync");
|
|
10
11
|
|
|
11
12
|
let tickRunning = false;
|
|
12
13
|
const CHILD_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
13
14
|
const CHILD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
14
15
|
const CHILD_KILL_GRACE_MS = 2000;
|
|
16
|
+
// Backoff so an idle machine doesn't spawn import/index children every 30s
|
|
17
|
+
// forever: after a few empty imports a source drops to a slower cadence and
|
|
18
|
+
// snaps back to every tick as soon as an import finds activity. Failing
|
|
19
|
+
// sources back off exponentially so a permanently broken source doesn't
|
|
20
|
+
// burn a child process (and a log line) per tick.
|
|
21
|
+
const SOURCE_IDLE_TICKS_BEFORE_BACKOFF = 4;
|
|
22
|
+
const SOURCE_IDLE_INTERVAL_MS = 5 * 60 * 1000;
|
|
23
|
+
const SOURCE_ERROR_BACKOFF_MIN_MS = 60 * 1000;
|
|
24
|
+
const SOURCE_ERROR_BACKOFF_MAX_MS = 30 * 60 * 1000;
|
|
25
|
+
// Sources covered by filesystem watchers don't need the fast poll at all:
|
|
26
|
+
// events mark them pending (imported on the next tick, triggered
|
|
27
|
+
// immediately), and this heartbeat poll only backstops dropped events —
|
|
28
|
+
// fs.watch loses events under load and emits nothing on network volumes.
|
|
29
|
+
const WATCHED_SOURCE_HEARTBEAT_MS = 15 * 60 * 1000;
|
|
30
|
+
const INDEX_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
31
|
+
// Memory files change rarely and the backup is a stat+hash sweep, so a
|
|
32
|
+
// moderate cadence keeps provider memories continuously archived without
|
|
33
|
+
// meaningful cost.
|
|
34
|
+
const MEMORY_BACKUP_INTERVAL_MS = 15 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
// Per-run tick state. Owned by runSupervisorForeground so direct tick()
|
|
37
|
+
// callers (tests, one-shot runs) always start fresh.
|
|
38
|
+
function createTickState() {
|
|
39
|
+
return {
|
|
40
|
+
sources: new Map(),
|
|
41
|
+
lastIndexAt: 0,
|
|
42
|
+
indexDirty: true,
|
|
43
|
+
lastMemoryBackupAt: 0,
|
|
44
|
+
pendingSources: new Set(),
|
|
45
|
+
tickAgain: false,
|
|
46
|
+
watch: null,
|
|
47
|
+
nextNotifyCheckAt: 0
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sourceTickStateFor(state, source) {
|
|
52
|
+
let sourceState = state.sources.get(source);
|
|
53
|
+
if (!sourceState) {
|
|
54
|
+
sourceState = { nextEligibleAt: 0, idleTicks: 0, failures: 0, idleLogged: false };
|
|
55
|
+
state.sources.set(source, sourceState);
|
|
56
|
+
}
|
|
57
|
+
return sourceState;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Returns true when the source just transitioned into idle backoff (so the
|
|
61
|
+
// caller can log the transition once instead of every tick).
|
|
62
|
+
function applySourceImportOutcome(sourceState, activity, now) {
|
|
63
|
+
sourceState.failures = 0;
|
|
64
|
+
if (activity > 0) {
|
|
65
|
+
sourceState.idleTicks = 0;
|
|
66
|
+
sourceState.nextEligibleAt = 0;
|
|
67
|
+
sourceState.idleLogged = false;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
sourceState.idleTicks += 1;
|
|
71
|
+
if (sourceState.idleTicks < SOURCE_IDLE_TICKS_BEFORE_BACKOFF) return false;
|
|
72
|
+
sourceState.nextEligibleAt = now + SOURCE_IDLE_INTERVAL_MS;
|
|
73
|
+
const firstTime = !sourceState.idleLogged;
|
|
74
|
+
sourceState.idleLogged = true;
|
|
75
|
+
return firstTime;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Watched sources skip idle backoff entirely: filesystem events snap them
|
|
79
|
+
// back via pendingSources, so after any import they rest until the heartbeat.
|
|
80
|
+
function applyWatchedSourceImportOutcome(sourceState, now) {
|
|
81
|
+
sourceState.failures = 0;
|
|
82
|
+
sourceState.idleTicks = 0;
|
|
83
|
+
sourceState.idleLogged = false;
|
|
84
|
+
sourceState.nextEligibleAt = now + WATCHED_SOURCE_HEARTBEAT_MS;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pending filesystem activity bypasses idle/heartbeat scheduling but not
|
|
88
|
+
// error backoff, so a chatty source that fails to import doesn't retry on
|
|
89
|
+
// every coalesce window.
|
|
90
|
+
function sourceEligibleForImport(sourceState, pending, now) {
|
|
91
|
+
if (now >= sourceState.nextEligibleAt) return true;
|
|
92
|
+
return pending && sourceState.failures === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Returns the backoff delay in ms applied after a failed import.
|
|
96
|
+
function applySourceImportFailure(sourceState, now) {
|
|
97
|
+
sourceState.failures += 1;
|
|
98
|
+
const backoffMs = Math.min(
|
|
99
|
+
SOURCE_ERROR_BACKOFF_MAX_MS,
|
|
100
|
+
SOURCE_ERROR_BACKOFF_MIN_MS * 2 ** (sourceState.failures - 1)
|
|
101
|
+
);
|
|
102
|
+
sourceState.nextEligibleAt = now + backoffMs;
|
|
103
|
+
return backoffMs;
|
|
104
|
+
}
|
|
15
105
|
|
|
16
106
|
function startSupervisorDetached(env = process.env) {
|
|
17
107
|
const p = paths(env);
|
|
@@ -37,57 +127,152 @@ async function runSupervisorForeground(env = process.env) {
|
|
|
37
127
|
});
|
|
38
128
|
if (collector) log(`collector listening on ${loadConfig(env).collector.otlpEndpoint}`, env);
|
|
39
129
|
|
|
130
|
+
const tickState = createTickState();
|
|
131
|
+
|
|
40
132
|
let stopping = false;
|
|
41
133
|
const stop = () => {
|
|
42
134
|
if (stopping) return;
|
|
43
135
|
stopping = true;
|
|
44
136
|
log("supervisor stopping", env);
|
|
137
|
+
if (tickState.watch) tickState.watch.close();
|
|
45
138
|
removeSupervisorPidIfOwned(process.pid, env);
|
|
46
139
|
process.exit(0);
|
|
47
140
|
};
|
|
48
141
|
process.on("SIGTERM", stop);
|
|
49
142
|
process.on("SIGINT", stop);
|
|
50
143
|
|
|
51
|
-
|
|
144
|
+
const runTick = () => {
|
|
145
|
+
if (stopping) return;
|
|
146
|
+
tick(env, tickState)
|
|
147
|
+
.catch((error) => log(`tick failed: ${error.message}`, env))
|
|
148
|
+
.finally(() => {
|
|
149
|
+
// Sources marked dirty while a tick ran get picked up right away
|
|
150
|
+
// instead of waiting for the interval.
|
|
151
|
+
if (tickState.tickAgain && !stopping) {
|
|
152
|
+
tickState.tickAgain = false;
|
|
153
|
+
runTick();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const sources = effectiveImportSources(loadConfig(env));
|
|
160
|
+
tickState.watch = startSourceWatchers(
|
|
161
|
+
Array.isArray(sources) && sources.length ? sources : [],
|
|
162
|
+
env,
|
|
163
|
+
(source) => {
|
|
164
|
+
tickState.pendingSources.add(source);
|
|
165
|
+
if (tickRunning) {
|
|
166
|
+
tickState.tickAgain = true;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
runTick();
|
|
170
|
+
},
|
|
171
|
+
{ onLog: (message) => log(message, env) }
|
|
172
|
+
);
|
|
173
|
+
const active = tickState.watch.activeWatchCount();
|
|
174
|
+
if (active) log(`watching ${active} source root(s) for changes`, env);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
log(`source watch disabled: ${error.message}`, env);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await tick(env, tickState);
|
|
180
|
+
if (tickState.tickAgain) {
|
|
181
|
+
tickState.tickAgain = false;
|
|
182
|
+
runTick();
|
|
183
|
+
}
|
|
52
184
|
const timer = setInterval(() => {
|
|
53
185
|
if (tickRunning) {
|
|
54
186
|
log("tick skipped: previous tick still running", env);
|
|
55
187
|
return;
|
|
56
188
|
}
|
|
57
|
-
|
|
189
|
+
runTick();
|
|
58
190
|
}, 30 * 1000);
|
|
59
191
|
|
|
60
192
|
await new Promise(() => {});
|
|
61
193
|
}
|
|
62
194
|
|
|
63
|
-
async function tick(env) {
|
|
195
|
+
async function tick(env, state = createTickState()) {
|
|
64
196
|
tickRunning = true;
|
|
65
197
|
try {
|
|
198
|
+
const now = Date.now();
|
|
66
199
|
try {
|
|
67
200
|
const cfg = loadConfig(env);
|
|
68
201
|
const sources = effectiveImportSources(cfg);
|
|
69
202
|
const importSources = Array.isArray(sources) && sources.length ? sources : ["all"];
|
|
70
203
|
for (const source of importSources) {
|
|
204
|
+
const sourceState = sourceTickStateFor(state, source);
|
|
205
|
+
const pending = state.pendingSources.has(source);
|
|
206
|
+
if (!sourceEligibleForImport(sourceState, pending, now)) continue;
|
|
207
|
+
state.pendingSources.delete(source);
|
|
71
208
|
try {
|
|
72
209
|
const imports = await importSourceInChild(source, cfg, env);
|
|
210
|
+
let activity = 0;
|
|
73
211
|
for (const result of imports) {
|
|
212
|
+
activity += (Number(result.imported) || 0) + (Number(result.pruned) || 0);
|
|
74
213
|
const summary = supervisorImportResultLog(result, source);
|
|
75
214
|
if (summary) log(summary, env);
|
|
76
215
|
if (result.errors?.length) log(`${result.provider} import errors from ${source}: ${result.errors.length}`, env);
|
|
77
216
|
}
|
|
217
|
+
if (activity > 0) state.indexDirty = true;
|
|
218
|
+
if (state.watch && state.watch.isWatched(source)) {
|
|
219
|
+
applyWatchedSourceImportOutcome(sourceState, now);
|
|
220
|
+
} else if (applySourceImportOutcome(sourceState, activity, now)) {
|
|
221
|
+
log(`${source} import idle: slowing to ${formatDuration(SOURCE_IDLE_INTERVAL_MS)} cadence until new activity`, env);
|
|
222
|
+
}
|
|
78
223
|
} catch (error) {
|
|
79
|
-
|
|
224
|
+
const backoffMs = applySourceImportFailure(sourceState, now);
|
|
225
|
+
log(`${source} history import skipped: ${error.message} (retrying in ${formatDuration(backoffMs)})`, env);
|
|
80
226
|
}
|
|
81
227
|
}
|
|
82
228
|
} catch (error) {
|
|
83
229
|
log(`history import skipped: ${error.message}`, env);
|
|
84
230
|
}
|
|
85
231
|
|
|
232
|
+
if (state.indexDirty || now - state.lastIndexAt >= INDEX_MIN_INTERVAL_MS) {
|
|
233
|
+
try {
|
|
234
|
+
const result = await reindexInChild(env);
|
|
235
|
+
if (result.index?.docCount != null) log(`index ready (${result.index.docCount} chunk(s))`, env);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
log(`index skipped: ${error.message}`, env);
|
|
238
|
+
} finally {
|
|
239
|
+
// Advance the clock even on failure so a persistent indexing error
|
|
240
|
+
// backs off to INDEX_MIN_INTERVAL_MS instead of retrying every tick.
|
|
241
|
+
state.lastIndexAt = now;
|
|
242
|
+
state.indexDirty = false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
86
246
|
try {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
247
|
+
const cfg = loadConfig(env);
|
|
248
|
+
if (cfg.notify?.slack?.enabled) {
|
|
249
|
+
// New archive writes (queue file present) trigger a pass right away;
|
|
250
|
+
// otherwise re-check when the soonest quiet window can elapse.
|
|
251
|
+
const queueWaiting = fs.existsSync(path.join(paths(env).state, "slack-notify-queue.jsonl"));
|
|
252
|
+
if (queueWaiting || now >= state.nextNotifyCheckAt) {
|
|
253
|
+
const result = await slackNotifyInChild(env);
|
|
254
|
+
const nextCheckMs = Math.max(30 * 1000, Number(result?.nextCheckMs) || 5 * 60 * 1000);
|
|
255
|
+
state.nextNotifyCheckAt = now + nextCheckMs;
|
|
256
|
+
if (result?.posted) log(`slack notify posted ${result.posted} session summar${result.posted === 1 ? "y" : "ies"}`, env);
|
|
257
|
+
for (const message of result?.errors || []) log(`slack notify error: ${message}`, env);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
89
260
|
} catch (error) {
|
|
90
|
-
log(`
|
|
261
|
+
log(`slack notify skipped: ${error.message}`, env);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (now - state.lastMemoryBackupAt >= MEMORY_BACKUP_INTERVAL_MS) {
|
|
265
|
+
try {
|
|
266
|
+
const summaries = await memoryBackupInChild(env);
|
|
267
|
+
const changed = summaries.reduce((sum, item) => sum + (Number(item.changed) || 0), 0);
|
|
268
|
+
if (changed > 0) log(`memory backup captured ${changed} change(s)`, env);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
log(`memory backup skipped: ${error.message}`, env);
|
|
271
|
+
} finally {
|
|
272
|
+
// Advance even on failure so a persistent error backs off to the
|
|
273
|
+
// backup interval instead of retrying every tick.
|
|
274
|
+
state.lastMemoryBackupAt = now;
|
|
275
|
+
}
|
|
91
276
|
}
|
|
92
277
|
|
|
93
278
|
try {
|
|
@@ -124,6 +309,38 @@ try {
|
|
|
124
309
|
return runJsonChild(script, env, `${source} import`, title);
|
|
125
310
|
}
|
|
126
311
|
|
|
312
|
+
function memoryBackupInChild(env = process.env) {
|
|
313
|
+
const title = childProcessTitle("memory-backup");
|
|
314
|
+
const script = `
|
|
315
|
+
process.title = ${JSON.stringify(title)};
|
|
316
|
+
(async () => {
|
|
317
|
+
const { collectMemoryBackup } = require(${JSON.stringify(path.join(__dirname, "memory-sources"))});
|
|
318
|
+
const summaries = await collectMemoryBackup(process.env);
|
|
319
|
+
process.stdout.write(JSON.stringify(summaries));
|
|
320
|
+
})().catch((error) => {
|
|
321
|
+
console.error(error && error.message ? error.message : String(error));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
});
|
|
324
|
+
`;
|
|
325
|
+
return runJsonChild(script, env, "memory backup", title);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function slackNotifyInChild(env = process.env) {
|
|
329
|
+
const title = childProcessTitle("slack-notify");
|
|
330
|
+
const script = `
|
|
331
|
+
process.title = ${JSON.stringify(title)};
|
|
332
|
+
(async () => {
|
|
333
|
+
const { runSlackNotify } = require(${JSON.stringify(path.join(__dirname, "slack-notify"))});
|
|
334
|
+
const result = await runSlackNotify(process.env);
|
|
335
|
+
process.stdout.write(JSON.stringify(result));
|
|
336
|
+
})().catch((error) => {
|
|
337
|
+
console.error(error && error.message ? error.message : String(error));
|
|
338
|
+
process.exit(1);
|
|
339
|
+
});
|
|
340
|
+
`;
|
|
341
|
+
return runJsonChild(script, env, "slack notify", title);
|
|
342
|
+
}
|
|
343
|
+
|
|
127
344
|
function reindexInChild(env = process.env) {
|
|
128
345
|
const title = childProcessTitle("index");
|
|
129
346
|
const script = `
|
|
@@ -350,6 +567,11 @@ function log(message, env = process.env) {
|
|
|
350
567
|
}
|
|
351
568
|
|
|
352
569
|
module.exports = {
|
|
570
|
+
applySourceImportFailure,
|
|
571
|
+
applySourceImportOutcome,
|
|
572
|
+
applyWatchedSourceImportOutcome,
|
|
573
|
+
createTickState,
|
|
574
|
+
sourceEligibleForImport,
|
|
353
575
|
runSupervisorForeground,
|
|
354
576
|
startSupervisorDetached,
|
|
355
577
|
stopSupervisor,
|
|
@@ -359,7 +581,9 @@ module.exports = {
|
|
|
359
581
|
removeSupervisorPidIfOwned,
|
|
360
582
|
shouldRunScheduledSync,
|
|
361
583
|
importSourceInChild,
|
|
584
|
+
memoryBackupInChild,
|
|
362
585
|
reindexInChild,
|
|
586
|
+
slackNotifyInChild,
|
|
363
587
|
runJsonChild,
|
|
364
588
|
syncInChild,
|
|
365
589
|
tick
|
package/src/sync.js
CHANGED
|
@@ -526,6 +526,7 @@ function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
|
|
|
526
526
|
const stat = safeStat(file);
|
|
527
527
|
if (!stat || !stat.isFile()) return;
|
|
528
528
|
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
529
|
+
if (!syncableArchiveObject(relative)) return;
|
|
529
530
|
const key = [normalizePrefix(prefix), relative].filter(Boolean).join("/");
|
|
530
531
|
objects.push({
|
|
531
532
|
file,
|
|
@@ -538,6 +539,11 @@ function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
|
|
|
538
539
|
return objects.sort((a, b) => a.key.localeCompare(b.key));
|
|
539
540
|
}
|
|
540
541
|
|
|
542
|
+
function syncableArchiveObject(relative) {
|
|
543
|
+
const key = String(relative || "").replace(/\\/g, "/");
|
|
544
|
+
return !key.startsWith("indexes/");
|
|
545
|
+
}
|
|
546
|
+
|
|
541
547
|
function listDirectoryObjects(root) {
|
|
542
548
|
const objects = [];
|
|
543
549
|
walk(root, (file) => {
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { isWebChatProvider, listSessions } = require("./archive");
|
|
7
|
+
const { ensureDir, readJson } = require("./paths");
|
|
8
|
+
const { HISTORY_PROVIDER_OPTIONS, IMPORT_SOURCE_ORDER } = require("./sources");
|
|
9
|
+
|
|
10
|
+
const CLAUDE_CODE_REPAIR_COMMAND = "agentlog repair claude-code-backups";
|
|
11
|
+
const CLAUDE_CODE_REPAIR_PREVIEW_COMMAND = `${CLAUDE_CODE_REPAIR_COMMAND} --dry-run`;
|
|
12
|
+
|
|
13
|
+
function preservedArchiveSession(session) {
|
|
14
|
+
return preservedWebChatSession(session) || sessionHasUnavailableSource(session);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function preservedWebChatSession(session) {
|
|
18
|
+
if (isWebChatProvider(session?.provider, session?.sourceType)) return true;
|
|
19
|
+
return /^\[(chatgpt|claude)\]conversations\//i.test(String(session?.scopeCanonical || ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function unavailableSourceSessions(env = process.env) {
|
|
23
|
+
return listSessions(env).filter((session) => !preservedWebChatSession(session) && sessionHasUnavailableSource(session));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionHasUnavailableSource(session) {
|
|
27
|
+
return unavailableSourcePaths(session).length > 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unavailableSourcePaths(session) {
|
|
31
|
+
return requiredSourcePaths(session).filter((sourcePath) => !safeSourceStat(sourcePath));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requiredSourcePaths(session) {
|
|
35
|
+
const values = [filesystemSourcePath(session?.sourcePath)];
|
|
36
|
+
if (sessionMayNeedAuxiliaryTranscripts(session)) {
|
|
37
|
+
for (const record of sessionRawRecords(session)) {
|
|
38
|
+
const sourcePath = filesystemSourcePath(record?.originalPath || record?.sourcePath);
|
|
39
|
+
if (auxiliaryTranscriptSourcePath(sourcePath)) values.push(sourcePath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...new Set(values.filter(Boolean).map((value) => path.resolve(value)))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sessionMayNeedAuxiliaryTranscripts(session) {
|
|
46
|
+
const provider = String(session?.provider || "");
|
|
47
|
+
const sourceType = String(session?.sourceType || "");
|
|
48
|
+
return provider === "claude_code" || provider === "claude_desktop" || provider === "claude_sdk" || sourceType.startsWith("claude-");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function auxiliaryTranscriptSourcePath(sourcePath) {
|
|
52
|
+
if (!sourcePath) return false;
|
|
53
|
+
return /(?:^|[\\/])audit\.jsonl$/i.test(sourcePath) || /\.jsonl(?:\.zst)?$/i.test(sourcePath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function filesystemSourcePath(value) {
|
|
57
|
+
const text = String(value || "").trim();
|
|
58
|
+
if (!text) return "";
|
|
59
|
+
const hashIndex = text.indexOf("#");
|
|
60
|
+
const base = hashIndex > 0 ? text.slice(0, hashIndex) : text;
|
|
61
|
+
if (!base) return "";
|
|
62
|
+
if (path.isAbsolute(base)) return path.resolve(base);
|
|
63
|
+
if (path.win32.isAbsolute(base)) return base;
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sessionRawRecords(session) {
|
|
68
|
+
const records = Array.isArray(session?.rawFiles) ? [...session.rawFiles] : [];
|
|
69
|
+
if (session?.rawPath) {
|
|
70
|
+
const manifest = readJson(path.join(session.rawPath, "manifest.json"), null);
|
|
71
|
+
for (const record of Array.isArray(manifest?.files) ? manifest.files : []) {
|
|
72
|
+
records.push(record);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return records;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function summarizeUnavailableSourceSessions(sessions, options = {}) {
|
|
79
|
+
const selected = (Array.isArray(sessions) ? sessions : []).filter((session) =>
|
|
80
|
+
!preservedWebChatSession(session) && (options.includeAvailable || sessionHasUnavailableSource(session))
|
|
81
|
+
);
|
|
82
|
+
const breakdown = unavailableSourceBreakdown(selected);
|
|
83
|
+
const totalMissingSources = breakdown.reduce((sum, row) => sum + row.missingSources, 0);
|
|
84
|
+
const claudeCodeRepair = summarizeClaudeCodeBackupRepair(selected, options.env || process.env);
|
|
85
|
+
return {
|
|
86
|
+
totalSessions: selected.length,
|
|
87
|
+
totalMissingSources,
|
|
88
|
+
breakdown,
|
|
89
|
+
claudeCodeRepair
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unavailableSourceBreakdown(sessions) {
|
|
94
|
+
const byKey = new Map();
|
|
95
|
+
for (const session of sessions || []) {
|
|
96
|
+
const source = sourceForSession(session);
|
|
97
|
+
const provider = String(session?.provider || "unknown");
|
|
98
|
+
const sourceType = String(session?.sourceType || "unknown");
|
|
99
|
+
const key = `${source}\0${provider}\0${sourceType}`;
|
|
100
|
+
const existing = byKey.get(key) || {
|
|
101
|
+
source,
|
|
102
|
+
provider,
|
|
103
|
+
sourceType,
|
|
104
|
+
sessions: 0,
|
|
105
|
+
missingSources: 0
|
|
106
|
+
};
|
|
107
|
+
existing.sessions++;
|
|
108
|
+
existing.missingSources += unavailableSourcePaths(session).length;
|
|
109
|
+
byKey.set(key, existing);
|
|
110
|
+
}
|
|
111
|
+
return [...byKey.values()].sort(compareSourceBreakdownRows);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sourceForSession(session) {
|
|
115
|
+
const provider = String(session?.provider || "");
|
|
116
|
+
const sourceType = String(session?.sourceType || "");
|
|
117
|
+
const exact = SOURCE_BY_PROVIDER_AND_TYPE.get(`${provider}\0${sourceType}`);
|
|
118
|
+
if (exact) return exact;
|
|
119
|
+
const sourceOption = HISTORY_PROVIDER_OPTIONS.find((item) => item.provider === provider && item.sourceType === sourceType);
|
|
120
|
+
if (sourceOption?.source) return sourceOption.source;
|
|
121
|
+
return SOURCE_BY_PROVIDER.get(provider) || sourceType || provider || "unknown";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compareSourceBreakdownRows(a, b) {
|
|
125
|
+
const left = sourceOrder(a.source);
|
|
126
|
+
const right = sourceOrder(b.source);
|
|
127
|
+
return left - right
|
|
128
|
+
|| a.source.localeCompare(b.source)
|
|
129
|
+
|| a.provider.localeCompare(b.provider)
|
|
130
|
+
|| a.sourceType.localeCompare(b.sourceType);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sourceOrder(source) {
|
|
134
|
+
const index = IMPORT_SOURCE_ORDER.indexOf(source);
|
|
135
|
+
return index === -1 ? 999 : index;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function summarizeClaudeCodeBackupRepair(sessions, env = process.env) {
|
|
139
|
+
const plan = planClaudeCodeBackupRepair(env, { sessions, dryRun: true });
|
|
140
|
+
return {
|
|
141
|
+
candidateSessions: plan.candidateSessions,
|
|
142
|
+
restoreSessions: plan.restoreSessions,
|
|
143
|
+
restoreCount: plan.restoreCount,
|
|
144
|
+
existingCount: plan.existingCount,
|
|
145
|
+
missingBackupCount: plan.missingBackupCount,
|
|
146
|
+
command: CLAUDE_CODE_REPAIR_COMMAND,
|
|
147
|
+
previewCommand: CLAUDE_CODE_REPAIR_PREVIEW_COMMAND
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function planClaudeCodeBackupRepair(env = process.env, options = {}) {
|
|
152
|
+
const roots = claudeCodeSourceRoots(env);
|
|
153
|
+
const sessions = (Array.isArray(options.sessions) ? options.sessions : unavailableSourceSessions(env))
|
|
154
|
+
.filter(claudeCodeBackupRepairSession);
|
|
155
|
+
const seenTargets = new Set();
|
|
156
|
+
const actions = [];
|
|
157
|
+
const skipped = [];
|
|
158
|
+
for (const session of sessions) {
|
|
159
|
+
let sessionActionCount = 0;
|
|
160
|
+
for (const record of sessionRawRecords(session)) {
|
|
161
|
+
const targetPath = filesystemSourcePath(record?.originalPath || record?.sourcePath);
|
|
162
|
+
if (!targetPath || !isInsideAnyRoot(targetPath, roots)) continue;
|
|
163
|
+
const targetKey = path.resolve(targetPath);
|
|
164
|
+
if (seenTargets.has(targetKey)) {
|
|
165
|
+
skipped.push(skipRecord(session, record, targetPath, "duplicate-target"));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
seenTargets.add(targetKey);
|
|
169
|
+
const backupPath = backupPathForRawRecord(record);
|
|
170
|
+
if (!backupPath || !safeSourceStat(backupPath)?.isFile()) {
|
|
171
|
+
skipped.push(skipRecord(session, record, targetPath, "missing-backup"));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const targetStat = safeSourceStat(targetPath);
|
|
175
|
+
if (targetStat && !options.overwrite) {
|
|
176
|
+
skipped.push(skipRecord(session, record, targetPath, "target-exists", backupPath));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
actions.push({
|
|
180
|
+
sessionId: session.sessionId || "",
|
|
181
|
+
title: session.title || "",
|
|
182
|
+
sourceType: session.sourceType || "",
|
|
183
|
+
provider: session.provider || "",
|
|
184
|
+
targetPath,
|
|
185
|
+
backupPath,
|
|
186
|
+
size: Number(record?.size || 0) || safeSourceStat(backupPath)?.size || 0,
|
|
187
|
+
mtime: record?.mtime || ""
|
|
188
|
+
});
|
|
189
|
+
sessionActionCount++;
|
|
190
|
+
}
|
|
191
|
+
if (!sessionActionCount) {
|
|
192
|
+
const sourcePath = filesystemSourcePath(session?.sourcePath);
|
|
193
|
+
if (sourcePath && isInsideAnyRoot(sourcePath, roots)) {
|
|
194
|
+
skipped.push({
|
|
195
|
+
sessionId: session.sessionId || "",
|
|
196
|
+
targetPath: sourcePath,
|
|
197
|
+
reason: "no-restorable-raw-backup"
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const restoreSessionIds = new Set(actions.map((item) => item.sessionId).filter(Boolean));
|
|
203
|
+
return {
|
|
204
|
+
dryRun: Boolean(options.dryRun),
|
|
205
|
+
overwrite: Boolean(options.overwrite),
|
|
206
|
+
sourceRoots: roots,
|
|
207
|
+
candidateSessions: sessions.length,
|
|
208
|
+
restoreSessions: restoreSessionIds.size,
|
|
209
|
+
restoreCount: actions.length,
|
|
210
|
+
existingCount: skipped.filter((item) => item.reason === "target-exists").length,
|
|
211
|
+
missingBackupCount: skipped.filter((item) => item.reason === "missing-backup" || item.reason === "no-restorable-raw-backup").length,
|
|
212
|
+
duplicateCount: skipped.filter((item) => item.reason === "duplicate-target").length,
|
|
213
|
+
actions,
|
|
214
|
+
skipped
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function repairClaudeCodeBackups(env = process.env, options = {}) {
|
|
219
|
+
const plan = planClaudeCodeBackupRepair(env, options);
|
|
220
|
+
if (options.dryRun) return { ...plan, restored: 0 };
|
|
221
|
+
let restored = 0;
|
|
222
|
+
for (const action of plan.actions) {
|
|
223
|
+
if (!isInsideAnyRoot(action.targetPath, plan.sourceRoots)) continue;
|
|
224
|
+
if (safeSourceStat(action.targetPath) && !options.overwrite) continue;
|
|
225
|
+
ensureDir(path.dirname(action.targetPath));
|
|
226
|
+
fs.copyFileSync(action.backupPath, action.targetPath);
|
|
227
|
+
try {
|
|
228
|
+
fs.chmodSync(action.targetPath, 0o600);
|
|
229
|
+
} catch {
|
|
230
|
+
// Best-effort permissions normalization.
|
|
231
|
+
}
|
|
232
|
+
if (action.mtime) {
|
|
233
|
+
const mtime = new Date(action.mtime);
|
|
234
|
+
if (Number.isFinite(mtime.getTime())) {
|
|
235
|
+
try {
|
|
236
|
+
fs.utimesSync(action.targetPath, mtime, mtime);
|
|
237
|
+
} catch {
|
|
238
|
+
// Best-effort timestamp preservation.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
restored++;
|
|
243
|
+
}
|
|
244
|
+
return { ...plan, restored };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function claudeCodeBackupRepairSession(session) {
|
|
248
|
+
return String(session?.provider || "") === "claude_code";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function claudeCodeSourceRoots(env = process.env) {
|
|
252
|
+
const home = env && env.HOME ? env.HOME : os.homedir();
|
|
253
|
+
return [
|
|
254
|
+
path.join(home, ".claude", "projects"),
|
|
255
|
+
path.join(home, ".claude", "file-history")
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function backupPathForRawRecord(record) {
|
|
260
|
+
const candidates = [record?.archivedPath, record?.sharedRawPath]
|
|
261
|
+
.map(filesystemSourcePath)
|
|
262
|
+
.filter(Boolean);
|
|
263
|
+
return candidates.find((candidate) => safeSourceStat(candidate)?.isFile()) || "";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function skipRecord(session, record, targetPath, reason, backupPath = "") {
|
|
267
|
+
return {
|
|
268
|
+
sessionId: session?.sessionId || "",
|
|
269
|
+
title: session?.title || "",
|
|
270
|
+
sourceType: session?.sourceType || "",
|
|
271
|
+
provider: session?.provider || "",
|
|
272
|
+
targetPath,
|
|
273
|
+
backupPath: backupPath || backupPathForRawRecord(record),
|
|
274
|
+
reason
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isInsideAnyRoot(value, roots) {
|
|
279
|
+
return roots.some((root) => isSameOrInside(value, root));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isSameOrInside(value, root) {
|
|
283
|
+
const relative = path.relative(path.resolve(root), path.resolve(value));
|
|
284
|
+
if (relative === "") return true;
|
|
285
|
+
// Entry names may legitimately start with ".." (e.g. "..foo"), so only an
|
|
286
|
+
// exact ".." segment marks the path as outside the root.
|
|
287
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function safeSourceStat(target) {
|
|
291
|
+
try {
|
|
292
|
+
return fs.statSync(target);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code)) return null;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const SOURCE_BY_PROVIDER_AND_TYPE = new Map([
|
|
300
|
+
["codex\0cli-history", "codex-cli"],
|
|
301
|
+
["claude_code\0cli-history", "claude"],
|
|
302
|
+
["claude_sdk\0claude-sdk-history", "claude-sdk"],
|
|
303
|
+
["claude_desktop\0claude-code-desktop-metadata", "claude-code-desktop"],
|
|
304
|
+
["claude_desktop\0claude-workspace-desktop", "claude-cowork"],
|
|
305
|
+
["cursor\0cursor-workspace-sqlite", "cursor"],
|
|
306
|
+
["cursor\0cursor-global-sqlite", "cursor"],
|
|
307
|
+
["cursor\0cursor-raw-sqlite-salvage", "cursor"],
|
|
308
|
+
["cursor\0cursor-agent-transcripts", "cursor"],
|
|
309
|
+
["opencode\0opencode-history", "opencode-cli"],
|
|
310
|
+
["opencode\0opencode-sqlite-history", "opencode-cli"],
|
|
311
|
+
["opencode\0opencode-cli-sqlite-history", "opencode-cli"],
|
|
312
|
+
["opencode\0opencode-desktop-sqlite-history", "opencode-desktop"],
|
|
313
|
+
["opencode\0opencode-web-sqlite-history", "opencode-web"],
|
|
314
|
+
["antigravity_cli\0antigravity-cli-brain", "antigravity-cli"],
|
|
315
|
+
["antigravity_ide\0antigravity-ide-brain", "antigravity-ide"],
|
|
316
|
+
...HISTORY_PROVIDER_OPTIONS
|
|
317
|
+
.filter((item) => item.provider && item.sourceType)
|
|
318
|
+
.map((item) => [`${item.provider}\0${item.sourceType}`, item.source])
|
|
319
|
+
]);
|
|
320
|
+
|
|
321
|
+
const SOURCE_BY_PROVIDER = {
|
|
322
|
+
aider: "aider",
|
|
323
|
+
antigravity: "antigravity",
|
|
324
|
+
antigravity_cli: "antigravity-cli",
|
|
325
|
+
antigravity_ide: "antigravity-ide",
|
|
326
|
+
chatgpt: "chatgpt",
|
|
327
|
+
claude_code: "claude",
|
|
328
|
+
claude_sdk: "claude-sdk",
|
|
329
|
+
claude_web: "claude-web",
|
|
330
|
+
cline: "cline",
|
|
331
|
+
copilot: "copilot-cli",
|
|
332
|
+
cursor: "cursor",
|
|
333
|
+
devin: "devin-cli",
|
|
334
|
+
factory: "factory",
|
|
335
|
+
gemini_cli: "gemini-cli",
|
|
336
|
+
grok: "grok-build",
|
|
337
|
+
pi: "pi",
|
|
338
|
+
windsurf: "windsurf"
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
CLAUDE_CODE_REPAIR_COMMAND,
|
|
343
|
+
CLAUDE_CODE_REPAIR_PREVIEW_COMMAND,
|
|
344
|
+
claudeCodeSourceRoots,
|
|
345
|
+
filesystemSourcePath,
|
|
346
|
+
planClaudeCodeBackupRepair,
|
|
347
|
+
preservedArchiveSession,
|
|
348
|
+
preservedWebChatSession,
|
|
349
|
+
repairClaudeCodeBackups,
|
|
350
|
+
requiredSourcePaths,
|
|
351
|
+
sessionHasUnavailableSource,
|
|
352
|
+
sessionRawRecords,
|
|
353
|
+
sourceForSession,
|
|
354
|
+
summarizeUnavailableSourceSessions,
|
|
355
|
+
unavailableSourceBreakdown,
|
|
356
|
+
unavailableSourcePaths,
|
|
357
|
+
unavailableSourceSessions
|
|
358
|
+
};
|