dsh-rewind-plugin 0.1.5 → 0.1.7
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 +11 -10
- package/lib/client.js +48 -5
- package/lib/index.js +18 -23
- package/package.json +1 -1
- package/scripts/verify-host.mjs +10 -8
package/README.md
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
|
|
4
4
|
|
|
5
|
-
> 状态:v0.1.
|
|
5
|
+
> 状态:v0.1.7 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
|
|
6
6
|
|
|
7
|
-
## 实现状态(v0.1.
|
|
7
|
+
## 实现状态(v0.1.7)
|
|
8
8
|
|
|
9
9
|
- ✅ host 端 `/rewind` 命令(两步文本引导 + 直接执行 + `preview` 影响清单)
|
|
10
10
|
- ✅ host 端变更台账(`tools/execute` 捕获 before、`tools/post-execute` 提交),按会话隔离
|
|
11
11
|
- ✅ **与其他审批类插件共存**:捕获在 around-dispatch 阶段,`tools/pre-execute` 被 `ask` 短路(如 dsh-edit-approval)后批准仍能记录;被拒绝的调用不留 pending 残留
|
|
12
12
|
- ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd 规则),相对路径台账/还原指向真实文件;台账记录解析后的 display path
|
|
13
13
|
- ✅ **fs 服务动态获取**(`ctx.inject(['fs'])`):fs 后挂载也不失效,无 fs 部署时命令仍可用
|
|
14
|
-
- ✅ 同窗口 in-place
|
|
14
|
+
- ✅ 同窗口 in-place 回退:追加空内容标记 + `surfaceOp: replace` 替换目标及之后全部 surface(真实 `dsh-session` 集成测试通过)
|
|
15
15
|
- ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)
|
|
16
16
|
- ✅ 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(14 项,含审批短路/会话 cwd 场景)
|
|
17
17
|
- ⏳ 二期:快捷键、git-first 快照式文件回退、命令路径的 client 两步浮层接管
|
|
@@ -68,14 +68,15 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
68
68
|
|
|
69
69
|
- 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
|
|
70
70
|
「回退对话和代码」(后者先展示影响清单再确认)。
|
|
71
|
-
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
- **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(按钮或 `/rewind <目标>`),
|
|
72
|
+
效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
|
|
73
|
+
Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
|
|
74
|
+
可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
|
|
74
75
|
- 键盘流:`/rewind` → 选消息 → `/rewind <序号> chat|both`;`/rewind preview <目标>`
|
|
75
76
|
只输出影响清单不执行。
|
|
76
|
-
- **回退后前端与 Agent
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
- **回退后前端与 Agent 一致**:回退标记是空内容消息(deriveMessages 会跳过,模型
|
|
78
|
+
上下文无任何标记噪音);client 端隐藏被撤回范围内的消息行与 `/rewind` 命令结果,
|
|
79
|
+
可见对话即"撤回点之前的内容"。会话日志(append-only 审计)不受影响。
|
|
79
80
|
|
|
80
81
|
## 已知限制(v0.1)
|
|
81
82
|
|
|
@@ -125,7 +126,7 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
125
126
|
|
|
126
127
|
### 3. 同窗口 in-place 对话回退
|
|
127
128
|
|
|
128
|
-
- `Session.append('
|
|
129
|
+
- `Session.append('assistant/message', { turn, step, message: 空标记 }, { surfaceOp: { op:'replace', start, end }, sourceEventSeqs })`:追加**空内容**标记(deriveMessages 跳过 → 模型上下文无噪音),把目标及其之后的所有 surface 节点从模型上下文替换掉。
|
|
129
130
|
- 效果:当前窗口上下文从目标点重新开始;**不产生新会话、不切换窗口**;原始日志完整保留(append-only 审计不变),仅不再进入模型上下文。
|
|
130
131
|
- 依赖:`@deepseek-ai/dsh-session`(`Session.append`、`foldSurface`)、`@deepseek-ai/dsh-llm`(`createUserMessage`)、`@deepseek-ai/dsh-commands`(命令注册)、`@deepseek-ai/dsh-agent`(`Agent.status` idle 守卫)。
|
|
131
132
|
|
package/lib/client.js
CHANGED
|
@@ -387,7 +387,7 @@ var NS = "rewind";
|
|
|
387
387
|
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
|
|
388
388
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
389
389
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
390
|
-
var
|
|
390
|
+
var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
391
391
|
function targetOfOutcome(text) {
|
|
392
392
|
if (text === void 0) return void 0;
|
|
393
393
|
const match = text.match(/seq (\d+)/);
|
|
@@ -417,11 +417,32 @@ function hiddenSeqsOf(session) {
|
|
|
417
417
|
const node = snap.chat.nodes.get(key);
|
|
418
418
|
if (node === void 0) continue;
|
|
419
419
|
const anchor = node.anchorSeq;
|
|
420
|
-
if (anchor
|
|
420
|
+
if (anchor >= latest.target && anchor <= latest.marker) hidden.add(anchor);
|
|
421
421
|
}
|
|
422
422
|
}
|
|
423
423
|
return hidden;
|
|
424
424
|
}
|
|
425
|
+
function userTextAt(session, seq) {
|
|
426
|
+
const snap = session.getSnapshot();
|
|
427
|
+
for (const key of snap.chat.order) {
|
|
428
|
+
const node = snap.chat.nodes.get(key);
|
|
429
|
+
if (node === void 0 || node.kind !== "user") continue;
|
|
430
|
+
const user = node.data;
|
|
431
|
+
if (user.seq === seq) {
|
|
432
|
+
return user.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return void 0;
|
|
436
|
+
}
|
|
437
|
+
function fillComposer(text) {
|
|
438
|
+
const textarea = document.querySelector(COMPOSER_SELECTOR);
|
|
439
|
+
if (textarea === null) return false;
|
|
440
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
|
441
|
+
setter?.call(textarea, text);
|
|
442
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
443
|
+
textarea.focus();
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
425
446
|
function apply(ctx) {
|
|
426
447
|
ctx.effect(function* () {
|
|
427
448
|
yield ctx.locale.register(NS, { zh, en });
|
|
@@ -485,24 +506,46 @@ function apply(ctx) {
|
|
|
485
506
|
}
|
|
486
507
|
const session = sessionFor();
|
|
487
508
|
const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
|
|
509
|
+
let hiddenCount = 0;
|
|
488
510
|
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
489
511
|
const key = seat.dataset.chatAnchorKey;
|
|
490
512
|
const anchor = key !== void 0 && session !== void 0 ? session.getSnapshot().chat.nodes.get(key)?.anchorSeq : void 0;
|
|
491
|
-
|
|
492
|
-
if (anchor !== void 0 && hiddenSeqs.has(anchor) || isMarker) {
|
|
513
|
+
if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
|
|
493
514
|
seat.style.display = "none";
|
|
494
515
|
hidden.add(seat);
|
|
516
|
+
hiddenCount += 1;
|
|
495
517
|
} else if (hidden.has(seat)) {
|
|
496
518
|
seat.style.display = "";
|
|
497
519
|
hidden.delete(seat);
|
|
498
520
|
}
|
|
499
521
|
}
|
|
522
|
+
if (session !== void 0) fillComposerForRewind(session, filledTargets);
|
|
523
|
+
if (hiddenSeqs.size > 0 || hiddenCount > 0) {
|
|
524
|
+
console.info(
|
|
525
|
+
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
500
528
|
for (const seat of document.querySelectorAll(USER_SEAT_SELECTOR)) {
|
|
501
529
|
if (!hidden.has(seat)) attach(seat);
|
|
502
530
|
}
|
|
503
531
|
};
|
|
532
|
+
const filledTargets = /* @__PURE__ */ new Set();
|
|
533
|
+
const fillComposerForRewind = (session, filled) => {
|
|
534
|
+
const snap = session.getSnapshot();
|
|
535
|
+
for (const key of snap.chat.order) {
|
|
536
|
+
const node = snap.chat.nodes.get(key);
|
|
537
|
+
if (node === void 0 || node.kind !== "command") continue;
|
|
538
|
+
const command = node.data;
|
|
539
|
+
if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
|
|
540
|
+
const target = targetOfOutcome(command.outcome.text);
|
|
541
|
+
if (target === void 0 || filled.has(target)) continue;
|
|
542
|
+
const text = userTextAt(session, target);
|
|
543
|
+
if (text === void 0 || text === "") continue;
|
|
544
|
+
if (fillComposer(text)) filled.add(target);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
504
547
|
observer = new MutationObserver(scan);
|
|
505
|
-
observer.observe(document.body, { childList: true, subtree: true });
|
|
548
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
|
|
506
549
|
scan();
|
|
507
550
|
yield () => {
|
|
508
551
|
observer?.disconnect();
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
|
|
5
5
|
// src/session-cwd.ts
|
|
@@ -169,16 +169,7 @@ function planRewind(events, surface, target) {
|
|
|
169
169
|
`user message at seq ${targetSeq} is no longer in the model context (shadowed by compaction)`
|
|
170
170
|
);
|
|
171
171
|
}
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
targetSeq,
|
|
175
|
-
targetIndex,
|
|
176
|
-
shadowedSeqs: [targetSeq],
|
|
177
|
-
surfaceStart: targetSeq,
|
|
178
|
-
surfaceEnd: targetSeq
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
const shadowedSeqs = surface.slice(targetIndex + 1);
|
|
172
|
+
const shadowedSeqs = surface.slice(targetIndex);
|
|
182
173
|
return {
|
|
183
174
|
targetSeq,
|
|
184
175
|
targetIndex,
|
|
@@ -280,16 +271,21 @@ async function commitEntry(fs, ledgerFor, pending, exec, result) {
|
|
|
280
271
|
after
|
|
281
272
|
});
|
|
282
273
|
}
|
|
283
|
-
function buildMarker(
|
|
284
|
-
return
|
|
285
|
-
content: [
|
|
286
|
-
|
|
287
|
-
text: `__DSH_REWIND__:{"target":${targetSeq}}
|
|
288
|
-
[\u56DE\u9000\u6807\u8BB0 / rewind marker] \u5BF9\u8BDD\u5DF2\u56DE\u9000\u5230 seq ${targetSeq}\uFF0C\u6B64\u6807\u8BB0\u4E4B\u540E\u7684\u5386\u53F2\u5DF2\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u4E2D\u79FB\u9664\u3002\u8BF7\u5FFD\u7565\u6B64\u6807\u8BB0\u672C\u8EAB\uFF0C\u7B49\u5F85\u7528\u6237\u7684\u4E0B\u4E00\u6761\u6D88\u606F\u3002`
|
|
289
|
-
}],
|
|
290
|
-
source: { kind: "user" }
|
|
274
|
+
function buildMarker() {
|
|
275
|
+
return createAssistantMessage({
|
|
276
|
+
content: [],
|
|
277
|
+
source: { provider: "dsh-rewind", model: "rewind-marker" }
|
|
291
278
|
});
|
|
292
279
|
}
|
|
280
|
+
function nextTurnOf(session) {
|
|
281
|
+
let max = -1;
|
|
282
|
+
for (const event of session.events) {
|
|
283
|
+
if (event.type === "turn/start" || event.type === "turn/end" || event.type === "assistant/message") {
|
|
284
|
+
if (event.data.turn > max) max = event.data.turn;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return max + 1;
|
|
288
|
+
}
|
|
293
289
|
function describeTarget(target) {
|
|
294
290
|
return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
|
|
295
291
|
}
|
|
@@ -341,10 +337,10 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
|
341
337
|
} catch (error) {
|
|
342
338
|
return rewindErrorResult(error);
|
|
343
339
|
}
|
|
344
|
-
const marker = buildMarker(
|
|
340
|
+
const marker = buildMarker();
|
|
345
341
|
let event;
|
|
346
342
|
try {
|
|
347
|
-
event = agent.session.append("
|
|
343
|
+
event = agent.session.append("assistant/message", { turn: nextTurnOf(agent.session), step: 0, message: marker }, {
|
|
348
344
|
surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
|
|
349
345
|
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
350
346
|
});
|
|
@@ -369,10 +365,9 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
|
369
365
|
restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
|
|
370
366
|
}
|
|
371
367
|
}
|
|
372
|
-
const withdrewLatest = plan.shadowedSeqs[0] === plan.targetSeq;
|
|
373
368
|
return {
|
|
374
369
|
kind: "success",
|
|
375
|
-
text:
|
|
370
|
+
text: `\u5DF2\u64A4\u56DE seq ${plan.targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09${restore}\u3002`,
|
|
376
371
|
sourceEventSeq: event.seq
|
|
377
372
|
};
|
|
378
373
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
package/scripts/verify-host.mjs
CHANGED
|
@@ -90,12 +90,12 @@ check('command registered', typeof registered?.handler === 'function' && registe
|
|
|
90
90
|
const listResult = await call(agent, '')
|
|
91
91
|
check('bare /rewind lists candidates', listResult.kind === 'success' && listResult.text.includes('second question') && listResult.text.includes('first question'), listResult.text)
|
|
92
92
|
|
|
93
|
-
// 3. /rewind @2 chat
|
|
93
|
+
// 3. /rewind @2 chat withdraws the target (seq 2) and everything after it
|
|
94
94
|
const before = [...session.surface.nodes]
|
|
95
95
|
const chatResult = await call(agent, '@2 chat')
|
|
96
96
|
const after = [...session.surface.nodes]
|
|
97
97
|
check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
|
|
98
|
-
check('surface cut to [0,1,
|
|
98
|
+
check('surface cut to [0,1,marker] (target withdrawn)', after.length === 3 && after[0] === 0 && after[1] === 1 && after[2] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
|
|
99
99
|
check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
|
|
100
100
|
|
|
101
101
|
const writeExec = (callId, filePath, content) => ({
|
|
@@ -141,27 +141,29 @@ const writeExec = (callId, filePath, content) => ({
|
|
|
141
141
|
check('both restores cwd-resolved file', both.kind === 'success' && fs.files.get('/workspace/rel.txt') === 'relative original', both.text)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// 7. preview reports the impact (rewind to seq
|
|
145
|
-
|
|
144
|
+
// 7. preview reports the impact (rewind to seq 0 — still on the surface after
|
|
145
|
+
// the earlier withdraw — reverts the anchor-0 write)
|
|
146
|
+
const previewResult = await call(agent, 'preview @0 both')
|
|
146
147
|
check('preview shows file impact', previewResult.kind === 'success' && previewResult.text.includes('/workspace/a.txt'), previewResult.text)
|
|
147
148
|
|
|
148
149
|
// 8. both mode restores the file
|
|
149
|
-
const bothResult = await call(agent, '@
|
|
150
|
+
const bothResult = await call(agent, '@0 both')
|
|
150
151
|
check('rewind both restores file', bothResult.kind === 'success' && bothResult.text.includes('还原 1 个文件'), bothResult.text)
|
|
151
152
|
check('file content restored', fs.files.get('/workspace/a.txt') === 'original content', fs.files.get('/workspace/a.txt'))
|
|
152
153
|
|
|
153
154
|
// 9. a running agent is force-stopped before the rewind (not refused)
|
|
154
|
-
|
|
155
|
+
const runningSession = buildSession('verify-running')
|
|
155
156
|
const running = {
|
|
156
|
-
...
|
|
157
|
+
...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running',
|
|
157
158
|
cancel: () => { cancelled = true; running.status = 'idle' },
|
|
158
159
|
}
|
|
160
|
+
let cancelled = false
|
|
159
161
|
const runningResult = await registered.handler({ commandId: Symbol('cid'), agent: running, rawInput: '@2 chat', signal: aborted() })
|
|
160
162
|
check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
|
|
161
163
|
check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
|
|
162
164
|
|
|
163
165
|
// 9b. a cancel that never quiesces aborts the rewind (timeout path)
|
|
164
|
-
const stuck = { ...
|
|
166
|
+
const stuck = { ...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running', cancel: () => {} }
|
|
165
167
|
const stuckResult = await registered.handler({ commandId: Symbol('cid'), agent: stuck, rawInput: '@2 chat', signal: aborted() })
|
|
166
168
|
check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
|
|
167
169
|
|