dsh-rewind-plugin 0.4.2 → 0.6.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * dsh-rewind client half: the `/rewind` command decoration, the
3
- * parameterized-input guard, the locale registration, and the session-scoped
4
- * portal bridge that renders the per-message ↶ rewind button (see
2
+ * dsh-rewind client half: the `/rewind` command decoration, the locale
3
+ * registration, and the session-scoped portal bridge that renders the
4
+ * per-message ↶ rewind button (see
5
5
  * `portals.tsx` for the button itself).
6
6
  *
7
7
  * The button is NOT injected by hand into the DOM anymore: the plugin
@@ -14,15 +14,15 @@
14
14
  * drift.
15
15
  *
16
16
  * The text-driven flow is the harness's STANDARD command decoration
17
- * (`ctx.commandUi.decorate`): a bare `/rewind` picked from the slash-menu
18
- * completion, or typed in full and Entered — opens the harness's own
19
- * popupSelect shell (search, ↑↓/Enter, Esc) listing the rewind candidates
20
- * instead of executing the command. Picking one continues the SAME flow as
21
- * the ↶ button: the mode popover, both-impact confirmation, execution, row
22
- * hiding and the composer refill (`runRewindAndFill`). The parameterized
23
- * forms (`/rewind @<seq> chat|both`, `/rewind preview …`) stay internal
24
- * channels the ↶ button and the popover drive through `session.command`; a
25
- * hand-typed parameterized line is stopped with a hint.
17
+ * (`ctx.commandUi.decorate`): a bare `/rewind` (or its alias `/undo`)
18
+ * picked from the slash-menu completion, or typed in full and Entered —
19
+ * opens the harness's own popupSelect shell (search, ↑↓/Enter, Esc) listing
20
+ * the rewind candidates instead of executing the command. Picking one
21
+ * continues the SAME flow as the ↶ button: the mode popover, both-impact
22
+ * confirmation, execution, row hiding and the composer refill
23
+ * (`runRewindAndFill`). The parameterized forms (`/rewind @<seq> chat|both`,
24
+ * `/rewind preview …`) stay internal channels the ↶ button and the popover
25
+ * drive through `session.command`.
26
26
  *
27
27
  * @module dsh-rewind/client
28
28
  */
@@ -37,6 +37,6 @@ export declare const inject: string[];
37
37
  export declare function apply(ctx: ClientContext): void;
38
38
  /**
39
39
  * Public contract — rewind visibility. Stable, semver-protected; the rest of
40
- * this module is internal. See `docs/client-contract.md`.
40
+ * this module is internal. See `docs/contract/client-contract.md`.
41
41
  */
42
42
  export { hiddenSeqsOf, targetSeqOfArgs, type HiddenChat } from './hidden.ts';
@@ -25,7 +25,6 @@ export declare const zh: {
25
25
  'popover.impact.delete': string;
26
26
  'popover.confirm': string;
27
27
  'popover.back': string;
28
- 'guard.hint': string;
29
28
  };
30
29
  /** The rewind namespace key union. */
31
30
  export type RewindKey = keyof typeof zh;
@@ -61,5 +60,4 @@ export declare const en: {
61
60
  'popover.impact.delete': string;
62
61
  'popover.confirm': string;
63
62
  'popover.back': string;
64
- 'guard.hint': string;
65
63
  };
@@ -26,13 +26,17 @@
26
26
  */
27
27
  import type { Context } from '@deepseek-ai/cordis';
28
28
  export { SnapshotStore } from './snapshot.ts';
29
- export type { CheckpointEntry, FileImpact, RestoreOutcome, RestoreJournal, RestoreJournalState, RestoreReconcileReport } from './snapshot.ts';
29
+ export type { CheckpointEntry, FileImpact, PruneStaleReport, RestoreOutcome, RestoreJournal, RestoreJournalState, RestoreReconcileReport } from './snapshot.ts';
30
30
  export declare const name = "dsh-rewind";
31
31
  export declare const inject: string[];
32
- /** Plugin config: optional override of the checkpoint store root. */
32
+ /** Plugin config. */
33
33
  export interface RewindConfig {
34
- /** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
34
+ /** Checkpoint store root (exact path; beats `DSH_REWIND_SNAPSHOT_DIR` and the harness-home default). */
35
35
  readonly snapshotDir?: string;
36
+ /** Harness home override (`config.dshHome` > `$DSH_HOME` > `~/.dsh`); feeds the default snapshot/cleanup paths. */
37
+ readonly dshHome?: string;
38
+ /** In-place content dedup (identical before-content → link). Default `true`. */
39
+ readonly dedup?: boolean;
36
40
  }
37
41
  /**
38
42
  * Register the `/rewind` command and the checkpoint pipeline (before-capture
@@ -53,6 +57,7 @@ export interface RewindConfig {
53
57
  * disk at `tools/post-execute` under the turn's anchor message seq.
54
58
  *
55
59
  * @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
56
- * @param config - optional override of the checkpoint store root.
60
+ * @param config - optional plugin config: `snapshotDir` (exact store-root override),
61
+ * `dshHome` (harness-home override feeding the default paths), `dedup`.
57
62
  */
58
63
  export declare function apply(ctx: Context, config?: RewindConfig): void;
@@ -49,6 +49,28 @@ export declare const en: {
49
49
  noUserMessages: string;
50
50
  chooseMode: string;
51
51
  'command.description': string;
52
+ 'cleanup.description': string;
53
+ 'cleanup.inputHint': string;
54
+ 'cleanup.status': string;
55
+ 'cleanup.enabled': string;
56
+ 'cleanup.disabled': string;
57
+ 'cleanup.present': string;
58
+ 'cleanup.absent': string;
59
+ 'cleanup.onOk': string;
60
+ 'cleanup.offOk': string;
61
+ 'cleanup.maxAgeOk': string;
62
+ 'cleanup.cfgInvalid': string;
63
+ 'cleanup.saveFailed': string;
64
+ 'cleanup.runDry': string;
65
+ 'cleanup.runApply': string;
66
+ 'cleanup.runFailed': string;
67
+ 'cleanup.skipped': string;
68
+ 'cleanup.clearDry': string;
69
+ 'cleanup.clearApply': string;
70
+ 'cleanup.clearActive': string;
71
+ 'cleanup.clearCancelled': string;
72
+ 'cleanup.clearFailed': string;
73
+ 'cleanup.usage': string;
52
74
  };
53
75
  /** The host rewind dictionary key union. */
54
76
  export type HostKey = keyof typeof en;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Snapshot cleanup policy: the persisted config file, its validation, the
3
+ * `/snapshot-auto-cleanup` command's argument grammar, and the auto-sweep
4
+ * throttle. Kept free of host wiring so the policy and the parser are
5
+ * unit-testable in isolation; `src/index.ts` is the only consumer.
6
+ *
7
+ * Semantics (the "cleanup" vocabulary deliberately avoids "retention"):
8
+ * - `enabled` toggles the AUTOMATIC (24h) sweep. `false` (the default) keeps
9
+ * every snapshot — the pre-feature behavior — and never writes a file.
10
+ * - `maxAgeDays` is the only "keep" knob: a finished session dir whose newest
11
+ * member stamp is older than this many days of idle is removed by a sweep.
12
+ * `0`/negative/non-integer are rejected, so a broken file can never steer
13
+ * the sweep into deleting everything.
14
+ * - The config file is created ONLY by an explicit `/snapshot-auto-cleanup`
15
+ * write. An absent file reads as the safe default (off); an unreadable or
16
+ * invalid file reports `ok:false` so a sweep fail-closes (deletes nothing)
17
+ * instead of guessing.
18
+ *
19
+ * @module dsh-rewind/snapshot-cleanup
20
+ */
21
+ /** The cleanup policy, as persisted under `~/.dsh/snapshot-cleanup.json`. */
22
+ export interface CleanupConfig {
23
+ readonly enabled: boolean;
24
+ readonly maxAgeDays: number;
25
+ }
26
+ export declare const CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
27
+ /** Environment variable overriding the config file path. */
28
+ export declare const CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
29
+ /** The default keep threshold: finished sessions idle > 30 days are pruned. */
30
+ export declare const DEFAULT_MAX_AGE_DAYS = 30;
31
+ /** The safe default policy (off) — a missing/corrupt file behaves like this. */
32
+ export declare const DEFAULT_CLEANUP_CONFIG: CleanupConfig;
33
+ /** Auto-sweep cadence (the user's hardcoded 24h rhythm — not user-set). */
34
+ export declare const AUTO_SWEEP_INTERVAL_MS: number;
35
+ /**
36
+ * Resolve the config file path (highest first): the `DSH_SNAPSHOT_CLEANUP_CONFIG`
37
+ * env override, else `<harness home>/snapshot-cleanup.json` — derived from
38
+ * `dshHome` (config.dshHome > `$DSH_HOME` > `~/.dsh`) so the plugin follows the
39
+ * harness home instead of hardcoding `~/.dsh`.
40
+ */
41
+ export declare function resolveCleanupConfigPath(dshHome?: string): string;
42
+ /** The state file that records the last automatic-sweep wall-clock time. */
43
+ export declare const STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
44
+ /**
45
+ * Resolve the last-sweep state path. It sits beside the config file so the
46
+ * 24h cadence SURVIVES a host restart (a real deployment is rarely up 24/7,
47
+ * so an in-memory timestamp would reset on every boot and re-sweep too often).
48
+ */
49
+ export declare function resolveCleanupStatePath(dshHome?: string): string;
50
+ /**
51
+ * Read the persisted last-sweep time (epoch ms). A missing or corrupt file
52
+ * reads as `0` ("never swept"), so the next activity runs the sweep — which is
53
+ * safe because the sweep is idempotent and never deletes the active session.
54
+ */
55
+ export declare function loadLastSweepAt(path: string): Promise<number>;
56
+ /** Persist the last-sweep time, atomically (temp + rename). */
57
+ export declare function saveLastSweepAt(path: string, ms: number): Promise<void>;
58
+ /** The slice of a store `runAutoCleanupCheck` needs (pruneStale). */
59
+ export interface AutoCleanupPruner {
60
+ pruneStale(opts: {
61
+ keepActiveId?: string;
62
+ maxAgeDays: number;
63
+ dryRun?: boolean;
64
+ }): Promise<unknown>;
65
+ }
66
+ /**
67
+ * The one-shot auto-cleanup check. Loads the policy + persisted last-sweep time
68
+ * and, only when enabled AND >=24h since the last sweep, runs the sweep and
69
+ * re-anchors the window on disk. Dependencies (store, paths, logger) are
70
+ * injected so the composition is unit-testable without a host. Never rejects:
71
+ * a corrupt config fail-closes (no deletion) and logs, a prune failure logs.
72
+ *
73
+ * `sessionId` is the active session directory that must never be pruned.
74
+ */
75
+ export declare function runAutoCleanupCheck(deps: {
76
+ pruner: AutoCleanupPruner;
77
+ configPath: string;
78
+ statePath: string;
79
+ log: (msg: string) => void;
80
+ }, sessionId: string | undefined): Promise<void>;
81
+ /**
82
+ * Validate one parsed JSON value into a {@link CleanupConfig}. Tolerates
83
+ * unknown extra keys; rejects a present-but-wrong-typed known key. Missing
84
+ * known keys fall back to the safe default.
85
+ */
86
+ export declare function parseCleanupConfig(raw: unknown): {
87
+ ok: true;
88
+ config: CleanupConfig;
89
+ } | {
90
+ ok: false;
91
+ error: string;
92
+ };
93
+ /**
94
+ * Load and validate the config file. A missing file is NOT an error: it reads
95
+ * as the safe default (off, `fromFile:false`). An unreadable, non-JSON, or
96
+ * structurally-invalid file is `ok:false` so a sweep fail-closes.
97
+ */
98
+ export declare function loadCleanupConfig(path: string): Promise<{
99
+ ok: true;
100
+ config: CleanupConfig;
101
+ fromFile: boolean;
102
+ } | {
103
+ ok: false;
104
+ error: string;
105
+ }>;
106
+ /**
107
+ * Persist a validated {@link CleanupConfig}, atomically (temp + rename). Any
108
+ * invalid value throws before the file is touched, so the command can never
109
+ * write a broken policy.
110
+ */
111
+ export declare function saveCleanupConfig(path: string, config: CleanupConfig): Promise<void>;
112
+ /** The `/snapshot-auto-cleanup` sub-command the parser can resolve to. */
113
+ export type CleanupCommandAction = 'status' | 'on' | 'off' | 'max-age' | 'run';
114
+ /** A parsed `/snapshot-auto-cleanup` command (excludes the error branch). */
115
+ export type CleanupCommand = {
116
+ action: 'status' | 'on' | 'off';
117
+ } | {
118
+ action: 'max-age';
119
+ value: number;
120
+ } | {
121
+ action: 'run';
122
+ target: 'rules' | 'current';
123
+ apply: boolean;
124
+ };
125
+ /**
126
+ * Parse the free-form text after `/snapshot-auto-cleanup`. Pure so it is
127
+ * unit-testable; `src/index.ts` maps the resolved action onto the store / the
128
+ * config file. `max-age` returns the validated positive day count.
129
+ *
130
+ * The `run` verb is the single manual-cleanup action. `--apply` is the ONLY
131
+ * execute-vs-dry-run switch (position-independent): without it the action is a
132
+ * dry-run preview. `--current` re-targets the action to the ACTIVE session's
133
+ * snapshots (the manual "clear this session now"); without it, `run` keeps its
134
+ * age-based stale-session sweep semantics. The old `run-apply` abbreviation is
135
+ * gone — use `run --apply`.
136
+ */
137
+ export declare function parseCleanupCommand(rawInput: string): CleanupCommand | {
138
+ error: string;
139
+ };
140
+ /**
141
+ * The 24h auto-sweep throttle. `lastAtMs` of `0` means "never ran" (a fresh
142
+ * process), so the first call always sweeps; after that a call within 24h is
143
+ * a no-op, matching the "every machine at most once per day" model.
144
+ */
145
+ export declare function shouldRunAutoSweep(lastAtMs: number, nowMs: number): boolean;
@@ -20,6 +20,11 @@
20
20
  * kept), and restores read/write the real file system with plain `node:fs`
