dsh-rewind-plugin 0.2.4 → 0.2.5

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  In-place conversation rewind for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the Claude Code `/rewind` semantics inside the **same session window** — cut the model context back to an earlier user message, and optionally restore workspace files from **disk-persisted before-backups**.
6
6
 
7
- > **Status:** published to npm (`dsh-rewind-plugin`, v0.2.4) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
7
+ > **Status:** published to npm (`dsh-rewind-plugin`, v0.2.5) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
10
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
@@ -61,6 +61,7 @@ The plugin appends an **empty-content marker** `assistant/message` into the sess
61
61
 
62
62
  - The marker carries `sourceEventSeqs` covering every shadowed node, and the `Session.append` surface rules validate the cut (only a contiguous range on the current surface).
63
63
  - Because the marker is **empty**, the harness derives it to `null` — it never enters the model context and never renders as conversation content. The agent and the user both see the conversation exactly as it was at the target.
64
+ - The marker's **turn number reuses the LAST STARTED turn** (`markerTurnOf`), never `lastTurn + 1`: the harness's agent loop numbers its next real turn exactly `last turn/start + 1`, so a marker numbered the same way would leave an `assistant/message` BEFORE the `turn/start` of the same turn — the client conversation-context builder rejects that ordering with `conversation Context …:turn-tail… received an update before its start Match`, history load fails, and the whole conversation disappears from the UI (the real defect in ≤ 0.2.4, fixed in 0.2.5). Reusing an already-consumed turn makes the marker a harmless trailing update on the previous completed turn's tail — it can never collide with a future turn.
64
65
  - The append-only log is **untouched** — the audit trail keeps every withdrawn event; only the model-visible surface is cut, so the next request derives its context from the target onward.
65
66
 
66
67
  A running turn (LLM thinking / streaming) is force-stopped first (`cancel({ kind: 'user' })`) and the rewind waits for quiescence; if it can't stop, the rewind is aborted with an error.
