dsh-rewind-plugin 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/README.zh.md +6 -5
- package/assets/screenshots/impact-list.png +0 -0
- package/assets/screenshots/mode-popover.png +0 -0
- package/assets/screenshots/rewind-button.png +0 -0
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/lib/client.js +76 -21
- package/lib/index.js +332 -67
- package/lib/types/client/candidates.d.ts +25 -0
- package/lib/types/client/hidden.d.ts +17 -6
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/locales.d.ts +67 -0
- package/lib/types/rewind.d.ts +35 -0
- package/lib/types/snapshot.d.ts +107 -10
- package/package.json +6 -1
|
@@ -13,8 +13,12 @@ export interface HiddenChat {
|
|
|
13
13
|
get(key: string): ChatConversationViewNode | undefined;
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Extract the rewind target seq from a `/rewind` command's structured `args`
|
|
18
|
+
* (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
|
|
19
|
+
* host's human outcome copy.
|
|
20
|
+
*/
|
|
21
|
+
export declare function targetSeqOfArgs(args: string | null | undefined): number | undefined;
|
|
18
22
|
/**
|
|
19
23
|
* True when a `/rewind` command node is an EXECUTED rewind for `seq` — the
|
|
20
24
|
* admission form the popover drives (`@<seq> chat` / `both`) that settled
|
|
@@ -28,12 +32,19 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
|
|
|
28
32
|
* of the "rewind conversation and code" option (Claude Code hides the
|
|
29
33
|
* code-restore options when the checkpoint has no tracked changes).
|
|
30
34
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* Reads ONLY the machine-readable `impact=<n>` trailer the host appends to
|
|
36
|
+
* preview text. Older host output without the trailer is treated as having no
|
|
37
|
+
* changes (never guesses from human copy). Unknown/absent text degrades to
|
|
38
|
+
* always-show so a working option is never hidden on a failed probe.
|
|
35
39
|
*/
|
|
36
40
|
export declare function hasFileImpact(text: string | undefined): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when a `/rewind` command node is the internal candidate-list probe
|
|
43
|
+
* (`/rewind __candidates`) the popupSelect runs to fetch the FULL candidate
|
|
44
|
+
* list from the host. Like previews, its flow node never surfaces in the
|
|
45
|
+
* transcript — it only feeds the popup — so it is hidden in every state.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCandidateCommand(command: CommandNode): boolean;
|
|
37
48
|
/**
|
|
38
49
|
* Anchor seqs that must be hidden from the rendered transcript so the user
|
|
39
50
|
* sees the conversation as the agent sees it: every impact-preview flow node
|
|
@@ -15,6 +15,8 @@ export declare const zh: {
|
|
|
15
15
|
'popover.impact.loading': string;
|
|
16
16
|
'popover.impact.failed': string;
|
|
17
17
|
'popover.impact.none': string;
|
|
18
|
+
'popover.impact.restore': string;
|
|
19
|
+
'popover.impact.delete': string;
|
|
18
20
|
'popover.confirm': string;
|
|
19
21
|
'popover.back': string;
|
|
20
22
|
'guard.hint': string;
|
|
@@ -43,6 +45,8 @@ export declare const en: {
|
|
|
43
45
|
'popover.impact.loading': string;
|
|
44
46
|
'popover.impact.failed': string;
|
|
45
47
|
'popover.impact.none': string;
|
|
48
|
+
'popover.impact.restore': string;
|
|
49
|
+
'popover.impact.delete': string;
|
|
46
50
|
'popover.confirm': string;
|
|
47
51
|
'popover.back': string;
|
|
48
52
|
'guard.hint': string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side localization for dsh-rewind's `/rewind` command output and command
|
|
3
|
+
* description.
|
|
4
|
+
*
|
|
5
|
+
* Architecture (matches the dsh ecosystem): the HOST half of a dual-face
|
|
6
|
+
* plugin has no locale service — only the browser client carries one. The host
|
|
7
|
+
* therefore renders its command-adjacent copy from a durable user preference
|
|
8
|
+
* (`ctx.settings` → `locale.preference`, registered by dsh-client-locale),
|
|
9
|
+
* defaulting to English — the ecosystem's neutral default language (the harness
|
|
10
|
+
* `FALLBACK_LOCALE` and the language dsh's own host commands use, e.g.
|
|
11
|
+
* dsh-plan-mode). See packages/client/locale in deepseek-harness.
|
|
12
|
+
*
|
|
13
|
+
* The client half (`src/client/locales.ts`) owns all interactive UI copy via
|
|
14
|
+
* `ctx.locale` + `t()`; the host's human text is a machine channel the client
|
|
15
|
+
* renders through machine tokens (`impact=<n>`, `args` @seq), never by parsing
|
|
16
|
+
* host prose.
|
|
17
|
+
*
|
|
18
|
+
* English is the key-set source of truth; zh is checked complete against it.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-rewind/locales
|
|
21
|
+
*/
|
|
22
|
+
/** Host-side supported locale ids, mirroring the harness's shipped locales. */
|
|
23
|
+
export type HostLocaleId = 'zh' | 'en';
|
|
24
|
+
/** English dictionary — the key-set source of truth (neutral default). */
|
|
25
|
+
export declare const en: {
|
|
26
|
+
'usage.title': string;
|
|
27
|
+
'usage.noArgs': string;
|
|
28
|
+
'usage.seq': string;
|
|
29
|
+
'usage.blocked': string;
|
|
30
|
+
'describeTarget.seq': string;
|
|
31
|
+
'describeTarget.index': string;
|
|
32
|
+
'plan.rewinding': string;
|
|
33
|
+
'plan.affects': string;
|
|
34
|
+
'plan.restore': string;
|
|
35
|
+
'plan.delete': string;
|
|
36
|
+
'plan.noChanges': string;
|
|
37
|
+
'error.invalidTarget': string;
|
|
38
|
+
'failures.suffix': string;
|
|
39
|
+
'failures.item': string;
|
|
40
|
+
inflight: string;
|
|
41
|
+
stopFailed: string;
|
|
42
|
+
cancelled: string;
|
|
43
|
+
failed: string;
|
|
44
|
+
'restore.count': string;
|
|
45
|
+
'delete.count': string;
|
|
46
|
+
'skip.count': string;
|
|
47
|
+
noRestorable: string;
|
|
48
|
+
success: string;
|
|
49
|
+
noUserMessages: string;
|
|
50
|
+
chooseMode: string;
|
|
51
|
+
'command.description': string;
|
|
52
|
+
};
|
|
53
|
+
/** The host rewind dictionary key union. */
|
|
54
|
+
export type HostKey = keyof typeof en;
|
|
55
|
+
/** Chinese dictionary, checked complete against the en key set. */
|
|
56
|
+
export declare const zh: Record<HostKey, string>;
|
|
57
|
+
/** The host dictionaries keyed by locale id. */
|
|
58
|
+
export declare const HOST_DICTS: Record<HostLocaleId, Record<HostKey, string>>;
|
|
59
|
+
/**
|
|
60
|
+
* Render one dictionary key with `{name}` template interpolation. Unknown
|
|
61
|
+
* params are ignored; a missing key falls back to the raw key so a dictionary
|
|
62
|
+
* gap is visible instead of blank.
|
|
63
|
+
* @param lang - the active locale.
|
|
64
|
+
* @param key - the dictionary key.
|
|
65
|
+
* @param params - `{name}` substitution values.
|
|
66
|
+
*/
|
|
67
|
+
export declare function translate(lang: HostLocaleId, key: HostKey, params?: Record<string, string | number>): string;
|
package/lib/types/rewind.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface RewindPlan {
|
|
|
60
60
|
}
|
|
61
61
|
/** Preview length cap for candidate listings. */
|
|
62
62
|
export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
63
|
+
/**
|
|
64
|
+
* Default cap on how many user messages a candidate listing returns (newest
|
|
65
|
+
* kept). Raised from 10 so long sessions don't look incomplete; callers can
|
|
66
|
+
* still pass an explicit `limit`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
63
69
|
/**
|
|
64
70
|
* Turn number for the rewind marker.
|
|
65
71
|
*
|
|
@@ -85,6 +91,18 @@ export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
|
85
91
|
export declare function markerTurnOf(events: readonly SessionEvent[]): number;
|
|
86
92
|
/** Narrow an event to a user message. */
|
|
87
93
|
export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
94
|
+
/**
|
|
95
|
+
* True for a HUMAN user message event — one whose `source.kind` is `'user'`.
|
|
96
|
+
*
|
|
97
|
+
* The surface can carry `user/message` events whose source is NOT the user:
|
|
98
|
+
* plugin/system context injection (including compaction checkpoints) and
|
|
99
|
+
* tool-result backfill all arrive as `user/message` with a non-`'user'`
|
|
100
|
+
* source, and the client renders those as `context` nodes, never as a user
|
|
101
|
+
* bubble. Only genuine user messages (and user steering during a running
|
|
102
|
+
* turn, which keeps `source.kind: 'user'`) are valid rewind targets — a
|
|
103
|
+
* rewind boundary must land on a human prompt, not on injected context.
|
|
104
|
+
*/
|
|
105
|
+
export declare function isHumanUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
88
106
|
/** Join the text blocks of a message into one plain string. */
|
|
89
107
|
export declare function messagePreview(message: UserMessage): string;
|
|
90
108
|
/**
|
|
@@ -104,6 +122,23 @@ export declare function parseRewindTarget(raw: string): RewindTarget | undefined
|
|
|
104
122
|
* @returns candidates numbered 1..N by recency.
|
|
105
123
|
*/
|
|
106
124
|
export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
|
|
125
|
+
/** Header line of the machine-readable candidate list (locale-independent). */
|
|
126
|
+
export declare const CANDIDATE_LIST_HEADER = "candidates=";
|
|
127
|
+
/**
|
|
128
|
+
* Encode a candidate list as the host→client machine channel (the same
|
|
129
|
+
* trailer pattern `formatPlan` uses for `impact=`). The client popupSelect
|
|
130
|
+
* parses this instead of reading the windowed chat snapshot, so the candidate
|
|
131
|
+
* list reflects the FULL host surface — not just the already-loaded history.
|
|
132
|
+
*
|
|
133
|
+
* Lines (each preview is already whitespace-collapsed and tab-free by
|
|
134
|
+
* `messagePreview`):
|
|
135
|
+
* candidates=<n>
|
|
136
|
+
* <seq>\t<time>\t<preview>
|
|
137
|
+
* … (one line per candidate, newest first, matching `listRewindCandidates`)
|
|
138
|
+
*
|
|
139
|
+
* A list with no candidates is just `candidates=0`.
|
|
140
|
+
*/
|
|
141
|
+
export declare function formatCandidateList(candidates: readonly RewindCandidate[]): string;
|
|
107
142
|
/**
|
|
108
143
|
* Resolve a target against the session log and surface into a validated plan.
|
|
109
144
|
* @param events - the full session event log.
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -65,6 +65,34 @@ export interface RestoreOutcome {
|
|
|
65
65
|
}
|
|
66
66
|
/** Deletes one file by its real path (node:fs, bypassing the fs service). */
|
|
67
67
|
export type DeleteFile = (path: string) => Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Current-on-disk state probe used by restore planning. Injected so the plan
|
|
70
|
+
* logic runs against a fake FS in tests; the production default reads the
|
|
71
|
+
* real file system with plain `node:fs` (see {@link defaultProbe}).
|
|
72
|
+
*/
|
|
73
|
+
export interface DiskProbe {
|
|
74
|
+
/**
|
|
75
|
+
* Full text of the file, or undefined when the file does not exist.
|
|
76
|
+
* Any thrown error is treated as a probe failure: restore planning then
|
|
77
|
+
* conservatively treats the file as DIFFERING from its record (a restore
|
|
78
|
+
* still attempts the write / a delete still attempts the unlink), so an
|
|
79
|
+
* unreadable file is never silently skipped.
|
|
80
|
+
*/
|
|
81
|
+
readText(path: string): Promise<string | undefined>;
|
|
82
|
+
/** True when the path is a symlink or a hard link (never planned/restored). */
|
|
83
|
+
isLink(path: string): Promise<boolean>;
|
|
84
|
+
}
|
|
85
|
+
/** One restore action the planner derived from record + disk reconciliation. */
|
|
86
|
+
export type PlannedAction = {
|
|
87
|
+
readonly path: string;
|
|
88
|
+
readonly action: 'restore';
|
|
89
|
+
readonly before: string;
|
|
90
|
+
} | {
|
|
91
|
+
readonly path: string;
|
|
92
|
+
readonly action: 'delete';
|
|
93
|
+
};
|
|
94
|
+
/** Production probe: real reads via node:fs, links detected by lstat + nlink. */
|
|
95
|
+
export declare const defaultProbe: DiskProbe;
|
|
68
96
|
/**
|
|
69
97
|
* On-disk checkpoint store. Every write goes straight through `node:fs`, so a
|
|
70
98
|
* restore reliably lands on the real file system.
|
|
@@ -94,18 +122,49 @@ export declare class SnapshotStore {
|
|
|
94
122
|
* single source of truth for both restore and impact preview.
|
|
95
123
|
*/
|
|
96
124
|
private earliestEntries;
|
|
97
|
-
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
98
|
-
impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
|
|
99
125
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
126
|
+
* The single source of truth for BOTH the impact preview and the restore
|
|
127
|
+
* pass: reconcile the earliest recorded entry per path (at/after the
|
|
128
|
+
* target) against the CURRENT on-disk state, and plan only the actions
|
|
129
|
+
* that would actually change the disk. This is the Claude Code model —
|
|
130
|
+
* `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
|
|
131
|
+
* live filesystem (`checkOriginFileChanged`) and count only real
|
|
132
|
+
* differences, so a rewind whose target state already matches the disk is
|
|
133
|
+
* a no-op with zero impact.
|
|
134
|
+
*
|
|
135
|
+
* - `before === null` (the file did not exist at the target) plans a
|
|
136
|
+
* `delete` ONLY when the file currently exists; an already-absent file
|
|
137
|
+
* is a no-op — this kills the "ghost impact" of replaying an entry a
|
|
138
|
+
* previous rewind already consumed.
|
|
139
|
+
* - `before === 'X'` plans a `restore` ONLY when the current content
|
|
140
|
+
* differs from X (or the file is missing); identical content is a no-op
|
|
141
|
+
* — this keeps repeated rewinds idempotent.
|
|
142
|
+
* - Symlinked / hard-linked paths are never planned (they are reported as
|
|
143
|
+
* skipped by the restore pass, never written through).
|
|
144
|
+
* - A probe failure (e.g. a permission error reading the file) plans the
|
|
145
|
+
* action conservatively as if the file differed, so an unreadable file
|
|
146
|
+
* is never silently dropped from the restore.
|
|
147
|
+
*
|
|
148
|
+
* @param sessionId - session whose snapshot store to plan against.
|
|
149
|
+
* @param targetSeq - rewind target; entries anchored at/after it apply.
|
|
150
|
+
* @param probe - current-disk state probe (defaults to the real FS).
|
|
151
|
+
* @returns the planned actions plus the link paths that were skipped.
|
|
152
|
+
*/
|
|
153
|
+
private planRestore;
|
|
154
|
+
/** Per-file restore impact: only actions that would actually change the disk. */
|
|
155
|
+
impactsAfter(sessionId: string, targetSeq: number, probe?: DiskProbe): Promise<FileImpact[]>;
|
|
156
|
+
/**
|
|
157
|
+
* Restore the workspace to the target message's checkpoint: execute exactly
|
|
158
|
+
* the actions {@link planRestore} derived from the record + current disk
|
|
159
|
+
* reconciliation — write the before content back, or delete the file when
|
|
160
|
+
* it was created after the target and still exists. Symlinked and
|
|
161
|
+
* hard-linked paths are skipped (reported, never written through); a
|
|
162
|
+
* restored file's parent directory is created when it was deleted after
|
|
163
|
+
* the backup; a delete whose file is ALREADY absent is a silent no-op (not
|
|
164
|
+
* a failure — the target state is already reached). Failures are per-file
|
|
165
|
+
* and never abort the pass.
|
|
107
166
|
*/
|
|
108
|
-
restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
|
|
167
|
+
restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe): Promise<RestoreOutcome>;
|
|
109
168
|
/**
|
|
110
169
|
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
111
170
|
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
@@ -113,4 +172,42 @@ export declare class SnapshotStore {
|
|
|
113
172
|
prune(sessionId: string, keep?: number): Promise<void>;
|
|
114
173
|
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
115
174
|
exists(path: string): Promise<boolean>;
|
|
175
|
+
/**
|
|
176
|
+
* All distinct paths ever recorded for a session — the "tracked files"
|
|
177
|
+
* set. Mirrors Claude Code's global `trackedFiles` collection (files stay
|
|
178
|
+
* tracked once a write-class tool touched them), derived from the disk
|
|
179
|
+
* entries so no extra persistence is needed.
|
|
180
|
+
*/
|
|
181
|
+
trackedPaths(sessionId: string): Promise<Set<string>>;
|
|
116
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Re-check every tracked file at a user-message boundary and record the
|
|
185
|
+
* current on-disk state for any file whose state changed since it was last
|
|
186
|
+
* seen — Claude Code's `fileHistoryMakeSnapshot` re-stats every tracked file
|
|
187
|
+
* at each user message and snapshots the new state (changed files get a new
|
|
188
|
+
* backup version, deleted files a null marker). Here the "new version" is a
|
|
189
|
+
* plain before-backup entry anchored at the boundary message, so an EXTERNAL
|
|
190
|
+
* edit or deletion (never seen by the write-class tool capture) enters the
|
|
191
|
+
* record and can be restored by a later rewind.
|
|
192
|
+
*
|
|
193
|
+
* Semantics: the recorded `before` is the file's state at the boundary —
|
|
194
|
+
* the state the boundary message's turn starts from, exactly like the
|
|
195
|
+
* tool-captured entries. An entry is written only when the state differs
|
|
196
|
+
* from the last-seen state (`states`); the FIRST sighting of a path always
|
|
197
|
+
* records (a restart leaves `states` empty, so the first boundary after a
|
|
198
|
+
* restart unconditionally records the current state — redundant but correct,
|
|
199
|
+
* mirroring Claude's resume-then-re-stat behavior).
|
|
200
|
+
*
|
|
201
|
+
* Symlinked / hard-linked paths are never re-checked (restores skip them).
|
|
202
|
+
* A probe failure skips the file with a warning-level no-op; it never
|
|
203
|
+
* aborts the boundary pass.
|
|
204
|
+
*
|
|
205
|
+
* @param store - the session's snapshot store.
|
|
206
|
+
* @param sessionId - session whose tracked files to re-check.
|
|
207
|
+
* @param anchorSeq - the boundary user-message seq (entry anchor).
|
|
208
|
+
* @param tracked - the session's tracked path set (read-only here).
|
|
209
|
+
* @param states - per-path last-seen state (path → content, null = absent).
|
|
210
|
+
* @param probe - current-disk state probe (defaults to the real FS).
|
|
211
|
+
* @returns the number of entries recorded.
|
|
212
|
+
*/
|
|
213
|
+
export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, states: Map<string, string | null>, probe?: DiskProbe): Promise<number>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
83
83
|
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
|
|
84
84
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
85
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
85
86
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
86
87
|
},
|
|
87
88
|
"peerDependenciesMeta": {
|
|
@@ -115,6 +116,9 @@
|
|
|
115
116
|
"@deepseek-ai/dsh-session": {
|
|
116
117
|
"optional": true
|
|
117
118
|
},
|
|
119
|
+
"@deepseek-ai/dsh-settings": {
|
|
120
|
+
"optional": true
|
|
121
|
+
},
|
|
118
122
|
"@deepseek-ai/dsh-tools": {
|
|
119
123
|
"optional": true
|
|
120
124
|
}
|
|
@@ -132,6 +136,7 @@
|
|
|
132
136
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
|
|
133
137
|
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.7",
|
|
134
138
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.7",
|
|
139
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
135
140
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
|
|
136
141
|
"@types/node": "^24.0.0",
|
|
137
142
|
"@types/react": "^18.3.31",
|