dsh-rewind-plugin 0.4.2 → 0.5.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/lib/index.js CHANGED
@@ -30,7 +30,24 @@ var en = {
30
30
  "success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
31
31
  "noUserMessages": "This session has no rewindable user messages yet.",
32
32
  "chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
33
- "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
33
+ "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
34
+ "cleanup.description": "Manage automatic cleanup of session snapshot backups",
35
+ "cleanup.inputHint": "on | off | max-age <days> | run [--apply] | status",
36
+ "cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
37
+ "cleanup.enabled": "enabled",
38
+ "cleanup.disabled": "disabled",
39
+ "cleanup.present": "file present",
40
+ "cleanup.absent": "no file \u2014 defaults",
41
+ "cleanup.onOk": "Auto-cleanup enabled.",
42
+ "cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
43
+ "cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
44
+ "cleanup.cfgInvalid": "Snapshot cleanup config invalid: {detail}. Using the safe default (disabled).",
45
+ "cleanup.saveFailed": "Could not save cleanup config: {detail}.",
46
+ "cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s). Re-run with --apply to delete.",
47
+ "cleanup.runApply": "Removed {deleted} session snapshot backup(s), freeing {freed} bytes; {kept} kept, {remaining} bytes remain.",
48
+ "cleanup.runFailed": "Cleanup failed: {detail}.",
49
+ "cleanup.skipped": "({skipped} active session(s) skipped.)",
50
+ "cleanup.usage": "Usage:\n /snapshot-auto-cleanup show status\n /snapshot-auto-cleanup on|off enable/disable auto-cleanup\n /snapshot-auto-cleanup max-age <days> set the idle cutoff\n /snapshot-auto-cleanup run [--apply] dry-run, or execute with --apply"
34
51
  };