@@ -76,6 +77,32 @@ The plugin tracks the write-class tools — `write`, `edit`, `str_replace_editor
76
77
 
77
78
  Backups persist across host restarts, bounded to the newest 100 anchor groups per session.
78
79
 
80
+ ## 🔧 Troubleshooting: history load failure (`…turn-tail… received an update before its start Match`)
81
+
82
+ Versions ≤ 0.2.4 corrupted client replay when a rewind was **followed by further
83
+ conversation**: the marker's turn number collided with the next real turn's
84
+ `turn/start`, so reopening the session showed
85
+ `Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)`
86
+ and the history vanished. Rewinds created from 0.2.5 on no longer produce the
87
+ collision, but **already-corrupted sessions need an offline repair** (the log is
88
+ append-only — it cannot be rewritten in memory):
89
+
90
+ ```sh
91
+ # 1. Fully quit dsh web / host first (while a session is resident in memory,
92
+ # a disk repair is overwritten by the next checkpoint)
93
+ # 2. Run the offline repair script (scans every session under ~/.dsh/sessions,
94
+ # rewriting each marker's turn back to the last started turn)
95
+ node scripts/repair-markers.mjs # default: scan ~/.dsh/sessions
96
+ node scripts/repair-markers.mjs --dry-run # report only, no writes
97
+ node scripts/repair-markers.mjs --dir <sessions root> # custom data dir
98
+ # 3. Restart dsh web — the repaired sessions load their history again
99
+ ```
100
+
101
+ The script only rewrites the `data.turn` of `dsh-rewind` empty-marker events
102
+ (keeping seqs, order, and the zstd frame structure intact), backs up the original
103
+ file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
104
+ any other event — safe to run repeatedly.
105
+
79
106
  ## 📦 Install
80
107
 
81
108
  Published to npm — the registry path is the recommended one. **Restart dsh web (`--profile web`) after installing.**
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)——把模型上下文剪回更早的一条用户消息,并可基于**落盘的写前备份**还原工作区文件。
6
6
 
7
- > **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.4),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
7
+ > **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.5),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
10
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
@@ -61,6 +61,7 @@
61
61
 
62
62
  - 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
63
63
  - 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。agent 与用户看到的对话都回到目标消息当时的样子。
64
+ - 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 的 agent loop 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失(0.2.4 及之前的真实缺陷,已在 0.2.5 修复)。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
64
65
  - append-only 日志**不被改写**——审计轨迹完整保留每条被撤回的事件,只有模型可见的 surface 被剪掉,下一条请求从目标消息起派生上下文。
65
66
 
66
67
  若 agent 正在运行(LLM 思考/流式输出),会先强制停止(`cancel({ kind: 'user' })`)并等待 quiescence 再回退;停不下来则中止并报错。
@@ -76,6 +77,26 @@
76
77
 
77
78
  备份跨 host 重启持久化,每会话有界保留最近 100 组锚点。
78
79
 
80
+ ## 🔧 故障修复:历史加载失败(`…turn-tail… received an update before its start Match`)
81
+
82
+ 0.2.4 及之前版本在回退**之后继续对话**的场景下会损坏会话的客户端重放:标记的 turn 号
83
+ 与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
84
+ `历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)`,
85
+ 历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
86
+ append-only 的,不能在内存中改写):
87
+
88
+ ```sh
89
+ # 1. 先完全退出 dsh web / host(会话处于驻留内存时,磁盘修复会被下次 checkpoint 覆盖)
90
+ # 2. 运行离线修复脚本(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
91
+ node scripts/repair-markers.mjs # 默认扫描 ~/.dsh/sessions
92
+ node scripts/repair-markers.mjs --dry-run # 只报告不写盘
93
+ node scripts/repair-markers.mjs --dir <sessions 根目录> # 指定数据目录
94
+ # 3. 重启 dsh web,损坏的会话即可正常加载历史
95
+ ```
96
+
97
+ 脚本只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / 帧结构不变),
98
+ 改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
99
+
79
100
  ## 📦 安装
80
101
 
81
102
  已发布 npm——推荐走 registry 路径。**装完重启 dsh web(`--profile web`)。**
package/lib/client.js CHANGED
@@ -586,7 +586,20 @@ function apply(ctx) {
586
586
  hidden.delete(seat);
587
587
  }
588
588
  }
589
- if (session !== void 0) fillComposerForRewind(session, filledTargets);
589
+ if (session !== void 0) {
590
+ const snap = session.getSnapshot();
591
+ if (!fillBaselineTaken && snap.chat.order.length > 0) {
592
+ fillBaselineTaken = true;
593
+ for (const key of snap.chat.order) {
594
+ const node = snap.chat.nodes.get(key);
595
+ if (node?.kind === "command") {
596
+ const command = node.data;
597
+ if (command.seq > fillBaselineSeq) fillBaselineSeq = command.seq;
598
+ }
599
+ }
600
+ }
601
+ fillComposerForRewind(session, filledTargets);
602
+ }
590
603
  if (hiddenSeqs.size > 0 || hiddenCount > 0) {
591
604
  console.info(
592
605
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
@@ -597,6 +610,8 @@ function apply(ctx) {
597
610
  }
598
611
  };
599
612
  const filledTargets = /* @__PURE__ */ new Set();
613
+ let fillBaselineSeq = -1;
614
+ let fillBaselineTaken = false;
600
615
  const fillComposerForRewind = (session, filled) => {
601
616
  const snap = session.getSnapshot();
602
617
  for (const key of snap.chat.order) {
@@ -604,6 +619,7 @@ function apply(ctx) {
604
619
  if (node === void 0 || node.kind !== "command") continue;
605
620
  const command = node.data;
606
621
  if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
622
+ if (command.seq <= fillBaselineSeq) continue;
607
623
  if (command.outcome.sourceEventSeq === void 0) continue;
608
624
  const target = targetOfOutcome(command.outcome.text);
609
625
  if (target === void 0 || filled.has(target)) continue;
package/lib/index.js CHANGED
@@ -12,6 +12,15 @@ var RewindError = class extends Error {
12
12
  code;
13
13
  };
14
14
  var CANDIDATE_PREVIEW_CHARS = 80;
15
+ function markerTurnOf(events) {
16
+ let lastStarted = 0;
17
+ for (const event of events) {
18
+ if (event.type === "turn/start" && event.data.turn > lastStarted) {
19
+ lastStarted = event.data.turn;
20
+ }
21
+ }
22
+ return lastStarted;
23
+ }
15
24
  function isUserMessageEvent(event) {
16
25
  return event.type === "user/message";
17
26
  }
@@ -364,15 +373,6 @@ function buildMarker() {
364
373
  source: { provider: "dsh-rewind", model: "rewind-marker" }
365
374
  });
366
375
  }
367
- function nextTurnOf(session) {
368
- let max = -1;
369
- for (const event of session.events) {
370
- if (event.type === "turn/start" || event.type === "turn/end" || event.type === "assistant/message") {
371
- if (event.data.turn > max) max = event.data.turn;
372
- }
373
- }
374
- return max + 1;
375
- }
376
376
  function describeTarget(target) {
377
377
  return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
378
378
  }
@@ -427,7 +427,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode) {
427
427
  const marker = buildMarker();
428
428
  let event;
429
429
  try {
430
- event = agent.session.append("assistant/message", { turn: nextTurnOf(agent.session), step: 0, message: marker }, {
430
+ event = agent.session.append("assistant/message", { turn: markerTurnOf(agent.session.events), step: 0, message: marker }, {
431
431
  surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
432
432
  sourceEventSeqs: [...plan.shadowedSeqs]
433
433
  });
@@ -60,6 +60,29 @@ export interface RewindPlan {
60
60
  }
61
61
  /** Preview length cap for candidate listings. */
62
62
  export declare const CANDIDATE_PREVIEW_CHARS = 80;
63
+ /**
64
+ * Turn number for the rewind marker.
65
+ *
66
+ * The marker MUST NOT reuse the harness's next-turn number. The agent loop
67
+ * numbers its next real turn `lastTurn/start + 1` (dsh-agent-loop), so a
68
+ * marker numbered `maxTurn + 1` collides: the log then holds an
69
+ * `assistant/message` (the marker) BEFORE the `turn/start` of the same turn,
70
+ * and the client conversation-context builder rejects that ordering with
71
+ * `conversation Context …:turn-tail… received an update before its start
72
+ * Match` — history load fails and the whole conversation disappears from the
73
+ * UI (reproduced across real sessions).
74
+ *
75
+ * The marker therefore reuses the LAST STARTED turn's number: the harness has
76
+ * already consumed it (its next turn is strictly larger), so it can never be
77
+ * reused by a future `turn/start`, and the marker lands as a harmless
78
+ * trailing update on that turn's already-closed tail context (its `turn/end`
79
+ * is already matched) — no new context, no reordering, nothing rendered, and
80
+ * the empty content still derives to `null` in the model context.
81
+ *
82
+ * @param events - the full session event log.
83
+ * @returns a turn number the harness can never reuse for a future `turn/start`.
84
+ */
85
+ export declare function markerTurnOf(events: readonly SessionEvent[]): number;
63
86
  /** Narrow an event to a user message. */
64
87
  export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
65
88
  /** Join the text blocks of a message into one plain string. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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",