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/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
- await tick(env);
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
- tick(env).catch((error) => log(`tick failed: ${error.message}`, env));
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
- log(`${source} history import skipped: ${error.message}`, env);
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 result = await reindexInChild(env);
88
- if (result.index?.docCount != null) log(`index ready (${result.index.docCount} chunk(s))`, env);
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(`index skipped: ${error.message}`, env);
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
+ };