dsh-rewind-plugin 0.1.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/LICENSE +21 -0
- package/README.md +198 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +469 -0
- package/lib/index.js +461 -0
- package/package.json +103 -0
- package/scripts/build.mjs +80 -0
- package/scripts/verify-host.mjs +159 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
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 RewindLedger = class {
|
|
18
|
+
entries = [];
|
|
19
|
+
/** Record one committed mutation. */
|
|
20
|
+
record(entry) {
|
|
21
|
+
this.entries.push(entry);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
25
|
+
* is inclusive: rewinding to a message also reverts the changes its own
|
|
26
|
+
* turn caused (the rewind cut removes that turn's assistant response and
|
|
27
|
+
* tool calls), so only changes anchored at earlier messages survive.
|
|
28
|
+
*/
|
|
29
|
+
changesAfter(targetSeq) {
|
|
30
|
+
const after = [];
|
|
31
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
32
|
+
const entry = this.entries[i];
|
|
33
|
+
if (entry.anchorSeq >= targetSeq) after.push(entry);
|
|
34
|
+
}
|
|
35
|
+
return after;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Unique per-file impact for preview. A file whose earliest affected change
|
|
39
|
+
* created it (`before === undefined`) is deleted on restore; any other file
|
|
40
|
+
* is written back to its pre-target content.
|
|
41
|
+
*/
|
|
42
|
+
impactsAfter(targetSeq) {
|
|
43
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const entry of this.entries) {
|
|
45
|
+
if (entry.anchorSeq < targetSeq) continue;
|
|
46
|
+
if (byPath.has(entry.path)) continue;
|
|
47
|
+
byPath.set(entry.path, { path: entry.path, action: entry.before === void 0 ? "delete" : "restore" });
|
|
48
|
+
}
|
|
49
|
+
return [...byPath.values()];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reverse every change anchored at or after `targetSeq`. Each entry writes
|
|
53
|
+
* its pre-change content back; a file that did not exist before the target
|
|
54
|
+
* is deleted instead. Failures are collected per file and never abort the pass.
|
|
55
|
+
* @param fs - the filesystem service (resolve/readText/writeText/processPath).
|
|
56
|
+
* @param deleteFile - backend-appropriate file deletion by process path.
|
|
57
|
+
* @param targetSeq - the rewind target; only later changes are reverted.
|
|
58
|
+
* @param options - session workspace cwd (relative ledger paths resolve
|
|
59
|
+
* against it, mirroring the fs tools) and an optional abort signal.
|
|
60
|
+
*/
|
|
61
|
+
async restoreAfter(fs, deleteFile, targetSeq, options = {}) {
|
|
62
|
+
const restored = [];
|
|
63
|
+
const deleted = [];
|
|
64
|
+
const failed = [];
|
|
65
|
+
const restoredSet = /* @__PURE__ */ new Set();
|
|
66
|
+
const deletedSet = /* @__PURE__ */ new Set();
|
|
67
|
+
for (const entry of this.changesAfter(targetSeq)) {
|
|
68
|
+
try {
|
|
69
|
+
const cwd = sessionCwd(options.cwd, entry.path);
|
|
70
|
+
const target = await fs.resolve(entry.path, {
|
|
71
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
72
|
+
signal: options.signal
|
|
73
|
+
});
|
|
74
|
+
if (entry.before === void 0) {
|
|
75
|
+
await deleteFile(fs.processPath(target));
|
|
76
|
+
if (!deletedSet.has(entry.path)) {
|
|
77
|
+
deletedSet.add(entry.path);
|
|
78
|
+
deleted.push(entry.path);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
await fs.writeText(target, entry.before, void 0, options.signal);
|
|
82
|
+
if (!restoredSet.has(entry.path)) {
|
|
83
|
+
restoredSet.add(entry.path);
|
|
84
|
+
restored.push(entry.path);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
failed.push({
|
|
89
|
+
path: entry.path,
|
|
90
|
+
message: error instanceof Error ? error.message : String(error)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { restored, deleted, failed };
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/rewind.ts
|
|
99
|
+
var RewindError = class extends Error {
|
|
100
|
+
constructor(code, message) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.code = code;
|
|
103
|
+
this.name = "RewindError";
|
|
104
|
+
}
|
|
105
|
+
code;
|
|
106
|
+
};
|
|
107
|
+
var CANDIDATE_PREVIEW_CHARS = 80;
|
|
108
|
+
function isUserMessageEvent(event) {
|
|
109
|
+
return event.type === "user/message";
|
|
110
|
+
}
|
|
111
|
+
function messagePreview(message) {
|
|
112
|
+
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
113
|
+
return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
|
|
114
|
+
}
|
|
115
|
+
function parseRewindTarget(raw) {
|
|
116
|
+
const token = raw.trim();
|
|
117
|
+
if (token === "") return void 0;
|
|
118
|
+
if (token.startsWith("@")) {
|
|
119
|
+
const seq = Number(token.slice(1));
|
|
120
|
+
return Number.isSafeInteger(seq) && seq >= 0 ? { kind: "seq", seq } : void 0;
|
|
121
|
+
}
|
|
122
|
+
const index = Number(token);
|
|
123
|
+
return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
|
|
124
|
+
}
|
|
125
|
+
function listRewindCandidates(events, surface, limit = 10) {
|
|
126
|
+
const surfaceIndexes = /* @__PURE__ */ new Map();
|
|
127
|
+
for (let i = 0; i < surface.length; i++) surfaceIndexes.set(surface[i], i);
|
|
128
|
+
const candidates = [];
|
|
129
|
+
for (let i = events.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
130
|
+
const event = events[i];
|
|
131
|
+
if (!isUserMessageEvent(event)) continue;
|
|
132
|
+
if (!surfaceIndexes.has(event.seq)) continue;
|
|
133
|
+
candidates.push({
|
|
134
|
+
seq: event.seq,
|
|
135
|
+
time: event.time,
|
|
136
|
+
preview: messagePreview(event.data),
|
|
137
|
+
index: candidates.length + 1
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return candidates;
|
|
141
|
+
}
|
|
142
|
+
function planRewind(events, surface, target) {
|
|
143
|
+
let targetSeq;
|
|
144
|
+
if (target.kind === "seq") {
|
|
145
|
+
targetSeq = target.seq;
|
|
146
|
+
} else {
|
|
147
|
+
const candidate = listRewindCandidates(events, surface, target.index)[target.index - 1];
|
|
148
|
+
if (candidate === void 0) {
|
|
149
|
+
throw new RewindError("invalid-index", `rewind index ${target.index} has no candidate`);
|
|
150
|
+
}
|
|
151
|
+
targetSeq = candidate.seq;
|
|
152
|
+
}
|
|
153
|
+
const targetEvent = events.find((event) => event.seq === targetSeq);
|
|
154
|
+
if (targetEvent === void 0) {
|
|
155
|
+
throw new RewindError("not-a-user-message", `no session event at seq ${targetSeq}`);
|
|
156
|
+
}
|
|
157
|
+
if (!isUserMessageEvent(targetEvent)) {
|
|
158
|
+
throw new RewindError(
|
|
159
|
+
"not-a-user-message",
|
|
160
|
+
`session event at seq ${targetSeq} is not a user message (${targetEvent.type})`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const targetIndex = surface.indexOf(targetSeq);
|
|
164
|
+
if (targetIndex === -1) {
|
|
165
|
+
throw new RewindError(
|
|
166
|
+
"not-on-surface",
|
|
167
|
+
`user message at seq ${targetSeq} is no longer in the model context (shadowed by compaction)`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (targetIndex === surface.length - 1) {
|
|
171
|
+
throw new RewindError(
|
|
172
|
+
"nothing-after",
|
|
173
|
+
`user message at seq ${targetSeq} is already the last context item; nothing to rewind`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const shadowedSeqs = surface.slice(targetIndex + 1);
|
|
177
|
+
return {
|
|
178
|
+
targetSeq,
|
|
179
|
+
targetIndex,
|
|
180
|
+
shadowedSeqs,
|
|
181
|
+
surfaceStart: shadowedSeqs[0],
|
|
182
|
+
surfaceEnd: shadowedSeqs[shadowedSeqs.length - 1]
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function formatCandidate(candidate) {
|
|
186
|
+
const time = new Date(candidate.time);
|
|
187
|
+
const hh = String(time.getHours()).padStart(2, "0");
|
|
188
|
+
const mm = String(time.getMinutes()).padStart(2, "0");
|
|
189
|
+
return `${candidate.index}. ${hh}:${mm} ${candidate.preview || "(\u65E0\u6587\u672C)"}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/index.ts
|
|
193
|
+
var name = "dsh-rewind";
|
|
194
|
+
var inject = ["commands", "tools"];
|
|
195
|
+
var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
|
|
196
|
+
var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
|
|
197
|
+
var USAGE = [
|
|
198
|
+
"Usage:",
|
|
199
|
+
" /rewind list recent user messages to rewind to",
|
|
200
|
+
" /rewind <\u5E8F\u53F7|@seq> choose a mode for that message",
|
|
201
|
+
" /rewind <\u5E8F\u53F7|@seq> chat rewind the conversation only",
|
|
202
|
+
" /rewind <\u5E8F\u53F7|@seq> both rewind conversation and restore files",
|
|
203
|
+
" /rewind preview <\u76EE\u6807> show the impact list without executing"
|
|
204
|
+
].join("\n");
|
|
205
|
+
function mutationPathOf(exec) {
|
|
206
|
+
const args = exec.arguments;
|
|
207
|
+
if (exec.name === "write" || exec.name === "edit") {
|
|
208
|
+
return typeof args.file_path === "string" ? args.file_path : void 0;
|
|
209
|
+
}
|
|
210
|
+
if (exec.name === "str_replace_editor") {
|
|
211
|
+
if (typeof args.command !== "string" || !MUTATING_EDITOR_COMMANDS.has(args.command)) return void 0;
|
|
212
|
+
return typeof args.path === "string" ? args.path : void 0;
|
|
213
|
+
}
|
|
214
|
+
return void 0;
|
|
215
|
+
}
|
|
216
|
+
function anchorSeqOf(session) {
|
|
217
|
+
for (let i = session.events.length - 1; i >= 0; i--) {
|
|
218
|
+
const event = session.events[i];
|
|
219
|
+
if (event.type === "user/message") return event.seq;
|
|
220
|
+
}
|
|
221
|
+
return void 0;
|
|
222
|
+
}
|
|
223
|
+
async function resolveTarget(fs, path, cwd, signal) {
|
|
224
|
+
try {
|
|
225
|
+
return await fs.resolve(path, {
|
|
226
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
227
|
+
signal
|
|
228
|
+
});
|
|
229
|
+
} catch {
|
|
230
|
+
return void 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function readTextOrUndefined(fs, target, signal) {
|
|
234
|
+
try {
|
|
235
|
+
return await fs.readText(target, signal);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const code = error?.code;
|
|
238
|
+
if (code === "ENOENT" || code === "FS_NOT_FOUND") return void 0;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function captureBefore(fs, exec, pending) {
|
|
243
|
+
if (!TRACKED_TOOLS.has(exec.name)) return;
|
|
244
|
+
const path = mutationPathOf(exec);
|
|
245
|
+
if (path === void 0) return;
|
|
246
|
+
const cwd = execSessionCwd(exec, path);
|
|
247
|
+
const target = await resolveTarget(fs, path, cwd, exec.signal);
|
|
248
|
+
if (target === void 0) return;
|
|
249
|
+
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
250
|
+
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, cwd, before });
|
|
251
|
+
}
|
|
252
|
+
async function commitEntry(fs, ledgerFor, pending, exec, result) {
|
|
253
|
+
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
254
|
+
const capture = pending.get(key);
|
|
255
|
+
if (capture === void 0) return;
|
|
256
|
+
pending.delete(key);
|
|
257
|
+
if (result.isError) return;
|
|
258
|
+
const agent = exec.agent;
|
|
259
|
+
if (agent === void 0) return;
|
|
260
|
+
const anchorSeq = anchorSeqOf(agent.session);
|
|
261
|
+
if (anchorSeq === void 0) return;
|
|
262
|
+
const target = await resolveTarget(fs, capture.path, capture.cwd, exec.signal);
|
|
263
|
+
if (target === void 0) return;
|
|
264
|
+
let after;
|
|
265
|
+
try {
|
|
266
|
+
after = await readTextOrUndefined(fs, target, exec.signal) ?? "";
|
|
267
|
+
} catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
ledgerFor(agent.session).record({
|
|
271
|
+
toolName: exec.name,
|
|
272
|
+
anchorSeq,
|
|
273
|
+
path: capture.path,
|
|
274
|
+
before: capture.before,
|
|
275
|
+
after
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function buildMarker(targetSeq) {
|
|
279
|
+
return createUserMessage({
|
|
280
|
+
content: [{
|
|
281
|
+
type: "text",
|
|
282
|
+
text: `[\u56DE\u9000\u6807\u8BB0 / rewind marker] \u5BF9\u8BDD\u5DF2\u56DE\u9000\u5230 seq ${targetSeq}\uFF0C\u6B64\u6807\u8BB0\u4E4B\u540E\u7684\u5386\u53F2\u5DF2\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u4E2D\u79FB\u9664\u3002\u8BF7\u5FFD\u7565\u6B64\u6807\u8BB0\u672C\u8EAB\uFF0C\u7B49\u5F85\u7528\u6237\u7684\u4E0B\u4E00\u6761\u6D88\u606F\u3002`
|
|
283
|
+
}],
|
|
284
|
+
source: { kind: "user" }
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function describeTarget(target) {
|
|
288
|
+
return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
|
|
289
|
+
}
|
|
290
|
+
function formatPlan(plan, files) {
|
|
291
|
+
const lines = [
|
|
292
|
+
`\u5C06\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 ${plan.shadowedSeqs.length} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002`
|
|
293
|
+
];
|
|
294
|
+
if (files.length > 0) {
|
|
295
|
+
lines.push(`\u5C06\u5F71\u54CD ${files.length} \u4E2A\u6587\u4EF6\uFF1A`);
|
|
296
|
+
for (const file of files) {
|
|
297
|
+
lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53F0\u8D26\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
|
|
301
|
+
}
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|
|
304
|
+
function resolveOrError(events, surface, raw) {
|
|
305
|
+
const target = parseRewindTarget(raw);
|
|
306
|
+
if (target === void 0) {
|
|
307
|
+
throw new RewindError("invalid-index", `\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "${raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09`);
|
|
308
|
+
}
|
|
309
|
+
return planRewind(events, surface, target);
|
|
310
|
+
}
|
|
311
|
+
function renderFailures(failed) {
|
|
312
|
+
if (failed.length === 0) return "";
|
|
313
|
+
return `\uFF1B${failed.length} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A${failed.map((f) => `${f.path}\uFF08${f.message}\uFF09`).join("\u3001")}`;
|
|
314
|
+
}
|
|
315
|
+
async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
316
|
+
const { agent } = invocation;
|
|
317
|
+
if (agent.status !== "idle") {
|
|
318
|
+
return { kind: "error", text: "agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u65E0\u6CD5\u56DE\u9000\u3002\u8BF7\u7B49\u5F85\u5F53\u524D\u56DE\u5408\u7ED3\u675F\u540E\u518D\u8BD5\u3002" };
|
|
319
|
+
}
|
|
320
|
+
let plan;
|
|
321
|
+
try {
|
|
322
|
+
plan = resolveOrError(agent.session.events, agent.session.surface.nodes, rawTarget);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return rewindErrorResult(error);
|
|
325
|
+
}
|
|
326
|
+
const marker = buildMarker(plan.targetSeq);
|
|
327
|
+
let event;
|
|
328
|
+
try {
|
|
329
|
+
event = agent.session.append("user/message", marker, {
|
|
330
|
+
surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
|
|
331
|
+
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return {
|
|
335
|
+
kind: "error",
|
|
336
|
+
text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
let restore = "";
|
|
340
|
+
if (mode === "both") {
|
|
341
|
+
const fs = ctx.get("fs", false);
|
|
342
|
+
if (fs === void 0) {
|
|
343
|
+
restore = "\uFF1B\u672A\u627E\u5230\u6587\u4EF6\u7CFB\u7EDF\u670D\u52A1\uFF0C\u672A\u8FD8\u539F\u6587\u4EF6\uFF08\u53EF\u4EC5\u7528 chat \u6A21\u5F0F\u56DE\u9000\u5BF9\u8BDD\uFF09";
|
|
344
|
+
} else {
|
|
345
|
+
const outcome = await ledger.restoreAfter(fs, (processPath) => unlink(processPath), plan.targetSeq, {
|
|
346
|
+
// Relative ledger paths resolve against the session workspace, exactly
|
|
347
|
+
// as the fs tools resolve them (per-entry in src/ledger.ts).
|
|
348
|
+
cwd: agent.session.header.cwd,
|
|
349
|
+
signal: invocation.signal
|
|
350
|
+
});
|
|
351
|
+
restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
kind: "success",
|
|
356
|
+
text: `\u5DF2\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u79FB\u9664 ${plan.shadowedSeqs.length} \u6761\u4E0A\u4E0B\u6587\uFF08\u65E5\u5FD7\u4FDD\u7559\uFF09${restore}\u3002`,
|
|
357
|
+
sourceEventSeq: event.seq
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function rewindErrorResult(error) {
|
|
361
|
+
if (error instanceof RewindError) {
|
|
362
|
+
const text = {
|
|
363
|
+
"no-user-messages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
364
|
+
"invalid-index": error.message,
|
|
365
|
+
"not-a-user-message": error.message,
|
|
366
|
+
"not-on-surface": error.message,
|
|
367
|
+
"nothing-after": error.message
|
|
368
|
+
}[error.code];
|
|
369
|
+
return { kind: "error", text };
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
async function handleRewind(ctx, ledger, invocation) {
|
|
374
|
+
const session = invocation.agent.session;
|
|
375
|
+
const input = invocation.rawInput.trim();
|
|
376
|
+
if (input === "") {
|
|
377
|
+
const candidates = listRewindCandidates(session.events, session.surface.nodes);
|
|
378
|
+
if (candidates.length === 0) {
|
|
379
|
+
return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
kind: "success",
|
|
383
|
+
text: `\u9009\u62E9\u8981\u56DE\u9000\u5230\u7684\u7528\u6237\u6D88\u606F\uFF08\u6700\u8FD1\u5728\u4E0A\uFF09\uFF1A
|
|
384
|
+
${candidates.map(formatCandidate).join("\n")}
|
|
385
|
+
|
|
386
|
+
\u7EE7\u7EED\uFF1A/rewind <\u5E8F\u53F7|@seq>\uFF0C\u6216\u76F4\u63A5 /rewind <\u5E8F\u53F7|@seq> chat|both \u6267\u884C\u3002`
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const parts = input.split(/\s+/);
|
|
390
|
+
if (parts[0] === "preview") {
|
|
391
|
+
const target2 = parts[1];
|
|
392
|
+
if (target2 === void 0) return { kind: "error", text: USAGE };
|
|
393
|
+
let plan;
|
|
394
|
+
try {
|
|
395
|
+
plan = resolveOrError(session.events, session.surface.nodes, target2);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return rewindErrorResult(error);
|
|
398
|
+
}
|
|
399
|
+
const impacts = ledger.impactsAfter(plan.targetSeq);
|
|
400
|
+
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
401
|
+
}
|
|
402
|
+
const target = parts[0];
|
|
403
|
+
const mode = parts[1];
|
|
404
|
+
if (mode !== void 0 && mode !== "chat" && mode !== "both") {
|
|
405
|
+
return { kind: "error", text: USAGE };
|
|
406
|
+
}
|
|
407
|
+
if (mode === void 0) {
|
|
408
|
+
const parsed = parseRewindTarget(target);
|
|
409
|
+
if (parsed === void 0) return { kind: "error", text: USAGE };
|
|
410
|
+
return {
|
|
411
|
+
kind: "success",
|
|
412
|
+
text: `\u5C06\u56DE\u9000\u5230 ${describeTarget(parsed)}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A
|
|
413
|
+
/rewind ${target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD
|
|
414
|
+
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return executeRewind(ctx, ledger, invocation, target, mode);
|
|
418
|
+
}
|
|
419
|
+
function apply(ctx) {
|
|
420
|
+
const ledgers = /* @__PURE__ */ new Map();
|
|
421
|
+
const ledgerFor = (session) => {
|
|
422
|
+
let ledger = ledgers.get(session.id);
|
|
423
|
+
if (ledger === void 0) {
|
|
424
|
+
ledger = new RewindLedger();
|
|
425
|
+
ledgers.set(session.id, ledger);
|
|
426
|
+
}
|
|
427
|
+
return ledger;
|
|
428
|
+
};
|
|
429
|
+
const pending = /* @__PURE__ */ new Map();
|
|
430
|
+
ctx.effect(function* () {
|
|
431
|
+
yield ctx.commands.register({
|
|
432
|
+
name: "rewind",
|
|
433
|
+
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",
|
|
434
|
+
handler: (invocation) => handleRewind(ctx, ledgerFor(invocation.agent.session), invocation)
|
|
435
|
+
});
|
|
436
|
+
}, "dsh-rewind command");
|
|
437
|
+
ctx.inject(["fs"], (scope) => {
|
|
438
|
+
const fs = scope.fs;
|
|
439
|
+
scope.on("tools/execute", async (exec, next) => {
|
|
440
|
+
try {
|
|
441
|
+
await captureBefore(fs, exec, pending);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
ctx.logger.warn(`[dsh-rewind] before-capture failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
444
|
+
}
|
|
445
|
+
return next();
|
|
446
|
+
});
|
|
447
|
+
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
448
|
+
try {
|
|
449
|
+
await commitEntry(fs, ledgerFor, pending, exec, result);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
ctx.logger.warn(`[dsh-rewind] ledger commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
452
|
+
}
|
|
453
|
+
return next();
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
export {
|
|
458
|
+
apply,
|
|
459
|
+
inject,
|
|
460
|
+
name
|
|
461
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-rewind-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"cordis",
|
|
9
|
+
"plugin",
|
|
10
|
+
"rewind",
|
|
11
|
+
"session",
|
|
12
|
+
"ui"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/SiriLee/dsh-rewind.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/SiriLee/dsh-rewind#readme",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./lib/index.js",
|
|
27
|
+
"./client": "./lib/client.js",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"lib",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"scripts",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-locale",
|
|
44
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
45
|
+
],
|
|
46
|
+
"platform": "web"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "node scripts/build.mjs",
|
|
51
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"verify:host": "node scripts/verify-host.mjs",
|
|
54
|
+
"prepare": "npm run build"
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@deepseek-ai/cordis": "*",
|
|
62
|
+
"@deepseek-ai/dsh-agent": "*",
|
|
63
|
+
"@deepseek-ai/dsh-client-locale": "*",
|
|
64
|
+
"@deepseek-ai/dsh-client-runtime": "*",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-slots": "*",
|
|
66
|
+
"@deepseek-ai/dsh-commands": "*",
|
|
67
|
+
"@deepseek-ai/dsh-fs": "*",
|
|
68
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
69
|
+
"@deepseek-ai/dsh-sandbox": "*",
|
|
70
|
+
"@deepseek-ai/dsh-session": "*",
|
|
71
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
72
|
+
},
|
|
73
|
+
"peerDependenciesMeta": {
|
|
74
|
+
"@deepseek-ai/cordis": { "optional": true },
|
|
75
|
+
"@deepseek-ai/dsh-agent": { "optional": true },
|
|
76
|
+
"@deepseek-ai/dsh-client-locale": { "optional": true },
|
|
77
|
+
"@deepseek-ai/dsh-client-runtime": { "optional": true },
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-slots": { "optional": true },
|
|
79
|
+
"@deepseek-ai/dsh-commands": { "optional": true },
|
|
80
|
+
"@deepseek-ai/dsh-fs": { "optional": true },
|
|
81
|
+
"@deepseek-ai/dsh-llm": { "optional": true },
|
|
82
|
+
"@deepseek-ai/dsh-sandbox": { "optional": true },
|
|
83
|
+
"@deepseek-ai/dsh-session": { "optional": true },
|
|
84
|
+
"@deepseek-ai/dsh-tools": { "optional": true }
|
|
85
|
+
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.4",
|
|
88
|
+
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
89
|
+
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.6",
|
|
90
|
+
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
|
|
91
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
|
|
92
|
+
"@deepseek-ai/dsh-commands": "0.1.0-rc.6",
|
|
93
|
+
"@deepseek-ai/dsh-fs": "0.1.0-rc.6",
|
|
94
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
95
|
+
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.6",
|
|
96
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
97
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
98
|
+
"@types/node": "^24.0.0",
|
|
99
|
+
"esbuild": "^0.28.2",
|
|
100
|
+
"typescript": "^7.0.2",
|
|
101
|
+
"vitest": "^4.1.10"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-rewind build — produces the npm-package artifacts under lib/:
|
|
4
|
+
* lib/index.js — host half: src/index.ts bundled to plain ESM. Every
|
|
5
|
+
* `@deepseek-ai/*` import stays external: the dsh loader
|
|
6
|
+
* resolves those from the harness installation, never from
|
|
7
|
+
* this package.
|
|
8
|
+
* lib/client.js — client half: src/client/index.ts bundled to CJS, then
|
|
9
|
+
* wrapped in the web boot handoff
|
|
10
|
+
* `window.__ModuleLoader__.load({ id, factory })`, the
|
|
11
|
+
* closure-factory format every `dsh.client` package's
|
|
12
|
+
* `./client` export must use. The client half is fully
|
|
13
|
+
* self-contained (plain DOM, no React), so nothing is
|
|
14
|
+
* external and the injected `require` is never called.
|
|
15
|
+
*
|
|
16
|
+
* Type checking is a separate step (`npm run typecheck`, tsc --noEmit); this
|
|
17
|
+
* script only transpiles (esbuild) and runs smoke checks: both outputs must
|
|
18
|
+
* parse, and the host half must import with the expected plugin shape.
|
|
19
|
+
*/
|
|
20
|
+
import { build } from 'esbuild'
|
|
21
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
26
|
+
const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
|
|
27
|
+
|
|
28
|
+
await mkdir(join(ROOT, 'lib'), { recursive: true })
|
|
29
|
+
|
|
30
|
+
// ---- host half: bundled TS -> ESM (@deepseek-ai/* stays external) ----
|
|
31
|
+
await build({
|
|
32
|
+
entryPoints: [join(ROOT, 'src', 'index.ts')],
|
|
33
|
+
outfile: join(ROOT, 'lib', 'index.js'),
|
|
34
|
+
format: 'esm',
|
|
35
|
+
platform: 'node',
|
|
36
|
+
target: 'es2024',
|
|
37
|
+
bundle: true,
|
|
38
|
+
external: ['@deepseek-ai/*'],
|
|
39
|
+
sourcemap: false,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ---- client half: bundled TS -> CJS, then wrapped in the loader handoff ----
|
|
43
|
+
await build({
|
|
44
|
+
entryPoints: [join(ROOT, 'src', 'client', 'index.ts')],
|
|
45
|
+
outfile: join(ROOT, 'lib', '_client.js'),
|
|
46
|
+
format: 'cjs',
|
|
47
|
+
platform: 'browser',
|
|
48
|
+
target: 'es2020',
|
|
49
|
+
bundle: true,
|
|
50
|
+
external: [],
|
|
51
|
+
sourcemap: false,
|
|
52
|
+
})
|
|
53
|
+
const clientSource = await readFile(join(ROOT, 'lib', '_client.js'), 'utf8')
|
|
54
|
+
await rm(join(ROOT, 'lib', '_client.js'))
|
|
55
|
+
|
|
56
|
+
const bundle = [
|
|
57
|
+
'/* dsh-rewind client bundle — generated by scripts/build.mjs from src/client/ */',
|
|
58
|
+
'window.__ModuleLoader__.load({',
|
|
59
|
+
` id: ${JSON.stringify(pkg.name)},`,
|
|
60
|
+
' factory: (require) => {',
|
|
61
|
+
' var module = { exports: {} };',
|
|
62
|
+
' var exports = module.exports;',
|
|
63
|
+
clientSource.replace(/\s+$/, '\n'),
|
|
64
|
+
' return module.exports;',
|
|
65
|
+
' }',
|
|
66
|
+
'});',
|
|
67
|
+
'',
|
|
68
|
+
].join('\n')
|
|
69
|
+
await writeFile(join(ROOT, 'lib', 'client.js'), bundle)
|
|
70
|
+
|
|
71
|
+
// ---- smoke checks ----
|
|
72
|
+
const host = await readFile(join(ROOT, 'lib', 'index.js'), 'utf8')
|
|
73
|
+
const exportBlock = host.slice(host.lastIndexOf('export {'))
|
|
74
|
+
for (const needle of ['name', 'inject', 'apply']) {
|
|
75
|
+
if (!exportBlock.includes(needle)) throw new Error(`host bundle missing export ${needle}`)
|
|
76
|
+
}
|
|
77
|
+
for (const needle of ['window.__ModuleLoader__.load', `id: ${JSON.stringify(pkg.name)}`]) {
|
|
78
|
+
if (!bundle.includes(needle)) throw new Error(`client bundle missing ${needle}`)
|
|
79
|
+
}
|
|
80
|
+
console.log('build ok: lib/index.js (host), lib/client.js (client)')
|