35
52
  var zh = {
36
53
  "usage.title": "\u7528\u6CD5\uFF1A",
@@ -58,7 +75,24 @@ var zh = {
58
75
  "success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
59
76
  "noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
60
77
  "chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
61
- "command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09"
78
+ "command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
79
+ "cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
80
+ "cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] | status",
81
+ "cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002\u914D\u7F6E\uFF1A{path}\uFF08{present}\uFF09\u3002",
82
+ "cleanup.enabled": "\u5DF2\u5F00\u542F",
83
+ "cleanup.disabled": "\u5DF2\u5173\u95ED",
84
+ "cleanup.present": "\u5B58\u5728\u914D\u7F6E\u6587\u4EF6",
85
+ "cleanup.absent": "\u65E0\u6587\u4EF6\u2014\u2014\u4F7F\u7528\u9ED8\u8BA4\u503C",
86
+ "cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
87
+ "cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
88
+ "cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
89
+ "cleanup.cfgInvalid": "\u5FEB\u7167\u6E05\u7406\u914D\u7F6E\u65E0\u6548\uFF1A{detail}\u3002\u5DF2\u4F7F\u7528\u5B89\u5168\u9ED8\u8BA4\u503C\uFF08\u5173\u95ED\uFF09\u3002",
90
+ "cleanup.saveFailed": "\u65E0\u6CD5\u4FDD\u5B58\u6E05\u7406\u914D\u7F6E\uFF1A{detail}\u3002",
91
+ "cleanup.runDry": "\u9884\u6F14\uFF1A\u5C06\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
92
+ "cleanup.runApply": "\u5DF2\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\uFF1B\u4FDD\u7559 {kept} \u4E2A\uFF0C\u5269\u4F59 {remaining} \u5B57\u8282\u3002",
93
+ "cleanup.runFailed": "\u6E05\u7406\u5931\u8D25\uFF1A{detail}\u3002",
94
+ "cleanup.skipped": "\uFF08\u8DF3\u8FC7\u4E86 {skipped} \u4E2A\u6D3B\u52A8\u4F1A\u8BDD\u3002\uFF09",
95
+ "cleanup.usage": "\u7528\u6CD5\uFF1A\n /snapshot-auto-cleanup \u67E5\u770B\u72B6\u6001\n /snapshot-auto-cleanup on|off \u5F00\u542F/\u5173\u95ED\u81EA\u52A8\u6E05\u7406\n /snapshot-auto-cleanup max-age <\u5929\u6570> \u8BBE\u7F6E\u5931\u6D3B\u9608\u503C\uFF08\u5929\uFF09\n /snapshot-auto-cleanup run [--apply] \u9884\u6F14\uFF0C\u6216\u52A0 --apply \u6267\u884C"
62
96
  };
63
97
  var HOST_DICTS = { en, zh };
64
98
  function translate(lang, key, params = {}) {
@@ -201,6 +235,9 @@ import { homedir } from "node:os";
201
235
  var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
202
236
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
203
237
  var MAX_ANCHOR_GROUPS = 100;
238
+ function isLinkEntry(entry) {
239
+ return "ref" in entry;
240
+ }
204
241
  var defaultProbe = {
205
242
  async readText(path) {
206
243
  try {
@@ -242,13 +279,19 @@ async function readEntry(file) {
242
279
  try {
243
280
  const parsed = JSON.parse(await readFile(file, "utf8"));
244
281
  if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
245
- return {
282
+ const base = {
246
283
  callId: String(parsed.callId ?? ""),
247
284
  anchorSeq: parsed.anchorSeq,
248
285
  path: parsed.path,
249
- before: typeof parsed.before === "string" ? parsed.before : null,
250
286
  time: typeof parsed.time === "number" ? parsed.time : 0
251
287
  };
288
+ if (typeof parsed.ref === "string") {
289
+ return { ...base, ref: parsed.ref };
290
+ }
291
+ return {
292
+ ...base,
293
+ before: typeof parsed.before === "string" ? parsed.before : null
294
+ };
252
295
  } catch {
253
296
  return void 0;
254
297
  }
@@ -261,9 +304,42 @@ async function isLinkPath(path) {
261
304
  return false;
262
305
  }
263
306
  }
307
+ function isSafeLinkRef(ref) {
308
+ return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
309
+ }
310
+ async function dirSizeAndLastActive(dir) {
311
+ let size = 0;
312
+ let lastActiveMs = 0;
313
+ const visit = async (current) => {
314
+ let st;
315
+ try {
316
+ st = await lstat(current);
317
+ } catch {
318
+ return;
319
+ }
320
+ if (st.mtimeMs > lastActiveMs) lastActiveMs = st.mtimeMs;
321
+ if (!st.isDirectory()) {
322
+ size += st.size;
323
+ return;
324
+ }
325
+ let names;
326
+ try {
327
+ names = await readdir(current);
328
+ } catch {
329
+ return;
330
+ }
331
+ for (const name2 of names) {
332
+ if (name2.startsWith(".")) continue;
333
+ await visit(join(current, name2));
334
+ }
335
+ };
336
+ await visit(dir);
337
+ return { size, lastActiveMs };
338
+ }
264
339
  var SnapshotStore = class _SnapshotStore {
265
- constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
340
+ constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT, opts) {
266
341
  this.root = root;
342
+ this.dedup = opts?.dedup ?? true;
267
343
  }
268
344
  root;
269
345
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
@@ -282,6 +358,18 @@ var SnapshotStore = class _SnapshotStore {
282
358
  * it, and even then the in-process order still holds.
283
359
  */
284
360
  lastEntryTime = 0;
361
+ /** Store options; `dedup` toggles in-place content dedup (default on). */
362
+ dedup;
363
+ /**
364
+ * In-memory per-path "most recent entry" for content dedup, keyed by
365
+ * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
366
+ * content and its own file ref, so a new record with the same content links
367
+ * to the immediately-prior entry (linear chain). Seeded lazily per session
368
+ * from the bounded on-disk window, so dedup survives a host restart.
369
+ */
370
+ lastEntry = /* @__PURE__ */ new Map();
371
+ /** Sessions whose dedup state has been seeded from disk this process. */
372
+ seededSessions = /* @__PURE__ */ new Set();
285
373
  /** Absolute path of one session's snapshot directory (id sanitized). */
286
374
  sessionDir(sessionId) {
287
375
  return join(this.root, safeSessionId(sessionId));
@@ -290,26 +378,91 @@ var SnapshotStore = class _SnapshotStore {
290
378
  anchorDir(sessionId, anchorSeq) {
291
379
  return join(this.sessionDir(sessionId), String(anchorSeq));
292
380
  }
293
- /** Commit one before-backup under its turn's anchor group (atomic write). */
381
+ /** Absolute file ref (relative to the session dir) of an entry. */
382
+ entryRefOf(sessionId, callId, anchorSeq) {
383
+ return `${anchorSeq}/${safeFileId(callId)}.json`;
384
+ }
385
+ /**
386
+ * Seed a session's dedup state from the existing (bounded) on-disk window:
387
+ * scan entries newest-first and record the most recent entry per path. This
388
+ * makes content dedup survive a host restart within the session window. A
389
+ * no-op after the first seed (or when `dedup` is disabled).
390
+ */
391
+ async ensureDedupSeeded(sessionId) {
392
+ if (!this.dedup || this.seededSessions.has(sessionId)) return;
393
+ this.seededSessions.add(sessionId);
394
+ try {
395
+ for (const entry of await this.entriesAfter(sessionId, 0)) {
396
+ const key = `${sessionId}\0${entry.path}`;
397
+ if (this.lastEntry.has(key)) continue;
398
+ const content = await this.resolveBefore(sessionId, entry);
399
+ this.lastEntry.set(key, { content, ref: this.entryRefOf(sessionId, entry.callId, entry.anchorSeq) });
400
+ }
401
+ } catch {
402
+ this.seededSessions.delete(sessionId);
403
+ }
404
+ }
405
+ /**
406
+ * Resolve an entry's effective `before` content, following a link chain to
407
+ * its terminal real snapshot. Refs are strictly backward in
408
+ * `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
409
+ * cyclic link throws — callers fail per-file (never silently dropping the
410
+ * path from a restore).
411
+ */
412
+ async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
413
+ if (!isLinkEntry(entry)) return entry.before;
414
+ const key = `${entry.anchorSeq}:${entry.callId}`;
415
+ if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
416
+ seen.add(key);
417
+ if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
418
+ const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
419
+ if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
420
+ return this.resolveBefore(sessionId, referenced, seen);
421
+ }
422
+ /** Commit one before-backup (or an in-place dedup link) under its anchor. */
294
423
  async recordEntry(sessionId, entry, opts) {
295
424
  const time = Math.max(Date.now(), this.lastEntryTime + 1);
296
425
  this.lastEntryTime = time;
426
+ await this.ensureDedupSeeded(sessionId);
297
427
  const dir = this.anchorDir(sessionId, entry.anchorSeq);
298
428
  await mkdir(dir, { recursive: true });
299
- const committed = { ...entry, time };
300
- await writeJsonAtomic(
301
- join(dir, `${safeFileId(entry.callId)}.json`),
302
- committed,
303
- // Test-only crash seam: fire after the temp write so a "half-written"
304
- // crash leaves no committed entry behind.
305
- () => opts?.crash?.("after-temp-write")
306
- );
429
+ const file = join(dir, `${safeFileId(entry.callId)}.json`);
430
+ const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
431
+ const key = `${sessionId}\0${entry.path}`;
432
+ const prior = this.lastEntry.get(key);
433
+ if (this.dedup && opts?.dedup !== false && prior !== void 0 && prior.content === entry.before) {
434
+ const committed = {
435
+ callId: entry.callId,
436
+ anchorSeq: entry.anchorSeq,
437
+ path: entry.path,
438
+ ref: prior.ref,
439
+ time
440
+ };
441
+ await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
442
+ this.lastEntry.set(key, { content: prior.content, ref: selfRef });
443
+ } else {
444
+ const committed = { ...entry, time };
445
+ await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
446
+ this.lastEntry.set(key, { content: entry.before, ref: selfRef });
447
+ }
307
448
  const now = Date.now();
308
449
  if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
309
450
  this.lastPruneAt = now;
310
451
  await this.prune(sessionId);
311
452
  }
312
453
  }
454
+ /**
455
+ * The effective content recorded by the path's MOST RECENT entry, or
456
+ * undefined when the path has never been recorded (a fresh tracking sight).
457
+ * This is the single in-memory "last known state" the boundary uses to
458
+ * decide whether a tracked file changed — the same source `recordEntry`
459
+ * dedups against, so there is one content copy and one comparison per
460
+ * decision, not two. Seeding is idempotent (once per session from disk).
461
+ */
462
+ async lastKnownContent(sessionId, path) {
463
+ await this.ensureDedupSeeded(sessionId);
464
+ return this.lastEntry.get(`${sessionId}\0${path}`)?.content;
465
+ }
313
466
  /**
314
467
  * All committed entries anchored at or after `targetSeq`, newest first (for
315
468
  * preview ordering). The boundary is inclusive: rewinding to a message also
@@ -379,32 +532,46 @@ var SnapshotStore = class _SnapshotStore {
379
532
  * @param sessionId - session whose snapshot store to plan against.
380
533
  * @param targetSeq - rewind target; entries anchored at/after it apply.
381
534
  * @param probe - current-disk state probe (defaults to the real FS).
382
- * @returns the planned actions plus the link paths that were skipped.
535
+ * @returns the planned actions, the link paths skipped, and per-file failures.
383
536
  */
384
537
  async planRestore(sessionId, targetSeq, probe) {
385
538
  const actions = [];
386
539
  const skipped = [];
540
+ const failed = [];
387
541
  for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
388
542
  try {
389
543
  if (await probe.isLink(entry.path)) {
390
544
  skipped.push(entry.path);
391
545
  continue;
392
546
  }
547
+ let before;
548
+ try {
549
+ before = await this.resolveBefore(sessionId, entry);
550
+ } catch (error) {
551
+ failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
552
+ continue;
553
+ }
393
554
  const current = await probe.readText(entry.path);
394
- if (entry.before === null) {
555
+ if (before === null) {
395
556
  if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
396
- } else if (current !== entry.before) {
397
- actions.push({ path: entry.path, action: "restore", before: entry.before });
557
+ } else if (current !== before) {
558
+ actions.push({ path: entry.path, action: "restore", before });
398
559
  }
399
560
  } catch (error) {
400
- if (entry.before === null) {
561
+ let before;
562
+ try {
563
+ before = await this.resolveBefore(sessionId, entry);
564
+ } catch {
565
+ before = null;
566
+ }
567
+ if (before === null) {
401
568
  actions.push({ path: entry.path, action: "delete" });
402
569
  } else {
403
- actions.push({ path: entry.path, action: "restore", before: entry.before });
570
+ actions.push({ path: entry.path, action: "restore", before });
404
571
  }
405
572
  }
406
573
  }
407
- return { actions, skipped };
574
+ return { actions, skipped, failed };
408
575
  }
409
576
  /** Per-file restore impact: only actions that would actually change the disk. */
410
577
  async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
@@ -436,8 +603,9 @@ var SnapshotStore = class _SnapshotStore {
436
603
  const deleted = [];
437
604
  const skipped = [];
438
605
  const failed = [];
439
- const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
606
+ const { actions, skipped: skippedPaths, failed: planFailed } = await this.planRestore(sessionId, targetSeq, probe);
440
607
  skipped.push(...skippedPaths);
608
+ failed.push(...planFailed);
441
609
  if (actions.length === 0) return { restored, deleted, skipped, failed };
442
610
  const journal = await this.beginRestore(sessionId, targetSeq, actions, probe);
443
611
  for (let i = 0; i < actions.length; i++) {
@@ -840,8 +1008,21 @@ var SnapshotStore = class _SnapshotStore {
840
1008
  * recycles terminal restore journals (see {@link pruneTerminalJournals}),
841
1009
  * so the per-commit cap bounds BOTH the checkpoint entries and the journal
842
1010
  * accumulation.
1011
+ *
1012
+ * Because dedup links reference prior entries, eviction is LINK-AWARE: before
1013
+ * deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
1014
+ * lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
1015
+ * as a real snapshot carrying the resolved content), so no kept link is left
1016
+ * dangling. Links form a linear predecessor chain, so materializing the first
1017
+ * link after each doomed real is enough — later links already point at that
1018
+ * materialized entry (or at other kept links), requiring no rewrite.
1019
+ *
1020
+ * `opts.crash` is the test-only seam: a crash fired inside a materialization
1021
+ * write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
1022
+ * real is still on disk and the kept link still resolves, so nothing dangles
1023
+ * and a later prune simply re-materializes.
843
1024
  */
844
- async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
1025
+ async prune(sessionId, keep = MAX_ANCHOR_GROUPS, opts) {
845
1026
  const sessionDir = this.sessionDir(sessionId);
846
1027
  let names;
847
1028
  try {
@@ -854,7 +1035,34 @@ var SnapshotStore = class _SnapshotStore {
854
1035
  const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
855
1036
  const excess = seqs.length - keep;
856
1037
  if (excess <= 0) return;
857
- for (const seq of seqs.slice(0, excess)) {
1038
+ const doomed = new Set(seqs.slice(0, excess));
1039
+ for (const seq of seqs.slice(excess)) {
1040
+ const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
1041
+ for (const file of files) {
1042
+ if (!file.endsWith(".json")) continue;
1043
+ const entry = await readEntry(join(this.anchorDir(sessionId, seq), file));
1044
+ if (entry === void 0 || !isLinkEntry(entry)) continue;
1045
+ if (!isSafeLinkRef(entry.ref)) continue;
1046
+ const slash = entry.ref.indexOf("/");
1047
+ const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
1048
+ if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
1049
+ let before;
1050
+ try {
1051
+ before = await this.resolveBefore(sessionId, entry);
1052
+ } catch {
1053
+ continue;
1054
+ }
1055
+ const real = {
1056
+ callId: entry.callId,
1057
+ anchorSeq: entry.anchorSeq,
1058
+ path: entry.path,
1059
+ before,
1060
+ time: entry.time
1061
+ };
1062
+ await writeJsonAtomic(join(this.anchorDir(sessionId, seq), file), real, () => opts?.crash?.("after-temp-write"));
1063
+ }
1064
+ }
1065
+ for (const seq of doomed) {
858
1066
  await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
859
1067
  }
860
1068
  }
@@ -889,6 +1097,77 @@ var SnapshotStore = class _SnapshotStore {
889
1097
  throw error;
890
1098
  }
891
1099
  }
1100
+ /**
1101
+ * Cross-session retention sweep: remove WHOLE session directories whose
1102
+ * newest member stamp is older than `maxAgeDays` days of idle, keeping the
1103
+ * active session (`keepActiveId`) untouched. This is the anti-growth policy
1104
+ * for finished sessions (rewind only ever reads the active session, so a
1105
+ * finished session's backups are provably dead weight).
1106
+ *
1107
+ * SAFETY:
1108
+ * - Only whole session directories are removed (dedup refs are
1109
+ * session-relative, so there is no cross-session dangling to materialize);
1110
+ * - the active session is never targeted (`keepActiveId`), and everything
1111
+ * else is protected by its own mtime — a session that is still written to
1112
+ * keeps scrolling its newest member stamp forward, so it is never old
1113
+ * enough to be pruned;
1114
+ * - a non-positive `maxAgeDays` throws instead of degenerating into a
1115
+ * mass-destructive `cutoff` in the far future;
1116
+ * - the walk uses `lstat` (no symlink following) and skips dot-prefixed
1117
+ * temp left overs, so measurement stays inside the store root.
1118
+ *
1119
+ * `dryRun` computes and reports exactly what would be removed without
1120
+ * deleting anything — the `/snapshot-auto-cleanup run` preview.
1121
+ */
1122
+ async pruneStale(opts) {
1123
+ const { keepActiveId, dryRun = false } = opts;
1124
+ const maxAgeDays = opts.maxAgeDays;
1125
+ if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) {
1126
+ throw new RangeError("pruneStale: maxAgeDays must be a positive finite number");
1127
+ }
1128
+ const cutoffMs = Date.now() - maxAgeDays * 864e5;
1129
+ let scanned = 0;
1130
+ let deleted = 0;
1131
+ let freedBytes = 0;
1132
+ let kept = 0;
1133
+ let skippedActive = 0;
1134
+ let remainingBytes = 0;
1135
+ const report = () => ({ scanned, deleted, freedBytes, kept, remainingBytes, skippedActive, dryRun });
1136
+ let names;
1137
+ try {
1138
+ names = await readdir(this.root);
1139
+ } catch (error) {
1140
+ if (error.code === "ENOENT") return report();
1141
+ throw error;
1142
+ }
1143
+ for (const name2 of names) {
1144
+ if (name2.startsWith(".")) continue;
1145
+ const full = join(this.root, name2);
1146
+ let st;
1147
+ try {
1148
+ st = await lstat(full);
1149
+ } catch {
1150
+ continue;
1151
+ }
1152
+ if (!st.isDirectory()) continue;
1153
+ scanned++;
1154
+ if (keepActiveId !== void 0 && safeSessionId(keepActiveId) === name2) {
1155
+ skippedActive++;
1156
+ remainingBytes += (await dirSizeAndLastActive(full)).size;
1157
+ continue;
1158
+ }
1159
+ const { size, lastActiveMs } = await dirSizeAndLastActive(full);
1160
+ if (lastActiveMs < cutoffMs) {
1161
+ deleted++;
1162
+ freedBytes += size;
1163
+ if (!dryRun) await rm(full, { recursive: true, force: true });
1164
+ } else {
1165
+ kept++;
1166
+ remainingBytes += size;
1167
+ }
1168
+ }
1169
+ return report();
1170
+ }
892
1171
  /**
893
1172
  * All distinct paths ever recorded for a session — the "tracked files"
894
1173
  * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
@@ -906,22 +1185,21 @@ var SnapshotStore = class _SnapshotStore {
906
1185
  function hashPath(path) {
907
1186
  return createHash("sha256").update(path).digest("hex").slice(0, 8);
908
1187
  }
909
- async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
1188
+ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
910
1189
  let recorded = 0;
911
1190
  for (const path of tracked) {
912
1191
  try {
913
1192
  if (await probe.isLink(path)) continue;
914
1193
  const current = await probe.readText(path);
915
1194
  const state = current ?? null;
916
- const prev = states.get(path);
917
- if (prev === void 0 || prev !== state) {
1195
+ const last = await store.lastKnownContent(sessionId, path);
1196
+ if (last === void 0 || last !== state) {
918
1197
  await store.recordEntry(sessionId, {
919
1198
  callId: `recheck-${anchorSeq}-${hashPath(path)}`,
920
1199
  anchorSeq,
921
1200
  path,
922
1201
  before: state
923
- });
924
- states.set(path, state);
1202
+ }, { dedup: false });
925
1203
  recorded++;
926
1204
  }
927
1205
  } catch {
@@ -930,6 +1208,130 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, pr
930
1208
  return recorded;
931
1209
  }
932
1210
 
1211
+ // src/snapshot-cleanup.ts
1212
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1213
+ import { dirname as dirname2, join as join2 } from "node:path";
1214
+ import { homedir as homedir2 } from "node:os";
1215
+ var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
1216
+ var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
1217
+ var DEFAULT_MAX_AGE_DAYS = 30;
1218
+ var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
1219
+ var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1220
+ function resolveCleanupConfigPath() {
1221
+ return process.env[CLEANUP_CONFIG_ENV] ?? join2(homedir2(), ".dsh", CLEANUP_CONFIG_FILENAME);
1222
+ }
1223
+ var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
1224
+ function resolveCleanupStatePath() {
1225
+ return join2(dirname2(resolveCleanupConfigPath()), STATE_FILENAME);
1226
+ }
1227
+ async function loadLastSweepAt(path) {
1228
+ try {
1229
+ const raw = JSON.parse(await readFile2(path, "utf8"));
1230
+ const value = raw["lastSweepAt"];
1231
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1232
+ } catch {
1233
+ return 0;
1234
+ }
1235
+ }
1236
+ async function saveLastSweepAt(path, ms) {
1237
+ const tmp = `${path}.tmp`;
1238
+ await mkdir2(dirname2(path), { recursive: true });
1239
+ await writeFile2(tmp, JSON.stringify({ lastSweepAt: ms }), "utf8");
1240
+ await rename2(tmp, path);
1241
+ }
1242
+ async function runAutoCleanupCheck(deps, sessionId) {
1243
+ try {
1244
+ const loaded = await loadCleanupConfig(deps.configPath);
1245
+ if (!loaded.ok) {
1246
+ deps.log(`[dsh-rewind] snapshot cleanup config invalid; auto-cleanup skipped: ${loaded.error}`);
1247
+ return;
1248
+ }
1249
+ if (!loaded.config.enabled) return;
1250
+ if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now())) return;
1251
+ await deps.pruner.pruneStale({ keepActiveId: sessionId, maxAgeDays: loaded.config.maxAgeDays });
1252
+ await saveLastSweepAt(deps.statePath, Date.now());
1253
+ } catch (error) {
1254
+ deps.log(`[dsh-rewind] snapshot auto-cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1255
+ }
1256
+ }
1257
+ function parseCleanupConfig(raw) {
1258
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1259
+ return { ok: false, error: "config must be a JSON object" };
1260
+ }
1261
+ const record = raw;
1262
+ let enabled = DEFAULT_CLEANUP_CONFIG.enabled;
1263
+ let maxAgeDays = DEFAULT_CLEANUP_CONFIG.maxAgeDays;
1264
+ if (record["enabled"] !== void 0) {
1265
+ if (typeof record["enabled"] !== "boolean") return { ok: false, error: '"enabled" must be a boolean' };
1266
+ enabled = record["enabled"];
1267
+ }
1268
+ if (record["maxAgeDays"] !== void 0) {
1269
+ const value = record["maxAgeDays"];
1270
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
1271
+ return { ok: false, error: '"maxAgeDays" must be a positive integer' };
1272
+ }
1273
+ maxAgeDays = value;
1274
+ }
1275
+ return { ok: true, config: { enabled, maxAgeDays } };
1276
+ }
1277
+ async function loadCleanupConfig(path) {
1278
+ let text;
1279
+ try {
1280
+ text = await readFile2(path, "utf8");
1281
+ } catch (error) {
1282
+ if (error.code === "ENOENT") {
1283
+ return { ok: true, config: { ...DEFAULT_CLEANUP_CONFIG }, fromFile: false };
1284
+ }
1285
+ return { ok: false, error: `config file unreadable: ${error instanceof Error ? error.message : String(error)}` };
1286
+ }
1287
+ let raw;
1288
+ try {
1289
+ raw = JSON.parse(text);
1290
+ } catch (error) {
1291
+ return { ok: false, error: `config file is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
1292
+ }
1293
+ const parsed = parseCleanupConfig(raw);
1294
+ if (!parsed.ok) return { ok: false, error: parsed.error };
1295
+ return { ok: true, config: parsed.config, fromFile: true };
1296
+ }
1297
+ async function saveCleanupConfig(path, config) {
1298
+ if (typeof config.enabled !== "boolean" || !Number.isInteger(config.maxAgeDays) || config.maxAgeDays <= 0) {
1299
+ throw new RangeError("invalid cleanup config: enabled must be a boolean and maxAgeDays a positive integer");
1300
+ }
1301
+ const tmp = `${path}.tmp`;
1302
+ await mkdir2(dirname2(path), { recursive: true });
1303
+ await writeFile2(tmp, JSON.stringify(config, null, 2), "utf8");
1304
+ await rename2(tmp, path);
1305
+ }
1306
+ function parseCleanupCommand(rawInput) {
1307
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1308
+ if (parts.length === 0) return { action: "status" };
1309
+ switch (parts[0]) {
1310
+ case "status":
1311
+ return parts.length === 1 ? { action: "status" } : { error: "usage: /snapshot-auto-cleanup status" };
1312
+ case "on":
1313
+ return parts.length === 1 ? { action: "on" } : { error: "usage: /snapshot-auto-cleanup on" };
1314
+ case "off":
1315
+ return parts.length === 1 ? { action: "off" } : { error: "usage: /snapshot-auto-cleanup off" };
1316
+ case "max-age": {
1317
+ if (parts.length !== 2) return { error: "usage: /snapshot-auto-cleanup max-age <days>" };
1318
+ const days = Number(parts[1]);
1319
+ if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
1320
+ return { action: "max-age", value: days };
1321
+ }
1322
+ case "run": {
1323
+ if (parts.length === 1) return { action: "run" };
1324
+ if (parts.length === 2 && parts[1] === "--apply") return { action: "run-apply" };
1325
+ return { error: "usage: /snapshot-auto-cleanup run [--apply]" };
1326
+ }
1327
+ default:
1328
+ return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
1329
+ }
1330
+ }
1331
+ function shouldRunAutoSweep(lastAtMs, nowMs) {
1332
+ return nowMs - lastAtMs >= AUTO_SWEEP_INTERVAL_MS;
1333
+ }
1334
+
933
1335
  // src/index.ts
934
1336
  var name = "dsh-rewind";
935
1337
  var inject = ["commands", "tools"];
@@ -1237,13 +1639,91 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
1237
1639
  }
1238
1640
  return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
1239
1641
  }
1642
+ var autoSweepChecked = false;
1643
+ async function maybeRunAutoCleanup(ctx, store, sessionId) {
1644
+ if (autoSweepChecked) return;
1645
+ autoSweepChecked = true;
1646
+ await runAutoCleanupCheck({
1647
+ pruner: store,
1648
+ configPath: resolveCleanupConfigPath(),
1649
+ statePath: resolveCleanupStatePath(),
1650
+ log: (msg) => ctx.logger.warn(msg)
1651
+ }, sessionId);
1652
+ }
1653
+ function formatCleanupReport(report) {
1654
+ const key = report.dryRun ? "cleanup.runDry" : "cleanup.runApply";
1655
+ const text = t(key, {
1656
+ deleted: report.deleted,
1657
+ freed: report.freedBytes,
1658
+ kept: report.kept,
1659
+ remaining: report.remainingBytes
1660
+ });
1661
+ return report.skippedActive > 0 ? `${text}
1662
+ ${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
1663
+ }
1664
+ async function handleSnapshotCleanup(store, invocation) {
1665
+ const parsed = parseCleanupCommand(invocation.rawInput);
1666
+ if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
1667
+ const configPath = resolveCleanupConfigPath();
1668
+ switch (parsed.action) {
1669
+ case "status": {
1670
+ const loaded = await loadCleanupConfig(configPath);
1671
+ if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1672
+ return {
1673
+ kind: "success",
1674
+ text: t("cleanup.status", {
1675
+ state: t(loaded.config.enabled ? "cleanup.enabled" : "cleanup.disabled"),
1676
+ days: loaded.config.maxAgeDays,
1677
+ path: configPath,
1678
+ present: t(loaded.fromFile ? "cleanup.present" : "cleanup.absent")
1679
+ })
1680
+ };
1681
+ }
1682
+ case "on":
1683
+ case "off": {
1684
+ const loaded = await loadCleanupConfig(configPath);
1685
+ const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, enabled: parsed.action === "on" };
1686
+ try {
1687
+ await saveCleanupConfig(configPath, next);
1688
+ } catch (error) {
1689
+ return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1690
+ }
1691
+ return { kind: "success", text: t(parsed.action === "on" ? "cleanup.onOk" : "cleanup.offOk") };
1692
+ }
1693
+ case "max-age": {
1694
+ const loaded = await loadCleanupConfig(configPath);
1695
+ const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, maxAgeDays: parsed.value };
1696
+ try {
1697
+ await saveCleanupConfig(configPath, next);
1698
+ } catch (error) {
1699
+ return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1700
+ }
1701
+ return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
1702
+ }
1703
+ case "run":
1704
+ case "run-apply": {
1705
+ const loaded = await loadCleanupConfig(configPath);
1706
+ if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1707
+ try {
1708
+ const report = await store.pruneStale({
1709
+ keepActiveId: invocation.agent.session.id,
1710
+ maxAgeDays: loaded.config.maxAgeDays,
1711
+ dryRun: parsed.action === "run"
1712
+ });
1713
+ if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(), Date.now());
1714
+ return { kind: "success", text: formatCleanupReport(report) };
1715
+ } catch (error) {
1716
+ return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1717
+ }
1718
+ }
1719
+ }
1720
+ }
1240
1721
  function apply(ctx, config) {
1241
- const store = new SnapshotStore(config?.snapshotDir);
1722
+ const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup });
1242
1723
  const pending = /* @__PURE__ */ new Map();
1243
1724
  const anchorCache = /* @__PURE__ */ new WeakMap();
1244
1725
  const inflight = /* @__PURE__ */ new Set();
1245
1726
  const trackedBySession = /* @__PURE__ */ new Map();
1246
- const statesBySession = /* @__PURE__ */ new Map();
1247
1727
  let fsService;
1248
1728
  ctx.inject(["settings"], (settingsCtx) => {
1249
1729
  const section = settingsCtx.settings.get(settingsNamespace("locale"));
@@ -1257,6 +1737,12 @@ function apply(ctx, config) {
1257
1737
  description: t("command.description"),
1258
1738
  handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
1259
1739
  });
1740
+ yield ctx.commands.register({
1741
+ name: "snapshot-auto-cleanup",
1742
+ description: t("cleanup.description"),
1743
+ input: { hint: t("cleanup.inputHint") },
1744
+ handler: (invocation) => handleSnapshotCleanup(store, invocation)
1745
+ });
1260
1746
  }, "dsh-rewind command");
1261
1747
  ctx.on("session/event", (session, event) => {
1262
1748
  if (event.type !== "user/message") return;
@@ -1265,18 +1751,14 @@ function apply(ctx, config) {
1265
1751
  void (async () => {
1266
1752
  try {
1267
1753
  const sessionId = session.id;
1754
+ void maybeRunAutoCleanup(ctx, store, sessionId);
1268
1755
  let tracked = trackedBySession.get(sessionId);
1269
1756
  if (tracked === void 0) {
1270
1757
  tracked = await store.trackedPaths(sessionId);
1271
1758
  trackedBySession.set(sessionId, tracked);
1272
1759
  }
1273
1760
  if (tracked.size === 0) return;
1274
- let states = statesBySession.get(sessionId);
1275
- if (states === void 0) {
1276
- states = /* @__PURE__ */ new Map();
1277
- statesBySession.set(sessionId, states);
1278
- }
1279
- await reconcileTracked(store, sessionId, event.seq, tracked, states);
1761
+ await reconcileTracked(store, sessionId, event.seq, tracked);
1280
1762
  } catch (error) {
1281
1763
  ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
1282
1764
  }
@@ -1295,6 +1777,7 @@ function apply(ctx, config) {
1295
1777
  });
1296
1778
  scope.on("tools/post-execute", async (exec, result, next) => {
1297
1779
  try {
1780
+ void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id);
1298
1781
  await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
1299
1782
  } catch (error) {
1300
1783
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);