21
21
  * — independent of the fs service.
22
22
  *
23
+ * Security note: this `node:fs` authority is the DSH host authority every host
24
+ * plugin holds — the model-facing fences constrain the model's tools, not this
25
+ * code. The store stays bounded to the model-touched paths, so excluding a
26
+ * file (e.g. `.env`) is a model-permission concern (see `SECURITY.md`).
27
+ *
23
28
  * Crash safety (this module's own engineering asset):
24
29
  * - Checkpoint commits are ATOMIC: the entry JSON is written to a sibling
25
30
  * temp file and renamed over the target, so a host crash mid-write can
@@ -48,7 +53,12 @@
48
53
  *
49
54
  * @module dsh-rewind/snapshot
50
55
  */
51
- /** Default store root: the dsh data directory. */
56
+ /**
57
+ * Default store root: `<harness home>/rewind-snapshots`. Resolved through
58
+ * {@link resolveDshHome} so the plugin follows `$DSH_HOME` (or a configured
59
+ * harness home) rather than hardcoding `~/.dsh` — matching the other
60
+ * first-party DSH packages. See `SECURITY.md` "Sensitive files".
61
+ */
52
62
  export declare const DEFAULT_SNAPSHOT_ROOT: string;
53
63
  /** Environment variable overriding the store root (tests, exotic homes). */
54
64
  export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
@@ -66,6 +76,30 @@ export interface CheckpointEntry {
66
76
  /** Epoch ms the entry was committed (stable ordering within a group). */
67
77
  readonly time: number;
68
78
  }
79
+ /**
80
+ * One in-place dedup link, keyed by tool call. When a tracked file is
81
+ * recorded with a `before` content identical to the immediately-prior entry
82
+ * for that path, the entry is stored as a LINK instead of a full copy: it
83
+ * carries no `before`, only a `ref` naming the prior entry file
84
+ * (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
85
+ * restore resolution and prune materialization rewrite-free.
86
+ *
87
+ * The real-entry format ({@link CheckpointEntry}) is unchanged so existing
88
+ * data reads identically; links are a NEW entry kind only the current build
89
+ * understands (old-build reads of links are explicitly out of scope).
90
+ */
91
+ export interface LinkEntry {
92
+ readonly callId: string;
93
+ readonly anchorSeq: number;
94
+ readonly path: string;
95
+ /** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
96
+ readonly ref: string;
97
+ readonly time: number;
98
+ }
99
+ /** Any on-disk entry: a full before-backup or an in-place dedup link. */
100
+ export type StoredEntry = CheckpointEntry | LinkEntry;
101
+ /** True when an entry is a dedup link (carries `ref`, not `before`). */
102
+ export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
69
103
  /** Per-file restore impact preview (`/rewind preview @seq both`). */
70
104
  export interface FileImpact {
71
105
  readonly path: string;
@@ -199,12 +233,60 @@ export type PlannedAction = {
199
233
  };
200
234
  /** Production probe: real reads via node:fs, links detected by lstat + nlink. */
201
235
  export declare const defaultProbe: DiskProbe;
236
+ /**
237
+ * Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
238
+ *
239
+ * The sweep is ANTI-DELETE: it only ever removes WHOLE session directories
240
+ * that have been idle past `maxAgeDays`. `scanned` counts every session dir
241
+ * evaluated; `kept` + `skippedActive` + `deleted` sum to it. `remainingBytes`
242
+ * is the total of directories that SURVIVE the policy (when `dryRun` it is the
243
+ * would-be total, not the current on-disk total), so it is comparable across
244
+ * dry and real runs.
245
+ */
246
+ export interface PruneStaleReport {
247
+ /** Number of session directories evaluated. */
248
+ readonly scanned: number;
249
+ /** Session directories removed (would-be count when `dryRun`). */
250
+ readonly deleted: number;
251
+ /** Bytes reclaimed (would-be bytes when `dryRun`). */
252
+ readonly freedBytes: number;
253
+ /** Session directories retained (not past the cutoff, not the active one). */
254
+ readonly kept: number;
255
+ /** Bytes across the retained + skipped-active directories. */
256
+ readonly remainingBytes: number;
257
+ /** Directories skipped because they are the active session. */
258
+ readonly skippedActive: number;
259
+ /** Whether nothing was really removed (the sweep only reported). */
260
+ readonly dryRun: boolean;
261
+ }
262
+ /**
263
+ * Result of a manual whole-session clear ({@link SnapshotStore.clearSession}).
264
+ *
265
+ * Unlike the age-based sweep, a clear removes EVERY snapshot of ONE session
266
+ * (all anchor groups, all checkpoint entries, all restore journals) on demand —
267
+ * the active session the user is driving, to drop the rewind overhead or to
268
+ * archive a conversation immediately. `dryRun` reports what would be removed
269
+ * without touching disk or memory.
270
+ */
271
+ export interface ClearSessionReport {
272
+ /** The session whose records were (or would be) cleared. */
273
+ readonly sessionId: string;
274
+ /** Number of anchor-group (user-message) directories present. */
275
+ readonly anchorGroups: number;
276
+ /** Number of committed checkpoint entries (full backups + dedup links). */
277
+ readonly entries: number;
278
+ /** Number of restore-journal files (terminal + pending). */
279
+ readonly journals: number;
280
+ /** Bytes occupied by the session directory (the amount freed). */
281
+ readonly bytes: number;
282
+ /** Whether nothing was really removed (the clear only reported). */
283
+ readonly dryRun: boolean;
284
+ }
202
285
  /**
203
286
  * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
204
287
  * restore reliably lands on the real file system.
205
288
  */
206
289
  export declare class SnapshotStore {
207
- readonly root: string;
208
290
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
209
291
  private static readonly PRUNE_INTERVAL_MS;
210
292
  private lastPruneAt;
@@ -221,15 +303,59 @@ export declare class SnapshotStore {
221
303
  * it, and even then the in-process order still holds.
222
304
  */
223
305
  private lastEntryTime;
224
- constructor(root?: string);
306
+ /** Store options; `dedup` toggles in-place content dedup (default on). */
307
+ private readonly dedup;
308
+ /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
309
+ readonly root: string;
310
+ /**
311
+ * In-memory per-path "most recent entry" for content dedup, keyed by
312
+ * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
313
+ * content and its own file ref, so a new record with the same content links
314
+ * to the immediately-prior entry (linear chain). Seeded lazily per session
315
+ * from the bounded on-disk window, so dedup survives a host restart.
316
+ */
317
+ private readonly lastEntry;
318
+ /** Sessions whose dedup state has been seeded from disk this process. */
319
+ private readonly seededSessions;
320
+ constructor(root?: string, opts?: {
321
+ readonly dedup?: boolean;
322
+ readonly dshHome?: string;
323
+ });
225
324
  /** Absolute path of one session's snapshot directory (id sanitized). */
226
325
  sessionDir(sessionId: string): string;
227
326
  /** Absolute path of one anchor group directory. */
228
327
  anchorDir(sessionId: string, anchorSeq: number): string;
229
- /** Commit one before-backup under its turn's anchor group (atomic write). */
328
+ /** Absolute file ref (relative to the session dir) of an entry. */
329
+ private entryRefOf;
330
+ /**
331
+ * Seed a session's dedup state from the existing (bounded) on-disk window:
332
+ * scan entries newest-first and record the most recent entry per path. This
333
+ * makes content dedup survive a host restart within the session window. A
334
+ * no-op after the first seed (or when `dedup` is disabled).
335
+ */
336
+ private ensureDedupSeeded;
337
+ /**
338
+ * Resolve an entry's effective `before` content, following a link chain to
339
+ * its terminal real snapshot. Refs are strictly backward in
340
+ * `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
341
+ * cyclic link throws — callers fail per-file (never silently dropping the
342
+ * path from a restore).
343
+ */
344
+ private resolveBefore;
345
+ /** Commit one before-backup (or an in-place dedup link) under its anchor. */
230
346
  recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>, opts?: {
347
+ readonly dedup?: boolean;
231
348
  readonly crash?: (point: CrashPoint) => void;
232
349
  }): Promise<void>;
350
+ /**
351
+ * The effective content recorded by the path's MOST RECENT entry, or
352
+ * undefined when the path has never been recorded (a fresh tracking sight).
353
+ * This is the single in-memory "last known state" the boundary uses to
354
+ * decide whether a tracked file changed — the same source `recordEntry`
355
+ * dedups against, so there is one content copy and one comparison per
356
+ * decision, not two. Seeding is idempotent (once per session from disk).
357
+ */
358
+ lastKnownContent(sessionId: string, path: string): Promise<string | null | undefined>;
233
359
  /**
234
360
  * All committed entries anchored at or after `targetSeq`, newest first (for
235
361
  * preview ordering). The boundary is inclusive: rewinding to a message also
@@ -237,7 +363,7 @@ export declare class SnapshotStore {
237
363
  * turn's assistant response and tool calls), so only entries anchored at
238
364
  * earlier messages survive.
239
365
  */
240
- entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
366
+ entriesAfter(sessionId: string, targetSeq: number): Promise<StoredEntry[]>;
241
367
  /**
242
368
  * Per-path EARLIEST committed entry anchored at or after the target — the
243
369
  * single source of truth for both restore and impact preview.
@@ -269,7 +395,7 @@ export declare class SnapshotStore {
269
395
  * @param sessionId - session whose snapshot store to plan against.
270
396
  * @param targetSeq - rewind target; entries anchored at/after it apply.
271
397
  * @param probe - current-disk state probe (defaults to the real FS).
272
- * @returns the planned actions plus the link paths that were skipped.
398
+ * @returns the planned actions, the link paths skipped, and per-file failures.
273
399
  */
274
400
  private planRestore;
275
401
  /** Per-file restore impact: only actions that would actually change the disk. */
@@ -384,8 +510,23 @@ export declare class SnapshotStore {
384
510
  * recycles terminal restore journals (see {@link pruneTerminalJournals}),
385
511
  * so the per-commit cap bounds BOTH the checkpoint entries and the journal
386
512
  * accumulation.
513
+ *
514
+ * Because dedup links reference prior entries, eviction is LINK-AWARE: before
515
+ * deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
516
+ * lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
517
+ * as a real snapshot carrying the resolved content), so no kept link is left
518
+ * dangling. Links form a linear predecessor chain, so materializing the first
519
+ * link after each doomed real is enough — later links already point at that
520
+ * materialized entry (or at other kept links), requiring no rewrite.
521
+ *
522
+ * `opts.crash` is the test-only seam: a crash fired inside a materialization
523
+ * write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
524
+ * real is still on disk and the kept link still resolves, so nothing dangles
525
+ * and a later prune simply re-materializes.
387
526
  */
388
- prune(sessionId: string, keep?: number): Promise<void>;
527
+ prune(sessionId: string, keep?: number, opts?: {
528
+ readonly crash?: (point: CrashPoint) => void;
529
+ }): Promise<void>;
389
530
  /**
390
531
  * Recycle terminal restore journals (`completed` / `rolled-back`): once an
391
532
  * op finished, its journal's before + rescue content is dead weight that
@@ -397,6 +538,33 @@ export declare class SnapshotStore {
397
538
  private pruneTerminalJournals;
398
539
  /** True when a path exists on disk (used by tests and diagnostics). */
399
540
  exists(path: string): Promise<boolean>;
541
+ /**
542
+ * Cross-session retention sweep: remove WHOLE session directories whose
543
+ * newest member stamp is older than `maxAgeDays` days of idle, keeping the
544
+ * active session (`keepActiveId`) untouched. This is the anti-growth policy
545
+ * for finished sessions (rewind only ever reads the active session, so a
546
+ * finished session's backups are provably dead weight).
547
+ *
548
+ * SAFETY:
549
+ * - Only whole session directories are removed (dedup refs are
550
+ * session-relative, so there is no cross-session dangling to materialize);
551
+ * - the active session is never targeted (`keepActiveId`), and everything
552
+ * else is protected by its own mtime — a session that is still written to
553
+ * keeps scrolling its newest member stamp forward, so it is never old
554
+ * enough to be pruned;
555
+ * - a non-positive `maxAgeDays` throws instead of degenerating into a
556
+ * mass-destructive `cutoff` in the far future;
557
+ * - the walk uses `lstat` (no symlink following) and skips dot-prefixed
558
+ * temp left overs, so measurement stays inside the store root.
559
+ *
560
+ * `dryRun` computes and reports exactly what would be removed without
561
+ * deleting anything — the `/snapshot-auto-cleanup run` preview.
562
+ */
563
+ pruneStale(opts: {
564
+ readonly keepActiveId?: string;
565
+ readonly maxAgeDays: number;
566
+ readonly dryRun?: boolean;
567
+ }): Promise<PruneStaleReport>;
400
568
  /**
401
569
  * All distinct paths ever recorded for a session — the "tracked files"
402
570
  * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
@@ -404,6 +572,49 @@ export declare class SnapshotStore {
404
572
  * entries so no extra persistence is needed.
405
573
  */
406
574
  trackedPaths(sessionId: string): Promise<Set<string>>;
575
+ /**
576
+ * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
577
+ * count, committed checkpoint-entry count, restore-journal count, and total
578
+ * bytes. Walks with `lstat` (never follows a symlink, so a hostile symlink
579
+ * cannot escape the store root or inflate the measurement) and skips
580
+ * dot-prefixed temp leftovers and non-`.json` members — they are never
581
+ * checkpoint entries.
582
+ */
583
+ private sessionStats;
584
+ /**
585
+ * Remove a session's ENTIRE snapshot directory — every anchor group, every
586
+ * checkpoint entry, and every restore journal — and reset the store's
587
+ * in-memory dedup state so the session starts recording fresh from the
588
+ * current workspace state. This is the manual "get rid of this session's
589
+ * records NOW" action on the ACTIVE session the user is driving (it is never
590
+ * targetable by id; that is a directory-manipulation concern the user can do
591
+ * directly).
592
+ *
593
+ * SEMANTICS — clearing is an explicit abandonment: issuing the command means
594
+ * the user accepts that this session's snapshot archive goes away. It is
595
+ * therefore NOT gated on the state of any restore journal. A clear and a
596
+ * restore are both slash commands the host runs to completion for an agent,
597
+ * so they never interleave — any non-terminal journal present on disk is a
598
+ * stale orphan from a previous (crashed) process, and discarding it is the
599
+ * correct, safe resolution of that abandoned restore.
600
+ *
601
+ * SAFETY (this module's real concern is the plugin's ongoing BEHAVIOR, not
602
+ * losing snapshots):
603
+ * - Only the session dir is removed; dedup refs are session-relative, so
604
+ * there is no cross-session dangling to materialize (the same rationale as
605
+ * {@link pruneStale}'s whole-dir removal).
606
+ * - The in-memory dedup state (`lastEntry` / `seededSessions`) is ALWAYS
607
+ * reset on an apply — even when the dir was already empty. A stale
608
+ * in-memory entry (e.g. a session whose dir was removed out-of-band) would
609
+ * otherwise link a later `recordEntry` to a deleted prior entry, leaving a
610
+ * dangling ref that breaks restore resolution. This is the primary
611
+ * correctness guarantee.
612
+ *
613
+ * `dryRun` computes the report without touching disk or memory.
614
+ */
615
+ clearSession(sessionId: string, opts?: {
616
+ readonly dryRun?: boolean;
617
+ }): Promise<ClearSessionReport>;
407
618
  }
408
619
  /**
409
620
  * Re-check every tracked file at a user-message boundary and record the
@@ -418,10 +629,13 @@ export declare class SnapshotStore {
418
629
  * Semantics: the recorded `before` is the file's state at the boundary —
419
630
  * the state the boundary message's turn starts from, exactly like the
420
631
  * tool-captured entries. An entry is written only when the state differs
421
- * from the last-seen state (`states`); the FIRST sighting of a path always
422
- * records (a restart leaves `states` empty, so the first boundary after a
423
- * restart unconditionally records the current state redundant but correct,
424
- * mirroring Claude's resume-then-re-stat behavior).
632
+ * from the path's most-recent recorded content (`lastKnownContent`); a fresh
633
+ * sighting (never recorded) always records. The state is compared against the
634
+ * SAME single in-memory source `recordEntry` dedups against, so there is one
635
+ * content copy and one comparison — not the two (a boundary map plus the
636
+ * dedup map) the previous model held. Only CHANGED files are recorded, and
637
+ * each is a full snapshot (`dedup: false`): a changed state always differs
638
+ * from the recent record, so the link decision would never apply there.
425
639
  *
426
640
  * Symlinked / hard-linked paths are never re-checked (restores skip them).
427
641
  * A probe failure skips the file with a warning-level no-op; it never
@@ -431,8 +645,7 @@ export declare class SnapshotStore {
431
645
  * @param sessionId - session whose tracked files to re-check.
432
646
  * @param anchorSeq - the boundary user-message seq (entry anchor).
433
647
  * @param tracked - the session's tracked path set (read-only here).
434
- * @param states - per-path last-seen state (path → content, null = absent).
435
648
  * @param probe - current-disk state probe (defaults to the real FS).
436
649
  * @returns the number of entries recorded.
437
650
  */
438
- export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, states: Map<string, string | null>, probe?: DiskProbe): Promise<number>;
651
+ export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, probe?: DiskProbe): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -39,6 +39,8 @@
39
39
  "cordis.patch.yml",
40
40
  "README.md",
41
41
  "README.en.md",
42
+ "SECURITY.md",
43
+ "CONTRIBUTING.md",
42
44
  "docs",
43
45
  "assets",
44
46
  "LICENSE"
@@ -62,6 +64,7 @@
62
64
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.client-test.json",
63
65
  "test": "vitest run",
64
66
  "verify:host": "node scripts/verify-host.mjs",
67
+ "check": "npm run typecheck && npm test && npm run build && node scripts/verify-host.mjs && npm pack --dry-run",
65
68
  "prepare": "npm run build"
66
69
  },
