dsh-rewind-plugin 0.1.6 → 0.1.8

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 CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
4
4
 
5
- > 状态:v0.1.5 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
5
+ > 状态:v0.1.8 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
6
6
 
7
- ## 实现状态(v0.1.5
7
+ ## 实现状态(v0.1.8
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 回退:追加标记节点 + `surfaceOp: replace` 替换目标点之后的 surface(真实 `dsh-session` 集成测试通过)
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
- - **撤回最近消息**:对**最后一条**用户消息回退(按钮或 `/rewind <目标>`),效果是
72
- **撤回该消息本身**(发错了立即重发的常见场景)——上下文回到它之前,命令结果
73
- 提示"已撤回 seq N,可重新发送"。
71
+ - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(按钮或 `/rewind <目标>`),
72
+ 效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
73
+ Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
74
+ 可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
74
75
  - 键盘流:`/rewind` → 选消息 → `/rewind <序号> chat|both`;`/rewind preview <目标>`
75
76
  只输出影响清单不执行。
76
- - **回退后前端与 Agent 一致**:回退/撤回后,client 端自动隐藏回退标记、`/rewind`
77
- 命令结果以及被回退范围内的消息(含 agent 回复),可见对话回到回退点之前的样子;
78
- 之后新发的消息正常显示。会话日志(append-only 审计)不受影响。
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('user/message', marker, { surfaceOp: { op:'replace', start, end }, sourceEventSeqs })`:在当前会话日志内追加回退标记节点,把目标点之后的所有 surface 节点从模型上下文替换掉。
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 MARKER_TEXT = "__DSH_REWIND__";
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+)/);
@@ -405,6 +405,7 @@ function hiddenSeqsOf(session) {
405
405
  if (node === void 0 || node.kind !== "command") continue;
406
406
  const command = node.data;
407
407
  if (command.name !== "rewind") continue;
408
+ if (command.outcome?.kind !== "success") continue;
408
409
  hidden.add(command.seq);
409
410
  const marker = command.outcome?.sourceEventSeq;
410
411
  const target = targetOfOutcome(command.outcome?.text);
@@ -417,27 +418,31 @@ function hiddenSeqsOf(session) {
417
418
  const node = snap.chat.nodes.get(key);
418
419
  if (node === void 0) continue;
419
420
  const anchor = node.anchorSeq;
420
- if (anchor > latest.target && anchor <= latest.marker) hidden.add(anchor);
421
+ if (anchor >= latest.target && anchor <= latest.marker) hidden.add(anchor);
421
422
  }
422
423
  }
423
424
  return hidden;
424
425
  }
425
- function hideMarkerRows(hidden) {
426
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
427
- const rows = [];
428
- while (walker.nextNode()) {
429
- const text = walker.currentNode.textContent ?? "";
430
- if (!text.includes(MARKER_TEXT)) continue;
431
- let el2 = walker.currentNode.parentElement;
432
- while (el2 !== null && el2 !== document.body && el2.dataset?.chatAnchorKey === void 0 && el2.dataset?.chatFlowKind === void 0 && !el2.classList.contains("dsh-rewind-popover")) {
433
- el2 = el2.parentElement;
426
+ function userTextAt(session, seq) {
427
+ const snap = session.getSnapshot();
428
+ for (const key of snap.chat.order) {
429
+ const node = snap.chat.nodes.get(key);
430
+ if (node === void 0 || node.kind !== "user") continue;
431
+ const user = node.data;
432
+ if (user.seq === seq) {
433
+ return user.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("");
434
434
  }
435
- if (el2 !== null && el2 !== document.body && !hidden.has(el2)) rows.push(el2);
436
- }
437
- for (const row of rows) {
438
- row.style.display = "none";
439
- hidden.add(row);
440
435
  }
436
+ return void 0;
437
+ }
438
+ function fillComposer(text) {
439
+ const textarea = document.querySelector(COMPOSER_SELECTOR);
440
+ if (textarea === null) return false;
441
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
442
+ setter?.call(textarea, text);
443
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
444
+ textarea.focus();
445
+ return true;
441
446
  }
442
447
  function apply(ctx) {
443
448
  ctx.effect(function* () {
@@ -510,12 +515,12 @@ function apply(ctx) {
510
515
  seat.style.display = "none";
511
516
  hidden.add(seat);
512
517
  hiddenCount += 1;
513
- } else if (hidden.has(seat) && !seat.textContent?.includes(MARKER_TEXT)) {
518
+ } else if (hidden.has(seat)) {
514
519
  seat.style.display = "";
515
520
  hidden.delete(seat);
516
521
  }
517
522
  }
518
- hideMarkerRows(hidden);
523
+ if (session !== void 0) fillComposerForRewind(session, filledTargets);
519
524
  if (hiddenSeqs.size > 0 || hiddenCount > 0) {
520
525
  console.info(
521
526
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
@@ -525,6 +530,21 @@ function apply(ctx) {
525
530
  if (!hidden.has(seat)) attach(seat);
526
531
  }
527
532
  };
533
+ const filledTargets = /* @__PURE__ */ new Set();
534
+ const fillComposerForRewind = (session, filled) => {
535
+ const snap = session.getSnapshot();
536
+ for (const key of snap.chat.order) {
537
+ const node = snap.chat.nodes.get(key);
538
+ if (node === void 0 || node.kind !== "command") continue;
539
+ const command = node.data;
540
+ if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
541
+ const target = targetOfOutcome(command.outcome.text);
542
+ if (target === void 0 || filled.has(target)) continue;
543
+ const text = userTextAt(session, target);
544
+ if (text === void 0 || text === "") continue;
545
+ if (fillComposer(text)) filled.add(target);
546
+ }
547
+ };
528
548
  observer = new MutationObserver(scan);
529
549
  observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
530
550
  scan();
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
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
- if (targetIndex === surface.length - 1) {
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(targetSeq) {
284
- return createUserMessage({
285
- content: [{
286
- type: "text",
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(plan.targetSeq);
340
+ const marker = buildMarker();
345
341
  let event;
346
342
  try {
347
- event = agent.session.append("user/message", marker, {
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: withdrewLatest ? `\u5DF2\u64A4\u56DE seq ${plan.targetSeq}\uFF08\u6700\u8FD1\u4E00\u6761\u6D88\u606F\uFF09\uFF0C\u53EF\u91CD\u65B0\u53D1\u9001${restore}\u3002` : `\u5DF2\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u79FB\u9664 ${plan.shadowedSeqs.length} \u6761\u4E0A\u4E0B\u6587\uFF08\u65E5\u5FD7\u4FDD\u7559\uFF09${restore}\u3002`,
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.6",
3
+ "version": "0.1.8",
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",
@@ -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 cuts the surface in place
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,2,marker]', after.length === 4 && after[0] === 0 && after[1] === 1 && after[2] === 2 && after[3] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
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 2 reverts the anchor-2 write)
145
- const previewResult = await call(agent, 'preview @2 both')
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, '@2 both')
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
- let cancelled = false
155
+ const runningSession = buildSession('verify-running')
155
156
  const running = {
156
- ...agent, status: 'running',
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 = { ...agent, status: 'running', cancel: () => {} }
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