dsh-rewind-plugin 0.1.10 → 0.2.1
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 +182 -174
- package/README.zh.md +214 -0
- package/assets/screenshots/guard-hint.png +0 -0
- 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/docs/harness-reference.md +34 -0
- package/lib/client.js +139 -95
- package/lib/index.js +237 -148
- package/lib/types/client/hidden.d.ts +33 -0
- package/lib/types/client/index.d.ts +4 -4
- package/lib/types/client/locales.d.ts +6 -2
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/index.d.ts +39 -16
- package/lib/types/session-cwd.d.ts +1 -1
- package/lib/types/snapshot.d.ts +116 -0
- package/package.json +4 -3
- package/lib/types/ledger.d.ts +0 -88
- package/scripts/build.mjs +0 -89
- package/scripts/verify-host.mjs +0 -179
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) {
|
|
@@ -179,6 +84,198 @@ function planRewind(events, surface, target) {
|
|
|
179
84
|
};
|
|
180
85
|
}
|
|
181
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);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/snapshot.ts
|
|
99
|
+
import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
100
|
+
import { dirname, 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
|
+
function safeSessionId(sessionId) {
|
|
109
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
110
|
+
return safe === ".." || safe === "." ? "session" : safe;
|
|
111
|
+
}
|
|
112
|
+
async function readEntry(file) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
115
|
+
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
116
|
+
return {
|
|
117
|
+
callId: String(parsed.callId ?? ""),
|
|
118
|
+
anchorSeq: parsed.anchorSeq,
|
|
119
|
+
path: parsed.path,
|
|
120
|
+
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
121
|
+
time: typeof parsed.time === "number" ? parsed.time : 0
|
|
122
|
+
};
|
|
123
|
+
} catch {
|
|
124
|
+
return void 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function isLinkPath(path) {
|
|
128
|
+
try {
|
|
129
|
+
const stat2 = await lstat(path);
|
|
130
|
+
return stat2.isSymbolicLink() || stat2.nlink > 1;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
var SnapshotStore = class _SnapshotStore {
|
|
136
|
+
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
137
|
+
this.root = root;
|
|
138
|
+
}
|
|
139
|
+
root;
|
|
140
|
+
/** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
|
|
141
|
+
static PRUNE_INTERVAL_MS = 1e3;
|
|
142
|
+
lastPruneAt = 0;
|
|
143
|
+
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
144
|
+
sessionDir(sessionId) {
|
|
145
|
+
return join(this.root, safeSessionId(sessionId));
|
|
146
|
+
}
|
|
147
|
+
/** Absolute path of one anchor group directory. */
|
|
148
|
+
anchorDir(sessionId, anchorSeq) {
|
|
149
|
+
return join(this.sessionDir(sessionId), String(anchorSeq));
|
|
150
|
+
}
|
|
151
|
+
/** Commit one before-backup under its turn's anchor group. */
|
|
152
|
+
async recordEntry(sessionId, entry) {
|
|
153
|
+
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
154
|
+
await mkdir(dir, { recursive: true });
|
|
155
|
+
const committed = { ...entry, time: Date.now() };
|
|
156
|
+
await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
|
|
159
|
+
this.lastPruneAt = now;
|
|
160
|
+
await this.prune(sessionId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
165
|
+
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
166
|
+
* reverts the changes its own turn caused (the rewind cut removes that
|
|
167
|
+
* turn's assistant response and tool calls), so only entries anchored at
|
|
168
|
+
* earlier messages survive.
|
|
169
|
+
*/
|
|
170
|
+
async entriesAfter(sessionId, targetSeq) {
|
|
171
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
172
|
+
let names;
|
|
173
|
+
try {
|
|
174
|
+
names = await readdir(sessionDir);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error.code === "ENOENT") return [];
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
const entries = [];
|
|
180
|
+
for (const name2 of names) {
|
|
181
|
+
const anchorSeq = Number(name2);
|
|
182
|
+
if (!Number.isSafeInteger(anchorSeq) || anchorSeq < targetSeq) continue;
|
|
183
|
+
const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
if (!file.endsWith(".json")) continue;
|
|
186
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
|
|
187
|
+
if (entry !== void 0) entries.push(entry);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Per-path EARLIEST committed entry anchored at or after the target — the
|
|
194
|
+
* single source of truth for both restore and impact preview.
|
|
195
|
+
*/
|
|
196
|
+
async earliestEntries(sessionId, targetSeq) {
|
|
197
|
+
const earliest = /* @__PURE__ */ new Map();
|
|
198
|
+
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
199
|
+
const current = earliest.get(entry.path);
|
|
200
|
+
if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
|
|
201
|
+
earliest.set(entry.path, entry);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return earliest;
|
|
205
|
+
}
|
|
206
|
+
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
207
|
+
async impactsAfter(sessionId, targetSeq) {
|
|
208
|
+
return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
|
|
209
|
+
path: entry.path,
|
|
210
|
+
action: entry.before === null ? "delete" : "restore"
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Restore the workspace to the target message's checkpoint: for every path
|
|
215
|
+
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
216
|
+
* before content back, or delete the file when it was created after the
|
|
217
|
+
* target. Symlinked and hard-linked paths are skipped (reported, never
|
|
218
|
+
* written through); a restored file's parent directory is created when it
|
|
219
|
+
* was deleted after the backup. Failures are per-file and never abort the
|
|
220
|
+
* pass.
|
|
221
|
+
*/
|
|
222
|
+
async restoreAfter(sessionId, targetSeq, deleteFile) {
|
|
223
|
+
const restored = [];
|
|
224
|
+
const deleted = [];
|
|
225
|
+
const skipped = [];
|
|
226
|
+
const failed = [];
|
|
227
|
+
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
228
|
+
try {
|
|
229
|
+
if (await isLinkPath(entry.path)) {
|
|
230
|
+
skipped.push(entry.path);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (entry.before === null) {
|
|
234
|
+
await deleteFile(entry.path);
|
|
235
|
+
deleted.push(entry.path);
|
|
236
|
+
} else {
|
|
237
|
+
await mkdir(dirname(entry.path), { recursive: true });
|
|
238
|
+
await writeFile(entry.path, entry.before, "utf8");
|
|
239
|
+
restored.push(entry.path);
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { restored, deleted, skipped, failed };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
249
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
250
|
+
*/
|
|
251
|
+
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
252
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
253
|
+
let names;
|
|
254
|
+
try {
|
|
255
|
+
names = await readdir(sessionDir);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error.code === "ENOENT") return;
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
261
|
+
const excess = seqs.length - keep;
|
|
262
|
+
if (excess <= 0) return;
|
|
263
|
+
for (const seq of seqs.slice(0, excess)) {
|
|
264
|
+
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
268
|
+
async exists(path) {
|
|
269
|
+
try {
|
|
270
|
+
await stat(path);
|
|
271
|
+
return true;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error.code === "ENOENT") return false;
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
182
279
|
// src/index.ts
|
|
183
280
|
var name = "dsh-rewind";
|
|
184
281
|
var inject = ["commands", "tools"];
|
|
@@ -186,8 +283,9 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
|
|
|
186
283
|
var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
|
|
187
284
|
var USAGE = [
|
|
188
285
|
"Usage:",
|
|
189
|
-
" /rewind
|
|
190
|
-
" \u56DE\u9000\u5230\
|
|
286
|
+
" /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
|
|
287
|
+
" /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
|
|
288
|
+
" \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
|
|
191
289
|
].join("\n");
|
|
192
290
|
function mutationPathOf(exec) {
|
|
193
291
|
const args = exec.arguments;
|
|
@@ -200,12 +298,19 @@ function mutationPathOf(exec) {
|
|
|
200
298
|
}
|
|
201
299
|
return void 0;
|
|
202
300
|
}
|
|
203
|
-
function anchorSeqOf(session) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
301
|
+
function anchorSeqOf(session, cache) {
|
|
302
|
+
const events = session.events;
|
|
303
|
+
const cached = cache.get(session.id);
|
|
304
|
+
if (cached !== void 0 && cached.eventsLength === events.length) return cached.anchor;
|
|
305
|
+
let anchor = cached?.anchor;
|
|
306
|
+
for (let i = events.length - 1; i >= (cached?.eventsLength ?? 0); i--) {
|
|
307
|
+
if (events[i].type === "user/message") {
|
|
308
|
+
anchor = events[i].seq;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
207
311
|
}
|
|
208
|
-
|
|
312
|
+
cache.set(session.id, { anchor, eventsLength: events.length });
|
|
313
|
+
return anchor;
|
|
209
314
|
}
|
|
210
315
|
async function resolveTarget(fs, path, cwd, signal) {
|
|
211
316
|
try {
|
|
@@ -234,9 +339,9 @@ async function captureBefore(fs, exec, pending) {
|
|
|
234
339
|
const target = await resolveTarget(fs, path, cwd, exec.signal);
|
|
235
340
|
if (target === void 0) return;
|
|
236
341
|
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
237
|
-
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath,
|
|
342
|
+
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
|
|
238
343
|
}
|
|
239
|
-
async function commitEntry(
|
|
344
|
+
async function commitEntry(store, pending, anchorCache, exec, result) {
|
|
240
345
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
241
346
|
const capture = pending.get(key);
|
|
242
347
|
if (capture === void 0) return;
|
|
@@ -244,22 +349,13 @@ async function commitEntry(fs, ledgerFor, pending, exec, result) {
|
|
|
244
349
|
if (result.isError) return;
|
|
245
350
|
const agent = exec.agent;
|
|
246
351
|
if (agent === void 0) return;
|
|
247
|
-
const anchorSeq = anchorSeqOf(agent.session);
|
|
352
|
+
const anchorSeq = anchorSeqOf(agent.session, anchorCache);
|
|
248
353
|
if (anchorSeq === void 0) return;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let after;
|
|
252
|
-
try {
|
|
253
|
-
after = await readTextOrUndefined(fs, target, exec.signal) ?? "";
|
|
254
|
-
} catch {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
ledgerFor(agent.session).record({
|
|
258
|
-
toolName: exec.name,
|
|
354
|
+
await store.recordEntry(agent.session.id, {
|
|
355
|
+
callId: exec.callId,
|
|
259
356
|
anchorSeq,
|
|
260
357
|
path: capture.path,
|
|
261
|
-
before: capture.before
|
|
262
|
-
after
|
|
358
|
+
before: capture.before ?? null
|
|
263
359
|
});
|
|
264
360
|
}
|
|
265
361
|
function buildMarker() {
|
|
@@ -290,7 +386,7 @@ function formatPlan(plan, files) {
|
|
|
290
386
|
lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
|
|
291
387
|
}
|
|
292
388
|
} else {
|
|
293
|
-
lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\
|
|
389
|
+
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");
|
|
294
390
|
}
|
|
295
391
|
return lines.join("\n");
|
|
296
392
|
}
|
|
@@ -313,7 +409,7 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
313
409
|
}
|
|
314
410
|
return true;
|
|
315
411
|
}
|
|
316
|
-
async function executeRewind(ctx,
|
|
412
|
+
async function executeRewind(ctx, store, invocation, rawTarget, mode) {
|
|
317
413
|
const { agent } = invocation;
|
|
318
414
|
if (agent.status !== "idle") {
|
|
319
415
|
agent.cancel({ kind: "user" });
|
|
@@ -343,18 +439,13 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
|
343
439
|
}
|
|
344
440
|
let restore = "";
|
|
345
441
|
if (mode === "both") {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
cwd: agent.session.header.cwd,
|
|
354
|
-
signal: invocation.signal
|
|
355
|
-
});
|
|
356
|
-
restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
|
|
357
|
-
}
|
|
442
|
+
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
443
|
+
const parts = [];
|
|
444
|
+
if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
|
|
445
|
+
if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
|
|
446
|
+
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
|
|
447
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
|
|
448
|
+
restore += renderFailures(outcome.failed);
|
|
358
449
|
}
|
|
359
450
|
return {
|
|
360
451
|
kind: "success",
|
|
@@ -374,7 +465,7 @@ function rewindErrorResult(error) {
|
|
|
374
465
|
}
|
|
375
466
|
throw error;
|
|
376
467
|
}
|
|
377
|
-
async function handleRewind(ctx,
|
|
468
|
+
async function handleRewind(ctx, store, invocation) {
|
|
378
469
|
const session = invocation.agent.session;
|
|
379
470
|
const input = invocation.rawInput.trim();
|
|
380
471
|
if (input === "") {
|
|
@@ -382,7 +473,7 @@ async function handleRewind(ctx, ledger, invocation) {
|
|
|
382
473
|
if (candidates.length === 0) {
|
|
383
474
|
return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
|
|
384
475
|
}
|
|
385
|
-
return executeRewind(ctx,
|
|
476
|
+
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
|
|
386
477
|
}
|
|
387
478
|
const parts = input.split(/\s+/);
|
|
388
479
|
if (parts[0] === "preview") {
|
|
@@ -394,7 +485,7 @@ async function handleRewind(ctx, ledger, invocation) {
|
|
|
394
485
|
} catch (error) {
|
|
395
486
|
return rewindErrorResult(error);
|
|
396
487
|
}
|
|
397
|
-
const impacts =
|
|
488
|
+
const impacts = await store.impactsAfter(session.id, plan.targetSeq);
|
|
398
489
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
399
490
|
}
|
|
400
491
|
const target = parts[0];
|
|
@@ -412,24 +503,17 @@ async function handleRewind(ctx, ledger, invocation) {
|
|
|
412
503
|
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
413
504
|
};
|
|
414
505
|
}
|
|
415
|
-
return executeRewind(ctx,
|
|
506
|
+
return executeRewind(ctx, store, invocation, target, mode);
|
|
416
507
|
}
|
|
417
|
-
function apply(ctx) {
|
|
418
|
-
const
|
|
419
|
-
const ledgerFor = (session) => {
|
|
420
|
-
let ledger = ledgers.get(session.id);
|
|
421
|
-
if (ledger === void 0) {
|
|
422
|
-
ledger = new RewindLedger();
|
|
423
|
-
ledgers.set(session.id, ledger);
|
|
424
|
-
}
|
|
425
|
-
return ledger;
|
|
426
|
-
};
|
|
508
|
+
function apply(ctx, config) {
|
|
509
|
+
const store = new SnapshotStore(config?.snapshotDir);
|
|
427
510
|
const pending = /* @__PURE__ */ new Map();
|
|
511
|
+
const anchorCache = /* @__PURE__ */ new Map();
|
|
428
512
|
ctx.effect(function* () {
|
|
429
513
|
yield ctx.commands.register({
|
|
430
514
|
name: "rewind",
|
|
431
515
|
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",
|
|
432
|
-
handler: (invocation) => handleRewind(ctx,
|
|
516
|
+
handler: (invocation) => handleRewind(ctx, store, invocation)
|
|
433
517
|
});
|
|
434
518
|
}, "dsh-rewind command");
|
|
435
519
|
ctx.inject(["fs"], (scope) => {
|
|
@@ -444,15 +528,20 @@ function apply(ctx) {
|
|
|
444
528
|
});
|
|
445
529
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
446
530
|
try {
|
|
447
|
-
await commitEntry(
|
|
531
|
+
await commitEntry(store, pending, anchorCache, exec, result);
|
|
448
532
|
} catch (error) {
|
|
449
|
-
ctx.logger.warn(`[dsh-rewind]
|
|
533
|
+
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
450
534
|
}
|
|
451
535
|
return next();
|
|
452
536
|
});
|
|
537
|
+
scope.on("tools/result", (exec) => {
|
|
538
|
+
pending.delete(`${exec.agent?.id ?? "anon"}:${exec.callId}`);
|
|
539
|
+
return void 0;
|
|
540
|
+
});
|
|
453
541
|
});
|
|
454
542
|
}
|
|
455
543
|
export {
|
|
544
|
+
SnapshotStore,
|
|
456
545
|
apply,
|
|
457
546
|
inject,
|
|
458
547
|
name
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure computation of the chat rows a rewind hides from the rendered
|
|
3
|
+
* transcript. Extracted from the client plugin (`src/client/index.ts`) so the
|
|
4
|
+
* multi-rewind cut logic stays unit-testable without a DOM.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-rewind/client/hidden
|
|
7
|
+
*/
|
|
8
|
+
import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
9
|
+
/** Minimal chat snapshot reader the hiding logic needs. */
|
|
10
|
+
export interface HiddenChat {
|
|
11
|
+
readonly order: readonly string[];
|
|
12
|
+
readonly nodes: {
|
|
13
|
+
get(key: string): ChatConversationViewNode | undefined;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
|
|
17
|
+
export declare function targetOfOutcome(text: string | undefined): number | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Anchor seqs that must be hidden from the rendered transcript so the user
|
|
20
|
+
* sees the conversation as the agent sees it: every EXECUTED `/rewind`
|
|
21
|
+
* command row (one that appended a marker; preview-only rows stay visible
|
|
22
|
+
* until a real rewind's range covers them) and every message withdrawn by a
|
|
23
|
+
* rewind — the target message itself, everything after it, and the (empty,
|
|
24
|
+
* unrendered) marker.
|
|
25
|
+
*
|
|
26
|
+
* The cut span is [min target, max marker] across ALL executed rewinds, not
|
|
27
|
+
* just the newest one: every rewind withdraws its target and everything after
|
|
28
|
+
* it, and a later rewind to a LATER point (after new traffic) must not re-show
|
|
29
|
+
* rows an earlier rewind already cut. Endpoints come from the command nodes:
|
|
30
|
+
* `sourceEventSeq` is the marker's log seq, and the outcome text carries the
|
|
31
|
+
* target seq.
|
|
32
|
+
*/
|
|
33
|
+
export declare function hiddenSeqsOf(snap: HiddenChat): Set<number>;
|
|
@@ -16,10 +16,10 @@
|
|
|
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
|
|
20
|
-
* below):
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
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
23
|
*
|
|
24
24
|
* @module dsh-rewind/client
|
|
25
25
|
*/
|
|
@@ -4,11 +4,13 @@ export declare const zh: {
|
|
|
4
4
|
'button.aria': string;
|
|
5
5
|
'button.title': string;
|
|
6
6
|
'popover.title': string;
|
|
7
|
-
'popover.
|
|
7
|
+
'popover.noText': string;
|
|
8
8
|
'popover.chat': string;
|
|
9
9
|
'popover.chat.hint': string;
|
|
10
10
|
'popover.both': string;
|
|
11
11
|
'popover.both.hint': string;
|
|
12
|
+
'popover.checking': string;
|
|
13
|
+
'popover.noChanges': string;
|
|
12
14
|
'popover.cancel': string;
|
|
13
15
|
'popover.impact.loading': string;
|
|
14
16
|
'popover.impact.failed': string;
|
|
@@ -30,11 +32,13 @@ export declare const en: {
|
|
|
30
32
|
'button.aria': string;
|
|
31
33
|
'button.title': string;
|
|
32
34
|
'popover.title': string;
|
|
33
|
-
'popover.
|
|
35
|
+
'popover.noText': string;
|
|
34
36
|
'popover.chat': string;
|
|
35
37
|
'popover.chat.hint': string;
|
|
36
38
|
'popover.both': string;
|
|
37
39
|
'popover.both.hint': string;
|
|
40
|
+
'popover.checking': string;
|
|
41
|
+
'popover.noChanges': string;
|
|
38
42
|
'popover.cancel': string;
|
|
39
43
|
'popover.impact.loading': string;
|
|
40
44
|
'popover.impact.failed': string;
|
|
@@ -25,4 +25,4 @@ export declare const CLASS: {
|
|
|
25
25
|
/** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
|
|
26
26
|
export declare const REWIND_ICON_SVG: string;
|
|
27
27
|
/** One injected stylesheet (scoped under `.dsh-rewind-*`). */
|
|
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-
|
|
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: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\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:disabled {\n opacity: 0.5;\n cursor: default;\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-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\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: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
|