dsh-retrace 0.4.9 → 0.4.11

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,11 +2,9 @@
2
2
 
3
3
  # 🧭 dsh-retrace
4
4
 
5
- **Retrace · 回溯** — Recall · Edit-and-resend · Regenerate, plus **in-conversation
6
- versioning**: a timeline of every rewind, artifact rollback, and a fork map of the
7
- paths your conversation explored (roadmap). A Harness enhancement plugin for the
8
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web GUI and
9
- Desktop app (both share the same Web frontend).
5
+ **Recall · Edit-and-resend · Regenerate**, plus **write-safe** in-conversation
6
+ versioning — the **Agent business layer (production-grade guarantees)** for
7
+ DeepSeek Harness.
10
8
 
11
9
  [![npm version](https://img.shields.io/npm/v/dsh-retrace)](https://www.npmjs.com/package/dsh-retrace)
12
10
  [![npm downloads](https://img.shields.io/npm/dm/dsh-retrace)](https://www.npmjs.com/package/dsh-retrace)
@@ -18,73 +16,73 @@ Desktop app (both share the same Web frontend).
18
16
 
19
17
  </div>
20
18
 
21
- DeepSeek Harness stores every conversation as an **append-only event log**, so there is
22
- no built-in "undo". `dsh-retrace` brings back the three moves every chat deserves —
23
- **撤回 (recall)**, **编辑重发 (edit-and-resend)**, **重新生成 (regenerate)** — and then
24
- goes further: because a recall only rewinds the **context**, while files the agent
25
- already changed stay changed, retrace versions your conversation **and its artifacts**
26
- in one place.
27
-
28
- Recall / edit **remove the target messages from the conversation view and the model
29
- context** — that is exactly the effect you see. What stays untouched is the underlying
30
- **durable transcript**: it remains append-only, old events are never rewritten or deleted,
31
- and the plugin merely appends one valid replacement event (the same `replace` primitive
32
- the built-in compaction uses) to rewind the surface — so the log keeps a full audit trail
33
- of every rewind. On top of that trail, retrace records version boundaries, touched files
34
- and (optionally) git state, and lets you roll back artifacts or jump back to any point
35
- in the conversation — all **inside the same session**, no session-switching.
36
-
37
- > ✅ **Timeline + artifact rollback are live (0.4.x)** — recall / edit /
38
- > regenerate, the version timeline, artifact rollback (git-first, snapshot
39
- > fallback), jump-to-conversation and marker pre-write validation (three-layer
40
- > contract guard) are all in. The fork map (P2) is in progress per [PLAN.md](./PLAN.md).
19
+ **Recall / edit-and-resend / regenerate** — the three moves every conversation
20
+ deserves. But rewinding is not just "delete a message": DeepSeek Harness stores
21
+ conversations in an append-only event log, so a recall only rewinds the context
22
+ while changed **artifact files stay changed**. dsh-retrace versions the
23
+ conversation **and its artifacts** together, and guarantees **every rewind is
24
+ legal — never dirtying the log, never breaking /compact**.
25
+
26
+ > 🛡️ **Write safety** · 🔍 **Deep offline checks** · 🔄 **Detect → repair → guard** — see below.
41
27
 
42
28
  ---
43
29
 
44
- ## ✨ Features
30
+ ## ⚡ One-minute install
45
31
 
46
- | Action | Where | What happens |
47
- | --- | --- | --- |
48
- | **↩ 撤回** (recall) | hover any assistant reply, or the row under any user message | Removes the **whole exchange round** (the input **and** the agent's output, tool rows included) from both the model context and the conversation view; the input text is echoed into the composer so you can re-ask or re-edit immediately. A small transient notice marks the rewind and disappears once you keep typing. |
49
- | **✎ 编辑重发** (edit & re-send) | row under any user message | The edited round is rewound and the new text is re-sent. By default **only the edited round** is replaced — earlier history stays visible; the optional "fresh conversation" setting rewinds the whole surface (earlier messages then leave the model context, and stay visible in the view as a marker notice by default). A collapsed **"original input"** reference sits right under the new message — click to expand, configurable off. |
50
- | **↻ 重新生成** (regenerate) | hover any assistant reply | The reply (and everything after it) is rewound and hidden, then the original prompt is re-sent so the agent answers again. |
32
+ > Requires DeepSeek Harness with the `dsh` CLI. **Restart DSH after install** (a running app does not hot-reload).
51
33
 
52
- **Versioning & rollback (live in 0.4.x)** — every rewind is also recorded as a **version**:
34
+ ```sh
35
+ dsh plugin --profile desktop add dsh-retrace # DSH Desktop
36
+ # or Web: dsh plugin --profile web add dsh-retrace
37
+ # or GitHub: dsh plugin --profile desktop add github:yamingmou/dsh-retrace
38
+ # or ZIP: dsh plugin --profile desktop add ~/plugins/dsh-retrace
39
+ ```
53
40
 
54
- - 🕘 **Timeline** — a **Versions** tab in the conversation view (on par with the official 对话/轨迹 tabs, since 0.4.2): every version (type, time, message count, file-change badges, summary), pushed live via `session/projection` (no polling), windowed for long histories; event inspection reuses the official Trajectory ledger.
55
- - ↩️ **Artifact rollback** — each version offers **context-only / artifacts-only / both** rollback with a dry-run preview; git-first (commit-free checkout of the listed paths) with content-addressed snapshot fallback. The rollback itself is recorded as a new version (`restore`) — rollback of a rollback.
56
- - 🧭 **Jump-to-conversation** — one click from a version to that point in the conversation (auto-loads earlier history, anchor highlight).
57
- - 🧹 **Bounded storage** — file snapshots keep the most recent N versions (default 50); a throttled background sweep prunes snapshots of truncated versions, keeping long sessions bounded.
41
+ **No command line?** Install the community plugin market once, then find
42
+ **dsh-retrace** in **Settings → Plugin Market** and install it with one click:
58
43
 
59
- **Why it's different**
44
+ ```sh
45
+ dsh plugin --profile desktop add dshmarket # one time
46
+ ```
60
47
 
61
- - 🎯 **Whole-round recall** — one click removes the input *and* its output (including tool rows), not just a single bubble.
62
- - 🖥️ **Web + Desktop** — the same plugin covers both surfaces of DeepSeek Harness.
63
- - 🔒 **Removed from view & context, not from the log** — recalled/edited messages disappear from the conversation view and the model context, while the durable transcript is never rewritten or deleted; the plugin only appends valid, typed session events (the same `replace` primitive the built-in compaction uses), so the log keeps a full audit trail.
64
- - 🧠 **View ⇄ context in sync** — the conversation view always reflects exactly what the agent sees.
65
- - ⚡ **Try in 30 seconds** — the dynamic form installs in your current session with no rebuild.
48
+ After the restart, hover any assistant reply → **↩ / ↻**; any user message → **✎**.
49
+ Full steps in [📦 Installation](#-installation).
66
50
 
67
51
  ---
68
52
 
69
- ## 🚀 Quick start
53
+ ## 🛡️ Production-grade guarantees (all live in 0.4.x)
70
54
 
71
- > Requires DeepSeek Harness with the `dsh` CLI. Installs the plugin as a profile
72
- > bundle and automatically rebuilds the Web client:
55
+ | | Capability | What it means |
56
+ |---|---|---|
57
+ | 🛡️ | **Write safety** | Every rewind passes a three-layer pre-write contract guard; running agents are auto-stopped (official `cancel`/`whenIdle`); turn-interval markers are wrapped in a temporary step — **rewinds never dirty the log, /compact never breaks** |
58
+ | 🔍 | **Deep offline checks** | Companion `dsh-log-contract` ships 30+ contract rules (token-meter pairing / cross-step references / physical order / inbox replay), validated against real corrupted-session fixtures — it finds the class of problem that makes /compact permanently fail |
59
+ | 🔄 | **Detect → repair → guard** | A watchdog snapshots the log at the first sign of concurrent writes; offline `fix` neutralizes problem markers and clips cross-step references in place; pre-write validation stops bad events before they land |
73
60
 
74
- ```sh
75
- # DSH Desktop (desktop profile)
76
- dsh plugin --profile desktop add dsh-retrace
61
+ ---
77
62
 
78
- # standalone Web (`dsh web` / web profile)
79
- dsh plugin --profile web add dsh-retrace
80
- ```
63
+ ## ✨ Features
64
+
65
+ | Action | Where | What happens |
66
+ | --- | --- | --- |
67
+ | **↩ Recall** | hover any assistant reply, or the row under any user message | Removes the **whole exchange round** (the input **and** the agent's output, tool rows included) from both the model context and the conversation view; the input text is echoed into the composer so you can re-ask or re-edit immediately. A small transient notice marks the rewind and disappears once you keep typing. |
68
+ | **✎ Edit & re-send** | row under any user message | The edited round is rewound and the new text is re-sent. By default **only the edited round** is replaced — earlier history stays visible; the optional "fresh conversation" setting rewinds the whole surface (earlier messages then leave the model context, and stay visible in the view as a marker notice by default). A collapsed **"original input"** reference sits right under the new message — click to expand, configurable off. |
69
+ | **↻ Regenerate** | hover any assistant reply | The reply (and everything after it) is rewound and hidden, then the original prompt is re-sent so the agent answers again. |
70
+
71
+ **Versioning & rollback (live in 0.4.x)** — every rewind is also recorded as a **version**:
81
72
 
82
- > ⚠️ **Restart after install.** A running app keeps the previously loaded bundle
83
- > in memory, so **quit and reopen DSH Desktop** (or restart the `dsh` process for
84
- > a standalone Web deployment) before the plugin activates.
73
+ | | What | |
74
+ |---|---|---|
75
+ | 🕘 | **Timeline** | a **Versions** tab in the conversation view: every version (type, time, message count, file-change badges), pushed live via `session/projection` (no polling), windowed for long histories |
76
+ | ↩️ | **Artifact rollback** | **context-only / artifacts-only / both** with dry-run preview; git-first + content-addressed snapshot fallback; the rollback is itself a new version (`restore`) |
77
+ | 🧭 | **Jump-to-conversation** | one click from a version to that point in the conversation (auto-loads history, anchor highlight) |
78
+ | 🧹 | **Bounded storage** | snapshots keep the most recent N versions (default 50); throttled background sweep prunes truncated ones |
85
79
 
86
- That's it — after the restart, hover any assistant reply, or any user message,
87
- and use ↩ / ✎ / ↻.
80
+ **Why it's different** (the interaction layer — the guarantees above are the storage layer):
81
+
82
+ - 🎯 **Whole-round recall** — removes the input *and* its output (tool rows included), not just a single bubble.
83
+ - 🖥️ **Web + Desktop** — one plugin, both DeepSeek Harness surfaces.
84
+ - 🧠 **View ⇄ context in sync** — the conversation view always reflects exactly what the agent sees.
85
+ - ⚡ **Try in 30 seconds** — the dynamic form installs in your current session with no rebuild.
88
86
 
89
87
  ---
90
88
 
@@ -105,14 +103,17 @@ dsh plugin --profile <name> add dsh-retrace
105
103
  > deployment) to load the plugin. To uninstall:
106
104
  > `dsh plugin --profile <name> remove dsh-retrace` (then restart again).
107
105
 
108
- It also shows up in [dsh-market](https://github.com/dsh-market/dsh-market) for
109
- one-click install from inside Settings (same restart applies).
110
-
111
106
  ### 2. Manual install (no `dsh` CLI)
112
107
 
113
108
  The same result with plain file edits and `pnpm` — exactly the steps
114
109
  `dsh plugin add` performs for you:
115
110
 
111
+ > **Downloaded this repo as a ZIP?** Unpack it somewhere stable (e.g.
112
+ > `~/plugins/dsh-retrace`), then either
113
+ > `dsh plugin --profile desktop add ~/plugins/dsh-retrace`, or follow the
114
+ > steps below with the dependency line pointing at the folder:
115
+ > `"dsh-retrace": "file:~/plugins/dsh-retrace"`.
116
+
116
117
  1. Open the profile manifest (defaults: `~/.dsh/profiles/desktop` on DSH
117
118
  Desktop, `~/.dsh/profiles/web` for standalone Web) and add **both** the
118
119
  dependency and the bundle-layer entry:
@@ -148,6 +149,9 @@ The same result with plain file edits and `pnpm` — exactly the steps
148
149
  For local development, point the dependency at a checkout instead of the
149
150
  registry: `"dsh-retrace": "file:/path/to/dsh-retrace"` — or let
150
151
  `dsh` do it: `dsh plugin --profile <name> add /path/to/dsh-retrace`.
152
+ For the latest GitHub commit without a release: use
153
+ `"dsh-retrace": "github:yamingmou/dsh-retrace"` (standard pnpm git
154
+ dependency syntax) in the same `dependencies` block, then `pnpm install`.
151
155
 
152
156
  ### 3. npm package + composition (classic)
153
157
 
@@ -219,13 +223,13 @@ The dynamic host registers the same operations behind the package-private
219
223
  rewound `session.deriveMessages()`.
220
224
  3. **Client** (`lib/client.js`) registers:
221
225
  - a `user-actions` conversation node under every user message
222
- (编辑 / 撤回 row with an inline editor); recall echoes the text into the
226
+ (an edit/recall row with an inline editor); recall echoes the text into the
223
227
  composer,
224
228
  - the `recall-marker` node renderer: a notice row that injects CSS hiding
225
229
  every shadowed message row from the flow (view and model context stay in
226
230
  sync), plus the optional original-input comparison block,
227
231
  - the `retrace` entry in the `conversation.chat.assistant-actions`
228
- strip (撤回 / 重新生成),
232
+ strip (recall / regenerate),
229
233
  - preference toggles and the retention limit under Settings → General.
230
234
 
231
235
  > Two different layers are at play: the **durable transcript** (append-only; old
@@ -256,16 +260,21 @@ The dynamic host registers the same operations behind the package-private
256
260
 
257
261
  ## 🗺️ Roadmap
258
262
 
259
- Built per [PLAN.md](./PLAN.md):
263
+ **What's in today (0.4.x):**
264
+
265
+ - Recall / edit-and-resend / regenerate, each written through a three-layer
266
+ **pre-write contract guard** and a safe-edit path (auto-stop the agent, temp-step
267
+ markers) — rewinds never corrupt the log or break `/compact`.
268
+ - In-session **version timeline** + **artifact rollback** (git-first, snapshot
269
+ fallback, dry-run preview, jump-to-conversation).
270
+ - **Fork map + session lineage** in the conversation view.
271
+ - **Real-time watchdog** — snapshots the log at the first sign of concurrent writes.
272
+ - Companion **`dsh-log-contract`**: 30+ offline contract rules + in-place repair
273
+ (`fix --neutralize` / `--clip-crossstep`) for sessions that would fail `/compact`.
260
274
 
261
- - **P1 — Timeline & artifact rollback** ✅ *shipped in 0.4.x*: an in-session version
262
- timeline (messages, thinking, touched files), artifact snapshots (git-first,
263
- snapshot-fallback, opt-in), rollback with dry-run preview, and jump-to-conversation
264
- navigation.
265
- - **P2 — Fork map** 🔨 *in progress*: a flow graph of the conversation's turns with fork
266
- points at every rewind, thinking flow per turn, branch-intent cards, and version
267
- comparison.
268
- - More locales beyond 简体中文 / English.
275
+ **What's next** — see the [public roadmap](./docs/ROADMAP.md) for the agent
276
+ business-layer plan (runtime guard, interruption governance, ecosystem-facing
277
+ interfaces). This README only describes what is already shipped.
269
278
 
270
279
  ---
271
280
 
@@ -311,10 +320,32 @@ and the [issue tracker](https://github.com/yamingmou/dsh-retrace/issues).
311
320
 
312
321
  ## 📚 Ecosystem
313
322
 
314
- Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin) and
315
- installable from [dsh-market](https://github.com/dsh-market/dsh-market). For a
316
- curated overview of the DeepSeek Harness plugin ecosystem, see
317
- [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin).
323
+ Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin).
324
+
325
+ Part of the **Agent business layer (production-grade guarantees)** — see the
326
+ [public roadmap](./docs/ROADMAP.md) for the framework-agnostic layer and how
327
+ dsh-retrace is its DeepSeek Harness implementation. Companion components:
328
+
329
+ - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) — the
330
+ business layer's "doctor": 30+ offline contract rules + in-place repair
331
+ (`fix --neutralize` / `--clip-crossstep`). Installed automatically as a
332
+ dependency; also published standalone for direct use.
333
+
334
+ > **Install straight from GitHub** (no npm registry needed — handy when you
335
+ > hand this repo's link to an AI or want the latest commit):
336
+ >
337
+ > ```sh
338
+ > dsh plugin --profile desktop add github:yamingmou/dsh-retrace
339
+ > # or with pnpm directly into a profile:
340
+ > cd ~/.dsh/profiles/desktop && pnpm add github:yamingmou/dsh-retrace
341
+ > ```
342
+ >
343
+ > Then restart DSH Desktop as usual. The `dsh-log-contract` dependency is
344
+ > pulled in automatically.
345
+
346
+ A curated overview of the DeepSeek Harness plugin ecosystem lives at
347
+ [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
348
+ (third-party listing — verify availability before relying on it).
318
349
 
319
350
  ---
320
351
 
@@ -330,24 +361,27 @@ DeepSeek Harness community.
330
361
  MIT
331
362
 
332
363
 
333
- ## 🧭 会话日志考古(retrace CLI)
364
+ ## 🧭 Session archaeology (`retrace` CLI)
334
365
 
335
- DSH 会话日志持久化了每次工具调用的完整输入输出——数据资产与审计资产。
336
- `retrace` CLI 提供只读考古能力(复用 dsh-log-contract 0.3.0 的契约与提取):
366
+ Every tool call's full input/output is persisted in the session log — a data and
367
+ audit asset. The `retrace` CLI provides read-only archaeology (reusing
368
+ dsh-log-contract's contracts and extraction):
337
369
 
338
370
  ```sh
339
- retrace index <session> # 工具调用索引(A1)
340
- retrace query <session> --cmd "seed-scale" # 按命令正则查输出(A1)
341
- retrace extract <session> --pattern "seed-scale" --out ./found # 导出输出(A2)
342
- retrace file-history <session> <path> # 文件 write/edit 历史版本(A3)
343
- retrace file-diff <session> <path> 0 5 # 两版本行级 diff(A3)
344
- retrace lineage <session> # 会话 parent 链谱系(A4)
371
+ retrace index <session> # tool-call index (A1)
372
+ retrace query <session> --cmd "seed-scale" # search outputs by command regex (A1)
373
+ retrace extract <session> --pattern "seed-scale" --out ./found # export outputs (A2)
374
+ retrace file-history <session> <path> # write/edit history of a file (A3)
375
+ retrace file-diff <session> <path> 0 5 # line diff between two versions (A3)
376
+ retrace lineage <session> # parent-chain lineage (A4)
345
377
  ```
346
378
 
347
- <session> 为完整日志路径或 sessionId(自动在 ~/.dsh/sessions 查找)。全部只读。
379
+ `<session>` is a full log path or a sessionId (auto-looked-up under
380
+ `~/.dsh/sessions`). All read-only.
348
381
 
349
- **分叉图里的会话谱系(A4,UI)**:Fork map 视图头部展示当前会话的
350
- `parentSession` 接续链(当前会话 → 父 → 根,`←` 方向)。数据来自
351
- `GET /api/plugins/retrace/lineage?sessionId=`(只读 header 遍历,带环保护),
352
- 与 CLI `retrace lineage` 同一语义。这样"这个会话是从哪个会话接着干/分叉出来的"
353
- 在界面上一眼可见——也是分叉图拓扑的元数据源。
382
+ **Session lineage in the fork map (A4, UI)**: the Fork map view header shows the
383
+ current session's `parentSession` chain (session → parent → root, `←` direction).
384
+ Data comes from `GET /api/plugins/retrace/lineage?sessionId=` (read-only header
385
+ walk with cycle protection), the same semantics as the CLI `retrace lineage` —
386
+ so "which session did this one continue/fork from" is visible at a glance, and
387
+ serves as the fork-topology metadata source.
package/README.zh.md CHANGED
@@ -2,10 +2,8 @@
2
2
 
3
3
  # 🧭 dsh-retrace
4
4
 
5
- **Retrace · 回溯** —— 在 **撤回 · 编辑重发 · 重新生成** 之上,更进一步:
6
- 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 对话提供
7
- **单会话内的版本化**——每一次回退的时间线、产物回退,以及对话走过的分叉路径图(路线图)。
8
- 同时支持 **Web 端** 与 **桌面客户端**(两者共用同一套 Web 前端)。
5
+ **撤回 · 编辑重发 · 重新生成**,加上**写安全**的会话版本化 —— DeepSeek Harness 的
6
+ **Agent 业务层(生产级保证)** 实现。
9
7
 
10
8
  [![npm version](https://img.shields.io/npm/v/dsh-retrace)](https://www.npmjs.com/package/dsh-retrace)
11
9
  [![npm downloads](https://img.shields.io/npm/dm/dsh-retrace)](https://www.npmjs.com/package/dsh-retrace)
@@ -17,19 +15,46 @@
17
15
 
18
16
  </div>
19
17
 
20
- DeepSeek Harness 的对话是「只追加(append-only)」的事件日志,本身没有撤销能力。
21
- `dsh-retrace` 先为对话补上聊天本该有的三个操作 —— **撤回**、**编辑重发**、
22
- **重新生成**;再往前一步:撤回只回退了**上下文**,而智能体已经改过的**产物文件**
23
- 不会自动还原——retrace 把对话**和它的产物**放在一起做版本化。
18
+ **撤回 / 编辑重发 / 重新生成** —— 每个会话都该有的三个操作。但回退不只是「撤掉一条
19
+ 消息」:DeepSeek Harness 把对话存在 append-only 事件日志里,撤回只回退上下文,改过的
20
+ **产物文件不会自动还原**。dsh-retrace 把对话**和它的产物**一起版本化,并且保证
21
+ **每次回退都合法、不弄脏日志、不破坏 /compact**。
24
22
 
25
- 撤回/编辑后,目标消息会**从对话视图和模型上下文中移除**——你看到的「删除」正是这个
26
- 效果。但底层的**持久化日志不会被改写或删除**:它始终保持只追加,旧事件原样保留,
27
- 插件只是在日志末尾追加一条合法的替换事件(与内置压缩使用的 `replace` 原语一致)来
28
- 回退对话表面,因此日志保留每一次回退的完整审计痕迹。在这条痕迹之上,retrace 记录
29
- 版本边界、触碰文件与(可选的)git 状态,支持产物回退与跳转到对话任意位置——全部
30
- 发生在**同一会话内**,不换会话。
23
+ > 🛡️ **写安全** · 🔍 **深层体检** · 🔄 **检测→修复→守护** —— 详见下方「生产级保证」。
31
24
 
32
- > ✅ **时间线 + 产物回退已上线(0.4.x)** —— 撤回/编辑/重新生成、版本时间线、产物回退(git 优先 + 快照兜底)、跳转对话、marker 写前校验均已可用;分叉图(P2)按 [PLAN.md](./PLAN.md) 推进中。
25
+ ---
26
+
27
+ ## ⚡ 一分钟安装
28
+
29
+ > 需要带 `dsh` CLI 的 DeepSeek Harness;装完**重启 DSH** 生效(运行中的应用不会热加载)。
30
+
31
+ ```sh
32
+ dsh plugin --profile desktop add dsh-retrace # DSH 桌面版
33
+ # 或 Web 部署:dsh plugin --profile web add dsh-retrace
34
+ # 或从 GitHub 直装:dsh plugin --profile desktop add github:yamingmou/dsh-retrace
35
+ # 或从 ZIP 解压后:dsh plugin --profile desktop add ~/plugins/dsh-retrace
36
+ ```
37
+
38
+ **没有命令行?** 先装一次社区插件市场,再在 **设置 → Plugin Market** 搜
39
+ **dsh-retrace** 一键安装:
40
+
41
+ ```sh
42
+ dsh plugin --profile desktop add dshmarket # 只需一次
43
+ ```
44
+
45
+ 重启后,悬停任意助手回复 → **↩ / ↻**;任意用户消息 → **✎**。详细步骤见
46
+ [📦 安装](#-安装)。
47
+
48
+ ---
49
+ ---
50
+
51
+ ## 🛡️ 生产级保证(0.4.x 全部已上线)
52
+
53
+ | | 能力 | 说明 |
54
+ |---|---|---|
55
+ | 🛡️ | **写安全** | 每次回退过三层写前契约校验;运行中的 agent 自动停止(官方 `cancel`/`whenIdle`);轮次间 marker 用临时 step 包裹 —— **回退永不弄脏日志,/compact 永不失效** |
56
+ | 🔍 | **深层体检** | 配套 `dsh-log-contract` 30+ 条契约规则(token-meter 配对 / 跨 step 引用 / 物理序 / inbox 重放),用真实损坏会话当测试集 —— 能找出让 /compact 永久失效的那类问题 |
57
+ | 🔄 | **检测→修复→守护** | 看门狗在并发写入第一时间快照日志;离线 `fix` 原地中和问题 marker、裁剪跨 step 引用;写前校验在坏事件落盘前拦住 |
33
58
 
34
59
  ---
35
60
 
@@ -43,42 +68,24 @@ DeepSeek Harness 的对话是「只追加(append-only)」的事件日志,
43
68
 
44
69
  **版本化与回退(0.4.x 已上线)** —— 每次回退都会被记录为一个**版本**:
45
70
 
46
- - 🕘 **时间线** —— 会话视图新增「版本」Tab(与官方「对话/轨迹」平级,0.4.2 起):展示每个版本(类型/时间/消息数/文件变更徽标/摘要),经 `session/projection` 推送帧实时更新(零轮询),大列表窗口化渲染;事件原文查看复用官方「轨迹」台账。
47
- - ↩️ **产物回退** —— 每个版本支持 仅对话 / 仅产物 / 两者 三种回退范围,先干跑预览再执行;git 优先(commit-free checkout 清单路径)+ 内容寻址快照兜底。回退本身记录为新版本(`restore`),可以再回退。
48
- - 🧭 **跳转对话** —— 从时间线一键跳转到对话对应位置(自动翻页加载更早历史 + 锚点高亮)。
49
- - 🧹 **存储有界** —— 文件快照只保留最近 N 个版本(默认 50);节流后台扫掠回收被截断版本的快照,长会话不膨胀。
71
+ | | 能力 | 说明 |
72
+ |---|---|---|
73
+ | 🕘 | **时间线** | 「版本」Tab(与官方「对话/轨迹」平级):每个版本的类型/时间/消息数/文件变更徽标,经 `session/projection` 推送帧实时更新(零轮询),大列表窗口化 |
74
+ | ↩️ | **产物回退** | 仅对话 / 仅产物 / 两者,先干跑预览再执行;git 优先 + 内容寻址快照兜底;回退本身是新版本(`restore`),可以再回退 |
75
+ | 🧭 | **跳转对话** | 从时间线一键跳转到对应位置(自动翻页加载更早历史 + 锚点高亮) |
76
+ | 🧹 | **存储有界** | 快照只保留最近 N 个版本(默认 50);节流后台扫掠回收被截断版本 |
77
+
78
+ **为什么与众不同**(交互层差异——上面的保证是存储层):
50
79
 
51
80
  **为什么与众不同**
52
81
 
53
82
  - 🎯 **整轮撤回** —— 一键移除输入 *和* 它的输出(含工具行),而不只是单条气泡。
54
83
  - 🖥️ **Web + Desktop 双端** —— 同一插件覆盖 DeepSeek Harness 两种界面。
55
- - 🔒 **删除的是视图与上下文,不是日志** —— 被撤回/编辑的消息从对话视图和模型上下文中
56
- 消失,但持久化日志从不被改写或删除;插件只追加合法、带类型的会话事件(与内置压缩
57
- 使用的 `replace` 原语一致),日志保留完整审计痕迹。
58
84
  - 🧠 **视图 ⇄ 上下文同步** —— 对话视图永远反映智能体真正看到的内容。
59
85
  - ⚡ **30 秒上手** —— 动态插件形式无需重建即可在当前会话试用。
60
86
 
61
87
  ---
62
88
 
63
- ## 🚀 快速开始
64
-
65
- > 需要带 `dsh` CLI 的 DeepSeek Harness。以 profile bundle 方式安装,并自动重建 Web 客户端:
66
-
67
- ```sh
68
- # DSH Desktop(desktop profile)
69
- dsh plugin --profile desktop add dsh-retrace
70
-
71
- # 独立 Web 部署(`dsh web` / web profile)
72
- dsh plugin --profile web add dsh-retrace
73
- ```
74
-
75
- > ⚠️ **安装后需要重启。** 运行中的应用仍在内存中保留之前加载的 bundle,请**退出并
76
- > 重新打开 DSH Desktop**(独立 Web 部署则重启 `dsh` 进程)后插件才会生效。
77
-
78
- 重启后,悬停任意助手回复或用户消息,即可使用 ↩ / ✎ / ↻。
79
-
80
- ---
81
-
82
89
  ## 📦 安装
83
90
 
84
91
  ### 1. Profile bundle(推荐)
@@ -94,13 +101,14 @@ dsh plugin --profile <name> add dsh-retrace
94
101
  > `dsh` 进程)来加载插件。卸载:`dsh plugin --profile <name> remove
95
102
  > dsh-retrace`(卸载后同样需要重启)。
96
103
 
97
- 同时可在 [dsh-market](https://github.com/dsh-market/dsh-market) 里一键安装
98
- (安装后同样需要重启)。
99
-
100
104
  ### 2. 手动安装(不依赖 `dsh` CLI)
101
105
 
102
106
  用纯文件编辑 + `pnpm` 装进同一个 profile —— 也就是 `dsh plugin add` 帮你做的那些步骤:
103
107
 
108
+ > **从 GitHub 下载了 ZIP?** 解压到固定位置(如 `~/plugins/dsh-retrace`),
109
+ > 然后执行 `dsh plugin --profile desktop add ~/plugins/dsh-retrace`;或按下面步骤,
110
+ > 把依赖行指向该文件夹:`"dsh-retrace": "file:~/plugins/dsh-retrace"`。
111
+
104
112
  1. 打开 profile 清单(默认位置:DSH Desktop 为 `~/.dsh/profiles/desktop`,
105
113
  独立 Web 为 `~/.dsh/profiles/web`),同时加入依赖**和** bundle 层条目:
106
114
 
@@ -227,11 +235,15 @@ Client 半区会依据包内 `dsh.client` 元数据被自动打包进 Web 客户
227
235
 
228
236
  ## 🗺️ 路线图
229
237
 
230
- 按 [PLAN.md](./PLAN.md) 推进:
238
+ **当前已具备(0.4.x):**
239
+
240
+ - 撤回 / 编辑重发 / 重新生成——每次回退都过**三层写前校验**与安全编辑路径(自动停 agent、临时 step 包裹 marker),**不会损坏日志、不会破坏 /compact**。
241
+ - 单会话**版本时间线** + **产物回退**(git 优先 + 快照兜底、干跑预览、跳转对话)。
242
+ - 对话视图内的**分叉图** + **会话谱系**。
243
+ - **实时看门狗**——并发写入第一时间快照日志。
244
+ - 配套 **`dsh-log-contract`**:30+ 条离线契约规则 + 原地修复(`fix --neutralize` / `--clip-crossstep`),能处理会让 /compact 永久失败的会话。
231
245
 
232
- - **P1 — 时间线与产物回退** ✅ 已上线(0.4.x):单会话内的版本时间线(版本/消息/思考/工具节点),产物快照(git 优先 + 快照兜底,可开关),带干跑预览的回退,以及跳转到对话位置;marker 写前校验(三层契约)守护日志。
233
- - **P2 — 分叉图** 🔨 推进中:对话回合的流程分叉图,每次回退都是分叉点,逐回合思考流,分支意图卡、版本对比。
234
- - 支持更多语言(当前:简体中文 / English)。
246
+ **未来计划**——见 [公开路线图](./docs/ROADMAP.md)(agent 业务层规划:运行时守护、中断治理、生态开放接口)。本 README 只描述已上线的能力。
235
247
 
236
248
  ---
237
249
 
@@ -276,9 +288,28 @@ npm pack --dry-run # 校验发布文件清单
276
288
 
277
289
  ## 📚 生态
278
290
 
279
- 收录于 [dsh-plugin topic](https://github.com/topics/dsh-plugin),可在
280
- [dsh-market](https://github.com/dsh-market/dsh-market) 一键安装。DeepSeek Harness
281
- 插件生态的精选总览见 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)。
291
+ 收录于 [dsh-plugin topic](https://github.com/topics/dsh-plugin)。
292
+
293
+ **Agent 业务层(生产级保证)** 的一部分——见 [公开路线图](./docs/ROADMAP.md)
294
+ (框架无关的业务层定义,dsh-retrace 是它在 DeepSeek Harness 上的实现)。配套组件:
295
+
296
+ - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) —— 业务层的
297
+ 「医生」:30+ 条离线契约规则 + 原地修复(`fix --neutralize` / `--clip-crossstep`)。
298
+ 作为依赖自动安装,也独立发布供直接使用。
299
+
300
+ > **直接从 GitHub 安装**(无需 npm registry —— 适合把本仓库链接丢给 AI,或想装最新提交):
301
+ >
302
+ > ```sh
303
+ > dsh plugin --profile desktop add github:yamingmou/dsh-retrace
304
+ > # 或直接用 pnpm 装进 profile:
305
+ > cd ~/.dsh/profiles/desktop && pnpm add github:yamingmou/dsh-retrace
306
+ > ```
307
+ >
308
+ > 然后照常重启 DSH Desktop。`dsh-log-contract` 依赖会自动带上。
309
+
310
+ DeepSeek Harness 插件生态的精选总览见
311
+ [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
312
+ (第三方收录,使用前请自行确认可用性)。
282
313
 
283
314
  ---
284
315
 
@@ -291,3 +322,27 @@ npm pack --dry-run # 校验发布文件清单
291
322
  ## 📄 License
292
323
 
293
324
  MIT
325
+
326
+ ---
327
+
328
+ ## 🧭 会话日志考古(retrace CLI)
329
+
330
+ DSH 会话日志持久化了每次工具调用的完整输入输出——数据资产与审计资产。
331
+ `retrace` CLI 提供只读考古能力(复用 dsh-log-contract 的契约与提取):
332
+
333
+ ```sh
334
+ retrace index <session> # 工具调用索引(A1)
335
+ retrace query <session> --cmd "seed-scale" # 按命令正则查输出(A1)
336
+ retrace extract <session> --pattern "seed-scale" --out ./found # 导出输出(A2)
337
+ retrace file-history <session> <path> # 文件 write/edit 历史版本(A3)
338
+ retrace file-diff <session> <path> 0 5 # 两版本行级 diff(A3)
339
+ retrace lineage <session> # 会话 parent 链谱系(A4)
340
+ ```
341
+
342
+ <session> 为完整日志路径或 sessionId(自动在 ~/.dsh/sessions 查找)。全部只读。
343
+
344
+ **分叉图里的会话谱系(A4, UI)**:Fork map 视图头部展示当前会话的
345
+ `parentSession` 接续链(当前会话 → 父 → 根,`←` 方向)。数据来自
346
+ `GET /api/plugins/retrace/lineage?sessionId=`(只读 header 遍历,带环保护),
347
+ 与 CLI `retrace lineage` 同一语义。这样「这个会话是从哪个会话接着干/分叉出来的」
348
+ 在界面上一眼可见——也是分叉图拓扑的元数据源。
@@ -1136,7 +1136,9 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1136
1136
  };
1137
1137
  const ROW_H = 64;
1138
1138
  const list = versions ?? [];
1139
- const visible = list.slice(Math.max(0, Math.floor(scrollTop / ROW_H) - 2), Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2));
1139
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1140
+ const visibleEnd = Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1141
+ const visible = list.slice(visibleStart, visibleEnd);
1140
1142
  (0, import_react.useEffect)(() => {
1141
1143
  if (list.length === 0) return void 0;
1142
1144
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-timeline-list"));
@@ -1175,11 +1177,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1175
1177
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1176
1178
  }, [
1177
1179
  (0, import_react.createElement)("div", { key: "spacer", style: { height: `${list.length * ROW_H}px`, position: "relative" } }, [
1178
- visible.map((record) => (0, import_react.createElement)(VersionRow, {
1180
+ visible.map((record, i) => (0, import_react.createElement)(VersionRow, {
1179
1181
  key: record.versionId,
1180
1182
  record,
1181
1183
  t,
1182
- top: list.indexOf(record) * ROW_H,
1184
+ top: (visibleStart + i) * ROW_H,
1183
1185
  onPreview: () => requestPreview(record),
1184
1186
  onTrajectory: () => switchToViewTab("trajectory"),
1185
1187
  onJump: () => jump(record.boundarySeq)
@@ -1301,10 +1303,9 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1301
1303
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-fork-list"));
1302
1304
  }, [nodes.length]);
1303
1305
  const ROW_H = 56;
1304
- const visible = nodes.slice(
1305
- Math.max(0, Math.floor(scrollTop / ROW_H) - 2),
1306
- Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1307
- );
1306
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1307
+ const visibleEnd = Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1308
+ const visible = nodes.slice(visibleStart, visibleEnd);
1308
1309
  return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
1309
1310
  (0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
1310
1311
  (0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("fork.title")),
@@ -1341,13 +1342,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1341
1342
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1342
1343
  }, [
1343
1344
  (0, import_react.createElement)("div", { key: "spacer", style: { height: `${nodes.length * ROW_H}px`, position: "relative" } }, [
1344
- visible.map((node) => (0, import_react.createElement)(ForkRow, {
1345
+ visible.map((node, i) => (0, import_react.createElement)(ForkRow, {
1345
1346
  key: node.seq,
1346
1347
  node,
1347
1348
  boundary: boundaryBySeq.get(node.seq),
1348
1349
  markerText: markerBySeq.get(node.seq),
1349
1350
  t,
1350
- top: nodes.indexOf(node) * ROW_H,
1351
+ top: (visibleStart + i) * ROW_H,
1351
1352
  onJump: () => jumpToAnchor(store, node.seq)
1352
1353
  }))
1353
1354
  ])
package/lib/client.js CHANGED
@@ -1485,7 +1485,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1485
1485
  // ---- windowed list (uniform rows, zero-dep) ----
1486
1486
  const ROW_H = 64
1487
1487
  const list = versions ?? []
1488
- const visible = list.slice(Math.max(0, Math.floor(scrollTop / ROW_H) - 2), Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2))
1488
+ // 2026-08-30 渲染卡死修复(与 ForkView 同):带起始索引切片,渲染用索引算 top——
1489
+ // 原 `list.indexOf(record)` 是 O(N²)(每可见行线性查找),大会话渲染风暴。
1490
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2)
1491
+ const visibleEnd = Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1492
+ const visible = list.slice(visibleStart, visibleEnd)
1489
1493
 
1490
1494
  // Same view-area height trap as the fork list (see bindListHeight).
1491
1495
  useEffect(() => {
@@ -1534,11 +1538,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1534
1538
  onScroll: (event) => setScrollTop(event.target.scrollTop),
1535
1539
  }, [
1536
1540
  createElement('div', { key: 'spacer', style: { height: `${list.length * ROW_H}px`, position: 'relative' } }, [
1537
- visible.map((record) => createElement(VersionRow, {
1541
+ visible.map((record, i) => createElement(VersionRow, {
1538
1542
  key: record.versionId,
1539
1543
  record,
1540
1544
  t,
1541
- top: list.indexOf(record) * ROW_H,
1545
+ top: (visibleStart + i) * ROW_H,
1542
1546
  onPreview: () => requestPreview(record),
1543
1547
  onTrajectory: () => switchToViewTab('trajectory'),
1544
1548
  onJump: () => jump(record.boundarySeq),
@@ -1702,11 +1706,14 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1702
1706
  }, [nodes.length])
1703
1707
 
1704
1708
  // ---- windowed list (uniform rows, zero-dep; same as the versions view) ----
1709
+ // 2026-08-30 渲染卡死修复:visible 改为带起始索引的切片,渲染时直接用索引算
1710
+ // top——原实现 `nodes.indexOf(node)` 在每次渲染对每个可见节点做 O(N) 线性查找
1711
+ // (2047 节点 × ~15 可见行 = 每次渲染 ~30K 次比较,React 重渲染风暴 → 转圈、
1712
+ // Renderer CPU 27.7%,526f1835 打开卡死)。其他会话节点少不触发,仅大会话暴露。
1705
1713
  const ROW_H = 56
1706
- const visible = nodes.slice(
1707
- Math.max(0, Math.floor(scrollTop / ROW_H) - 2),
1708
- Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2),
1709
- )
1714
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2)
1715
+ const visibleEnd = Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1716
+ const visible = nodes.slice(visibleStart, visibleEnd)
1710
1717
 
1711
1718
  return createElement('div', { className: 'dsh-rt-view' }, [
1712
1719
  createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
@@ -1745,13 +1752,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1745
1752
  onScroll: (event) => setScrollTop(event.target.scrollTop),
1746
1753
  }, [
1747
1754
  createElement('div', { key: 'spacer', style: { height: `${nodes.length * ROW_H}px`, position: 'relative' } }, [
1748
- visible.map((node) => createElement(ForkRow, {
1755
+ visible.map((node, i) => createElement(ForkRow, {
1749
1756
  key: node.seq,
1750
1757
  node,
1751
1758
  boundary: boundaryBySeq.get(node.seq),
1752
1759
  markerText: markerBySeq.get(node.seq),
1753
1760
  t,
1754
- top: nodes.indexOf(node) * ROW_H,
1761
+ top: (visibleStart + i) * ROW_H,
1755
1762
  onJump: () => jumpToAnchor(store, node.seq),
1756
1763
  })),
1757
1764
  ]),
@@ -1142,7 +1142,9 @@ return {
1142
1142
  };
1143
1143
  const ROW_H = 64;
1144
1144
  const list = versions ?? [];
1145
- const visible = list.slice(Math.max(0, Math.floor(scrollTop / ROW_H) - 2), Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2));
1145
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1146
+ const visibleEnd = Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1147
+ const visible = list.slice(visibleStart, visibleEnd);
1146
1148
  (0, import_react.useEffect)(() => {
1147
1149
  if (list.length === 0) return void 0;
1148
1150
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-timeline-list"));
@@ -1181,11 +1183,11 @@ return {
1181
1183
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1182
1184
  }, [
1183
1185
  (0, import_react.createElement)("div", { key: "spacer", style: { height: `${list.length * ROW_H}px`, position: "relative" } }, [
1184
- visible.map((record) => (0, import_react.createElement)(VersionRow, {
1186
+ visible.map((record, i) => (0, import_react.createElement)(VersionRow, {
1185
1187
  key: record.versionId,
1186
1188
  record,
1187
1189
  t,
1188
- top: list.indexOf(record) * ROW_H,
1190
+ top: (visibleStart + i) * ROW_H,
1189
1191
  onPreview: () => requestPreview(record),
1190
1192
  onTrajectory: () => switchToViewTab("trajectory"),
1191
1193
  onJump: () => jump(record.boundarySeq)
@@ -1307,10 +1309,9 @@ return {
1307
1309
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-fork-list"));
1308
1310
  }, [nodes.length]);
1309
1311
  const ROW_H = 56;
1310
- const visible = nodes.slice(
1311
- Math.max(0, Math.floor(scrollTop / ROW_H) - 2),
1312
- Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1313
- );
1312
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1313
+ const visibleEnd = Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1314
+ const visible = nodes.slice(visibleStart, visibleEnd);
1314
1315
  return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
1315
1316
  (0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
1316
1317
  (0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("fork.title")),
@@ -1347,13 +1348,13 @@ return {
1347
1348
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1348
1349
  }, [
1349
1350
  (0, import_react.createElement)("div", { key: "spacer", style: { height: `${nodes.length * ROW_H}px`, position: "relative" } }, [
1350
- visible.map((node) => (0, import_react.createElement)(ForkRow, {
1351
+ visible.map((node, i) => (0, import_react.createElement)(ForkRow, {
1351
1352
  key: node.seq,
1352
1353
  node,
1353
1354
  boundary: boundaryBySeq.get(node.seq),
1354
1355
  markerText: markerBySeq.get(node.seq),
1355
1356
  t,
1356
- top: nodes.indexOf(node) * ROW_H,
1357
+ top: (visibleStart + i) * ROW_H,
1357
1358
  onJump: () => jumpToAnchor(store, node.seq)
1358
1359
  }))
1359
1360
  ])
@@ -121,13 +121,19 @@ return {
121
121
  content: [],
122
122
  source: { kind: 'model', provider: model.provider, model: model.model },
123
123
  }
124
- // R2 路径一(2026-08-30 事故闭环):有打开的 step(回合中编辑)时,marker 携带
125
- // 该 step 的 turn/step —— token-meter 配对通过,不产生 T1 违规、不刷屏。
126
- // 无打开 step(轮次间编辑,常态)才回退 turn:null(配合 markerT1Broken 标注)。
124
+ // R2 根治(2026-08-30 事故闭环 v2):
125
+ // - 有打开的 step(回合中编辑):marker 携带该 step 的 turn/step → token-meter 配对通过;
126
+ // - 无打开 step(轮次间编辑,常态):**自动开一个临时 step 包裹 marker**
127
+ // (step/start → marker → step/end,turn 用 nextTurn,step 恒 1)。
128
+ // 官方 token-meter 要求 assistant/message 必须有打开的 step(lib/index.js:590,
129
+ // stepStart===void 0 即抛)——turn-null 或伪造 turn/step 都过不了;
130
+ // 临时 step 是唯一能让轮次间 marker 合法化的形态(foldSurface/token-meter/客户端
131
+ // Location boundary 三层验证全过)。不再产生 turn-null marker → 不再刷屏。
127
132
  const openStep = findOpenStep(session)
133
+ const nextTurn = nextTurnOf(session)
128
134
  const data = {
129
- turn: openStep ? openStep.turn : null,
130
- step: openStep ? openStep.step : null,
135
+ turn: openStep ? openStep.turn : nextTurn,
136
+ step: openStep ? openStep.step : 1,
131
137
  message: marker,
132
138
  editor: {
133
139
  targetSeq,
@@ -139,11 +145,20 @@ return {
139
145
  if (typeof validate === 'function') {
140
146
  const result = await validate(session, { type: 'assistant/message', data, surfaceOp, sourceEventSeqs })
141
147
  if (result && result.t1Ok === false) {
142
- // R2:标注该 marker 会破坏 /compact(不阻断编辑)。
148
+ // 理论上临时 step 后 T1 恒通过;残留 fallback 标注(防御)。
143
149
  data.editor.markerT1Broken = true
144
150
  }
145
151
  }
146
- return session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
152
+ // 轮次间编辑:开临时 step 包裹(marker 前后成对,token-meter 需要打开的 step)
153
+ const openedStep = openStep === null
154
+ if (openedStep) {
155
+ session.append('step/start', { turn: nextTurn, step: 1 })
156
+ }
157
+ const markerEvent = session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
158
+ if (openedStep) {
159
+ session.append('step/end', { turn: nextTurn, step: 1 })
160
+ }
161
+ return markerEvent
147
162
  }
148
163
 
149
164
  /**
@@ -164,6 +179,20 @@ return {
164
179
  return open
165
180
  }
166
181
 
182
+ /**
183
+ * 下一个 turn 号:scan 最大 turn(turn/start、step/start、assistant/message、user/message
184
+ * 的 data.turn 中取最大)+1。无任何 turn 时从 1 起。
185
+ */
186
+ function nextTurnOf(session) {
187
+ const events = Array.isArray(session?.events) ? session.events : []
188
+ let max = 0
189
+ for (const event of events) {
190
+ const t = event?.data?.turn
191
+ if (typeof t === 'number' && Number.isSafeInteger(t) && t > max) max = t
192
+ }
193
+ return max + 1
194
+ }
195
+
167
196
  function createEditorApi(ctx, sessions, agents, log = () => {}, hooks = {}) {
168
197
  /** One in-flight op per session; later ops wait for the earlier one. */
169
198
  const locks = new Map()
package/lib/host-core.js CHANGED
@@ -112,13 +112,19 @@ export async function appendEditorMarker(session, span, op, targetSeq, originalT
112
112
  content: [],
113
113
  source: { kind: 'model', provider: model.provider, model: model.model },
114
114
  }
115
- // R2 路径一(2026-08-30 事故闭环):有打开的 step(回合中编辑)时,marker 携带
116
- // 该 step 的 turn/step —— token-meter 配对通过,不产生 T1 违规、不刷屏。
117
- // 无打开 step(轮次间编辑,常态)才回退 turn:null(配合 markerT1Broken 标注)。
115
+ // R2 根治(2026-08-30 事故闭环 v2):
116
+ // - 有打开的 step(回合中编辑):marker 携带该 step 的 turn/step → token-meter 配对通过;
117
+ // - 无打开 step(轮次间编辑,常态):**自动开一个临时 step 包裹 marker**
118
+ // (step/start → marker → step/end,turn 用 nextTurn,step 恒 1)。
119
+ // 官方 token-meter 要求 assistant/message 必须有打开的 step(lib/index.js:590,
120
+ // stepStart===void 0 即抛)——turn-null 或伪造 turn/step 都过不了;
121
+ // 临时 step 是唯一能让轮次间 marker 合法化的形态(foldSurface/token-meter/客户端
122
+ // Location boundary 三层验证全过)。不再产生 turn-null marker → 不再刷屏。
118
123
  const openStep = findOpenStep(session)
124
+ const nextTurn = nextTurnOf(session)
119
125
  const data = {
120
- turn: openStep ? openStep.turn : null,
121
- step: openStep ? openStep.step : null,
126
+ turn: openStep ? openStep.turn : nextTurn,
127
+ step: openStep ? openStep.step : 1,
122
128
  message: marker,
123
129
  editor: {
124
130
  targetSeq,
@@ -130,11 +136,20 @@ export async function appendEditorMarker(session, span, op, targetSeq, originalT
130
136
  if (typeof validate === 'function') {
131
137
  const result = await validate(session, { type: 'assistant/message', data, surfaceOp, sourceEventSeqs })
132
138
  if (result && result.t1Ok === false) {
133
- // R2:标注该 marker 会破坏 /compact(不阻断编辑)。
139
+ // 理论上临时 step 后 T1 恒通过;残留 fallback 标注(防御)。
134
140
  data.editor.markerT1Broken = true
135
141
  }
136
142
  }
137
- return session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
143
+ // 轮次间编辑:开临时 step 包裹(marker 前后成对,token-meter 需要打开的 step)
144
+ const openedStep = openStep === null
145
+ if (openedStep) {
146
+ session.append('step/start', { turn: nextTurn, step: 1 })
147
+ }
148
+ const markerEvent = session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
149
+ if (openedStep) {
150
+ session.append('step/end', { turn: nextTurn, step: 1 })
151
+ }
152
+ return markerEvent
138
153
  }
139
154
 
140
155
  /**
@@ -155,6 +170,20 @@ export function findOpenStep(session) {
155
170
  return open
156
171
  }
157
172
 
173
+ /**
174
+ * 下一个 turn 号:scan 最大 turn(turn/start、step/start、assistant/message、user/message
175
+ * 的 data.turn 中取最大)+1。无任何 turn 时从 1 起。
176
+ */
177
+ export function nextTurnOf(session) {
178
+ const events = Array.isArray(session?.events) ? session.events : []
179
+ let max = 0
180
+ for (const event of events) {
181
+ const t = event?.data?.turn
182
+ if (typeof t === 'number' && Number.isSafeInteger(t) && t > max) max = t
183
+ }
184
+ return max + 1
185
+ }
186
+
158
187
  export function createEditorApi(ctx, sessions, agents, log = () => {}, hooks = {}) {
159
188
  /** One in-flight op per session; later ops wait for the earlier one. */
160
189
  const locks = new Map()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-retrace",
3
3
  "description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
4
- "version": "0.4.9",
4
+ "version": "0.4.11",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",