dsh-rewind-plugin 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +133 -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,54 @@ __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 hiddenSeqsOf(snap) {
|
|
42
|
+
const hidden = /* @__PURE__ */ new Set();
|
|
43
|
+
let minTarget = Number.POSITIVE_INFINITY;
|
|
44
|
+
let maxMarker = Number.NEGATIVE_INFINITY;
|
|
45
|
+
for (const key of snap.order) {
|
|
46
|
+
const node = snap.nodes.get(key);
|
|
47
|
+
if (node === void 0 || node.kind !== "command") continue;
|
|
48
|
+
const command = node.data;
|
|
49
|
+
if (command.name !== "rewind") continue;
|
|
50
|
+
if (command.outcome?.kind !== "success") continue;
|
|
51
|
+
if (command.outcome.sourceEventSeq === void 0) continue;
|
|
52
|
+
hidden.add(command.seq);
|
|
53
|
+
const marker = command.outcome.sourceEventSeq;
|
|
54
|
+
const target = targetOfOutcome(command.outcome.text);
|
|
55
|
+
if (target !== void 0) {
|
|
56
|
+
if (target < minTarget) minTarget = target;
|
|
57
|
+
if (marker > maxMarker) maxMarker = marker;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (Number.isFinite(minTarget)) {
|
|
61
|
+
for (const key of snap.order) {
|
|
62
|
+
const node = snap.nodes.get(key);
|
|
63
|
+
if (node === void 0) continue;
|
|
64
|
+
const anchor = node.anchorSeq;
|
|
65
|
+
if (anchor >= minTarget && anchor <= maxMarker) hidden.add(anchor);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return hidden;
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
// src/client/locales.ts
|
|
36
72
|
var zh = {
|
|
37
73
|
"button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
|
|
38
74
|
"button.title": "\u56DE\u9000",
|
|
39
75
|
"popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
|
|
40
|
-
"popover.
|
|
76
|
+
"popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
|
|
41
77
|
"popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
|
|
42
78
|
"popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
|
|
43
79
|
"popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
|
|
44
80
|
"popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
|
|
81
|
+
"popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
|
|
82
|
+
"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
83
|
"popover.cancel": "\u53D6\u6D88",
|
|
46
84
|
"popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
|
|
47
85
|
"popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
|
|
@@ -54,11 +92,13 @@ var en = {
|
|
|
54
92
|
"button.aria": "Rewind to this message",
|
|
55
93
|
"button.title": "Rewind",
|
|
56
94
|
"popover.title": "Rewind to this message",
|
|
57
|
-
"popover.
|
|
95
|
+
"popover.noText": "(no text)",
|
|
58
96
|
"popover.chat": "Rewind conversation only",
|
|
59
97
|
"popover.chat.hint": "Cut the model context only; workspace files stay untouched",
|
|
60
98
|
"popover.both": "Rewind conversation and code",
|
|
61
99
|
"popover.both.hint": "Cut the context and restore workspace files",
|
|
100
|
+
"popover.checking": "Checking for file changes\u2026",
|
|
101
|
+
"popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
|
|
62
102
|
"popover.cancel": "Cancel",
|
|
63
103
|
"popover.impact.loading": "Fetching impact list\u2026",
|
|
64
104
|
"popover.impact.failed": "Could not fetch the impact list: {message}",
|
|
@@ -114,9 +154,10 @@ var STYLE = `
|
|
|
114
154
|
z-index: 1000;
|
|
115
155
|
width: 288px;
|
|
116
156
|
padding: 12px;
|
|
157
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
117
158
|
border-radius: 12px;
|
|
118
|
-
background: var(--dsw-specific-
|
|
119
|
-
box-shadow:
|
|
159
|
+
background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
|
|
160
|
+
box-shadow: var(--dsw-shadow-lv3);
|
|
120
161
|
font-size: 14px;
|
|
121
162
|
line-height: 20px;
|
|
122
163
|
color: var(--dsw-alias-label-primary);
|
|
@@ -151,6 +192,10 @@ var STYLE = `
|
|
|
151
192
|
.dsh-rewind-popover-option:hover {
|
|
152
193
|
background: var(--dsw-alias-interactive-bg-hover);
|
|
153
194
|
}
|
|
195
|
+
.dsh-rewind-popover-option:disabled {
|
|
196
|
+
opacity: 0.5;
|
|
197
|
+
cursor: default;
|
|
198
|
+
}
|
|
154
199
|
.dsh-rewind-popover-option-label {
|
|
155
200
|
font-weight: 500;
|
|
156
201
|
}
|
|
@@ -187,8 +232,11 @@ var STYLE = `
|
|
|
187
232
|
cursor: pointer;
|
|
188
233
|
}
|
|
189
234
|
.dsh-rewind-popover-primary {
|
|
190
|
-
background: var(--dsw-alias-
|
|
191
|
-
color: var(--dsw-alias-
|
|
235
|
+
background: var(--dsw-alias-button-primary-fill);
|
|
236
|
+
color: var(--dsw-alias-label-primary-foreground);
|
|
237
|
+
}
|
|
238
|
+
.dsh-rewind-popover-primary:hover:not(:disabled) {
|
|
239
|
+
background: var(--dsw-alias-button-primary-hover);
|
|
192
240
|
}
|
|
193
241
|
.dsh-rewind-popover-primary:disabled {
|
|
194
242
|
opacity: 0.5;
|
|
@@ -207,9 +255,10 @@ var STYLE = `
|
|
|
207
255
|
z-index: 1000;
|
|
208
256
|
max-width: min(440px, calc(100vw - 24px));
|
|
209
257
|
padding: 8px 12px;
|
|
258
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
210
259
|
border-radius: 10px;
|
|
211
|
-
background: var(--dsw-specific-
|
|
212
|
-
box-shadow:
|
|
260
|
+
background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
|
|
261
|
+
box-shadow: var(--dsw-shadow-lv3);
|
|
213
262
|
font-size: 13px;
|
|
214
263
|
line-height: 18px;
|
|
215
264
|
color: var(--dsw-alias-label-primary);
|
|
@@ -230,11 +279,11 @@ function closePopover() {
|
|
|
230
279
|
disposeOutside = null;
|
|
231
280
|
}
|
|
232
281
|
}
|
|
233
|
-
function formatTarget(seq, time, preview) {
|
|
282
|
+
function formatTarget(t, seq, time, preview) {
|
|
234
283
|
const d = new Date(time);
|
|
235
284
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
236
285
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
237
|
-
const previewText = preview.length > 0 ? preview : "
|
|
286
|
+
const previewText = preview.length > 0 ? preview : t("popover.noText");
|
|
238
287
|
return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
|
|
239
288
|
}
|
|
240
289
|
function findCommand(snapshot, match) {
|
|
@@ -269,6 +318,15 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
|
|
|
269
318
|
check();
|
|
270
319
|
});
|
|
271
320
|
}
|
|
321
|
+
function isPreviewFor(node, seq) {
|
|
322
|
+
const args = node.args ?? "";
|
|
323
|
+
return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
|
|
324
|
+
}
|
|
325
|
+
async function previewImpact(session, seq) {
|
|
326
|
+
const result = await session.command(`/rewind preview @${seq} both`);
|
|
327
|
+
if (!result.ok || result.value?.matched !== true) return null;
|
|
328
|
+
return waitForCommand(session, (node) => isPreviewFor(node, seq));
|
|
329
|
+
}
|
|
272
330
|
function el(tag, className, text) {
|
|
273
331
|
const node = document.createElement(tag);
|
|
274
332
|
node.className = className;
|
|
@@ -285,7 +343,7 @@ function modeOption(label, hint, onClick) {
|
|
|
285
343
|
button.addEventListener("click", onClick);
|
|
286
344
|
return button;
|
|
287
345
|
}
|
|
288
|
-
function renderImpactStep(root, opts, back) {
|
|
346
|
+
function renderImpactStep(root, opts, back, cached) {
|
|
289
347
|
const { session, seq, t } = opts;
|
|
290
348
|
const impact = el("div", CLASS.popoverImpact, t("popover.impact.loading"));
|
|
291
349
|
const actions = el("div", CLASS.popoverActions);
|
|
@@ -302,24 +360,10 @@ function renderImpactStep(root, opts, back) {
|
|
|
302
360
|
confirm.disabled = true;
|
|
303
361
|
actions.append(confirm);
|
|
304
362
|
root.replaceChildren(impact, actions);
|
|
305
|
-
let outcome = null;
|
|
306
363
|
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
|
-
);
|
|
364
|
+
const outcome = cached ?? await previewImpact(session, seq);
|
|
321
365
|
if (outcome === null) {
|
|
322
|
-
impact.textContent = t("popover.impact.failed", { message: "
|
|
366
|
+
impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
|
|
323
367
|
return;
|
|
324
368
|
}
|
|
325
369
|
if (outcome.kind === "error") {
|
|
@@ -342,41 +386,65 @@ function openPopover(opts) {
|
|
|
342
386
|
const root = el("div", CLASS.popover);
|
|
343
387
|
root.setAttribute("role", "dialog");
|
|
344
388
|
root.setAttribute("aria-label", t("popover.title"));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
el("div", CLASS.popoverTarget, formatTarget(seq, time, preview))
|
|
348
|
-
);
|
|
389
|
+
let bothState = { state: "loading" };
|
|
390
|
+
let impactOutcome = null;
|
|
349
391
|
const renderModes = () => {
|
|
350
|
-
|
|
392
|
+
const children = [
|
|
351
393
|
el("div", CLASS.popoverTitle, t("popover.title")),
|
|
352
|
-
el("div", CLASS.popoverTarget, formatTarget(seq, time, preview)),
|
|
394
|
+
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
353
395
|
modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
|
|
354
396
|
closePopover();
|
|
355
397
|
void session.command(`/rewind @${seq} chat`);
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
398
|
+
})
|
|
399
|
+
];
|
|
400
|
+
if (bothState.state === "noChanges") {
|
|
401
|
+
children.push(el("div", CLASS.popoverImpact, t("popover.noChanges")));
|
|
402
|
+
} else {
|
|
403
|
+
const option = modeOption(
|
|
404
|
+
t("popover.both"),
|
|
405
|
+
bothState.state === "loading" ? t("popover.checking") : t("popover.both.hint"),
|
|
406
|
+
() => {
|
|
407
|
+
renderImpactStep(root, opts, renderModes, impactOutcome);
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
if (bothState.state === "loading") option.disabled = true;
|
|
411
|
+
children.push(option);
|
|
412
|
+
}
|
|
413
|
+
const actions = el("div", CLASS.popoverActions);
|
|
414
|
+
const cancel = document.createElement("button");
|
|
415
|
+
cancel.type = "button";
|
|
416
|
+
cancel.className = CLASS.popoverGhost;
|
|
417
|
+
cancel.textContent = t("popover.cancel");
|
|
418
|
+
cancel.addEventListener("click", closePopover);
|
|
419
|
+
actions.append(cancel);
|
|
420
|
+
children.push(actions);
|
|
421
|
+
root.replaceChildren(...children);
|
|
422
|
+
};
|
|
423
|
+
const position = () => {
|
|
424
|
+
const rect = anchor.getBoundingClientRect();
|
|
425
|
+
const gap = 4;
|
|
426
|
+
const height = root.offsetHeight;
|
|
427
|
+
const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
|
|
428
|
+
root.style.top = `${Math.round(top)}px`;
|
|
429
|
+
root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
|
|
371
430
|
};
|
|
372
431
|
renderModes();
|
|
373
432
|
document.body.append(root);
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
433
|
+
position();
|
|
434
|
+
const hasFileImpact = (text) => text === void 0 || text.includes("\u5C06\u5F71\u54CD");
|
|
435
|
+
void (async () => {
|
|
436
|
+
const outcome = await previewImpact(session, seq);
|
|
437
|
+
impactOutcome = outcome;
|
|
438
|
+
if (outcome !== null && outcome.kind === "success") {
|
|
439
|
+
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
440
|
+
}
|
|
441
|
+
renderModes();
|
|
442
|
+
position();
|
|
443
|
+
})().catch(() => {
|
|
444
|
+
bothState = { state: "hasChanges" };
|
|
445
|
+
renderModes();
|
|
446
|
+
position();
|
|
447
|
+
});
|
|
380
448
|
popoverEl = root;
|
|
381
449
|
const onPointerDown = (event) => {
|
|
382
450
|
const target = event.target;
|
|
@@ -405,41 +473,9 @@ var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
|
|
|
405
473
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
406
474
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
407
475
|
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
476
|
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;
|
|
477
|
+
const text = node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
478
|
+
return text.length <= 80 ? text : `${text.slice(0, 79)}\u2026`;
|
|
443
479
|
}
|
|
444
480
|
function userTextAt(session, seq) {
|
|
445
481
|
const snap = session.getSnapshot();
|
|
@@ -474,6 +510,13 @@ function apply(ctx) {
|
|
|
474
510
|
const hidden = /* @__PURE__ */ new WeakSet();
|
|
475
511
|
const buttons = /* @__PURE__ */ new Map();
|
|
476
512
|
let observer = null;
|
|
513
|
+
const refreshButtonLabels = () => {
|
|
514
|
+
for (const button of buttons.values()) {
|
|
515
|
+
button.setAttribute("aria-label", t("button.aria"));
|
|
516
|
+
button.title = t("button.title");
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
const unsubscribeLocale = ctx.locale.subscribe(refreshButtonLabels);
|
|
477
520
|
const sessionFor = () => {
|
|
478
521
|
const sessionId = ctx.sessions.list.getSnapshot().current;
|
|
479
522
|
if (sessionId === void 0) return void 0;
|
|
@@ -524,7 +567,7 @@ function apply(ctx) {
|
|
|
524
567
|
if (!button.isConnected) buttons.delete(key);
|
|
525
568
|
}
|
|
526
569
|
const session = sessionFor();
|
|
527
|
-
const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
|
|
570
|
+
const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session.getSnapshot().chat) : /* @__PURE__ */ new Set();
|
|
528
571
|
let hiddenCount = 0;
|
|
529
572
|
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
530
573
|
const key = seat.dataset.chatAnchorKey;
|
|
@@ -621,6 +664,7 @@ function apply(ctx) {
|
|
|
621
664
|
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
|
|
622
665
|
scan();
|
|
623
666
|
yield () => {
|
|
667
|
+
unsubscribeLocale();
|
|
624
668
|
document.removeEventListener("keydown", onKeyDownGuard, true);
|
|
625
669
|
document.removeEventListener("click", onClickGuard, true);
|
|
626
670
|
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 {
|