dsh-rewind-plugin 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/README.zh.md +6 -5
- package/assets/screenshots/impact-list.png +0 -0
- package/assets/screenshots/mode-popover.png +0 -0
- package/assets/screenshots/rewind-button.png +0 -0
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/lib/client.js +76 -21
- package/lib/index.js +332 -67
- package/lib/types/client/candidates.d.ts +25 -0
- package/lib/types/client/hidden.d.ts +17 -6
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/locales.d.ts +67 -0
- package/lib/types/rewind.d.ts +35 -0
- package/lib/types/snapshot.d.ts +107 -10
- package/package.json +6 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
5
|
+
|
|
6
|
+
// src/locales.ts
|
|
7
|
+
var en = {
|
|
8
|
+
"usage.title": "Usage:",
|
|
9
|
+
"usage.noArgs": " /rewind (no args) withdraw the most recent user message",
|
|
10
|
+
"usage.seq": " /rewind @<seq> chat|both rewind to the given message (chat = conversation only / both = conversation + files)",
|
|
11
|
+
"usage.blocked": " Manual /rewind input is intercepted; use the \u21B6 button next to a message",
|
|
12
|
+
"describeTarget.seq": "seq {seq}",
|
|
13
|
+
"describeTarget.index": "message {index}",
|
|
14
|
+
"plan.rewinding": "Rewind to seq {targetSeq}, removing {count} node(s) from the model context (conversation log kept).",
|
|
15
|
+
"plan.affects": "Affects {count} file(s):",
|
|
16
|
+
"plan.restore": "restore {path}",
|
|
17
|
+
"plan.delete": "delete {path}",
|
|
18
|
+
"plan.noChanges": "No restorable changes after the target.",
|
|
19
|
+
"error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
|
|
20
|
+
"failures.suffix": "; {count} file(s) failed to restore: {list}",
|
|
21
|
+
"failures.item": "{path} ({message})",
|
|
22
|
+
"inflight": "A rewind is already running for this session; please wait.",
|
|
23
|
+
"stopFailed": "Could not stop the running agent; rewind cancelled. Please try again.",
|
|
24
|
+
"cancelled": "Rewind cancelled.",
|
|
25
|
+
"failed": "Rewind failed: {error}. The session is unchanged.",
|
|
26
|
+
"restore.count": "restored {count} file(s)",
|
|
27
|
+
"delete.count": "deleted {count} file(s)",
|
|
28
|
+
"skip.count": "skipped {count} link(s)",
|
|
29
|
+
"noRestorable": "; no restorable write-class changes after the target",
|
|
30
|
+
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
31
|
+
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
32
|
+
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
33
|
+
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
|
|
34
|
+
};
|
|
35
|
+
var zh = {
|
|
36
|
+
"usage.title": "\u7528\u6CD5\uFF1A",
|
|
37
|
+
"usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
|
|
38
|
+
"usage.seq": " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
|
|
39
|
+
"usage.blocked": " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE",
|
|
40
|
+
"describeTarget.seq": "seq {seq}",
|
|
41
|
+
"describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
|
|
42
|
+
"plan.rewinding": "\u5C06\u56DE\u9000\u5230 seq {targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 {count} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002",
|
|
43
|
+
"plan.affects": "\u5C06\u5F71\u54CD {count} \u4E2A\u6587\u4EF6\uFF1A",
|
|
44
|
+
"plan.restore": "\u8FD8\u539F {path}",
|
|
45
|
+
"plan.delete": "\u5220\u9664 {path}",
|
|
46
|
+
"plan.noChanges": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u9700\u8981\u8FD8\u539F\u7684\u53D8\u66F4\u3002",
|
|
47
|
+
"error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
|
|
48
|
+
"failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
|
|
49
|
+
"failures.item": "{path}\uFF08{message}\uFF09",
|
|
50
|
+
"inflight": "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002",
|
|
51
|
+
"stopFailed": "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
|
|
52
|
+
"cancelled": "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002",
|
|
53
|
+
"failed": "\u56DE\u9000\u5931\u8D25\uFF1A{error}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002",
|
|
54
|
+
"restore.count": "\u8FD8\u539F {count} \u4E2A\u6587\u4EF6",
|
|
55
|
+
"delete.count": "\u5220\u9664 {count} \u4E2A\u6587\u4EF6",
|
|
56
|
+
"skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u94FE\u63A5",
|
|
57
|
+
"noRestorable": "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4",
|
|
58
|
+
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
59
|
+
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
60
|
+
"chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
|
|
61
|
+
"command.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"
|
|
62
|
+
};
|
|
63
|
+
var HOST_DICTS = { en, zh };
|
|
64
|
+
function translate(lang, key, params = {}) {
|
|
65
|
+
const dict = HOST_DICTS[lang] ?? en;
|
|
66
|
+
let text = dict[key] ?? key;
|
|
67
|
+
for (const [name2, value] of Object.entries(params)) {
|
|
68
|
+
text = text.split(`{${name2}}`).join(String(value));
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
4
72
|
|
|
5
73
|
// src/rewind.ts
|
|
6
74
|
var RewindError = class extends Error {
|
|
@@ -12,6 +80,7 @@ var RewindError = class extends Error {
|
|
|
12
80
|
code;
|
|
13
81
|
};
|
|
14
82
|
var CANDIDATE_PREVIEW_CHARS = 80;
|
|
83
|
+
var DEFAULT_CANDIDATE_LIMIT = 50;
|
|
15
84
|
function markerTurnOf(events) {
|
|
16
85
|
let lastStarted = 0;
|
|
17
86
|
for (const event of events) {
|
|
@@ -24,6 +93,9 @@ function markerTurnOf(events) {
|
|
|
24
93
|
function isUserMessageEvent(event) {
|
|
25
94
|
return event.type === "user/message";
|
|
26
95
|
}
|
|
96
|
+
function isHumanUserMessageEvent(event) {
|
|
97
|
+
return isUserMessageEvent(event) && event.data.source.kind === "user";
|
|
98
|
+
}
|
|
27
99
|
function messagePreview(message) {
|
|
28
100
|
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
29
101
|
return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
|
|
@@ -38,13 +110,13 @@ function parseRewindTarget(raw) {
|
|
|
38
110
|
const index = Number(token);
|
|
39
111
|
return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
|
|
40
112
|
}
|
|
41
|
-
function listRewindCandidates(events, surface, limit =
|
|
113
|
+
function listRewindCandidates(events, surface, limit = DEFAULT_CANDIDATE_LIMIT) {
|
|
42
114
|
const surfaceIndexes = /* @__PURE__ */ new Map();
|
|
43
115
|
for (let i = 0; i < surface.length; i++) surfaceIndexes.set(surface[i], i);
|
|
44
116
|
const candidates = [];
|
|
45
117
|
for (let i = events.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
46
118
|
const event = events[i];
|
|
47
|
-
if (!
|
|
119
|
+
if (!isHumanUserMessageEvent(event)) continue;
|
|
48
120
|
if (!surfaceIndexes.has(event.seq)) continue;
|
|
49
121
|
candidates.push({
|
|
50
122
|
seq: event.seq,
|
|
@@ -55,6 +127,14 @@ function listRewindCandidates(events, surface, limit = 10) {
|
|
|
55
127
|
}
|
|
56
128
|
return candidates;
|
|
57
129
|
}
|
|
130
|
+
var CANDIDATE_LIST_HEADER = "candidates=";
|
|
131
|
+
function formatCandidateList(candidates) {
|
|
132
|
+
const lines = [`${CANDIDATE_LIST_HEADER}${candidates.length}`];
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
lines.push(`${candidate.seq} ${candidate.time} ${candidate.preview}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
58
138
|
function planRewind(events, surface, target) {
|
|
59
139
|
let targetSeq;
|
|
60
140
|
if (target.kind === "seq") {
|
|
@@ -70,10 +150,10 @@ function planRewind(events, surface, target) {
|
|
|
70
150
|
if (targetEvent === void 0) {
|
|
71
151
|
throw new RewindError("not-a-user-message", `no session event at seq ${targetSeq}`);
|
|
72
152
|
}
|
|
73
|
-
if (!
|
|
153
|
+
if (!isHumanUserMessageEvent(targetEvent)) {
|
|
74
154
|
throw new RewindError(
|
|
75
155
|
"not-a-user-message",
|
|
76
|
-
`session event at seq ${targetSeq} is not a user message (${targetEvent.type})`
|
|
156
|
+
`session event at seq ${targetSeq} is not a human user message (${targetEvent.type})`
|
|
77
157
|
);
|
|
78
158
|
}
|
|
79
159
|
const targetIndex = surface.indexOf(targetSeq);
|
|
@@ -105,12 +185,24 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
105
185
|
}
|
|
106
186
|
|
|
107
187
|
// src/snapshot.ts
|
|
188
|
+
import { createHash } from "node:crypto";
|
|
108
189
|
import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
109
190
|
import { dirname, join } from "node:path";
|
|
110
191
|
import { homedir } from "node:os";
|
|
111
192
|
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
112
193
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
113
194
|
var MAX_ANCHOR_GROUPS = 100;
|
|
195
|
+
var defaultProbe = {
|
|
196
|
+
async readText(path) {
|
|
197
|
+
try {
|
|
198
|
+
return await readFile(path, "utf8");
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error.code === "ENOENT") return void 0;
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
isLink: isLinkPath
|
|
205
|
+
};
|
|
114
206
|
function safeFileId(callId) {
|
|
115
207
|
return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
116
208
|
}
|
|
@@ -212,43 +304,99 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
212
304
|
}
|
|
213
305
|
return earliest;
|
|
214
306
|
}
|
|
215
|
-
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
216
|
-
async impactsAfter(sessionId, targetSeq) {
|
|
217
|
-
return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
|
|
218
|
-
path: entry.path,
|
|
219
|
-
action: entry.before === null ? "delete" : "restore"
|
|
220
|
-
}));
|
|
221
|
-
}
|
|
222
307
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
308
|
+
* The single source of truth for BOTH the impact preview and the restore
|
|
309
|
+
* pass: reconcile the earliest recorded entry per path (at/after the
|
|
310
|
+
* target) against the CURRENT on-disk state, and plan only the actions
|
|
311
|
+
* that would actually change the disk. This is the Claude Code model —
|
|
312
|
+
* `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
|
|
313
|
+
* live filesystem (`checkOriginFileChanged`) and count only real
|
|
314
|
+
* differences, so a rewind whose target state already matches the disk is
|
|
315
|
+
* a no-op with zero impact.
|
|
316
|
+
*
|
|
317
|
+
* - `before === null` (the file did not exist at the target) plans a
|
|
318
|
+
* `delete` ONLY when the file currently exists; an already-absent file
|
|
319
|
+
* is a no-op — this kills the "ghost impact" of replaying an entry a
|
|
320
|
+
* previous rewind already consumed.
|
|
321
|
+
* - `before === 'X'` plans a `restore` ONLY when the current content
|
|
322
|
+
* differs from X (or the file is missing); identical content is a no-op
|
|
323
|
+
* — this keeps repeated rewinds idempotent.
|
|
324
|
+
* - Symlinked / hard-linked paths are never planned (they are reported as
|
|
325
|
+
* skipped by the restore pass, never written through).
|
|
326
|
+
* - A probe failure (e.g. a permission error reading the file) plans the
|
|
327
|
+
* action conservatively as if the file differed, so an unreadable file
|
|
328
|
+
* is never silently dropped from the restore.
|
|
329
|
+
*
|
|
330
|
+
* @param sessionId - session whose snapshot store to plan against.
|
|
331
|
+
* @param targetSeq - rewind target; entries anchored at/after it apply.
|
|
332
|
+
* @param probe - current-disk state probe (defaults to the real FS).
|
|
333
|
+
* @returns the planned actions plus the link paths that were skipped.
|
|
230
334
|
*/
|
|
231
|
-
async
|
|
232
|
-
const
|
|
233
|
-
const deleted = [];
|
|
335
|
+
async planRestore(sessionId, targetSeq, probe) {
|
|
336
|
+
const actions = [];
|
|
234
337
|
const skipped = [];
|
|
235
|
-
const failed = [];
|
|
236
338
|
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
237
339
|
try {
|
|
238
|
-
if (await
|
|
340
|
+
if (await probe.isLink(entry.path)) {
|
|
239
341
|
skipped.push(entry.path);
|
|
240
342
|
continue;
|
|
241
343
|
}
|
|
344
|
+
const current = await probe.readText(entry.path);
|
|
345
|
+
if (entry.before === null) {
|
|
346
|
+
if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
|
|
347
|
+
} else if (current !== entry.before) {
|
|
348
|
+
actions.push({ path: entry.path, action: "restore", before: entry.before });
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
242
351
|
if (entry.before === null) {
|
|
243
|
-
|
|
244
|
-
deleted.push(entry.path);
|
|
352
|
+
actions.push({ path: entry.path, action: "delete" });
|
|
245
353
|
} else {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
354
|
+
actions.push({ path: entry.path, action: "restore", before: entry.before });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { actions, skipped };
|
|
359
|
+
}
|
|
360
|
+
/** Per-file restore impact: only actions that would actually change the disk. */
|
|
361
|
+
async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
|
|
362
|
+
const { actions } = await this.planRestore(sessionId, targetSeq, probe);
|
|
363
|
+
return actions.sort((a, b) => a.path.localeCompare(b.path)).map((action) => ({ path: action.path, action: action.action }));
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Restore the workspace to the target message's checkpoint: execute exactly
|
|
367
|
+
* the actions {@link planRestore} derived from the record + current disk
|
|
368
|
+
* reconciliation — write the before content back, or delete the file when
|
|
369
|
+
* it was created after the target and still exists. Symlinked and
|
|
370
|
+
* hard-linked paths are skipped (reported, never written through); a
|
|
371
|
+
* restored file's parent directory is created when it was deleted after
|
|
372
|
+
* the backup; a delete whose file is ALREADY absent is a silent no-op (not
|
|
373
|
+
* a failure — the target state is already reached). Failures are per-file
|
|
374
|
+
* and never abort the pass.
|
|
375
|
+
*/
|
|
376
|
+
async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
|
|
377
|
+
const restored = [];
|
|
378
|
+
const deleted = [];
|
|
379
|
+
const skipped = [];
|
|
380
|
+
const failed = [];
|
|
381
|
+
const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
|
|
382
|
+
skipped.push(...skippedPaths);
|
|
383
|
+
for (const action of actions) {
|
|
384
|
+
try {
|
|
385
|
+
if (action.action === "delete") {
|
|
386
|
+
try {
|
|
387
|
+
await deleteFile(action.path);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error.code !== "ENOENT") throw error;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
deleted.push(action.path);
|
|
393
|
+
} else {
|
|
394
|
+
await mkdir(dirname(action.path), { recursive: true });
|
|
395
|
+
await writeFile(action.path, action.before, "utf8");
|
|
396
|
+
restored.push(action.path);
|
|
249
397
|
}
|
|
250
398
|
} catch (error) {
|
|
251
|
-
failed.push({ path:
|
|
399
|
+
failed.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
|
|
252
400
|
}
|
|
253
401
|
}
|
|
254
402
|
return { restored, deleted, skipped, failed };
|
|
@@ -283,19 +431,64 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
283
431
|
throw error;
|
|
284
432
|
}
|
|
285
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* All distinct paths ever recorded for a session — the "tracked files"
|
|
436
|
+
* set. Mirrors Claude Code's global `trackedFiles` collection (files stay
|
|
437
|
+
* tracked once a write-class tool touched them), derived from the disk
|
|
438
|
+
* entries so no extra persistence is needed.
|
|
439
|
+
*/
|
|
440
|
+
async trackedPaths(sessionId) {
|
|
441
|
+
const paths = /* @__PURE__ */ new Set();
|
|
442
|
+
for (const entry of await this.entriesAfter(sessionId, 0)) {
|
|
443
|
+
paths.add(entry.path);
|
|
444
|
+
}
|
|
445
|
+
return paths;
|
|
446
|
+
}
|
|
286
447
|
};
|
|
448
|
+
function hashPath(path) {
|
|
449
|
+
return createHash("sha256").update(path).digest("hex").slice(0, 8);
|
|
450
|
+
}
|
|
451
|
+
async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
|
|
452
|
+
let recorded = 0;
|
|
453
|
+
for (const path of tracked) {
|
|
454
|
+
try {
|
|
455
|
+
if (await probe.isLink(path)) continue;
|
|
456
|
+
const current = await probe.readText(path);
|
|
457
|
+
const state = current ?? null;
|
|
458
|
+
const prev = states.get(path);
|
|
459
|
+
if (prev === void 0 || prev !== state) {
|
|
460
|
+
await store.recordEntry(sessionId, {
|
|
461
|
+
callId: `recheck-${anchorSeq}-${hashPath(path)}`,
|
|
462
|
+
anchorSeq,
|
|
463
|
+
path,
|
|
464
|
+
before: state
|
|
465
|
+
});
|
|
466
|
+
states.set(path, state);
|
|
467
|
+
recorded++;
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return recorded;
|
|
473
|
+
}
|
|
287
474
|
|
|
288
475
|
// src/index.ts
|
|
289
476
|
var name = "dsh-rewind";
|
|
290
477
|
var inject = ["commands", "tools"];
|
|
291
478
|
var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
|
|
292
479
|
var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
|
|
293
|
-
var
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
480
|
+
var activeLocale = "en";
|
|
481
|
+
function t(key, params) {
|
|
482
|
+
return translate(activeLocale, key, params);
|
|
483
|
+
}
|
|
484
|
+
function usage() {
|
|
485
|
+
return [
|
|
486
|
+
t("usage.title"),
|
|
487
|
+
t("usage.noArgs"),
|
|
488
|
+
t("usage.seq"),
|
|
489
|
+
t("usage.blocked")
|
|
490
|
+
].join("\n");
|
|
491
|
+
}
|
|
299
492
|
function mutationPathOf(exec) {
|
|
300
493
|
const args = exec.arguments;
|
|
301
494
|
if (exec.name === "write" || exec.name === "edit") {
|
|
@@ -352,7 +545,7 @@ async function captureBefore(fs, exec, pending) {
|
|
|
352
545
|
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
353
546
|
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
|
|
354
547
|
}
|
|
355
|
-
async function commitEntry(store, pending, anchorCache, exec, result) {
|
|
548
|
+
async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
|
|
356
549
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
357
550
|
const capture = pending.get(key);
|
|
358
551
|
if (capture === void 0) return;
|
|
@@ -368,6 +561,12 @@ async function commitEntry(store, pending, anchorCache, exec, result) {
|
|
|
368
561
|
path: capture.path,
|
|
369
562
|
before: capture.before ?? null
|
|
370
563
|
});
|
|
564
|
+
let tracked = trackedBySession.get(agent.session.id);
|
|
565
|
+
if (tracked === void 0) {
|
|
566
|
+
tracked = /* @__PURE__ */ new Set();
|
|
567
|
+
trackedBySession.set(agent.session.id, tracked);
|
|
568
|
+
}
|
|
569
|
+
tracked.add(capture.path);
|
|
371
570
|
}
|
|
372
571
|
function buildMarker() {
|
|
373
572
|
return createAssistantMessage({
|
|
@@ -376,33 +575,62 @@ function buildMarker() {
|
|
|
376
575
|
});
|
|
377
576
|
}
|
|
378
577
|
function describeTarget(target) {
|
|
379
|
-
return target.kind === "seq" ?
|
|
578
|
+
return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
|
|
380
579
|
}
|
|
381
580
|
function formatPlan(plan, files) {
|
|
382
581
|
const lines = [
|
|
383
|
-
|
|
582
|
+
t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
|
|
384
583
|
];
|
|
385
584
|
if (files.length > 0) {
|
|
386
|
-
lines.push(
|
|
585
|
+
lines.push(t("plan.affects", { count: files.length }));
|
|
387
586
|
for (const file of files) {
|
|
388
|
-
lines.push(` ${file.action === "restore" ? "
|
|
587
|
+
lines.push(` ${file.action === "restore" ? t("plan.restore", { path: file.path }) : t("plan.delete", { path: file.path })}`);
|
|
389
588
|
}
|
|
390
589
|
} else {
|
|
391
|
-
lines.push("
|
|
590
|
+
lines.push(t("plan.noChanges"));
|
|
392
591
|
}
|
|
393
592
|
lines.push(`impact=${files.length}`);
|
|
593
|
+
for (const file of files) {
|
|
594
|
+
lines.push(`${file.action}:${file.path}`);
|
|
595
|
+
}
|
|
394
596
|
return lines.join("\n");
|
|
395
597
|
}
|
|
396
598
|
function resolveOrError(events, surface, raw) {
|
|
397
599
|
const target = parseRewindTarget(raw);
|
|
398
600
|
if (target === void 0) {
|
|
399
|
-
throw new RewindError("invalid-index",
|
|
601
|
+
throw new RewindError("invalid-index", t("error.invalidTarget", { raw }));
|
|
400
602
|
}
|
|
401
603
|
return planRewind(events, surface, target);
|
|
402
604
|
}
|
|
403
605
|
function renderFailures(failed) {
|
|
404
606
|
if (failed.length === 0) return "";
|
|
405
|
-
return
|
|
607
|
+
return t("failures.suffix", {
|
|
608
|
+
count: failed.length,
|
|
609
|
+
list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
async function resolveObservationTarget(fs, path) {
|
|
613
|
+
try {
|
|
614
|
+
return await fs.resolve(path);
|
|
615
|
+
} catch {
|
|
616
|
+
return void 0;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function syncRestoreObservations(ctx, fs, agent, outcome) {
|
|
620
|
+
if (fs === void 0) return;
|
|
621
|
+
const actor = { agent };
|
|
622
|
+
for (const path of outcome.deleted) {
|
|
623
|
+
const target = await resolveObservationTarget(fs, path);
|
|
624
|
+
if (target === void 0) continue;
|
|
625
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, actor);
|
|
626
|
+
}
|
|
627
|
+
for (const path of outcome.restored) {
|
|
628
|
+
const target = await resolveObservationTarget(fs, path);
|
|
629
|
+
if (target === void 0) continue;
|
|
630
|
+
const info = await fs.stat(target);
|
|
631
|
+
if (info === void 0) continue;
|
|
632
|
+
ctx.emit("fs/observed", target, { kind: "present", version: info.version }, actor);
|
|
633
|
+
}
|
|
406
634
|
}
|
|
407
635
|
async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
408
636
|
if (signal.aborted) return false;
|
|
@@ -425,11 +653,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
425
653
|
if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
426
654
|
}
|
|
427
655
|
}
|
|
428
|
-
async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
|
|
656
|
+
async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
|
|
429
657
|
const { agent } = invocation;
|
|
430
658
|
const sessionId = agent.session.id;
|
|
431
659
|
if (inflight.has(sessionId)) {
|
|
432
|
-
return { kind: "error", text: "
|
|
660
|
+
return { kind: "error", text: t("inflight") };
|
|
433
661
|
}
|
|
434
662
|
inflight.add(sessionId);
|
|
435
663
|
try {
|
|
@@ -437,11 +665,11 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
|
|
|
437
665
|
agent.cancel({ kind: "user" });
|
|
438
666
|
const stopped = await waitForAgentIdle(agent, invocation.signal);
|
|
439
667
|
if (!stopped) {
|
|
440
|
-
return { kind: "error", text: "
|
|
668
|
+
return { kind: "error", text: t("stopFailed") };
|
|
441
669
|
}
|
|
442
670
|
}
|
|
443
671
|
if (invocation.signal.aborted) {
|
|
444
|
-
return { kind: "error", text: "
|
|
672
|
+
return { kind: "error", text: t("cancelled") };
|
|
445
673
|
}
|
|
446
674
|
let plan;
|
|
447
675
|
try {
|
|
@@ -459,22 +687,23 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
|
|
|
459
687
|
} catch (error) {
|
|
460
688
|
return {
|
|
461
689
|
kind: "error",
|
|
462
|
-
text:
|
|
690
|
+
text: t("failed", { error: error instanceof Error ? error.message : String(error) })
|
|
463
691
|
};
|
|
464
692
|
}
|
|
465
693
|
let restore = "";
|
|
466
694
|
if (mode === "both") {
|
|
467
695
|
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
696
|
+
await syncRestoreObservations(ctx, fs, agent, outcome);
|
|
468
697
|
const parts = [];
|
|
469
|
-
if (outcome.restored.length > 0) parts.push(
|
|
470
|
-
if (outcome.deleted.length > 0) parts.push(
|
|
471
|
-
if (outcome.skipped.length > 0) parts.push(
|
|
472
|
-
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "
|
|
698
|
+
if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
|
|
699
|
+
if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
|
|
700
|
+
if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
|
|
701
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
|
|
473
702
|
restore += renderFailures(outcome.failed);
|
|
474
703
|
}
|
|
475
704
|
return {
|
|
476
705
|
kind: "success",
|
|
477
|
-
text:
|
|
706
|
+
text: t("success", { targetSeq: plan.targetSeq, restore }),
|
|
478
707
|
sourceEventSeq: event.seq
|
|
479
708
|
};
|
|
480
709
|
} finally {
|
|
@@ -484,7 +713,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
|
|
|
484
713
|
function rewindErrorResult(error) {
|
|
485
714
|
if (error instanceof RewindError) {
|
|
486
715
|
const text = {
|
|
487
|
-
"no-user-messages": "
|
|
716
|
+
"no-user-messages": t("noUserMessages"),
|
|
488
717
|
"invalid-index": error.message,
|
|
489
718
|
"not-a-user-message": error.message,
|
|
490
719
|
"not-on-surface": error.message
|
|
@@ -493,20 +722,20 @@ function rewindErrorResult(error) {
|
|
|
493
722
|
}
|
|
494
723
|
throw error;
|
|
495
724
|
}
|
|
496
|
-
async function handleRewind(ctx, store, invocation, inflight) {
|
|
725
|
+
async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
497
726
|
const session = invocation.agent.session;
|
|
498
727
|
const input = invocation.rawInput.trim();
|
|
499
728
|
if (input === "") {
|
|
500
729
|
const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
|
|
501
730
|
if (candidates.length === 0) {
|
|
502
|
-
return { kind: "error", text: "
|
|
731
|
+
return { kind: "error", text: t("noUserMessages") };
|
|
503
732
|
}
|
|
504
|
-
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
|
|
733
|
+
return executeRewind(ctx, store, fs, invocation, `@${candidates[0].seq}`, "chat", inflight);
|
|
505
734
|
}
|
|
506
735
|
const parts = input.split(/\s+/);
|
|
507
736
|
if (parts[0] === "preview") {
|
|
508
737
|
const target2 = parts[1];
|
|
509
|
-
if (target2 === void 0) return { kind: "error", text:
|
|
738
|
+
if (target2 === void 0) return { kind: "error", text: usage() };
|
|
510
739
|
let plan;
|
|
511
740
|
try {
|
|
512
741
|
plan = resolveOrError(session.events, session.surface.nodes, target2);
|
|
@@ -516,37 +745,73 @@ async function handleRewind(ctx, store, invocation, inflight) {
|
|
|
516
745
|
const impacts = await store.impactsAfter(session.id, plan.targetSeq);
|
|
517
746
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
518
747
|
}
|
|
748
|
+
if (parts[0] === "__candidates") {
|
|
749
|
+
const candidates = listRewindCandidates(session.events, session.surface.nodes);
|
|
750
|
+
return { kind: "success", text: formatCandidateList(candidates) };
|
|
751
|
+
}
|
|
519
752
|
const target = parts[0];
|
|
520
753
|
const mode = parts[1];
|
|
521
754
|
if (mode !== void 0 && mode !== "chat" && mode !== "both") {
|
|
522
|
-
return { kind: "error", text:
|
|
755
|
+
return { kind: "error", text: usage() };
|
|
523
756
|
}
|
|
524
757
|
if (mode === void 0) {
|
|
525
758
|
const parsed = parseRewindTarget(target);
|
|
526
|
-
if (parsed === void 0) return { kind: "error", text:
|
|
759
|
+
if (parsed === void 0) return { kind: "error", text: usage() };
|
|
527
760
|
return {
|
|
528
761
|
kind: "success",
|
|
529
|
-
text:
|
|
530
|
-
/rewind ${target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD
|
|
531
|
-
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
762
|
+
text: t("chooseMode", { target: describeTarget(parsed) })
|
|
532
763
|
};
|
|
533
764
|
}
|
|
534
|
-
return executeRewind(ctx, store, invocation, target, mode, inflight);
|
|
765
|
+
return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
|
|
535
766
|
}
|
|
536
767
|
function apply(ctx, config) {
|
|
537
768
|
const store = new SnapshotStore(config?.snapshotDir);
|
|
538
769
|
const pending = /* @__PURE__ */ new Map();
|
|
539
770
|
const anchorCache = /* @__PURE__ */ new WeakMap();
|
|
540
771
|
const inflight = /* @__PURE__ */ new Set();
|
|
772
|
+
const trackedBySession = /* @__PURE__ */ new Map();
|
|
773
|
+
const statesBySession = /* @__PURE__ */ new Map();
|
|
774
|
+
let fsService;
|
|
775
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
776
|
+
const section = settingsCtx.settings.get(settingsNamespace("locale"));
|
|
777
|
+
if (section?.preference === "zh" || section?.preference === "en") {
|
|
778
|
+
activeLocale = section.preference;
|
|
779
|
+
}
|
|
780
|
+
});
|
|
541
781
|
ctx.effect(function* () {
|
|
542
782
|
yield ctx.commands.register({
|
|
543
783
|
name: "rewind",
|
|
544
|
-
description: "
|
|
545
|
-
handler: (invocation) => handleRewind(ctx, store, invocation, inflight)
|
|
784
|
+
description: t("command.description"),
|
|
785
|
+
handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
|
|
546
786
|
});
|
|
547
787
|
}, "dsh-rewind command");
|
|
788
|
+
ctx.on("session/event", (session, event) => {
|
|
789
|
+
if (event.type !== "user/message") return;
|
|
790
|
+
const header = session.header;
|
|
791
|
+
if (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0) return;
|
|
792
|
+
void (async () => {
|
|
793
|
+
try {
|
|
794
|
+
const sessionId = session.id;
|
|
795
|
+
let tracked = trackedBySession.get(sessionId);
|
|
796
|
+
if (tracked === void 0) {
|
|
797
|
+
tracked = await store.trackedPaths(sessionId);
|
|
798
|
+
trackedBySession.set(sessionId, tracked);
|
|
799
|
+
}
|
|
800
|
+
if (tracked.size === 0) return;
|
|
801
|
+
let states = statesBySession.get(sessionId);
|
|
802
|
+
if (states === void 0) {
|
|
803
|
+
states = /* @__PURE__ */ new Map();
|
|
804
|
+
statesBySession.set(sessionId, states);
|
|
805
|
+
}
|
|
806
|
+
await reconcileTracked(store, sessionId, event.seq, tracked, states);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
809
|
+
}
|
|
810
|
+
})();
|
|
811
|
+
}, { global: true });
|
|
548
812
|
ctx.inject(["fs"], (scope) => {
|
|
549
813
|
const fs = scope.fs;
|
|
814
|
+
fsService = fs;
|
|
550
815
|
scope.on("tools/execute", async (exec, next) => {
|
|
551
816
|
try {
|
|
552
817
|
await captureBefore(fs, exec, pending);
|
|
@@ -557,7 +822,7 @@ function apply(ctx, config) {
|
|
|
557
822
|
});
|
|
558
823
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
559
824
|
try {
|
|
560
|
-
await commitEntry(store, pending, anchorCache, exec, result);
|
|
825
|
+
await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
|
|
561
826
|
} catch (error) {
|
|
562
827
|
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
563
828
|
}
|
|
@@ -15,6 +15,14 @@ import type { RewindKey } from './locales.ts';
|
|
|
15
15
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
16
16
|
/** Preview length cap for candidate rows (matches the host's candidate list). */
|
|
17
17
|
export declare const PREVIEW_CHARS = 80;
|
|
18
|
+
/**
|
|
19
|
+
* Default cap on how many user messages the rewind picker lists (newest kept).
|
|
20
|
+
*
|
|
21
|
+
* A fixed 10 made long sessions look "incomplete" (only the newest 10 shown).
|
|
22
|
+
* 50 keeps the picker scrollable/searchable via the popupSelect shell while
|
|
23
|
+
* covering far longer sessions; callers can still pass an explicit `limit`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
18
26
|
/** One selectable rewind target. */
|
|
19
27
|
export interface RewindCandidate {
|
|
20
28
|
/** Absolute log seq of the `user/message` event. */
|
|
@@ -73,4 +81,21 @@ export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandi
|
|
|
73
81
|
export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
|
|
74
82
|
/** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
|
|
75
83
|
export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* Parse the host's candidate-list encoding (see `formatCandidateList` in
|
|
86
|
+
* src/rewind.ts) into typed candidates. Malformed lines are skipped; a
|
|
87
|
+
* missing/zero header yields an empty list.
|
|
88
|
+
*/
|
|
89
|
+
export declare function rewindCandidatesFromHostText(text: string): RewindCandidate[];
|
|
90
|
+
/**
|
|
91
|
+
* Map typed candidates to popupSelect rows (the host-derived path). The
|
|
92
|
+
* popupSelect sources its options from the FULL host surface via the
|
|
93
|
+
* `__candidates` channel instead of the windowed chat snapshot.
|
|
94
|
+
*/
|
|
95
|
+
export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
|
|
96
|
+
/**
|
|
97
|
+
* Parse the host's candidate-list encoding (see `formatCandidateList` in
|
|
98
|
+
* src/rewind.ts) into popupSelect rows.
|
|
99
|
+
*/
|
|
100
|
+
export declare function rewindOptionsFromHostText(text: string, t: Translate): SelectOption[];
|
|
76
101
|
export {};
|