dsh-rewind-plugin 0.1.9 → 0.2.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/README.md +46 -46
- package/lib/client.js +99 -41
- package/lib/index.js +200 -157
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/styles.d.ts +2 -1
- package/lib/types/index.d.ts +39 -16
- package/lib/types/session-cwd.d.ts +1 -1
- package/lib/types/snapshot.d.ts +102 -0
- package/package.json +1 -1
- package/scripts/verify-host.mjs +115 -80
- package/lib/types/ledger.d.ts +0 -88
package/lib/index.js
CHANGED
|
@@ -2,101 +2,6 @@
|
|
|
2
2
|
import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
|
|
5
|
-
// src/session-cwd.ts
|
|
6
|
-
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
7
|
-
var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
8
|
-
function sessionCwd(cwd, requestedPath) {
|
|
9
|
-
if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
|
|
10
|
-
return canonicalPath(cwd);
|
|
11
|
-
}
|
|
12
|
-
function execSessionCwd(exec, requestedPath) {
|
|
13
|
-
return sessionCwd(exec.agent?.session.header.cwd, requestedPath);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// src/ledger.ts
|
|
17
|
-
var MAX_LEDGER_ENTRIES = 2e3;
|
|
18
|
-
var RewindLedger = class {
|
|
19
|
-
entries = [];
|
|
20
|
-
/** Record one committed mutation, dropping the oldest entry when over the cap. */
|
|
21
|
-
record(entry) {
|
|
22
|
-
this.entries.push(entry);
|
|
23
|
-
if (this.entries.length > MAX_LEDGER_ENTRIES) this.entries.shift();
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
27
|
-
* is inclusive: rewinding to a message also reverts the changes its own
|
|
28
|
-
* turn caused (the rewind cut removes that turn's assistant response and
|
|
29
|
-
* tool calls), so only changes anchored at earlier messages survive.
|
|
30
|
-
*/
|
|
31
|
-
changesAfter(targetSeq) {
|
|
32
|
-
const after = [];
|
|
33
|
-
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
34
|
-
const entry = this.entries[i];
|
|
35
|
-
if (entry.anchorSeq >= targetSeq) after.push(entry);
|
|
36
|
-
}
|
|
37
|
-
return after;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Unique per-file impact for preview. A file whose earliest affected change
|
|
41
|
-
* created it (`before === undefined`) is deleted on restore; any other file
|
|
42
|
-
* is written back to its pre-target content.
|
|
43
|
-
*/
|
|
44
|
-
impactsAfter(targetSeq) {
|
|
45
|
-
const byPath = /* @__PURE__ */ new Map();
|
|
46
|
-
for (const entry of this.entries) {
|
|
47
|
-
if (entry.anchorSeq < targetSeq) continue;
|
|
48
|
-
if (byPath.has(entry.path)) continue;
|
|
49
|
-
byPath.set(entry.path, { path: entry.path, action: entry.before === void 0 ? "delete" : "restore" });
|
|
50
|
-
}
|
|
51
|
-
return [...byPath.values()];
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Reverse every change anchored at or after `targetSeq`. Each entry writes
|
|
55
|
-
* its pre-change content back; a file that did not exist before the target
|
|
56
|
-
* is deleted instead. Failures are collected per file and never abort the pass.
|
|
57
|
-
* @param fs - the filesystem service (resolve/readText/writeText/processPath).
|
|
58
|
-
* @param deleteFile - backend-appropriate file deletion by process path.
|
|
59
|
-
* @param targetSeq - the rewind target; only later changes are reverted.
|
|
60
|
-
* @param options - session workspace cwd (relative ledger paths resolve
|
|
61
|
-
* against it, mirroring the fs tools) and an optional abort signal.
|
|
62
|
-
*/
|
|
63
|
-
async restoreAfter(fs, deleteFile, targetSeq, options = {}) {
|
|
64
|
-
const restored = [];
|
|
65
|
-
const deleted = [];
|
|
66
|
-
const failed = [];
|
|
67
|
-
const restoredSet = /* @__PURE__ */ new Set();
|
|
68
|
-
const deletedSet = /* @__PURE__ */ new Set();
|
|
69
|
-
for (const entry of this.changesAfter(targetSeq)) {
|
|
70
|
-
try {
|
|
71
|
-
const cwd = sessionCwd(options.cwd, entry.path);
|
|
72
|
-
const target = await fs.resolve(entry.path, {
|
|
73
|
-
...cwd !== void 0 ? { cwd } : {},
|
|
74
|
-
signal: options.signal
|
|
75
|
-
});
|
|
76
|
-
if (entry.before === void 0) {
|
|
77
|
-
await deleteFile(fs.processPath(target));
|
|
78
|
-
if (!deletedSet.has(entry.path)) {
|
|
79
|
-
deletedSet.add(entry.path);
|
|
80
|
-
deleted.push(entry.path);
|
|
81
|
-
}
|
|
82
|
-
} else {
|
|
83
|
-
await fs.writeText(target, entry.before, void 0, options.signal);
|
|
84
|
-
if (!restoredSet.has(entry.path)) {
|
|
85
|
-
restoredSet.add(entry.path);
|
|
86
|
-
restored.push(entry.path);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} catch (error) {
|
|
90
|
-
failed.push({
|
|
91
|
-
path: entry.path,
|
|
92
|
-
message: error instanceof Error ? error.message : String(error)
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return { restored, deleted, failed };
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
5
|
// src/rewind.ts
|
|
101
6
|
var RewindError = class extends Error {
|
|
102
7
|
constructor(code, message) {
|
|
@@ -178,13 +83,181 @@ function planRewind(events, surface, target) {
|
|
|
178
83
|
surfaceEnd: shadowedSeqs[shadowedSeqs.length - 1]
|
|
179
84
|
};
|
|
180
85
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
86
|
+
|
|
87
|
+
// src/session-cwd.ts
|
|
88
|
+
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
89
|
+
var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
90
|
+
function sessionCwd(cwd, requestedPath) {
|
|
91
|
+
if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
|
|
92
|
+
return canonicalPath(cwd);
|
|
93
|
+
}
|
|
94
|
+
function execSessionCwd(exec, requestedPath) {
|
|
95
|
+
return sessionCwd(exec.agent?.session.header.cwd, requestedPath);
|
|
186
96
|
}
|
|
187
97
|
|
|
98
|
+
// src/snapshot.ts
|
|
99
|
+
import { mkdir, readFile, readdir, rm, writeFile, lstat } from "node:fs/promises";
|
|
100
|
+
import { join } from "node:path";
|
|
101
|
+
import { homedir } from "node:os";
|
|
102
|
+
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
103
|
+
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
104
|
+
var MAX_ANCHOR_GROUPS = 100;
|
|
105
|
+
function safeFileId(callId) {
|
|
106
|
+
return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
107
|
+
}
|
|
108
|
+
async function readEntry(file) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
111
|
+
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
112
|
+
return {
|
|
113
|
+
callId: String(parsed.callId ?? ""),
|
|
114
|
+
anchorSeq: parsed.anchorSeq,
|
|
115
|
+
path: parsed.path,
|
|
116
|
+
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
117
|
+
time: typeof parsed.time === "number" ? parsed.time : 0
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return void 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function isSymbolicLink(path) {
|
|
124
|
+
try {
|
|
125
|
+
return (await lstat(path)).isSymbolicLink();
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
var SnapshotStore = class {
|
|
131
|
+
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
132
|
+
this.root = root;
|
|
133
|
+
}
|
|
134
|
+
root;
|
|
135
|
+
/** Absolute path of one anchor group directory. */
|
|
136
|
+
anchorDir(sessionId, anchorSeq) {
|
|
137
|
+
return join(this.root, sessionId, String(anchorSeq));
|
|
138
|
+
}
|
|
139
|
+
/** Commit one before-backup under its turn's anchor group. */
|
|
140
|
+
async recordEntry(sessionId, entry) {
|
|
141
|
+
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
142
|
+
await mkdir(dir, { recursive: true });
|
|
143
|
+
const committed = { ...entry, time: Date.now() };
|
|
144
|
+
await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
|
|
145
|
+
await this.prune(sessionId);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
149
|
+
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
150
|
+
* reverts the changes its own turn caused (the rewind cut removes that
|
|
151
|
+
* turn's assistant response and tool calls), so only entries anchored at
|
|
152
|
+
* earlier messages survive.
|
|
153
|
+
*/
|
|
154
|
+
async entriesAfter(sessionId, targetSeq) {
|
|
155
|
+
const sessionDir = join(this.root, sessionId);
|
|
156
|
+
let names;
|
|
157
|
+
try {
|
|
158
|
+
names = await readdir(sessionDir);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error.code === "ENOENT") return [];
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const entries = [];
|
|
164
|
+
for (const name2 of names) {
|
|
165
|
+
const anchorSeq = Number(name2);
|
|
166
|
+
if (!Number.isSafeInteger(anchorSeq) || anchorSeq < targetSeq) continue;
|
|
167
|
+
const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
if (!file.endsWith(".json")) continue;
|
|
170
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
|
|
171
|
+
if (entry !== void 0) entries.push(entry);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
|
|
175
|
+
}
|
|
176
|
+
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
177
|
+
async impactsAfter(sessionId, targetSeq) {
|
|
178
|
+
const earliest = /* @__PURE__ */ new Map();
|
|
179
|
+
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
180
|
+
const current = earliest.get(entry.path);
|
|
181
|
+
if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
|
|
182
|
+
earliest.set(entry.path, entry);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [...earliest.values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
|
|
186
|
+
path: entry.path,
|
|
187
|
+
action: entry.before === null ? "delete" : "restore"
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Restore the workspace to the target message's checkpoint: for every path
|
|
192
|
+
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
193
|
+
* before content back, or delete the file when it was created after the
|
|
194
|
+
* target. Symbolic links are skipped (reported, never written through).
|
|
195
|
+
* Failures are per-file and never abort the pass.
|
|
196
|
+
*/
|
|
197
|
+
async restoreAfter(sessionId, targetSeq, deleteFile) {
|
|
198
|
+
const restored = [];
|
|
199
|
+
const deleted = [];
|
|
200
|
+
const skipped = [];
|
|
201
|
+
const failed = [];
|
|
202
|
+
const earliest = /* @__PURE__ */ new Map();
|
|
203
|
+
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
204
|
+
const current = earliest.get(entry.path);
|
|
205
|
+
if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
|
|
206
|
+
earliest.set(entry.path, entry);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const entry of earliest.values()) {
|
|
210
|
+
try {
|
|
211
|
+
if (await isSymbolicLink(entry.path)) {
|
|
212
|
+
skipped.push(entry.path);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (entry.before === null) {
|
|
216
|
+
await deleteFile(entry.path);
|
|
217
|
+
deleted.push(entry.path);
|
|
218
|
+
} else {
|
|
219
|
+
await writeFile(entry.path, entry.before, "utf8");
|
|
220
|
+
restored.push(entry.path);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { restored, deleted, skipped, failed };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
230
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
231
|
+
*/
|
|
232
|
+
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
233
|
+
const sessionDir = join(this.root, sessionId);
|
|
234
|
+
let names;
|
|
235
|
+
try {
|
|
236
|
+
names = await readdir(sessionDir);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.code === "ENOENT") return;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
242
|
+
const excess = seqs.length - keep;
|
|
243
|
+
if (excess <= 0) return;
|
|
244
|
+
for (const seq of seqs.slice(0, excess)) {
|
|
245
|
+
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
249
|
+
async exists(path) {
|
|
250
|
+
const { stat } = await import("node:fs/promises");
|
|
251
|
+
try {
|
|
252
|
+
await stat(path);
|
|
253
|
+
return true;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error.code === "ENOENT") return false;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
188
261
|
// src/index.ts
|
|
189
262
|
var name = "dsh-rewind";
|
|
190
263
|
var inject = ["commands", "tools"];
|
|
@@ -192,11 +265,8 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
|
|
|
192
265
|
var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
|
|
193
266
|
var USAGE = [
|
|
194
267
|
"Usage:",
|
|
195
|
-
" /rewind
|
|
196
|
-
"
|
|
197
|
-
" /rewind <\u5E8F\u53F7|@seq> chat rewind the conversation only",
|
|
198
|
-
" /rewind <\u5E8F\u53F7|@seq> both rewind conversation and restore files",
|
|
199
|
-
" /rewind preview <\u76EE\u6807> show the impact list without executing"
|
|
268
|
+
" /rewind \u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F\uFF08\u4E0D\u63A5\u53D7\u53C2\u6570\uFF09",
|
|
269
|
+
" \u56DE\u9000\u5230\u66F4\u65E9\u7684\u6D88\u606F\u8BF7\u4F7F\u7528\u8BE5\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
|
|
200
270
|
].join("\n");
|
|
201
271
|
function mutationPathOf(exec) {
|
|
202
272
|
const args = exec.arguments;
|
|
@@ -243,9 +313,9 @@ async function captureBefore(fs, exec, pending) {
|
|
|
243
313
|
const target = await resolveTarget(fs, path, cwd, exec.signal);
|
|
244
314
|
if (target === void 0) return;
|
|
245
315
|
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
246
|
-
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath,
|
|
316
|
+
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
|
|
247
317
|
}
|
|
248
|
-
async function commitEntry(
|
|
318
|
+
async function commitEntry(store, pending, exec, result) {
|
|
249
319
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
250
320
|
const capture = pending.get(key);
|
|
251
321
|
if (capture === void 0) return;
|
|
@@ -255,20 +325,11 @@ async function commitEntry(fs, ledgerFor, pending, exec, result) {
|
|
|
255
325
|
if (agent === void 0) return;
|
|
256
326
|
const anchorSeq = anchorSeqOf(agent.session);
|
|
257
327
|
if (anchorSeq === void 0) return;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
let after;
|
|
261
|
-
try {
|
|
262
|
-
after = await readTextOrUndefined(fs, target, exec.signal) ?? "";
|
|
263
|
-
} catch {
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
ledgerFor(agent.session).record({
|
|
267
|
-
toolName: exec.name,
|
|
328
|
+
await store.recordEntry(agent.session.id, {
|
|
329
|
+
callId: exec.callId,
|
|
268
330
|
anchorSeq,
|
|
269
331
|
path: capture.path,
|
|
270
|
-
before: capture.before
|
|
271
|
-
after
|
|
332
|
+
before: capture.before ?? null
|
|
272
333
|
});
|
|
273
334
|
}
|
|
274
335
|
function buildMarker() {
|
|
@@ -299,7 +360,7 @@ function formatPlan(plan, files) {
|
|
|
299
360
|
lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
|
|
300
361
|
}
|
|
301
362
|
} else {
|
|
302
|
-
lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\
|
|
363
|
+
lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u5FEB\u7167\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
|
|
303
364
|
}
|
|
304
365
|
return lines.join("\n");
|
|
305
366
|
}
|
|
@@ -322,7 +383,7 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
322
383
|
}
|
|
323
384
|
return true;
|
|
324
385
|
}
|
|
325
|
-
async function executeRewind(ctx,
|
|
386
|
+
async function executeRewind(ctx, store, invocation, rawTarget, mode) {
|
|
326
387
|
const { agent } = invocation;
|
|
327
388
|
if (agent.status !== "idle") {
|
|
328
389
|
agent.cancel({ kind: "user" });
|
|
@@ -352,18 +413,13 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
|
352
413
|
}
|
|
353
414
|
let restore = "";
|
|
354
415
|
if (mode === "both") {
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
cwd: agent.session.header.cwd,
|
|
363
|
-
signal: invocation.signal
|
|
364
|
-
});
|
|
365
|
-
restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
|
|
366
|
-
}
|
|
416
|
+
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
417
|
+
const parts = [];
|
|
418
|
+
if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
|
|
419
|
+
if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
|
|
420
|
+
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u7B26\u53F7\u94FE\u63A5`);
|
|
421
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
|
|
422
|
+
restore += renderFailures(outcome.failed);
|
|
367
423
|
}
|
|
368
424
|
return {
|
|
369
425
|
kind: "success",
|
|
@@ -383,21 +439,15 @@ function rewindErrorResult(error) {
|
|
|
383
439
|
}
|
|
384
440
|
throw error;
|
|
385
441
|
}
|
|
386
|
-
async function handleRewind(ctx,
|
|
442
|
+
async function handleRewind(ctx, store, invocation) {
|
|
387
443
|
const session = invocation.agent.session;
|
|
388
444
|
const input = invocation.rawInput.trim();
|
|
389
445
|
if (input === "") {
|
|
390
|
-
const candidates = listRewindCandidates(session.events, session.surface.nodes);
|
|
446
|
+
const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
|
|
391
447
|
if (candidates.length === 0) {
|
|
392
448
|
return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
|
|
393
449
|
}
|
|
394
|
-
return {
|
|
395
|
-
kind: "success",
|
|
396
|
-
text: `\u9009\u62E9\u8981\u56DE\u9000\u5230\u7684\u7528\u6237\u6D88\u606F\uFF08\u6700\u8FD1\u5728\u4E0A\uFF09\uFF1A
|
|
397
|
-
${candidates.map(formatCandidate).join("\n")}
|
|
398
|
-
|
|
399
|
-
\u7EE7\u7EED\uFF1A/rewind <\u5E8F\u53F7|@seq>\uFF0C\u6216\u76F4\u63A5 /rewind <\u5E8F\u53F7|@seq> chat|both \u6267\u884C\u3002`
|
|
400
|
-
};
|
|
450
|
+
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
|
|
401
451
|
}
|
|
402
452
|
const parts = input.split(/\s+/);
|
|
403
453
|
if (parts[0] === "preview") {
|
|
@@ -409,7 +459,7 @@ ${candidates.map(formatCandidate).join("\n")}
|
|
|
409
459
|
} catch (error) {
|
|
410
460
|
return rewindErrorResult(error);
|
|
411
461
|
}
|
|
412
|
-
const impacts =
|
|
462
|
+
const impacts = await store.impactsAfter(session.id, plan.targetSeq);
|
|
413
463
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
414
464
|
}
|
|
415
465
|
const target = parts[0];
|
|
@@ -427,24 +477,16 @@ ${candidates.map(formatCandidate).join("\n")}
|
|
|
427
477
|
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
428
478
|
};
|
|
429
479
|
}
|
|
430
|
-
return executeRewind(ctx,
|
|
480
|
+
return executeRewind(ctx, store, invocation, target, mode);
|
|
431
481
|
}
|
|
432
|
-
function apply(ctx) {
|
|
433
|
-
const
|
|
434
|
-
const ledgerFor = (session) => {
|
|
435
|
-
let ledger = ledgers.get(session.id);
|
|
436
|
-
if (ledger === void 0) {
|
|
437
|
-
ledger = new RewindLedger();
|
|
438
|
-
ledgers.set(session.id, ledger);
|
|
439
|
-
}
|
|
440
|
-
return ledger;
|
|
441
|
-
};
|
|
482
|
+
function apply(ctx, config) {
|
|
483
|
+
const store = new SnapshotStore(config?.snapshotDir);
|
|
442
484
|
const pending = /* @__PURE__ */ new Map();
|
|
443
485
|
ctx.effect(function* () {
|
|
444
486
|
yield ctx.commands.register({
|
|
445
487
|
name: "rewind",
|
|
446
488
|
description: "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
|
|
447
|
-
handler: (invocation) => handleRewind(ctx,
|
|
489
|
+
handler: (invocation) => handleRewind(ctx, store, invocation)
|
|
448
490
|
});
|
|
449
491
|
}, "dsh-rewind command");
|
|
450
492
|
ctx.inject(["fs"], (scope) => {
|
|
@@ -459,15 +501,16 @@ function apply(ctx) {
|
|
|
459
501
|
});
|
|
460
502
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
461
503
|
try {
|
|
462
|
-
await commitEntry(
|
|
504
|
+
await commitEntry(store, pending, exec, result);
|
|
463
505
|
} catch (error) {
|
|
464
|
-
ctx.logger.warn(`[dsh-rewind]
|
|
506
|
+
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
465
507
|
}
|
|
466
508
|
return next();
|
|
467
509
|
});
|
|
468
510
|
});
|
|
469
511
|
}
|
|
470
512
|
export {
|
|
513
|
+
SnapshotStore,
|
|
471
514
|
apply,
|
|
472
515
|
inject,
|
|
473
516
|
name
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* before executing. Execution always goes through `session.command(...)`, the
|
|
17
17
|
* same host path the `/rewind` command uses.
|
|
18
18
|
*
|
|
19
|
+
* Manual composer input of `/rewind` is deliberately blocked (the guard
|
|
20
|
+
* below): the command exists only as the per-message button's internal
|
|
21
|
+
* channel, so any `/rewind` line typed by hand — bare or with arguments — is
|
|
22
|
+
* stopped with a hint pointing at the button.
|
|
23
|
+
*
|
|
19
24
|
* @module dsh-rewind/client
|
|
20
25
|
*/
|
|
21
26
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
@@ -15,6 +15,7 @@ export declare const zh: {
|
|
|
15
15
|
'popover.impact.none': string;
|
|
16
16
|
'popover.confirm': string;
|
|
17
17
|
'popover.back': string;
|
|
18
|
+
'guard.hint': string;
|
|
18
19
|
};
|
|
19
20
|
/** The rewind namespace key union. */
|
|
20
21
|
export type RewindKey = keyof typeof zh;
|
|
@@ -40,4 +41,5 @@ export declare const en: {
|
|
|
40
41
|
'popover.impact.none': string;
|
|
41
42
|
'popover.confirm': string;
|
|
42
43
|
'popover.back': string;
|
|
44
|
+
'guard.hint': string;
|
|
43
45
|
};
|
|
@@ -20,8 +20,9 @@ export declare const CLASS: {
|
|
|
20
20
|
readonly popoverActions: 'dsh-rewind-popover-actions';
|
|
21
21
|
readonly popoverPrimary: 'dsh-rewind-popover-primary';
|
|
22
22
|
readonly popoverGhost: 'dsh-rewind-popover-ghost';
|
|
23
|
+
readonly guardHint: 'dsh-rewind-guard-hint';
|
|
23
24
|
};
|
|
24
25
|
/** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
|
|
25
26
|
export declare const REWIND_ICON_SVG: string;
|
|
26
27
|
/** One injected stylesheet (scoped under `.dsh-rewind-*`). */
|
|
27
|
-
export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n";
|
|
28
|
+
export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border-radius: 10px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,35 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-rewind host half: the `/rewind` command and the
|
|
3
|
-
*
|
|
4
|
-
* `src/client/`).
|
|
2
|
+
* dsh-rewind host half: the `/rewind` command and the Claude-Code-style
|
|
3
|
+
* checkpoint store, composed as one dual-face bundle row (the browser half
|
|
4
|
+
* lives in `src/client/`).
|
|
5
5
|
*
|
|
6
6
|
* Rewind mechanism: planning is pure (`src/rewind.ts`); execution appends a
|
|
7
|
-
* marker `
|
|
8
|
-
* surface node after the target message with the marker. The
|
|
9
|
-
* (and the rendered transcript) is untouched — only the
|
|
10
|
-
* is cut, so the next request derives its context from
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* marker `assistant/message` into the session log whose `surfaceOp` replaces
|
|
8
|
+
* every surface node after the target message with the marker. The
|
|
9
|
+
* append-only log (and the rendered transcript) is untouched — only the
|
|
10
|
+
* model-visible surface is cut, so the next request derives its context from
|
|
11
|
+
* the target onward.
|
|
12
|
+
*
|
|
13
|
+
* File restore (mode `both`) follows Claude Code's checkpointing: the plugin
|
|
14
|
+
* backs up each tracked write-class edit BEFORE it happens (at the
|
|
15
|
+
* `tools/execute` around-dispatch stage, so an approval short-circuit cannot
|
|
16
|
+
* skip the capture and a denied call never records), commits the backup under
|
|
17
|
+
* the turn's anchor message seq at `tools/post-execute`, and a rewind to
|
|
18
|
+
* message N restores every backup anchored at or after N — modified files are
|
|
19
|
+
* written back to their pre-edit content, files created after N are deleted.
|
|
20
|
+
* Backups persist on disk under the dsh data directory (newest 100 message
|
|
21
|
+
* groups per session), so restores work after a host restart, and they
|
|
22
|
+
* read/write the real file system with plain `node:fs` — independent of the
|
|
23
|
+
* fs service. See `src/snapshot.ts`.
|
|
13
24
|
*
|
|
14
25
|
* @module dsh-rewind
|
|
15
26
|
*/
|
|
16
27
|
import type { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
export { SnapshotStore } from './snapshot.ts';
|
|
29
|
+
export type { CheckpointEntry, FileImpact, RestoreOutcome } from './snapshot.ts';
|
|
17
30
|
export declare const name = "dsh-rewind";
|
|
18
31
|
export declare const inject: string[];
|
|
32
|
+
/** Plugin config: optional override of the checkpoint store root. */
|
|
33
|
+
export interface RewindConfig {
|
|
34
|
+
/** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
|
|
35
|
+
readonly snapshotDir?: string;
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
|
-
* Register the `/rewind` command and the
|
|
38
|
+
* Register the `/rewind` command and the checkpoint pipeline (before-capture
|
|
39
|
+
* at `tools/execute`, disk commit at `tools/post-execute`).
|
|
21
40
|
*
|
|
22
|
-
* The command is fs-independent and registers immediately. The
|
|
23
|
-
* `fs
|
|
24
|
-
*
|
|
25
|
-
*
|
|
41
|
+
* The command is fs-independent and registers immediately. The checkpoint
|
|
42
|
+
* pipeline needs `fs` to resolve tracked paths to their real display paths,
|
|
43
|
+
* so it mounts through a dynamic `ctx.inject(['fs'])` — it takes effect
|
|
44
|
+
* whenever the fs service becomes available (and never fails the plugin's
|
|
45
|
+
* load when a deployment has no fs; without it, no entries are recorded and
|
|
46
|
+
* `both` restores report "no tracked changes").
|
|
26
47
|
*
|
|
27
48
|
* Capture runs in `tools/execute` (the around-dispatch stage), NOT in
|
|
28
49
|
* `tools/pre-execute`: a pre-execute `{ kind: 'ask' }` short-circuit from
|
|
29
50
|
* another plugin (e.g. dsh-edit-approval) skips later pre-execute listeners,
|
|
30
51
|
* and a denied call never dispatches — so approved calls are still captured,
|
|
31
|
-
* denied calls never leave a pending entry behind.
|
|
52
|
+
* denied calls never leave a pending entry behind. Entries are committed to
|
|
53
|
+
* disk at `tools/post-execute` under the turn's anchor message seq.
|
|
32
54
|
*
|
|
33
55
|
* @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
|
|
56
|
+
* @param config - optional override of the checkpoint store root.
|
|
34
57
|
*/
|
|
35
|
-
export declare function apply(ctx: Context): void;
|
|
58
|
+
export declare function apply(ctx: Context, config?: RewindConfig): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session-cwd resolution for
|
|
2
|
+
* Session-cwd resolution for snapshot tracking reads, mirroring the fs tools'
|
|
3
3
|
* own rule (`@deepseek-ai/dsh-tool-fs/session-cwd.ts`): relative paths
|
|
4
4
|
* resolve against the calling agent's session workspace
|
|
5
5
|
* (`exec.agent.session.header.cwd`), not the server's launch dir.
|