67
70
  "publishConfig": {
@@ -75,6 +78,7 @@
75
78
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.1.1-rc.2",
76
79
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.6 || ^0.1.1-rc.2",
77
80
  "@deepseek-ai/dsh-fs": "^0.1.0-rc.6 || ^0.1.1-rc.2",
81
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-rc.2",
78
82
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-rc.2",
79
83
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6 || ^0.1.1-rc.2",
80
84
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.2",
@@ -103,6 +107,9 @@
103
107
  "@deepseek-ai/dsh-fs": {
104
108
  "optional": true
105
109
  },
110
+ "@deepseek-ai/dsh-home-paths": {
111
+ "optional": true
112
+ },
106
113
  "@deepseek-ai/dsh-llm": {
107
114
  "optional": true
108
115
  },
@@ -133,6 +140,7 @@
133
140
  "@deepseek-ai/dsh-compaction-basic": "^0.1.1-rc.2",
134
141
  "@deepseek-ai/dsh-fs": "^0.1.1-rc.2",
135
142
  "@deepseek-ai/dsh-goal": "^0.1.1-rc.2",
143
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
136
144
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
137
145
  "@deepseek-ai/dsh-sandbox": "^0.1.1-rc.2",
138
146
  "@deepseek-ai/dsh-session": "^0.1.1-rc.2",