dsh-retrace 0.4.10 → 0.4.12

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,113 +16,73 @@ Desktop app (both share the same Web frontend).
18
16
 
19
17
  </div>
20
18
 
21
- > ## ⚡ Install in one minute
22
- >
23
- > **Option A — one command (recommended):** with DeepSeek Harness's `dsh` CLI:
24
- >
25
- > ```sh
26
- > dsh plugin --profile desktop add dsh-retrace # DSH Desktop
27
- > # or: dsh plugin --profile web add dsh-retrace # standalone Web
28
- > ```
29
- >
30
- > **Option B — downloaded this repo as ZIP (or handing this link to an AI):**
31
- > unpack it and run `dsh plugin --profile desktop add <folder>` — or install
32
- > straight from GitHub, no unpacking:
33
- >
34
- > ```sh
35
- > dsh plugin --profile desktop add github:yamingmou/dsh-retrace
36
- > ```
37
- >
38
- > Follow [📦 Installation](#-installation) → *Manual install* for the exact
39
- > file-edit steps.
40
- >
41
- > **Option C — no command line at all:** install the community plugin market
42
- > once, then install dsh-retrace from its UI:
43
- >
44
- > ```sh
45
- > dsh plugin --profile desktop add dshmarket # one time
46
- > ```
47
- >
48
- > Restart, then **Settings → Plugin Market** → search **dsh-retrace** →
49
- > **Install** (one click). The market lists plugins from the curated
50
- > [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
51
- > registry; dsh-retrace is listed there.
52
- >
53
- > > ⚠️ **Restart required after install.** Quit and reopen **DSH Desktop**
54
- > > (or restart the `dsh` process for a standalone Web deployment) — a running
55
- > > app keeps the previous bundle in memory and will not hot-reload it.
56
- >
57
- > Hover any assistant reply → **↩ / ↻**; any user message → **✎** — that's it.
58
-
59
- DeepSeek Harness stores every conversation as an **append-only event log**, so there is
60
- no built-in "undo". `dsh-retrace` brings back the three moves every chat deserves —
61
- **撤回 (recall)**, **编辑重发 (edit-and-resend)**, **重新生成 (regenerate)** — and then
62
- goes further: because a recall only rewinds the **context**, while files the agent
63
- already changed stay changed, retrace versions your conversation **and its artifacts**
64
- in one place.
65
-
66
- Recall / edit **remove the target messages from the conversation view and the model
67
- context** — that is exactly the effect you see. What stays untouched is the underlying
68
- **durable transcript**: it remains append-only, old events are never rewritten or deleted,
69
- and the plugin merely appends one valid replacement event (the same `replace` primitive
70
- the built-in compaction uses) to rewind the surface — so the log keeps a full audit trail
71
- of every rewind. On top of that trail, retrace records version boundaries, touched files
72
- and (optionally) git state, and lets you roll back artifacts or jump back to any point
73
- in the conversation — all **inside the same session**, no session-switching.
74
-
75
- > ✅ **Timeline + artifact rollback are live (0.4.x)** — recall / edit /
76
- > regenerate, the version timeline, artifact rollback (git-first, snapshot
77
- > fallback), jump-to-conversation and marker pre-write validation (three-layer
78
- > 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.
79
27
 
80
28
  ---
81
29
 
82
- ## ✨ Features
30
+ ## ⚡ One-minute install
83
31
 
84
- | Action | Where | What happens |
85
- | --- | --- | --- |
86
- | **↩ 撤回** (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. |
87
- | **✎ 编辑重发** (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. |
88
- | **↻ 重新生成** (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).
89
33
 
90
- **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
+ ```
91
40
 
92
- - 🕘 **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.
93
- - ↩️ **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.
94
- - 🧭 **Jump-to-conversation** — one click from a version to that point in the conversation (auto-loads earlier history, anchor highlight).
95
- - 🧹 **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:
96
43
 
97
- **Why it's different**
44
+ ```sh
45
+ dsh plugin --profile desktop add dshmarket # one time
46
+ ```
98
47
 
99
- - 🎯 **Whole-round recall** — one click removes the input *and* its output (including tool rows), not just a single bubble.
100
- - 🖥️ **Web + Desktop** — the same plugin covers both surfaces of DeepSeek Harness.
101
- - 🔒 **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.
102
- - 🧠 **View ⇄ context in sync** — the conversation view always reflects exactly what the agent sees.
103
- - ⚡ **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).
104
50
 
105
51
  ---
106
52
 
107
- ## 🚀 Quick start
53
+ ## 🛡️ Production-grade guarantees (all live in 0.4.x)
108
54
 
109
- The one-line install is at the top of this page (**⚡ Install in one minute**).
110
- This section covers the same ground with more detail.
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 |
111
60
 
112
- > Requires DeepSeek Harness with the `dsh` CLI. Installs the plugin as a profile
113
- > bundle and automatically rebuilds the Web client:
61
+ ---
114
62
 
115
- ```sh
116
- # DSH Desktop (desktop profile)
117
- dsh plugin --profile desktop add dsh-retrace
63
+ ## ✨ Features
118
64
 
119
- # standalone Web (`dsh web` / web profile)
120
- dsh plugin --profile web add dsh-retrace
121
- ```
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**:
72
+
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 |
122
79
 
123
- > ⚠️ **Restart after install.** A running app keeps the previously loaded bundle
124
- > in memory, so **quit and reopen DSH Desktop** (or restart the `dsh` process for
125
- > a standalone Web deployment) before the plugin activates.
80
+ **Why it's different** (the interaction layer — the guarantees above are the storage layer):
126
81
 
127
- After the restart, hover any assistant reply, or any user message, and use ↩ / ✎ / ↻.
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.
128
86
 
129
87
  ---
130
88
 
@@ -265,13 +223,13 @@ The dynamic host registers the same operations behind the package-private
265
223
  rewound `session.deriveMessages()`.
266
224
  3. **Client** (`lib/client.js`) registers:
267
225
  - a `user-actions` conversation node under every user message
268
- (编辑 / 撤回 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
269
227
  composer,
270
228
  - the `recall-marker` node renderer: a notice row that injects CSS hiding
271
229
  every shadowed message row from the flow (view and model context stay in
272
230
  sync), plus the optional original-input comparison block,
273
231
  - the `retrace` entry in the `conversation.chat.assistant-actions`
274
- strip (撤回 / 重新生成),
232
+ strip (recall / regenerate),
275
233
  - preference toggles and the retention limit under Settings → General.
276
234
 
277
235
  > Two different layers are at play: the **durable transcript** (append-only; old
@@ -302,16 +260,21 @@ The dynamic host registers the same operations behind the package-private
302
260
 
303
261
  ## 🗺️ Roadmap
304
262
 
305
- 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`.
306
274
 
307
- - **P1 — Timeline & artifact rollback** ✅ *shipped in 0.4.x*: an in-session version
308
- timeline (messages, thinking, touched files), artifact snapshots (git-first,
309
- snapshot-fallback, opt-in), rollback with dry-run preview, and jump-to-conversation
310
- navigation.
311
- - **P2 — Fork map** 🔨 *in progress*: a flow graph of the conversation's turns with fork
312
- points at every rewind, thinking flow per turn, branch-intent cards, and version
313
- comparison.
314
- - 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.
315
278
 
316
279
  ---
317
280
 
@@ -359,6 +322,15 @@ and the [issue tracker](https://github.com/yamingmou/dsh-retrace/issues).
359
322
 
360
323
  Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin).
361
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
+
362
334
  > **Install straight from GitHub** (no npm registry needed — handy when you
363
335
  > hand this repo's link to an AI or want the latest commit):
364
336
  >
@@ -389,24 +361,27 @@ DeepSeek Harness community.
389
361
  MIT
390
362
 
391
363
 
392
- ## 🧭 会话日志考古(retrace CLI)
364
+ ## 🧭 Session archaeology (`retrace` CLI)
393
365
 
394
- DSH 会话日志持久化了每次工具调用的完整输入输出——数据资产与审计资产。
395
- `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):
396
369
 
397
370
  ```sh
398
- retrace index <session> # 工具调用索引(A1)
399
- retrace query <session> --cmd "seed-scale" # 按命令正则查输出(A1)
400
- retrace extract <session> --pattern "seed-scale" --out ./found # 导出输出(A2)
401
- retrace file-history <session> <path> # 文件 write/edit 历史版本(A3)
402
- retrace file-diff <session> <path> 0 5 # 两版本行级 diff(A3)
403
- 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)
404
377
  ```
405
378
 
406
- <session> 为完整日志路径或 sessionId(自动在 ~/.dsh/sessions 查找)。全部只读。
379
+ `<session>` is a full log path or a sessionId (auto-looked-up under
380
+ `~/.dsh/sessions`). All read-only.
407
381
 
408
- **分叉图里的会话谱系(A4,UI)**:Fork map 视图头部展示当前会话的
409
- `parentSession` 接续链(当前会话 → 父 → 根,`←` 方向)。数据来自
410
- `GET /api/plugins/retrace/lineage?sessionId=`(只读 header 遍历,带环保护),
411
- 与 CLI `retrace lineage` 同一语义。这样"这个会话是从哪个会话接着干/分叉出来的"
412
- 在界面上一眼可见——也是分叉图拓扑的元数据源。
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,54 +15,46 @@
17
15
 
18
16
  </div>
19
17
 
20
- > ## ⚡ 一分钟安装
21
- >
22
- > **方式 A —— 一条命令(推荐):** 需要带 `dsh` CLI 的 DeepSeek Harness:
23
- >
24
- > ```sh
25
- > dsh plugin --profile desktop add dsh-retrace # DSH 桌面版
26
- > # 或:dsh plugin --profile web add dsh-retrace # 独立 Web 部署
27
- > ```
28
- >
29
- > **方式 B —— 从 GitHub 下载了 ZIP(或把本仓库链接直接丢给 AI)?** 解压到固定位置
30
- > (如 `~/plugins/dsh-retrace`)后执行 `dsh plugin --profile desktop add ~/plugins/dsh-retrace`;
31
- > 或**直接从 GitHub 安装,无需解压**:
32
- >
33
- > ```sh
34
- > dsh plugin --profile desktop add github:yamingmou/dsh-retrace
35
- > ```
36
- >
37
- > 更详细的文件级步骤见 [📦 安装](#-安装) → *手动安装*。
38
- >
39
- > **方式 C —— 完全没有命令行?** 先装一次社区插件市场,再从市场 UI 一键安装
40
- > dsh-retrace:
41
- >
42
- > ```sh
43
- > dsh plugin --profile desktop add dshmarket # 只需一次
44
- > ```
45
- >
46
- > 重启后进入 **设置 → Plugin Market** → 搜索 **dsh-retrace** → **安装**(一键)。
47
- > 市场收录自 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
48
- > 精选目录,dsh-retrace 已在其中。
49
- >
50
- > > ⚠️ **安装后必须重启。** 运行中的应用不会热加载 bundle,请**退出并重新打开
51
- > > DSH Desktop**(独立 Web 部署则重启 `dsh` 进程)后插件才会生效。
52
- >
53
- > 重启后悬停任意助手回复 → **↩ / ↻**;任意用户消息 → **✎**,即可使用。
18
+ **撤回 / 编辑重发 / 重新生成** —— 每个会话都该有的三个操作。但回退不只是「撤掉一条
19
+ 消息」:DeepSeek Harness 把对话存在 append-only 事件日志里,撤回只回退上下文,改过的
20
+ **产物文件不会自动还原**。dsh-retrace 把对话**和它的产物**一起版本化,并且保证
21
+ **每次回退都合法、不弄脏日志、不破坏 /compact**。
22
+
23
+ > 🛡️ **写安全** · 🔍 **深层体检** · 🔄 **检测→修复→守护** —— 详见下方「生产级保证」。
24
+
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
+ [📦 安装](#-安装)。
54
47
 
55
- DeepSeek Harness 的对话是「只追加(append-only)」的事件日志,本身没有撤销能力。
56
- `dsh-retrace` 先为对话补上聊天本该有的三个操作 —— **撤回**、**编辑重发**、
57
- **重新生成**;再往前一步:撤回只回退了**上下文**,而智能体已经改过的**产物文件**
58
- 不会自动还原——retrace 把对话**和它的产物**放在一起做版本化。
48
+ ---
49
+ ---
59
50
 
60
- 撤回/编辑后,目标消息会**从对话视图和模型上下文中移除**——你看到的「删除」正是这个
61
- 效果。但底层的**持久化日志不会被改写或删除**:它始终保持只追加,旧事件原样保留,
62
- 插件只是在日志末尾追加一条合法的替换事件(与内置压缩使用的 `replace` 原语一致)来
63
- 回退对话表面,因此日志保留每一次回退的完整审计痕迹。在这条痕迹之上,retrace 记录
64
- 版本边界、触碰文件与(可选的)git 状态,支持产物回退与跳转到对话任意位置——全部
65
- 发生在**同一会话内**,不换会话。
51
+ ## 🛡️ 生产级保证(0.4.x 全部已上线)
66
52
 
67
- > ✅ **时间线 + 产物回退已上线(0.4.x)** —— 撤回/编辑/重新生成、版本时间线、产物回退(git 优先 + 快照兜底)、跳转对话、marker 写前校验均已可用;分叉图(P2)按 [PLAN.md](./PLAN.md) 推进中。
53
+ | | 能力 | 说明 |
54
+ |---|---|---|
55
+ | 🛡️ | **写安全** | 每次回退过三层写前契约校验;运行中的 agent 自动停止(官方 `cancel`/`whenIdle`);轮次间 marker 用临时 step 包裹 —— **回退永不弄脏日志,/compact 永不失效** |
56
+ | 🔍 | **深层体检** | 配套 `dsh-log-contract` 30+ 条契约规则(token-meter 配对 / 跨 step 引用 / 物理序 / inbox 重放),用真实损坏会话当测试集 —— 能找出让 /compact 永久失效的那类问题 |
57
+ | 🔄 | **检测→修复→守护** | 看门狗在并发写入第一时间快照日志;离线 `fix` 原地中和问题 marker、裁剪跨 step 引用;写前校验在坏事件落盘前拦住 |
68
58
 
69
59
  ---
70
60
 
@@ -78,44 +68,24 @@ DeepSeek Harness 的对话是「只追加(append-only)」的事件日志,
78
68
 
79
69
  **版本化与回退(0.4.x 已上线)** —— 每次回退都会被记录为一个**版本**:
80
70
 
81
- - 🕘 **时间线** —— 会话视图新增「版本」Tab(与官方「对话/轨迹」平级,0.4.2 起):展示每个版本(类型/时间/消息数/文件变更徽标/摘要),经 `session/projection` 推送帧实时更新(零轮询),大列表窗口化渲染;事件原文查看复用官方「轨迹」台账。
82
- - ↩️ **产物回退** —— 每个版本支持 仅对话 / 仅产物 / 两者 三种回退范围,先干跑预览再执行;git 优先(commit-free checkout 清单路径)+ 内容寻址快照兜底。回退本身记录为新版本(`restore`),可以再回退。
83
- - 🧭 **跳转对话** —— 从时间线一键跳转到对话对应位置(自动翻页加载更早历史 + 锚点高亮)。
84
- - 🧹 **存储有界** —— 文件快照只保留最近 N 个版本(默认 50);节流后台扫掠回收被截断版本的快照,长会话不膨胀。
71
+ | | 能力 | 说明 |
72
+ |---|---|---|
73
+ | 🕘 | **时间线** | 「版本」Tab(与官方「对话/轨迹」平级):每个版本的类型/时间/消息数/文件变更徽标,经 `session/projection` 推送帧实时更新(零轮询),大列表窗口化 |
74
+ | ↩️ | **产物回退** | 仅对话 / 仅产物 / 两者,先干跑预览再执行;git 优先 + 内容寻址快照兜底;回退本身是新版本(`restore`),可以再回退 |
75
+ | 🧭 | **跳转对话** | 从时间线一键跳转到对应位置(自动翻页加载更早历史 + 锚点高亮) |
76
+ | 🧹 | **存储有界** | 快照只保留最近 N 个版本(默认 50);节流后台扫掠回收被截断版本 |
77
+
78
+ **为什么与众不同**(交互层差异——上面的保证是存储层):
85
79
 
86
80
  **为什么与众不同**
87
81
 
88
82
  - 🎯 **整轮撤回** —— 一键移除输入 *和* 它的输出(含工具行),而不只是单条气泡。
89
83
  - 🖥️ **Web + Desktop 双端** —— 同一插件覆盖 DeepSeek Harness 两种界面。
90
- - 🔒 **删除的是视图与上下文,不是日志** —— 被撤回/编辑的消息从对话视图和模型上下文中
91
- 消失,但持久化日志从不被改写或删除;插件只追加合法、带类型的会话事件(与内置压缩
92
- 使用的 `replace` 原语一致),日志保留完整审计痕迹。
93
84
  - 🧠 **视图 ⇄ 上下文同步** —— 对话视图永远反映智能体真正看到的内容。
94
85
  - ⚡ **30 秒上手** —— 动态插件形式无需重建即可在当前会话试用。
95
86
 
96
87
  ---
97
88
 
98
- ## 🚀 快速开始
99
-
100
- 页首的 **⚡ 一分钟安装** 是最短路径;本节给出同样内容的更详细说明。
101
-
102
- > 需要带 `dsh` CLI 的 DeepSeek Harness。以 profile bundle 方式安装,并自动重建 Web 客户端:
103
-
104
- ```sh
105
- # DSH Desktop(desktop profile)
106
- dsh plugin --profile desktop add dsh-retrace
107
-
108
- # 独立 Web 部署(`dsh web` / web profile)
109
- dsh plugin --profile web add dsh-retrace
110
- ```
111
-
112
- > ⚠️ **安装后需要重启。** 运行中的应用仍在内存中保留之前加载的 bundle,请**退出并
113
- > 重新打开 DSH Desktop**(独立 Web 部署则重启 `dsh` 进程)后插件才会生效。
114
-
115
- 重启后,悬停任意助手回复或用户消息,即可使用 ↩ / ✎ / ↻。
116
-
117
- ---
118
-
119
89
  ## 📦 安装
120
90
 
121
91
  ### 1. Profile bundle(推荐)
@@ -265,11 +235,15 @@ Client 半区会依据包内 `dsh.client` 元数据被自动打包进 Web 客户
265
235
 
266
236
  ## 🗺️ 路线图
267
237
 
268
- 按 [PLAN.md](./PLAN.md) 推进:
238
+ **当前已具备(0.4.x):**
269
239
 
270
- - **P1 — 时间线与产物回退** ✅ 已上线(0.4.x):单会话内的版本时间线(版本/消息/思考/工具节点),产物快照(git 优先 + 快照兜底,可开关),带干跑预览的回退,以及跳转到对话位置;marker 写前校验(三层契约)守护日志。
271
- - **P2 — 分叉图** 🔨 推进中:对话回合的流程分叉图,每次回退都是分叉点,逐回合思考流,分支意图卡、版本对比。
272
- - 支持更多语言(当前:简体中文 / English)。
240
+ - 撤回 / 编辑重发 / 重新生成——每次回退都过**三层写前校验**与安全编辑路径(自动停 agent、临时 step 包裹 marker),**不会损坏日志、不会破坏 /compact**。
241
+ - 单会话**版本时间线** + **产物回退**(git 优先 + 快照兜底、干跑预览、跳转对话)。
242
+ - 对话视图内的**分叉图** + **会话谱系**。
243
+ - **实时看门狗**——并发写入第一时间快照日志。
244
+ - 配套 **`dsh-log-contract`**:30+ 条离线契约规则 + 原地修复(`fix --neutralize` / `--clip-crossstep`),能处理会让 /compact 永久失败的会话。
245
+
246
+ **未来计划**——见 [公开路线图](./docs/ROADMAP.md)(agent 业务层规划:运行时守护、中断治理、生态开放接口)。本 README 只描述已上线的能力。
273
247
 
274
248
  ---
275
249
 
@@ -316,6 +290,13 @@ npm pack --dry-run # 校验发布文件清单
316
290
 
317
291
  收录于 [dsh-plugin topic](https://github.com/topics/dsh-plugin)。
318
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
+
319
300
  > **直接从 GitHub 安装**(无需 npm registry —— 适合把本仓库链接丢给 AI,或想装最新提交):
320
301
  >
321
302
  > ```sh
@@ -341,3 +322,27 @@ DeepSeek Harness 插件生态的精选总览见
341
322
  ## 📄 License
342
323
 
343
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
+ 在界面上一眼可见——也是分叉图拓扑的元数据源。
package/bin/retrace.mjs CHANGED
File without changes
package/lib/badge.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * dsh-retrace — lib/badge.js
3
+ *
4
+ * 会话短码(identity badge)——2026-08-31 用户指定的小功能。
5
+ *
6
+ * 需求:与人交互时需要稳定的会话识别符(标题会变、session id 太长)。
7
+ * 解法:**从 session id(唯一值)确定性导出短码**——不依赖全量扫描、
8
+ * 不依赖外部短码表、插件运行时自算即可。同一 session id 永远得到同一短码。
9
+ *
10
+ * 与 `会话短码表.json`(工作区+序号语义格式 dx023dx001)的关系:
11
+ * - 外部表 = 人工维护的语义短码(线名/父子关系可读),由修复线维护;
12
+ * - 本模块 = 插件自算的确定性短码(人机交互兜底),任何环境可复现;
13
+ * - 两者并存:铭牌优先显示外部语义短码(若可查),否则用本模块兜底。
14
+ *
15
+ * 算法:FNV-1a 64-bit(uuid 段)→ base36 → 固定 10 位(补 0)。
16
+ * - 10 位纯字母数字(36^10 ≈ 3.6e15 空间,会话规模下碰撞可忽略);
17
+ * - 只依赖 id 本身,无状态、无 IO、纯函数(可测试、可在 client/host 复用)。
18
+ */
19
+
20
+ /** 提取 id 中的 uuid 段(hex),无匹配则退回 hex 清洗。 */
21
+ export function uuidOf(id) {
22
+ const text = String(id ?? '')
23
+ const m = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
24
+ if (m) return m[0].replace(/-/g, '').toLowerCase()
25
+ const cleaned = text.replace(/[^0-9a-f]/gi, '').toLowerCase()
26
+ return cleaned.length >= 8 ? cleaned.slice(0, 32) : ''
27
+ }
28
+
29
+ /** FNV-1a 64-bit(BigInt,避免精度丢失)。 */
30
+ export function fnv1a64(text) {
31
+ let hash = 0xcbf29ce484222325n
32
+ const prime = 0x100000001b3n
33
+ const mask = 0xffffffffffffffffn
34
+ for (let i = 0; i < text.length; i++) {
35
+ hash ^= BigInt(text.charCodeAt(i))
36
+ hash = (hash * prime) & mask
37
+ }
38
+ return hash
39
+ }
40
+
41
+ const BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz'
42
+
43
+ /** BigInt → base36 字符串。 */
44
+ export function toBase36(value) {
45
+ if (value < 0n) value = -value
46
+ if (value === 0n) return '0'
47
+ let out = ''
48
+ let v = value
49
+ while (v > 0n) {
50
+ out = BASE36[Number(v % 36n)] + out
51
+ v /= 36n
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * 会话短码:uuid 段 → FNV-1a 64 → base36 → 固定 10 位(补 0 截断)。
58
+ * @param {string} sessionId - 会话 id(`session-<uuid>` 或裸 uuid)。
59
+ * @returns {string} 10 位短码;无法提取 uuid 时返回空串(调用方自行兜底)。
60
+ */
61
+ export function sessionBadge(sessionId) {
62
+ const uuid = uuidOf(sessionId)
63
+ if (uuid.length === 0) return ''
64
+ return toBase36(fnv1a64(uuid)).padStart(10, '0').slice(0, 10)
65
+ }
@@ -32,6 +32,44 @@ __export(client_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(client_exports);
34
34
  var import_react = require("react");
35
+
36
+ // lib/badge.js
37
+ function uuidOf(id) {
38
+ const text = String(id ?? "");
39
+ const m = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
40
+ if (m) return m[0].replace(/-/g, "").toLowerCase();
41
+ const cleaned = text.replace(/[^0-9a-f]/gi, "").toLowerCase();
42
+ return cleaned.length >= 8 ? cleaned.slice(0, 32) : "";
43
+ }
44
+ function fnv1a64(text) {
45
+ let hash = 0xcbf29ce484222325n;
46
+ const prime = 0x100000001b3n;
47
+ const mask = 0xffffffffffffffffn;
48
+ for (let i = 0; i < text.length; i++) {
49
+ hash ^= BigInt(text.charCodeAt(i));
50
+ hash = hash * prime & mask;
51
+ }
52
+ return hash;
53
+ }
54
+ var BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz";
55
+ function toBase36(value) {
56
+ if (value < 0n) value = -value;
57
+ if (value === 0n) return "0";
58
+ let out = "";
59
+ let v = value;
60
+ while (v > 0n) {
61
+ out = BASE36[Number(v % 36n)] + out;
62
+ v /= 36n;
63
+ }
64
+ return out;
65
+ }
66
+ function sessionBadge(sessionId) {
67
+ const uuid = uuidOf(sessionId);
68
+ if (uuid.length === 0) return "";
69
+ return toBase36(fnv1a64(uuid)).padStart(10, "0").slice(0, 10);
70
+ }
71
+
72
+ // lib/client.js
35
73
  var name = "dsh-retrace";
36
74
  var inject = ["slots", "locale", "conversationEvents"];
37
75
  var NS = "retrace";
@@ -139,7 +177,9 @@ var zh = {
139
177
  "fork.lineageThis": "\u5F53\u524D\u4F1A\u8BDD",
140
178
  "fork.lineageParent": "\u7236\u4F1A\u8BDD",
141
179
  "fork.lineageRoot": "\u6839\u4F1A\u8BDD",
142
- "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002"
180
+ "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002",
181
+ "fork.badge": "\u4F1A\u8BDD\u77ED\u7801",
182
+ "fork.badgeHint": "\u77ED\u7801\u7531\u4F1A\u8BDD\u552F\u4E00 id \u786E\u5B9A\u6027\u5BFC\u51FA\uFF0C\u6C38\u4E0D\u53D8\uFF08\u6807\u9898\u4F1A\u53D8\uFF09\u3002"
143
183
  };
144
184
  var en = {
145
185
  "action.edit": "Edit",
@@ -233,7 +273,9 @@ var en = {
233
273
  "fork.lineageThis": "This session",
234
274
  "fork.lineageParent": "Parent session",
235
275
  "fork.lineageRoot": "Root session",
236
- "fork.lineageEmpty": "This session has no parent (standalone root)."
276
+ "fork.lineageEmpty": "This session has no parent (standalone root).",
277
+ "fork.badge": "Session badge",
278
+ "fork.badgeHint": "A stable shortcode derived from the session id (titles change, badges never do)."
237
279
  };
238
280
  var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
239
281
  function isReplacementSurfaceEvent(event) {
@@ -931,6 +973,10 @@ var CSS = `
931
973
  .dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
932
974
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
933
975
  .dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
976
+ .dsh-rt-fork-badge{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:6px 8px;flex:none;min-width:0}
977
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
978
+ .dsh-rt-fork-badge-code{font-family:var(--dsw-font-mono);font-size:12px;font-weight:600;line-height:16px;color:var(--dsw-alias-state-info-primary);flex:none}
979
+ .dsh-rt-fork-badge-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
934
980
  `;
935
981
  var JUMP_PAGE_BUDGET = 24;
936
982
  function switchToViewTab(viewId) {
@@ -1136,7 +1182,9 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1136
1182
  };
1137
1183
  const ROW_H = 64;
1138
1184
  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));
1185
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1186
+ const visibleEnd = Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1187
+ const visible = list.slice(visibleStart, visibleEnd);
1140
1188
  (0, import_react.useEffect)(() => {
1141
1189
  if (list.length === 0) return void 0;
1142
1190
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-timeline-list"));
@@ -1175,11 +1223,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1175
1223
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1176
1224
  }, [
1177
1225
  (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, {
1226
+ visible.map((record, i) => (0, import_react.createElement)(VersionRow, {
1179
1227
  key: record.versionId,
1180
1228
  record,
1181
1229
  t,
1182
- top: list.indexOf(record) * ROW_H,
1230
+ top: (visibleStart + i) * ROW_H,
1183
1231
  onPreview: () => requestPreview(record),
1184
1232
  onTrajectory: () => switchToViewTab("trajectory"),
1185
1233
  onJump: () => jump(record.boundarySeq)
@@ -1301,10 +1349,9 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1301
1349
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-fork-list"));
1302
1350
  }, [nodes.length]);
1303
1351
  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
- );
1352
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1353
+ const visibleEnd = Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1354
+ const visible = nodes.slice(visibleStart, visibleEnd);
1308
1355
  return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
1309
1356
  (0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
1310
1357
  (0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("fork.title")),
@@ -1315,6 +1362,12 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1315
1362
  onClick: refresh
1316
1363
  }, t("fork.refresh"))
1317
1364
  ]),
1365
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1366
+ sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
1367
+ (0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
1368
+ (0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(sessionId)}]`),
1369
+ (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
1370
+ ]),
1318
1371
  Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
1319
1372
  (0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
1320
1373
  lineage.map((hop, i) => (0, import_react.createElement)("div", {
@@ -1326,6 +1379,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1326
1379
  { key: "tag", className: "dsh-rt-fork-lineage-tag" },
1327
1380
  i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
1328
1381
  ),
1382
+ (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(hop.id)}]`),
1329
1383
  (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
1330
1384
  i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
1331
1385
  ]))
@@ -1341,13 +1395,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1341
1395
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1342
1396
  }, [
1343
1397
  (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, {
1398
+ visible.map((node, i) => (0, import_react.createElement)(ForkRow, {
1345
1399
  key: node.seq,
1346
1400
  node,
1347
1401
  boundary: boundaryBySeq.get(node.seq),
1348
1402
  markerText: markerBySeq.get(node.seq),
1349
1403
  t,
1350
- top: nodes.indexOf(node) * ROW_H,
1404
+ top: (visibleStart + i) * ROW_H,
1351
1405
  onJump: () => jumpToAnchor(store, node.seq)
1352
1406
  }))
1353
1407
  ])
package/lib/client.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `/api/plugins/retrace/*` registered by the Host half.
17
17
  */
18
18
  import { createElement, useEffect, useState } from 'react'
19
+ import { sessionBadge } from './badge.js'
19
20
 
20
21
  export const name = 'dsh-retrace'
21
22
  export const inject = ['slots', 'locale', 'conversationEvents']
@@ -148,6 +149,8 @@ const zh = {
148
149
  'fork.lineageParent': '父会话',
149
150
  'fork.lineageRoot': '根会话',
150
151
  'fork.lineageEmpty': '本会话没有父会话(独立根会话)。',
152
+ 'fork.badge': '会话短码',
153
+ 'fork.badgeHint': '短码由会话唯一 id 确定性导出,永不变(标题会变)。',
151
154
  }
152
155
  /** English dictionary, checked complete against the zh key set. */
153
156
  const en = {
@@ -243,6 +246,8 @@ const en = {
243
246
  'fork.lineageParent': 'Parent session',
244
247
  'fork.lineageRoot': 'Root session',
245
248
  'fork.lineageEmpty': 'This session has no parent (standalone root).',
249
+ 'fork.badge': 'Session badge',
250
+ 'fork.badgeHint': 'A stable shortcode derived from the session id (titles change, badges never do).',
246
251
  }
247
252
 
248
253
  // ---------------------------------------------------------------------------
@@ -1164,6 +1169,10 @@ const CSS = `
1164
1169
  .dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1165
1170
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
1166
1171
  .dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
1172
+ .dsh-rt-fork-badge{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:6px 8px;flex:none;min-width:0}
1173
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
1174
+ .dsh-rt-fork-badge-code{font-family:var(--dsw-font-mono);font-size:12px;font-weight:600;line-height:16px;color:var(--dsw-alias-state-info-primary);flex:none}
1175
+ .dsh-rt-fork-badge-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1167
1176
  `
1168
1177
 
1169
1178
  const JUMP_PAGE_BUDGET = 24
@@ -1485,7 +1494,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1485
1494
  // ---- windowed list (uniform rows, zero-dep) ----
1486
1495
  const ROW_H = 64
1487
1496
  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))
1497
+ // 2026-08-30 渲染卡死修复(与 ForkView 同):带起始索引切片,渲染用索引算 top——
1498
+ // 原 `list.indexOf(record)` 是 O(N²)(每可见行线性查找),大会话渲染风暴。
1499
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2)
1500
+ const visibleEnd = Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1501
+ const visible = list.slice(visibleStart, visibleEnd)
1489
1502
 
1490
1503
  // Same view-area height trap as the fork list (see bindListHeight).
1491
1504
  useEffect(() => {
@@ -1534,11 +1547,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1534
1547
  onScroll: (event) => setScrollTop(event.target.scrollTop),
1535
1548
  }, [
1536
1549
  createElement('div', { key: 'spacer', style: { height: `${list.length * ROW_H}px`, position: 'relative' } }, [
1537
- visible.map((record) => createElement(VersionRow, {
1550
+ visible.map((record, i) => createElement(VersionRow, {
1538
1551
  key: record.versionId,
1539
1552
  record,
1540
1553
  t,
1541
- top: list.indexOf(record) * ROW_H,
1554
+ top: (visibleStart + i) * ROW_H,
1542
1555
  onPreview: () => requestPreview(record),
1543
1556
  onTrajectory: () => switchToViewTab('trajectory'),
1544
1557
  onJump: () => jump(record.boundarySeq),
@@ -1702,11 +1715,14 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1702
1715
  }, [nodes.length])
1703
1716
 
1704
1717
  // ---- windowed list (uniform rows, zero-dep; same as the versions view) ----
1718
+ // 2026-08-30 渲染卡死修复:visible 改为带起始索引的切片,渲染时直接用索引算
1719
+ // top——原实现 `nodes.indexOf(node)` 在每次渲染对每个可见节点做 O(N) 线性查找
1720
+ // (2047 节点 × ~15 可见行 = 每次渲染 ~30K 次比较,React 重渲染风暴 → 转圈、
1721
+ // Renderer CPU 27.7%,526f1835 打开卡死)。其他会话节点少不触发,仅大会话暴露。
1705
1722
  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
- )
1723
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2)
1724
+ const visibleEnd = Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1725
+ const visible = nodes.slice(visibleStart, visibleEnd)
1710
1726
 
1711
1727
  return createElement('div', { className: 'dsh-rt-view' }, [
1712
1728
  createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
@@ -1719,6 +1735,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1719
1735
  }, t('fork.refresh')),
1720
1736
  ]),
1721
1737
 
1738
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1739
+ sessionId && createElement('div', { key: 'badge', className: 'dsh-rt-fork-badge', title: t('fork.badgeHint') }, [
1740
+ createElement('span', { key: 'tag', className: 'dsh-rt-fork-badge-tag' }, t('fork.badge')),
1741
+ createElement('code', { key: 'code', className: 'dsh-rt-fork-badge-code' }, `[${sessionBadge(sessionId)}]`),
1742
+ createElement('span', { key: 'id', className: 'dsh-rt-fork-badge-id' }, sessionId),
1743
+ ]),
1744
+
1722
1745
  Array.isArray(lineage) && lineage.length > 1 && createElement('div', { key: 'lineage', className: 'dsh-rt-fork-lineage' }, [
1723
1746
  createElement('div', { key: 'lt', className: 'dsh-rt-fork-spine-label' }, t('fork.lineage')),
1724
1747
  lineage.map((hop, i) => createElement('div', {
@@ -1727,6 +1750,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1727
1750
  }, [
1728
1751
  createElement('span', { key: 'tag', className: 'dsh-rt-fork-lineage-tag' },
1729
1752
  i === 0 ? t('fork.lineageThis') : (i === lineage.length - 1 ? t('fork.lineageRoot') : t('fork.lineageParent'))),
1753
+ createElement('code', { key: 'badge', className: 'dsh-rt-fork-badge-code' }, `[${sessionBadge(hop.id)}]`),
1730
1754
  createElement('span', { key: 'id', className: 'dsh-rt-fork-lineage-id' }, hop.id),
1731
1755
  i < lineage.length - 1 && createElement('span', { key: 'arrow', className: 'dsh-rt-fork-lineage-arrow' }, '←'),
1732
1756
  ])),
@@ -1745,13 +1769,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1745
1769
  onScroll: (event) => setScrollTop(event.target.scrollTop),
1746
1770
  }, [
1747
1771
  createElement('div', { key: 'spacer', style: { height: `${nodes.length * ROW_H}px`, position: 'relative' } }, [
1748
- visible.map((node) => createElement(ForkRow, {
1772
+ visible.map((node, i) => createElement(ForkRow, {
1749
1773
  key: node.seq,
1750
1774
  node,
1751
1775
  boundary: boundaryBySeq.get(node.seq),
1752
1776
  markerText: markerBySeq.get(node.seq),
1753
1777
  t,
1754
- top: nodes.indexOf(node) * ROW_H,
1778
+ top: (visibleStart + i) * ROW_H,
1755
1779
  onJump: () => jumpToAnchor(store, node.seq),
1756
1780
  })),
1757
1781
  ]),
@@ -38,6 +38,44 @@ return {
38
38
  });
39
39
  module.exports = __toCommonJS(client_exports);
40
40
  var import_react = require("react");
41
+
42
+ // lib/badge.js
43
+ function uuidOf(id) {
44
+ const text = String(id ?? "");
45
+ const m = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
46
+ if (m) return m[0].replace(/-/g, "").toLowerCase();
47
+ const cleaned = text.replace(/[^0-9a-f]/gi, "").toLowerCase();
48
+ return cleaned.length >= 8 ? cleaned.slice(0, 32) : "";
49
+ }
50
+ function fnv1a64(text) {
51
+ let hash = 0xcbf29ce484222325n;
52
+ const prime = 0x100000001b3n;
53
+ const mask = 0xffffffffffffffffn;
54
+ for (let i = 0; i < text.length; i++) {
55
+ hash ^= BigInt(text.charCodeAt(i));
56
+ hash = hash * prime & mask;
57
+ }
58
+ return hash;
59
+ }
60
+ var BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz";
61
+ function toBase36(value) {
62
+ if (value < 0n) value = -value;
63
+ if (value === 0n) return "0";
64
+ let out = "";
65
+ let v = value;
66
+ while (v > 0n) {
67
+ out = BASE36[Number(v % 36n)] + out;
68
+ v /= 36n;
69
+ }
70
+ return out;
71
+ }
72
+ function sessionBadge(sessionId) {
73
+ const uuid = uuidOf(sessionId);
74
+ if (uuid.length === 0) return "";
75
+ return toBase36(fnv1a64(uuid)).padStart(10, "0").slice(0, 10);
76
+ }
77
+
78
+ // lib/client.js
41
79
  var name = "dsh-retrace";
42
80
  var inject = ["slots", "locale", "conversationEvents"];
43
81
  var NS = "retrace";
@@ -145,7 +183,9 @@ return {
145
183
  "fork.lineageThis": "\u5F53\u524D\u4F1A\u8BDD",
146
184
  "fork.lineageParent": "\u7236\u4F1A\u8BDD",
147
185
  "fork.lineageRoot": "\u6839\u4F1A\u8BDD",
148
- "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002"
186
+ "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002",
187
+ "fork.badge": "\u4F1A\u8BDD\u77ED\u7801",
188
+ "fork.badgeHint": "\u77ED\u7801\u7531\u4F1A\u8BDD\u552F\u4E00 id \u786E\u5B9A\u6027\u5BFC\u51FA\uFF0C\u6C38\u4E0D\u53D8\uFF08\u6807\u9898\u4F1A\u53D8\uFF09\u3002"
149
189
  };
150
190
  var en = {
151
191
  "action.edit": "Edit",
@@ -239,7 +279,9 @@ return {
239
279
  "fork.lineageThis": "This session",
240
280
  "fork.lineageParent": "Parent session",
241
281
  "fork.lineageRoot": "Root session",
242
- "fork.lineageEmpty": "This session has no parent (standalone root)."
282
+ "fork.lineageEmpty": "This session has no parent (standalone root).",
283
+ "fork.badge": "Session badge",
284
+ "fork.badgeHint": "A stable shortcode derived from the session id (titles change, badges never do)."
243
285
  };
244
286
  var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
245
287
  function isReplacementSurfaceEvent(event) {
@@ -937,6 +979,10 @@ return {
937
979
  .dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
938
980
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
939
981
  .dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
982
+ .dsh-rt-fork-badge{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:6px 8px;flex:none;min-width:0}
983
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
984
+ .dsh-rt-fork-badge-code{font-family:var(--dsw-font-mono);font-size:12px;font-weight:600;line-height:16px;color:var(--dsw-alias-state-info-primary);flex:none}
985
+ .dsh-rt-fork-badge-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
940
986
  `;
941
987
  var JUMP_PAGE_BUDGET = 24;
942
988
  function switchToViewTab(viewId) {
@@ -1142,7 +1188,9 @@ return {
1142
1188
  };
1143
1189
  const ROW_H = 64;
1144
1190
  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));
1191
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1192
+ const visibleEnd = Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1193
+ const visible = list.slice(visibleStart, visibleEnd);
1146
1194
  (0, import_react.useEffect)(() => {
1147
1195
  if (list.length === 0) return void 0;
1148
1196
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-timeline-list"));
@@ -1181,11 +1229,11 @@ return {
1181
1229
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1182
1230
  }, [
1183
1231
  (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, {
1232
+ visible.map((record, i) => (0, import_react.createElement)(VersionRow, {
1185
1233
  key: record.versionId,
1186
1234
  record,
1187
1235
  t,
1188
- top: list.indexOf(record) * ROW_H,
1236
+ top: (visibleStart + i) * ROW_H,
1189
1237
  onPreview: () => requestPreview(record),
1190
1238
  onTrajectory: () => switchToViewTab("trajectory"),
1191
1239
  onJump: () => jump(record.boundarySeq)
@@ -1307,10 +1355,9 @@ return {
1307
1355
  return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-fork-list"));
1308
1356
  }, [nodes.length]);
1309
1357
  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
- );
1358
+ const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_H) - 2);
1359
+ const visibleEnd = Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2);
1360
+ const visible = nodes.slice(visibleStart, visibleEnd);
1314
1361
  return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
1315
1362
  (0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
1316
1363
  (0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("fork.title")),
@@ -1321,6 +1368,12 @@ return {
1321
1368
  onClick: refresh
1322
1369
  }, t("fork.refresh"))
1323
1370
  ]),
1371
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1372
+ sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
1373
+ (0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
1374
+ (0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(sessionId)}]`),
1375
+ (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
1376
+ ]),
1324
1377
  Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
1325
1378
  (0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
1326
1379
  lineage.map((hop, i) => (0, import_react.createElement)("div", {
@@ -1332,6 +1385,7 @@ return {
1332
1385
  { key: "tag", className: "dsh-rt-fork-lineage-tag" },
1333
1386
  i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
1334
1387
  ),
1388
+ (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(hop.id)}]`),
1335
1389
  (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
1336
1390
  i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
1337
1391
  ]))
@@ -1347,13 +1401,13 @@ return {
1347
1401
  onScroll: (event) => setScrollTop(event.target.scrollTop)
1348
1402
  }, [
1349
1403
  (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, {
1404
+ visible.map((node, i) => (0, import_react.createElement)(ForkRow, {
1351
1405
  key: node.seq,
1352
1406
  node,
1353
1407
  boundary: boundaryBySeq.get(node.seq),
1354
1408
  markerText: markerBySeq.get(node.seq),
1355
1409
  t,
1356
- top: nodes.indexOf(node) * ROW_H,
1410
+ top: (visibleStart + i) * ROW_H,
1357
1411
  onJump: () => jumpToAnchor(store, node.seq)
1358
1412
  }))
1359
1413
  ])
package/lib/http.js CHANGED
@@ -81,8 +81,8 @@ function sendError(res, error) {
81
81
  }
82
82
 
83
83
  /** Route one request. `seam` is the versioning seam (lib/versioning.js). */
84
- export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback, log = () => {} }) {
85
- const api = createEditorApi(ctx, sessions, agents, log)
84
+ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback, hooks = {}, log = () => {} }) {
85
+ const api = createEditorApi(ctx, sessions, agents, log, hooks)
86
86
 
87
87
  function handleVersions(req, res, sessionId, config) {
88
88
  seam.setConfig(sessionId, config)
package/lib/index.js CHANGED
@@ -25,6 +25,8 @@ import { createVersioningSeam } from './versioning.js'
25
25
  import { createRollbackExecutor } from './rollback.js'
26
26
  import { createMarkerGuard } from './prewrite-guard.js'
27
27
  import { createWatchdog } from './watchdog.js'
28
+ import { attachExitWarning } from './interrupt-guard.js'
29
+ import { sessionBadge } from './badge.js'
28
30
 
29
31
  export const name = 'dsh-retrace'
30
32
  export const inject = ['sessions', 'agents', 'webServer', 'fs', 'subprocess', 'sandboxPolicy']
@@ -52,6 +54,7 @@ export function apply(ctx) {
52
54
  agents: ctx.agents,
53
55
  seam,
54
56
  rollback,
57
+ hooks,
55
58
  log,
56
59
  })
57
60
 
@@ -78,6 +81,11 @@ export function apply(ctx) {
78
81
  harness.handle('retrace.recall', (args) => api.recall(args)),
79
82
  harness.handle('retrace.editAndResend', (args) => api.editAndResend(args)),
80
83
  harness.handle('retrace.regenerate', (args) => api.regenerate(args)),
84
+ // 会话短码(2026-08-31):session id → 确定性 10 位短码,人机交互识别用。
85
+ harness.handle('retrace.sessionBadge', (args) => {
86
+ const sessionId = String(args?.sessionId ?? '')
87
+ return { ok: true, value: { sessionId, badge: sessionBadge(sessionId) } }
88
+ }),
81
89
  ]
82
90
  return () => disposers.forEach((dispose) => dispose())
83
91
  })()
@@ -85,9 +93,13 @@ export function apply(ctx) {
85
93
  // R1 实时看门狗:轮询文件尾部 seq vs 内存长度,捕获并发写入/旧光标回放现场。
86
94
  const watchdog = createWatchdog(ctx, log)
87
95
 
96
+ // R4 中断轮次治理:退出/重载时对未闭合 turn 会话提示(只检测不写事件)。
97
+ const warnUnclosed = attachExitWarning(ctx, log)
98
+
88
99
  ctx.effect(() => () => {
89
100
  disposeRoute()
90
101
  disposeHarness()
91
102
  watchdog.dispose()
103
+ warnUnclosed()
92
104
  }, 'dsh-retrace: transports')
93
105
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * dsh-retrace · lib/interrupt-guard.js
3
+ *
4
+ * R4 中断轮次治理(2026-08-30 任务书 §4)。
5
+ *
6
+ * 目标:退出/关闭会话前对「未闭合 turn」的会话提示用户,避免误以为任务
7
+ * 已干净结束。
8
+ *
9
+ * 设计(按实现方案 §R4,铁律「先核实官方 turn 关闭路径」):
10
+ * - 官方 `dsh-agent-loop/lib/index.js:620` 在 finally 块保证 `turn/end`
11
+ * 必然写入(含 reason.kind='error' 的中断)——正常退出/中断路径不会
12
+ * 留下未闭合 turn;
13
+ * - 未闭合 turn 只出现在「崩溃 / 强杀 / 断电」场景:有 turn/start 无
14
+ * turn/end(interrupted/aborted 是官方正常闭合,不在此列);
15
+ * - 因此本守卫**只检测 + 提示,绝不自动写 turn/end**(与官方持久化竞态
16
+ * 会引入双写;且插件不应在运行期补写持久化事件——铁律「插件内不要
17
+ * 删除/重编号持久化事件」的同类约束)。
18
+ *
19
+ * 检测函数:
20
+ * unclosedTurns(session) → { turn, state: 'open' }[]
21
+ * 扫描 session.events:turn/start 开、turn/end 关;尾部仍开 = 未闭合。
22
+ */
23
+
24
+ /**
25
+ * 扫描会话,找未闭合的轮次。
26
+ * @param {object} session - DSH session 对象(session.events 为事件数组)。
27
+ * @returns {Array<{turn: number, state: 'open'}>}
28
+ *
29
+ * ⚠️ 判定(2026-08-31 独立审查修正):只报**真正未闭合**的轮次——有
30
+ * turn/start 且尾部没有对应 turn/end(崩溃/强杀现场)。
31
+ * turn/end 的 reason.kind 为 'interrupted'/'aborted' 是官方**正常闭合**
32
+ * (用户主动中断,agent-loop finally 保证写入),archive 实测 69 会话中
33
+ * 666 aborted + 126 interrupted 全是正常闭合、真 open 只 10 个——把它们
34
+ * 当"未闭合"会在每次退出对 91% 会话打误导性 warning。
35
+ */
36
+ export function unclosedTurns(session) {
37
+ const events = Array.isArray(session?.events) ? session.events : []
38
+ const found = []
39
+ let openTurn = null
40
+ for (const event of events) {
41
+ if (!event || typeof event !== 'object') continue
42
+ if (event.type === 'turn/start') {
43
+ openTurn = event.data?.turn
44
+ } else if (event.type === 'turn/end') {
45
+ openTurn = null
46
+ }
47
+ }
48
+ // 尾部仍有未闭合 turn(崩溃/强杀现场)
49
+ if (openTurn !== null) {
50
+ found.push({ turn: openTurn, state: 'open' })
51
+ }
52
+ return found
53
+ }
54
+
55
+ /**
56
+ * 挂载退出前提示:在插件 dispose 时对「当前 attach 且有未闭合轮次」的会话
57
+ * 记录一条 warning(非阻断)。调用方(apply 的 ctx.effect 清理)负责在
58
+ * 退出/重载时触发。
59
+ *
60
+ * 不做客户端弹窗(非阻断提示由宿主 UI 在会话内呈现——本插件只负责检测与
61
+ * 日志;客户端时间线渲染未闭合轮次的提示属 UI 层,后续可扩展)。
62
+ * @param {object} ctx - cordis ctx。
63
+ * @param {(line: string) => void} log - 日志函数。
64
+ * @param {() => Array<object>} [sessionList] - 活跃会话列表(默认 ctx.sessions)。
65
+ */
66
+ export function attachExitWarning(ctx, log, sessionList) {
67
+ return () => {
68
+ const sessions = typeof sessionList === 'function' ? sessionList() : ctx?.sessions
69
+ if (!sessions || typeof sessions !== 'object') return
70
+ const ids = typeof sessions.keys === 'function' ? [...sessions.keys()] : Object.keys(sessions)
71
+ for (const id of ids) {
72
+ const session = typeof sessions.get === 'function' ? sessions.get(id) : sessions[id]
73
+ if (!session) continue
74
+ const unclosed = unclosedTurns(session)
75
+ if (unclosed.length === 0) continue
76
+ const summary = unclosed.map((u) => `turn ${u.turn} (${u.state})`).join(', ')
77
+ log(`retrace-interrupt-guard: 会话 ${id} 存在未闭合轮次 ${summary}——中断未干净收尾,重启后可能触发旧光标回放;建议退出前先体检(dsh-log-contract check)`)
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 在给定会话上运行检测(供 CLI/测试直接调用)。
84
+ * @param {object} session - 会话对象或 { events: [...] }。
85
+ * @returns {Array<{turn: number, state: string}>}
86
+ */
87
+ export function detectUnclosed(session) {
88
+ return unclosedTurns(session)
89
+ }
@@ -32,6 +32,45 @@
32
32
  */
33
33
  import { editorError } from './host-core.js'
34
34
 
35
+ /**
36
+ * 回档幅度阈值(快照点守卫,2026-08-31 c2d05ce9 事故闭环)。
37
+ *
38
+ * 业务语义(生产级运行保障,见 工程-生产级运行时/快照点与回档机制-设计-20260831.md):
39
+ * - 一次编辑/撤回/重发遮蔽的 surface 节点占比 > 本阈值 = **回档请求**——
40
+ * 等于把生产基线大幅改写 → 拒绝落盘,引导从快照点创建官方分支;
41
+ * - 与 client 侧 SHADOW_SAFETY_RATIO(0.4)同源:UI 降级判定与写前拦截共用同一阈值;
42
+ * - 阈值可通过 createMarkerGuard({ rollbackRatio }) 覆盖(测试/未来配置)。
43
+ */
44
+ export const ROLLBACK_RATIO = 0.4
45
+
46
+ /**
47
+ * 短会话豁免阈值(2026-08-31 独立审查问题 B):surface 节点 ≤ 6(≈ 3 轮)
48
+ * 的会话不做回档幅度拦截——否则 1-2 轮新会话编辑/撤回/重发 100% 被拒,
49
+ * 现有功能回归。事故防御针对大会话的「编辑早期消息遮蔽几十轮」,短会话无此风险。
50
+ */
51
+ export const ROLLBACK_MIN_SURFACE = 6
52
+
53
+ /**
54
+ * 该 marker 是否来自 rollback(restore op)——rollback 本身就是「从快照点
55
+ * 回档」的官方机制,不应再被回档幅度守卫拦截(否则大范围回档永远不可用,
56
+ * 且错误文案引导「打分支」而插件内无此操作 = 死路)。2026-08-31 独立审查问题 A。
57
+ */
58
+ export function isRestoreMarker(envelope) {
59
+ const id = envelope?.data?.message?.id
60
+ return typeof id === 'string' && id.startsWith('retrace-restore-')
61
+ }
62
+
63
+ /** 计算一次 replace 的遮蔽占比(0..1)。无 surface/无 sourceEventSeqs/短会话 → 0(不拦截)。 */
64
+ export function rollbackShareOf(session, envelope) {
65
+ if (!session || !envelope) return 0
66
+ const shadowed = Array.isArray(envelope.sourceEventSeqs) ? envelope.sourceEventSeqs.length : 0
67
+ if (shadowed <= 0) return 0
68
+ const surfaceNodes = session.surface?.nodes
69
+ const total = Array.isArray(surfaceNodes) ? surfaceNodes.length : 0
70
+ if (total <= ROLLBACK_MIN_SURFACE) return 0 // 短会话豁免(问题 B)
71
+ return shadowed / total
72
+ }
73
+
35
74
  /** R2 —— T1 折叠自检:与 dsh-token-meter/_foldEvent 及 checks.js T1 同语义。 */
36
75
  export function tokenMeterFoldOk(events) {
37
76
  // 与 checks.js:372 同语义:无任何 step/start 的日志(极早期格式/简化夹具)
@@ -61,7 +100,7 @@ export function tokenMeterFoldOk(events) {
61
100
  * @param {(sessionId: string) => boolean} [options.enabled] 门控(默认恒 true)。
62
101
  * @returns {{ validateMarkerAppend(session, envelope): Promise<void> }}
63
102
  */
64
- export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled = () => true } = {}) {
103
+ export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled = () => true, rollbackRatio = ROLLBACK_RATIO } = {}) {
65
104
  let factory = prewriterFactory ?? null
66
105
  return {
67
106
  /**
@@ -72,6 +111,27 @@ export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled =
72
111
  */
73
112
  async validateMarkerAppend(session, envelope) {
74
113
  if (typeof enabled === 'function' && enabled(session?.id) === false) return { t1Ok: true }
114
+ // 快照点守卫(2026-08-31 c2d05ce9 事故闭环):回档请求(遮蔽 > 阈值)
115
+ // 拒绝落盘——生产基线不支持原地大幅改写,引导从快照点创建官方分支。
116
+ // 在契约校验之前独立检查(业务规则,与合法性正交);host 层强制,UI 绕过也拦。
117
+ // 豁免(2026-08-31 独立审查):restore(rollback 回档本身合法)与短会话(≤3 轮)。
118
+ if (
119
+ envelope?.surfaceOp?.op === 'replace' &&
120
+ Array.isArray(envelope.sourceEventSeqs) &&
121
+ !isRestoreMarker(envelope)
122
+ ) {
123
+ const share = rollbackShareOf(session, envelope)
124
+ if (share > rollbackRatio) {
125
+ const pct = Math.round(share * 100)
126
+ log(
127
+ `retrace: rollback guard — replace shadows ${envelope.sourceEventSeqs.length}/${session?.surface?.nodes?.length ?? '?'} surface nodes (${pct}% > ${Math.round(rollbackRatio * 100)}%); refusing in-place rewrite; guide user to fork a branch from the snapshot point`,
128
+ )
129
+ throw editorError(
130
+ 'rollback-guide',
131
+ `此操作将回档到较早的快照点(遮蔽 ${pct}% 的对话)。生产基线不支持原地大幅改写;请从快照点创建会话分支,在新分支中继续——原会话完整保留。`,
132
+ )
133
+ }
134
+ }
75
135
  if (factory === null) {
76
136
  try {
77
137
  factory = (await import('dsh-log-contract')).createPreWriter
package/package.json CHANGED
@@ -1,10 +1,17 @@
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.10",
4
+ "version": "0.4.12",
5
+ "packageManager": "pnpm@11.7.0",
5
6
  "type": "module",
6
7
  "main": "lib/index.js",
7
8
  "types": "lib/types/index.d.ts",
9
+ "scripts": {
10
+ "build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
11
+ "check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
12
+ "test": "vitest run",
13
+ "prepublishOnly": "pnpm check && pnpm build && pnpm test"
14
+ },
8
15
  "exports": {
9
16
  ".": {
10
17
  "default": "./lib/index.js"
@@ -95,10 +102,5 @@
95
102
  },
96
103
  "bin": {
97
104
  "retrace": "bin/retrace.mjs"
98
- },
99
- "scripts": {
100
- "build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
101
- "check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
102
- "test": "vitest run"
103
105
  }
104
- }
106
+ }