dsh-rewind-plugin 0.2.0 → 0.2.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 +195 -188
- 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 +140 -89
- package/lib/index.js +66 -35
- package/lib/types/client/hidden.d.ts +33 -0
- package/lib/types/client/locales.d.ts +6 -2
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/snapshot.d.ts +17 -3
- package/package.json +4 -3
- package/scripts/build.mjs +0 -89
- package/scripts/verify-host.mjs +0 -206
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# DeepSeek Harness interface reference
|
|
2
|
+
|
|
3
|
+
> Maintainer doc: the harness subsystems this plugin depends on, and the key
|
|
4
|
+
> source files behind each interface. Local fork (if present):
|
|
5
|
+
> `<workspace>/oss/deepseek-harness/` — official repo:
|
|
6
|
+
> [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
|
|
7
|
+
|
|
8
|
+
## Subsystem docs (`docs/subsystems/`)
|
|
9
|
+
|
|
10
|
+
- [session.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session.md) — `Session` / `SessionStore` / event model (`Session.append`, `surfaceOp`, `sourceEventSeqs`)
|
|
11
|
+
- [core.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/core.md) — `Agent` (`status`, `session`) and other core types
|
|
12
|
+
- [commands.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/commands.md) — command registration (`ctx.commands.register`, `CommandInvocation`, `CommandResult`)
|
|
13
|
+
- [tools.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/tools.md) — tool execution seam (`tools/pre-execute` / `tools/post-execute`, `ToolExecution`)
|
|
14
|
+
- [session-query.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-query.md) — session query / `foldSurface` read-only interfaces
|
|
15
|
+
|
|
16
|
+
Also under `docs/` at the repo root: `persistence-catalog.md` (full
|
|
17
|
+
`SessionEventMap`), `tool-catalog.md` (tool inventory), `config-catalog.md`
|
|
18
|
+
(configuration inventory).
|
|
19
|
+
|
|
20
|
+
## Key source (`packages/`)
|
|
21
|
+
|
|
22
|
+
| Interface | File |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `Session.append`, surface validation | [packages/core/session/src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/session/src/index.ts) |
|
|
25
|
+
| `foldSurface`, replacement rules | [packages/core/session/src/surface.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/session/src/surface.ts) |
|
|
26
|
+
| `SessionEventMap`, `SurfaceOp` | [packages/core/session/src/types.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/session/src/types.ts) |
|
|
27
|
+
| `createUserMessage`, `MessageSource` | [packages/llm/llm/src/message.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/llm/llm/src/message.ts) |
|
|
28
|
+
| `CommandDefinition`, `CommandInvocation` | [packages/interaction/commands/src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/interaction/commands/src/index.ts) |
|
|
29
|
+
| `Agent` (`status` / `session`) | [packages/core/agent/src/runtime-types.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/agent/src/runtime-types.ts) |
|
|
30
|
+
| `tools/pre-execute` / `execute` / `post-execute` | [packages/core/tools/src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/tools/src/index.ts) |
|
|
31
|
+
| Client DOM anchors (`data-chat-flow-kind` / `data-chat-anchor-key`) | [packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) |
|
|
32
|
+
| User bubble rendering | [packages/client/ui-conversation/src/client/chat/MessageItem.tsx](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/ui-conversation/src/client/chat/MessageItem.tsx) |
|
|
33
|
+
| Client `SessionFace` (`command` / `cancel`) | [packages/client/runtime/src/client/contract/session.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/runtime/src/client/contract/session.ts) |
|
|
34
|
+
| Client `PendingWait` (`respond`) | [packages/client/runtime/src/client/sessions/pending.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/runtime/src/client/sessions/pending.ts) |
|
package/lib/client.js
CHANGED
|
@@ -32,16 +32,61 @@ __export(index_exports, {
|
|
|
32
32
|
});
|
|
33
33
|
module.exports = __toCommonJS(index_exports);
|
|
34
34
|
|
|
35
|
+
// src/client/hidden.ts
|
|
36
|
+
function targetOfOutcome(text) {
|
|
37
|
+
if (text === void 0) return void 0;
|
|
38
|
+
const match = text.match(/seq (\d+)/);
|
|
39
|
+
return match !== null ? Number(match[1]) : void 0;
|
|
40
|
+
}
|
|
41
|
+
function isPreviewCommand(command) {
|
|
42
|
+
return (command.args ?? "").includes("preview");
|
|
43
|
+
}
|
|
44
|
+
function hiddenSeqsOf(snap) {
|
|
45
|
+
const hidden = /* @__PURE__ */ new Set();
|
|
46
|
+
let minTarget = Number.POSITIVE_INFINITY;
|
|
47
|
+
let maxMarker = Number.NEGATIVE_INFINITY;
|
|
48
|
+
for (const key of snap.order) {
|
|
49
|
+
const node = snap.nodes.get(key);
|
|
50
|
+
if (node === void 0 || node.kind !== "command") continue;
|
|
51
|
+
const command = node.data;
|
|
52
|
+
if (command.name !== "rewind") continue;
|
|
53
|
+
if (isPreviewCommand(command)) {
|
|
54
|
+
hidden.add(command.seq);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (command.outcome?.kind !== "success") continue;
|
|
58
|
+
const marker = command.outcome.sourceEventSeq;
|
|
59
|
+
if (marker === void 0) continue;
|
|
60
|
+
hidden.add(command.seq);
|
|
61
|
+
const target = targetOfOutcome(command.outcome.text);
|
|
62
|
+
if (target !== void 0) {
|
|
63
|
+
if (target < minTarget) minTarget = target;
|
|
64
|
+
if (marker > maxMarker) maxMarker = marker;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (Number.isFinite(minTarget)) {
|
|
68
|
+
for (const key of snap.order) {
|
|
69
|
+
const node = snap.nodes.get(key);
|
|
70
|
+
if (node === void 0) continue;
|
|
71
|
+
const anchor = node.anchorSeq;
|
|
72
|
+
if (anchor >= minTarget && anchor <= maxMarker) hidden.add(anchor);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return hidden;
|
|
76
|
+
}
|
|
77
|
+
|
|
35
78
|
// src/client/locales.ts
|
|
36
79
|
var zh = {
|
|
37
80
|
"button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
|
|
38
81
|
"button.title": "\u56DE\u9000",
|
|
39
82
|
"popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
|
|
40
|
-
"popover.
|
|
83
|
+
"popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
|
|
41
84
|
"popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
|
|
42
85
|
"popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
|
|
43
86
|
"popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
|
|
44
87
|
"popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
|
|
88
|
+
"popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
|
|
89
|
+
"popover.noChanges": "\u6B64\u6D88\u606F\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u6587\u4EF6\u53D8\u66F4\uFF0C\u4EC5\u53EF\u56DE\u9000\u5BF9\u8BDD",
|
|
45
90
|
"popover.cancel": "\u53D6\u6D88",
|
|
46
91
|
"popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
|
|
47
92
|
"popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
|
|
@@ -54,11 +99,13 @@ var en = {
|
|
|
54
99
|
"button.aria": "Rewind to this message",
|
|
55
100
|
"button.title": "Rewind",
|
|
56
101
|
"popover.title": "Rewind to this message",
|
|
57
|
-
"popover.
|
|
102
|
+
"popover.noText": "(no text)",
|
|
58
103
|
"popover.chat": "Rewind conversation only",
|
|
59
104
|
"popover.chat.hint": "Cut the model context only; workspace files stay untouched",
|
|
60
105
|
"popover.both": "Rewind conversation and code",
|
|
61
106
|
"popover.both.hint": "Cut the context and restore workspace files",
|
|
107
|
+
"popover.checking": "Checking for file changes\u2026",
|
|
108
|
+
"popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
|
|
62
109
|
"popover.cancel": "Cancel",
|
|
63
110
|
"popover.impact.loading": "Fetching impact list\u2026",
|
|
64
111
|
"popover.impact.failed": "Could not fetch the impact list: {message}",
|
|
@@ -114,9 +161,10 @@ var STYLE = `
|
|
|
114
161
|
z-index: 1000;
|
|
115
162
|
width: 288px;
|
|
116
163
|
padding: 12px;
|
|
164
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
117
165
|
border-radius: 12px;
|
|
118
|
-
background: var(--dsw-specific-
|
|
119
|
-
box-shadow:
|
|
166
|
+
background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
|
|
167
|
+
box-shadow: var(--dsw-shadow-lv3);
|
|
120
168
|
font-size: 14px;
|
|
121
169
|
line-height: 20px;
|
|
122
170
|
color: var(--dsw-alias-label-primary);
|
|
@@ -151,6 +199,10 @@ var STYLE = `
|
|
|
151
199
|
.dsh-rewind-popover-option:hover {
|
|
152
200
|
background: var(--dsw-alias-interactive-bg-hover);
|
|
153
201
|
}
|
|
202
|
+
.dsh-rewind-popover-option:disabled {
|
|
203
|
+
opacity: 0.5;
|
|
204
|
+
cursor: default;
|
|
205
|
+
}
|
|
154
206
|
.dsh-rewind-popover-option-label {
|
|
155
207
|
font-weight: 500;
|
|
156
208
|
}
|
|
@@ -187,8 +239,11 @@ var STYLE = `
|
|
|
187
239
|
cursor: pointer;
|
|
188
240
|
}
|
|
189
241
|
.dsh-rewind-popover-primary {
|
|
190
|
-
background: var(--dsw-alias-
|
|
191
|
-
color: var(--dsw-alias-
|
|
242
|
+
background: var(--dsw-alias-button-primary-fill);
|
|
243
|
+
color: var(--dsw-alias-label-primary-foreground);
|
|
244
|
+
}
|
|
245
|
+
.dsh-rewind-popover-primary:hover:not(:disabled) {
|
|
246
|
+
background: var(--dsw-alias-button-primary-hover);
|
|
192
247
|
}
|
|
193
248
|
.dsh-rewind-popover-primary:disabled {
|
|
194
249
|
opacity: 0.5;
|
|
@@ -207,9 +262,10 @@ var STYLE = `
|
|
|
207
262
|
z-index: 1000;
|
|
208
263
|
max-width: min(440px, calc(100vw - 24px));
|
|
209
264
|
padding: 8px 12px;
|
|
265
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
210
266
|
border-radius: 10px;
|
|
211
|
-
background: var(--dsw-specific-
|
|
212
|
-
box-shadow:
|
|
267
|
+
background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
|
|
268
|
+
box-shadow: var(--dsw-shadow-lv3);
|
|
213
269
|
font-size: 13px;
|
|
214
270
|
line-height: 18px;
|
|
215
271
|
color: var(--dsw-alias-label-primary);
|
|
@@ -230,11 +286,11 @@ function closePopover() {
|
|
|
230
286
|
disposeOutside = null;
|
|
231
287
|
}
|
|
232
288
|
}
|
|
233
|
-
function formatTarget(seq, time, preview) {
|
|
289
|
+
function formatTarget(t, seq, time, preview) {
|
|
234
290
|
const d = new Date(time);
|
|
235
291
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
236
292
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
237
|
-
const previewText = preview.length > 0 ? preview : "
|
|
293
|
+
const previewText = preview.length > 0 ? preview : t("popover.noText");
|
|
238
294
|
return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
|
|
239
295
|
}
|
|
240
296
|
function findCommand(snapshot, match) {
|
|
@@ -269,6 +325,15 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
|
|
|
269
325
|
check();
|
|
270
326
|
});
|
|
271
327
|
}
|
|
328
|
+
function isPreviewFor(node, seq) {
|
|
329
|
+
const args = node.args ?? "";
|
|
330
|
+
return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
|
|
331
|
+
}
|
|
332
|
+
async function previewImpact(session, seq) {
|
|
333
|
+
const result = await session.command(`/rewind preview @${seq} both`);
|
|
334
|
+
if (!result.ok || result.value?.matched !== true) return null;
|
|
335
|
+
return waitForCommand(session, (node) => isPreviewFor(node, seq));
|
|
336
|
+
}
|
|
272
337
|
function el(tag, className, text) {
|
|
273
338
|
const node = document.createElement(tag);
|
|
274
339
|
node.className = className;
|
|
@@ -285,7 +350,7 @@ function modeOption(label, hint, onClick) {
|
|
|
285
350
|
button.addEventListener("click", onClick);
|
|
286
351
|
return button;
|
|
287
352
|
}
|
|
288
|
-
function renderImpactStep(root, opts, back) {
|
|
353
|
+
function renderImpactStep(root, opts, back, cached) {
|
|
289
354
|
const { session, seq, t } = opts;
|
|
290
355
|
const impact = el("div", CLASS.popoverImpact, t("popover.impact.loading"));
|
|
291
356
|
const actions = el("div", CLASS.popoverActions);
|
|
@@ -302,24 +367,10 @@ function renderImpactStep(root, opts, back) {
|
|
|
302
367
|
confirm.disabled = true;
|
|
303
368
|
actions.append(confirm);
|
|
304
369
|
root.replaceChildren(impact, actions);
|
|
305
|
-
let outcome = null;
|
|
306
370
|
void (async () => {
|
|
307
|
-
const
|
|
308
|
-
if (!result.ok || result.value?.matched !== true) {
|
|
309
|
-
impact.textContent = t("popover.impact.failed", {
|
|
310
|
-
message: result.ok ? "command not matched" : result.error?.message ?? "unknown error"
|
|
311
|
-
});
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
outcome = await waitForCommand(
|
|
315
|
-
session,
|
|
316
|
-
(node) => {
|
|
317
|
-
const args = node.args ?? "";
|
|
318
|
-
return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
|
|
319
|
-
}
|
|
320
|
-
);
|
|
371
|
+
const outcome = cached ?? await previewImpact(session, seq);
|
|
321
372
|
if (outcome === null) {
|
|
322
|
-
impact.textContent = t("popover.impact.failed", { message: "
|
|
373
|
+
impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
|
|
323
374
|
return;
|
|
324
375
|
}
|
|
325
376
|
if (outcome.kind === "error") {
|
|
@@ -342,41 +393,65 @@ function openPopover(opts) {
|
|
|
342
393
|
const root = el("div", CLASS.popover);
|
|
343
394
|
root.setAttribute("role", "dialog");
|
|
344
395
|
root.setAttribute("aria-label", t("popover.title"));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
el("div", CLASS.popoverTarget, formatTarget(seq, time, preview))
|
|
348
|
-
);
|
|
396
|
+
let bothState = { state: "loading" };
|
|
397
|
+
let impactOutcome = null;
|
|
349
398
|
const renderModes = () => {
|
|
350
|
-
|
|
399
|
+
const children = [
|
|
351
400
|
el("div", CLASS.popoverTitle, t("popover.title")),
|
|
352
|
-
el("div", CLASS.popoverTarget, formatTarget(seq, time, preview)),
|
|
401
|
+
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
353
402
|
modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
|
|
354
403
|
closePopover();
|
|
355
404
|
void session.command(`/rewind @${seq} chat`);
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
405
|
+
})
|
|
406
|
+
];
|
|
407
|
+
if (bothState.state === "noChanges") {
|
|
408
|
+
children.push(el("div", CLASS.popoverImpact, t("popover.noChanges")));
|
|
409
|
+
} else {
|
|
410
|
+
const option = modeOption(
|
|
411
|
+
t("popover.both"),
|
|
412
|
+
bothState.state === "loading" ? t("popover.checking") : t("popover.both.hint"),
|
|
413
|
+
() => {
|
|
414
|
+
renderImpactStep(root, opts, renderModes, impactOutcome);
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
if (bothState.state === "loading") option.disabled = true;
|
|
418
|
+
children.push(option);
|
|
419
|
+
}
|
|
420
|
+
const actions = el("div", CLASS.popoverActions);
|
|
421
|
+
const cancel = document.createElement("button");
|
|
422
|
+
cancel.type = "button";
|
|
423
|
+
cancel.className = CLASS.popoverGhost;
|
|
424
|
+
cancel.textContent = t("popover.cancel");
|
|
425
|
+
cancel.addEventListener("click", closePopover);
|
|
426
|
+
actions.append(cancel);
|
|
427
|
+
children.push(actions);
|
|
428
|
+
root.replaceChildren(...children);
|
|
429
|
+
};
|
|
430
|
+
const position = () => {
|
|
431
|
+
const rect = anchor.getBoundingClientRect();
|
|
432
|
+
const gap = 4;
|
|
433
|
+
const height = root.offsetHeight;
|
|
434
|
+
const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
|
|
435
|
+
root.style.top = `${Math.round(top)}px`;
|
|
436
|
+
root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
|
|
371
437
|
};
|
|
372
438
|
renderModes();
|
|
373
439
|
document.body.append(root);
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
440
|
+
position();
|
|
441
|
+
const hasFileImpact = (text) => text === void 0 || text.includes("\u5C06\u5F71\u54CD");
|
|
442
|
+
void (async () => {
|
|
443
|
+
const outcome = await previewImpact(session, seq);
|
|
444
|
+
impactOutcome = outcome;
|
|
445
|
+
if (outcome !== null && outcome.kind === "success") {
|
|
446
|
+
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
447
|
+
}
|
|
448
|
+
renderModes();
|
|
449
|
+
position();
|
|
450
|
+
})().catch(() => {
|
|
451
|
+
bothState = { state: "hasChanges" };
|
|
452
|
+
renderModes();
|
|
453
|
+
position();
|
|
454
|
+
});
|
|
380
455
|
popoverEl = root;
|
|
381
456
|
const onPointerDown = (event) => {
|
|
382
457
|
const target = event.target;
|
|
@@ -405,41 +480,9 @@ var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
|
|
|
405
480
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
406
481
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
407
482
|
var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
408
|
-
function targetOfOutcome(text) {
|
|
409
|
-
if (text === void 0) return void 0;
|
|
410
|
-
const match = text.match(/seq (\d+)/);
|
|
411
|
-
return match !== null ? Number(match[1]) : void 0;
|
|
412
|
-
}
|
|
413
483
|
function messagePreviewOf(node) {
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
function hiddenSeqsOf(session) {
|
|
417
|
-
const hidden = /* @__PURE__ */ new Set();
|
|
418
|
-
const snap = session.getSnapshot();
|
|
419
|
-
let latest = null;
|
|
420
|
-
for (const key of snap.chat.order) {
|
|
421
|
-
const node = snap.chat.nodes.get(key);
|
|
422
|
-
if (node === void 0 || node.kind !== "command") continue;
|
|
423
|
-
const command = node.data;
|
|
424
|
-
if (command.name !== "rewind") continue;
|
|
425
|
-
if (command.outcome?.kind !== "success") continue;
|
|
426
|
-
if (command.outcome.sourceEventSeq === void 0) continue;
|
|
427
|
-
hidden.add(command.seq);
|
|
428
|
-
const marker = command.outcome.sourceEventSeq;
|
|
429
|
-
const target = targetOfOutcome(command.outcome.text);
|
|
430
|
-
if (target !== void 0 && (latest === null || marker > latest.marker)) {
|
|
431
|
-
latest = { marker, target };
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
if (latest !== null) {
|
|
435
|
-
for (const key of snap.chat.order) {
|
|
436
|
-
const node = snap.chat.nodes.get(key);
|
|
437
|
-
if (node === void 0) continue;
|
|
438
|
-
const anchor = node.anchorSeq;
|
|
439
|
-
if (anchor >= latest.target && anchor <= latest.marker) hidden.add(anchor);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return hidden;
|
|
484
|
+
const text = node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
485
|
+
return text.length <= 80 ? text : `${text.slice(0, 79)}\u2026`;
|
|
443
486
|
}
|
|
444
487
|
function userTextAt(session, seq) {
|
|
445
488
|
const snap = session.getSnapshot();
|
|
@@ -474,6 +517,13 @@ function apply(ctx) {
|
|
|
474
517
|
const hidden = /* @__PURE__ */ new WeakSet();
|
|
475
518
|
const buttons = /* @__PURE__ */ new Map();
|
|
476
519
|
let observer = null;
|
|
520
|
+
const refreshButtonLabels = () => {
|
|
521
|
+
for (const button of buttons.values()) {
|
|
522
|
+
button.setAttribute("aria-label", t("button.aria"));
|
|
523
|
+
button.title = t("button.title");
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
const unsubscribeLocale = ctx.locale.subscribe(refreshButtonLabels);
|
|
477
527
|
const sessionFor = () => {
|
|
478
528
|
const sessionId = ctx.sessions.list.getSnapshot().current;
|
|
479
529
|
if (sessionId === void 0) return void 0;
|
|
@@ -524,7 +574,7 @@ function apply(ctx) {
|
|
|
524
574
|
if (!button.isConnected) buttons.delete(key);
|
|
525
575
|
}
|
|
526
576
|
const session = sessionFor();
|
|
527
|
-
const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
|
|
577
|
+
const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session.getSnapshot().chat) : /* @__PURE__ */ new Set();
|
|
528
578
|
let hiddenCount = 0;
|
|
529
579
|
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
530
580
|
const key = seat.dataset.chatAnchorKey;
|
|
@@ -621,6 +671,7 @@ function apply(ctx) {
|
|
|
621
671
|
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
|
|
622
672
|
scan();
|
|
623
673
|
yield () => {
|
|
674
|
+
unsubscribeLocale();
|
|
624
675
|
document.removeEventListener("keydown", onKeyDownGuard, true);
|
|
625
676
|
document.removeEventListener("click", onClickGuard, true);
|
|
626
677
|
if (guardHintEl !== null) guardHintEl.remove();
|
package/lib/index.js
CHANGED
|
@@ -96,8 +96,8 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// src/snapshot.ts
|
|
99
|
-
import { mkdir, readFile, readdir, rm,
|
|
100
|
-
import { join } from "node:path";
|
|
99
|
+
import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
100
|
+
import { dirname, join } from "node:path";
|
|
101
101
|
import { homedir } from "node:os";
|
|
102
102
|
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
103
103
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
@@ -105,6 +105,10 @@ var MAX_ANCHOR_GROUPS = 100;
|
|
|
105
105
|
function safeFileId(callId) {
|
|
106
106
|
return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
107
107
|
}
|
|
108
|
+
function safeSessionId(sessionId) {
|
|
109
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
110
|
+
return safe === ".." || safe === "." ? "session" : safe;
|
|
111
|
+
}
|
|
108
112
|
async function readEntry(file) {
|
|
109
113
|
try {
|
|
110
114
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
@@ -120,21 +124,29 @@ async function readEntry(file) {
|
|
|
120
124
|
return void 0;
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
|
-
async function
|
|
127
|
+
async function isLinkPath(path) {
|
|
124
128
|
try {
|
|
125
|
-
|
|
129
|
+
const stat2 = await lstat(path);
|
|
130
|
+
return stat2.isSymbolicLink() || stat2.nlink > 1;
|
|
126
131
|
} catch {
|
|
127
132
|
return false;
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
|
-
var SnapshotStore = class {
|
|
135
|
+
var SnapshotStore = class _SnapshotStore {
|
|
131
136
|
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
132
137
|
this.root = root;
|
|
133
138
|
}
|
|
134
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
|
+
}
|
|
135
147
|
/** Absolute path of one anchor group directory. */
|
|
136
148
|
anchorDir(sessionId, anchorSeq) {
|
|
137
|
-
return join(this.
|
|
149
|
+
return join(this.sessionDir(sessionId), String(anchorSeq));
|
|
138
150
|
}
|
|
139
151
|
/** Commit one before-backup under its turn's anchor group. */
|
|
140
152
|
async recordEntry(sessionId, entry) {
|
|
@@ -142,7 +154,11 @@ var SnapshotStore = class {
|
|
|
142
154
|
await mkdir(dir, { recursive: true });
|
|
143
155
|
const committed = { ...entry, time: Date.now() };
|
|
144
156
|
await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
|
|
145
|
-
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
|
|
159
|
+
this.lastPruneAt = now;
|
|
160
|
+
await this.prune(sessionId);
|
|
161
|
+
}
|
|
146
162
|
}
|
|
147
163
|
/**
|
|
148
164
|
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
@@ -152,7 +168,7 @@ var SnapshotStore = class {
|
|
|
152
168
|
* earlier messages survive.
|
|
153
169
|
*/
|
|
154
170
|
async entriesAfter(sessionId, targetSeq) {
|
|
155
|
-
const sessionDir =
|
|
171
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
156
172
|
let names;
|
|
157
173
|
try {
|
|
158
174
|
names = await readdir(sessionDir);
|
|
@@ -173,8 +189,11 @@ var SnapshotStore = class {
|
|
|
173
189
|
}
|
|
174
190
|
return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
|
|
175
191
|
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
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) {
|
|
178
197
|
const earliest = /* @__PURE__ */ new Map();
|
|
179
198
|
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
180
199
|
const current = earliest.get(entry.path);
|
|
@@ -182,7 +201,11 @@ var SnapshotStore = class {
|
|
|
182
201
|
earliest.set(entry.path, entry);
|
|
183
202
|
}
|
|
184
203
|
}
|
|
185
|
-
return
|
|
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) => ({
|
|
186
209
|
path: entry.path,
|
|
187
210
|
action: entry.before === null ? "delete" : "restore"
|
|
188
211
|
}));
|
|
@@ -191,24 +214,19 @@ var SnapshotStore = class {
|
|
|
191
214
|
* Restore the workspace to the target message's checkpoint: for every path
|
|
192
215
|
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
193
216
|
* before content back, or delete the file when it was created after the
|
|
194
|
-
* target.
|
|
195
|
-
*
|
|
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.
|
|
196
221
|
*/
|
|
197
222
|
async restoreAfter(sessionId, targetSeq, deleteFile) {
|
|
198
223
|
const restored = [];
|
|
199
224
|
const deleted = [];
|
|
200
225
|
const skipped = [];
|
|
201
226
|
const failed = [];
|
|
202
|
-
const
|
|
203
|
-
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
204
|
-
const current = earliest.get(entry.path);
|
|
205
|
-
if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
|
|
206
|
-
earliest.set(entry.path, entry);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
for (const entry of earliest.values()) {
|
|
227
|
+
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
210
228
|
try {
|
|
211
|
-
if (await
|
|
229
|
+
if (await isLinkPath(entry.path)) {
|
|
212
230
|
skipped.push(entry.path);
|
|
213
231
|
continue;
|
|
214
232
|
}
|
|
@@ -216,6 +234,7 @@ var SnapshotStore = class {
|
|
|
216
234
|
await deleteFile(entry.path);
|
|
217
235
|
deleted.push(entry.path);
|
|
218
236
|
} else {
|
|
237
|
+
await mkdir(dirname(entry.path), { recursive: true });
|
|
219
238
|
await writeFile(entry.path, entry.before, "utf8");
|
|
220
239
|
restored.push(entry.path);
|
|
221
240
|
}
|
|
@@ -230,7 +249,7 @@ var SnapshotStore = class {
|
|
|
230
249
|
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
231
250
|
*/
|
|
232
251
|
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
233
|
-
const sessionDir =
|
|
252
|
+
const sessionDir = this.sessionDir(sessionId);
|
|
234
253
|
let names;
|
|
235
254
|
try {
|
|
236
255
|
names = await readdir(sessionDir);
|
|
@@ -247,7 +266,6 @@ var SnapshotStore = class {
|
|
|
247
266
|
}
|
|
248
267
|
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
249
268
|
async exists(path) {
|
|
250
|
-
const { stat } = await import("node:fs/promises");
|
|
251
269
|
try {
|
|
252
270
|
await stat(path);
|
|
253
271
|
return true;
|
|
@@ -265,8 +283,9 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
|
|
|
265
283
|
var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
|
|
266
284
|
var USAGE = [
|
|
267
285
|
"Usage:",
|
|
268
|
-
" /rewind
|
|
269
|
-
" \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"
|
|
270
289
|
].join("\n");
|
|
271
290
|
function mutationPathOf(exec) {
|
|
272
291
|
const args = exec.arguments;
|
|
@@ -279,12 +298,19 @@ function mutationPathOf(exec) {
|
|
|
279
298
|
}
|
|
280
299
|
return void 0;
|
|
281
300
|
}
|
|
282
|
-
function anchorSeqOf(session) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
}
|
|
286
311
|
}
|
|
287
|
-
|
|
312
|
+
cache.set(session.id, { anchor, eventsLength: events.length });
|
|
313
|
+
return anchor;
|
|
288
314
|
}
|
|
289
315
|
async function resolveTarget(fs, path, cwd, signal) {
|
|
290
316
|
try {
|
|
@@ -315,7 +341,7 @@ async function captureBefore(fs, exec, pending) {
|
|
|
315
341
|
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
316
342
|
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
|
|
317
343
|
}
|
|
318
|
-
async function commitEntry(store, pending, exec, result) {
|
|
344
|
+
async function commitEntry(store, pending, anchorCache, exec, result) {
|
|
319
345
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
320
346
|
const capture = pending.get(key);
|
|
321
347
|
if (capture === void 0) return;
|
|
@@ -323,7 +349,7 @@ async function commitEntry(store, pending, exec, result) {
|
|
|
323
349
|
if (result.isError) return;
|
|
324
350
|
const agent = exec.agent;
|
|
325
351
|
if (agent === void 0) return;
|
|
326
|
-
const anchorSeq = anchorSeqOf(agent.session);
|
|
352
|
+
const anchorSeq = anchorSeqOf(agent.session, anchorCache);
|
|
327
353
|
if (anchorSeq === void 0) return;
|
|
328
354
|
await store.recordEntry(agent.session.id, {
|
|
329
355
|
callId: exec.callId,
|
|
@@ -417,7 +443,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode) {
|
|
|
417
443
|
const parts = [];
|
|
418
444
|
if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
|
|
419
445
|
if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
|
|
420
|
-
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\
|
|
446
|
+
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
|
|
421
447
|
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
|
|
422
448
|
restore += renderFailures(outcome.failed);
|
|
423
449
|
}
|
|
@@ -482,6 +508,7 @@ async function handleRewind(ctx, store, invocation) {
|
|
|
482
508
|
function apply(ctx, config) {
|
|
483
509
|
const store = new SnapshotStore(config?.snapshotDir);
|
|
484
510
|
const pending = /* @__PURE__ */ new Map();
|
|
511
|
+
const anchorCache = /* @__PURE__ */ new Map();
|
|
485
512
|
ctx.effect(function* () {
|
|
486
513
|
yield ctx.commands.register({
|
|
487
514
|
name: "rewind",
|
|
@@ -501,12 +528,16 @@ function apply(ctx, config) {
|
|
|
501
528
|
});
|
|
502
529
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
503
530
|
try {
|
|
504
|
-
await commitEntry(store, pending, exec, result);
|
|
531
|
+
await commitEntry(store, pending, anchorCache, exec, result);
|
|
505
532
|
} catch (error) {
|
|
506
533
|
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
507
534
|
}
|
|
508
535
|
return next();
|
|
509
536
|
});
|
|
537
|
+
scope.on("tools/result", (exec) => {
|
|
538
|
+
pending.delete(`${exec.agent?.id ?? "anon"}:${exec.callId}`);
|
|
539
|
+
return void 0;
|
|
540
|
+
});
|
|
510
541
|
});
|
|
511
542
|
}
|
|
512
543
|
export {
|