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.
- package/CONTRIBUTING.md +72 -0
- package/README.en.md +53 -40
- package/README.md +44 -40
- package/SECURITY.md +160 -0
- package/docs/README.md +30 -0
- package/docs/architecture.md +128 -0
- package/docs/compat/audit.md +149 -0
- package/docs/compat/tracking-boundary.md +103 -0
- package/docs/compat/tracking-boundary.zh.md +55 -0
- package/docs/compat/troubleshooting.md +41 -0
- package/docs/{troubleshooting.zh.md → compat/troubleshooting.zh.md} +15 -0
- package/docs/format.md +151 -0
- package/docs/harness-reference.md +8 -4
- package/docs/release/release.md +69 -0
- package/docs/{release.md → release/release.zh.md} +6 -52
- package/docs/snapshot-auto-cleanup.md +90 -0
- package/docs/snapshot-auto-cleanup.zh.md +57 -0
- package/lib/client.js +7 -64
- package/lib/index.js +688 -45
- package/lib/types/client/index.d.ts +13 -13
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/index.d.ts +9 -4
- package/lib/types/locales.d.ts +22 -0
- package/lib/types/snapshot-cleanup.d.ts +145 -0
- package/lib/types/snapshot.d.ts +226 -13
- package/package.json +9 -1
- package/docs/compat-audit.md +0 -137
- package/docs/troubleshooting.md +0 -44
- /package/docs/{client-contract.md → contract/client-contract.md} +0 -0
- /package/docs/{client-contract.zh.md → contract/client-contract.zh.md} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-rewind client half: the `/rewind` command decoration, the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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`
|
|
18
|
-
* completion, or typed in full and Entered —
|
|
19
|
-
* popupSelect shell (search, ↑↓/Enter, Esc) listing
|
|
20
|
-
* instead of executing the command. Picking one
|
|
21
|
-
* the ↶ button: the mode popover, both-impact
|
|
22
|
-
* hiding and the composer refill
|
|
23
|
-
* forms (`/rewind @<seq> chat|both`,
|
|
24
|
-
* channels the ↶ button and the popover
|
|
25
|
-
*
|
|
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
|
};
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
32
|
+
/** Plugin config. */
|
|
33
33
|
export interface RewindConfig {
|
|
34
|
-
/** Checkpoint store root (
|
|
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
|
|
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;
|
package/lib/types/locales.d.ts
CHANGED
|
@@ -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;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
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>,
|
|
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.
|
|
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",
|