@khorsheed/dsh-client-message-tools 0.3.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.3.1(2026-09-26)
4
+
5
+ 适配宿主 rc.1 线并实证 0.1.5/0.1.7 双线可用(0.1.5-rc.1 全量 boot 实证,2026-09-25)。
6
+
7
+ - 会话内消息的 source 改盖 V4 生产者归属 kind(退役的 `plugin` 外壳在 rc.1 持久层写入即抛);`conversation.chat.node` 的 rc.1 契约复验通过
8
+ - 恢复投影(restore)移到 fold 语义门后发布
9
+ - 「打开来源」等跳转改走官方浏览器开链;`select` 的 RemoteResult/mainView 推导修正
10
+ - 图标自持化:rc.1 图标图样由 `sync-icon-artwork` 生成器摊平进包内 `src/client/icons.tsx`(双线渲染同一份图样);包自持的补充图标迁名 `icons-local.tsx`
11
+ - 插件清单展示元数据(`locale/*.json`):rc.1 宿主插件页的卡面标题/描述中文化
12
+ - 撤回分隔线的展开回放不再丢图:被撤回用户消息的图片现在经官方附件画廊槽位(`renderMessageImages`)随原文一起重放;组合里没有附件 UI 时退化为纯文本回放,不报错
13
+ - 纯图片消息从前显示「已撤回 0 条消息」并落到「撤回的内容不在当前已加载的历史中」的空态(内容其实加载着),现在计为 1 条并正常回放;`WithdrawnEntry` 新增 `images`
14
+ - 两份 README 补齐记录:回放包含图片、纯图片消息计入 N;同时写明编辑与撤回回填仍只带文本(宿主没有公开的"文本 + 图片一起设置草稿"API,图片只能作为一次新上传重新登记)
15
+
3
16
  ## 0.3.0(2026-09-11)
4
17
 
5
18
  适配宿主 0.1.5 线。
package/README.en.md CHANGED
@@ -12,7 +12,7 @@ Said something too fast, or the conversation went off the rails? Until now all y
12
12
 
13
13
  - **Action row on every user message** — copy, edit, and withdraw on user bubbles and admitted steering messages.
14
14
  - **Edit in place** — inline editor with a working model chip; saving regenerates the turn from that point, and edited bubbles edit again.
15
- - **Real withdrawal, not a marker** — the message and the whole tail after it leave the model context, collapsing into an expandable 「已撤回 N 条消息」 divider.
15
+ - **Real withdrawal, not a marker** — the message and the whole tail after it leave the model context, collapsing into an expandable 「已撤回 N 条消息」 divider; expanding it replays the withdrawn user text, the images sent with those messages, and the assistant text (image-only messages count toward N).
16
16
  - **Draft backfill** — a landed withdrawal puts the original text back into the composer draft, never auto-sent.
17
17
  - **Restore to tail** — 「恢复到对话末尾」 replays user messages verbatim and assistant text as a 「已恢复」 group; tool calls never replay.
18
18
 
@@ -38,15 +38,15 @@ dsh plugin --profile web remove @khorsheed/dsh-client-message-tools
38
38
 
39
39
  ## Compatibility
40
40
 
41
- - npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ✅ full — adapted to the 0.1.5-rc.1 format v2/v3 (`assistant/attempt` replaces `assistant/chunk`; the surfaceOp replace fields `start`/`end` are renamed `startSeq`/`endSeq`), full build+test green; minHost moves up to 0.1.5-rc.1 — older hosts stay on the previous release line.
42
- - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.5-rc.1)
41
+ - npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ✅ full — adapted to the 0.1.5-rc.1 format v2/v3 (`assistant/attempt` replaces `assistant/chunk`; the surfaceOp replace fields `start`/`end` are renamed `startSeq`/`endSeq`), full build+test green; minHost moves up to 0.1.5-rc.1 — older hosts stay on the previous release line. The Session V4 producer-owned source (kind `message-tools`) also writes durably on a 0.1.5 host: 0.1.5's `user/message` admission only requires a non-empty kind string.
42
+ - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.7-rc.1) — Session V4 adaptation: the write side moved to the producer-owned source (kind `message-tools`; V4 native admission refuses the retired `kind: 'plugin'` wrapper), and the read side accepts the new kind, `plugin:message-tools` (the V3→V4 migrated form), and the released V3 wrapper (served verbatim on a 0.1.5 host); the `conversation.chat.node` rc.1 contract (the new hookContext/inject shapes, the `SessionEventLike` match-event union) re-verified; the ui-primitives icon rename (`Icon*Outline14/16` → `Icon*OutlineMedium`) followed.
43
43
 
44
44
  **Version line mapping**: the first release after 0.2.0 supports host `0.1.5-rc.1` and later; hosts on `0.1.2-rc.1` stay on `0.2.0`, hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.0`).
45
45
 
46
46
  ## Known Limitations
47
47
 
48
48
  - **Full-span hiding relies on an undocumented DOM attribute** — if upstream drops it, hiding degrades to renderer-only (user messages still hidden) with one `console.warn`, never an error.
49
- - **Edit is text-only** — image attachments of the original message are not carried into the resend.
49
+ - **Edit and withdrawal backfill are text-only** — image attachments of the original message do not enter the resend or the backfilled composer draft; they stay visible in the divider's expanded replay, so you can send them again yourself (the host has no public "set draft text + images" API — images can only be re-registered as a fresh upload).
50
50
  - **Restore is a tail replay, not an in-place repair** — assistant text replays as framed user-role messages, tool calls/results never; the span stays out of the model context, and the restore entry exists only on withdrawal dividers.
51
51
  - **Context and queued messages are out of scope** — context messages keep the official renderer (no action row); the official queue dock already edits/removes queued messages.
52
52
 
@@ -57,13 +57,13 @@ dsh plugin --profile web remove @khorsheed/dsh-client-message-tools
57
57
 
58
58
  **Architecture.** The browser half shadows the official user-message renderer (`conversation.chat.node`, keys `user` and `steering`, priority -1) with a visual clone that adds the action row, and registers four `ConversationNodeDefinition`s: a withdrawal projects as an expandable 「已撤回 N 条消息」 divider (N = user-visible messages hidden by the span), an edit replacement as an in-place bubble with an 「已编辑」 badge, and a restore entry as a replayed row in the 「已恢复」 group. The host half is the `messageTools` Typert Remote service (`withdraw` / `edit` / `restore`); the client mounts its generated Remote contribution via `ctx.remote.$mount`, so no core-package edits are needed.
59
59
 
60
- **Edit** is an in-place replacement: the host appends one `user/message` replacement whose content IS the edited text (spanning the target and the surface tail — editing an old message discards what followed it), then starts regeneration via `agent.followup` with a minimal plugin-sourced trigger. The edited text appears exactly once in the model context; the old content stays in the log as the audit trail. A running turn is cancelled first and awaited until fully settled — settle means the cancelled turn's teardown (tool results, `turn/end`) has fully landed in the log, not the `running` flip; the wait is bounded, a turn that never settles rejects with 「编辑失败」, and a failed cancel rejects without editing. Edit chains work: an edited bubble edits again, targeting the previous replacement's seq. The editor's model chip shares the per-session `ModelDirectory` (`ctx.get('modelDirectories')`, optional) with the composer and the /model popup — without ui-model-selection no chip renders — and the switch applies to the regenerated turn.
60
+ **Edit** is an in-place replacement: the host appends one `user/message` replacement whose content IS the edited text (spanning the target and the surface tail — editing an old message discards what followed it), then starts regeneration via `agent.followup` with a minimal producer-sourced trigger. The edited text appears exactly once in the model context; the old content stays in the log as the audit trail. A running turn is cancelled first and awaited until fully settled — settle means the cancelled turn's teardown (tool results, `turn/end`) has fully landed in the log, not the `running` flip; the wait is bounded, a turn that never settles rejects with 「编辑失败」, and a failed cancel rejects without editing. Edit chains work: an edited bubble edits again, targeting the previous replacement's seq. The editor's model chip shares the per-session `ModelDirectory` (`ctx.get('modelDirectories')`, optional) with the composer and the /model popup — without ui-model-selection no chip renders — and the switch applies to the regenerated turn.
61
61
 
62
- **Withdrawal** is real, not a marker: the host appends a `user/message` surface replacement (the compaction mechanism) spanning the target and every surface node after it, so the span leaves `session.surface` and never reaches the model again; the same cancel-and-settle choreography as edit keeps streaming content from landing after the replacement. The event is plugin-sourced, cites every shadowed node in `sourceEventSeqs`, and is durable (`SessionStore.flush`) before the method returns — it is the audit trail. No new event type is introduced: an out-of-harness type cannot carry `ignorable: true`, and a persisted unknown type would make session-persistence refuse the log on reload. A landed withdrawal backfills the target's original text into the composer draft (blank fills, non-empty appends a new line, info notice) — never auto-sent, nothing on failure; edits never backfill.
62
+ **Withdrawal** is real, not a marker: the host appends a `user/message` surface replacement (the compaction mechanism) spanning the target and every surface node after it, so the span leaves `session.surface` and never reaches the model again; the same cancel-and-settle choreography as edit keeps streaming content from landing after the replacement. The event carries the producer-owned source (kind `message-tools`), cites every shadowed node in `sourceEventSeqs`, and is durable (`SessionStore.flush`) before the method returns — it is the audit trail. No new event type is introduced: an out-of-harness type cannot carry `ignorable: true`, and a persisted unknown type would make session-persistence refuse the log on reload. A landed withdrawal backfills the target's original text into the composer draft (blank fills, non-empty appends a new line, info notice) — never auto-sent, nothing on failure; edits never backfill.
63
63
 
64
- **Projection and hiding.** The plugin's Definition claims the replacement into a divider anchored at its seq. Hiding is two-layer: the shadowed user renderer renders nothing inside a hidden span, and a dynamic stylesheet drops every chat row whose `data-chat-flow-key` (`ChatNodeSeat.tsx`) anchors inside the span, covering assistant steps, tool calls, and turn tails. The hider probes that undocumented attribute on its first non-empty rule set and, on a miss, enters a bounded retry (MutationObserver plus deadline) so a not-yet-mounted chat doesn't disable it; only an expired window disables it — one `console.warn`, renderer-only hiding — and the observer outlives the disable, so late-mounting rows reactivate it. It never throws. The divider expands in place to a read-only replay of the span (user originals plus assistant text, folded from the live node store) with the 「恢复到对话末尾」 action; a span whose rows fell out of the loaded window shows 「撤回的内容不在当前已加载的历史中」 instead.
64
+ **Projection and hiding.** The plugin's Definition claims the replacement into a divider anchored at its seq. Hiding is two-layer: the shadowed user renderer renders nothing inside a hidden span, and a dynamic stylesheet drops every chat row whose `data-chat-flow-key` (`ChatNodeSeat.tsx`) anchors inside the span, covering assistant steps, tool calls, and turn tails. The hider probes that undocumented attribute on its first non-empty rule set and, on a miss, enters a bounded retry (MutationObserver plus deadline) so a not-yet-mounted chat doesn't disable it; only an expired window disables it — one `console.warn`, renderer-only hiding — and the observer outlives the disable, so late-mounting rows reactivate it. It never throws. The divider expands in place to a read-only replay of the span (user originals — text and the images sent with them — plus assistant text, folded from the live node store; images render through the official attachment gallery slot `renderMessageImages`, and a composition without it degrades to the text replay instead of throwing) with the 「恢复到对话末尾」 action; a span whose rows fell out of the loaded window shows 「撤回的内容不在当前已加载的历史中」 instead.
65
65
 
66
- **Restore** is a tail replay of the whole withdrawn span, never an in-place repair: the surface fold is positional — a replaced span splices into exactly one node (`applySurfacePlan`) — so the span cannot re-enter the model context where it was, and its model-side hiding is never undone. The host walks the log interval `[start, seq)` delimited by the replacement itself and appends every replayable entry in original order: user messages verbatim (an edit replacement's content IS the last edit's text, so it replays as such) and each assistant reply's text as a framed plugin-sourced user message — `assistant/message` cannot carry a plugin source and the trace forbids assistant appends outside a step, so role fidelity is carried by the frame `(以下是先前被撤回、现随恢复放回的助手回复)`, stripped from display. An interrupted attempt without `assistant/message` is merged from its `assistant/attempt` event's embedded compact stream (reasoning-only content preserved). Tool calls/results never replay: the pairing cannot be re-entered and side effects are not replayable. Replayed rows render as the 「已恢复」 group — user bubbles with the full action row, assistant text via the official `MarkdownText` — and the divider carries a 「已恢复」 badge while a live restore row cites the span; withdrawing the restored rows again clears the badge and re-enables the action (restore events stay in the log).
66
+ **Restore** is a tail replay of the whole withdrawn span, never an in-place repair: the surface fold is positional — a replaced span splices into exactly one node (`applySurfacePlan`) — so the span cannot re-enter the model context where it was, and its model-side hiding is never undone. The host walks the log interval `[start, seq)` delimited by the replacement itself and appends every replayable entry in original order: user messages verbatim (an edit replacement's content IS the last edit's text, so it replays as such) and each assistant reply's text as a framed producer-sourced user message — `assistant/message` requires the model source and the trace forbids assistant appends outside a step, so role fidelity is carried by the frame `(以下是先前被撤回、现随恢复放回的助手回复)`, stripped from display. An interrupted attempt without `assistant/message` is merged from its `assistant/attempt` event's embedded compact stream (reasoning-only content preserved). Tool calls/results never replay: the pairing cannot be re-entered and side effects are not replayable. Replayed rows render as the 「已恢复」 group — user bubbles with the full action row, assistant text via the official `MarkdownText` — and the divider carries a 「已恢复」 badge while a live restore row cites the span; withdrawing the restored rows again clears the badge and re-enables the action (restore events stay in the log).
67
67
 
68
68
  **Why the projection cannot hide the span (the upstream seam a durable fix needs).** The assembler runs every Definition's `match` per event with no veto (`conversation-assembler.ts:370`), and `match(event)` reads only the current event; shadowed events keep `surfaceOp: 'append'` (the range metadata lives on the replacement only), so they still match the built-in Definitions, which exclude *replacement* events, not shadowed ones. Node `visibility` is set only by the owning Definition, the assembler forbids withdrawing a materialized node, and a node key is bound to its Definition's kind — a plugin can neither flip nor impersonate an official node. Shadowing the other `conversation.chat.node` keys fails too: the official components are not exported, and `command`/`turn-tail`/`tool-call` declare child slots a shadow cannot re-declare (`tool-call` owns the unbounded per-tool-name `tool.call.toolview` slot). The official compaction pipeline makes the same choice by design — a replaced span stays in the transcript.
69
69
 
@@ -71,7 +71,7 @@ dsh plugin --profile web remove @khorsheed/dsh-client-message-tools
71
71
 
72
72
  - *Edit replacement* — the model reads the edited text and nothing that followed the original, plus one short trigger (`(用户编辑了上一条消息,请按编辑后的内容重新回答)`). Token effect: the shadowed span's tokens leave subsequent requests; the edit adds the edited message plus the trigger. KV cache: the prompt prefix is invalidated from the edit point.
73
73
  - *Withdrawal replacement* — the span is replaced by one placeholder (`(用户撤回了这条消息及其后的所有内容)`). Token effect: the span's tokens leave; one short message is added. KV cache: the prefix is invalidated from the replacement point — the same trade compaction makes; withdrawing an older message discards more cached prefix.
74
- - *Restore replay* — the span's replayable content (user messages verbatim, assistant text behind the frame) appends at the tail in original order, plugin-tagged and citing original events; tool tokens stay out. KV cache: none beyond an ordinary tail append.
74
+ - *Restore replay* — the span's replayable content (user messages verbatim, assistant text behind the frame) appends at the tail in original order, producer-tagged and citing original events; tool tokens stay out. KV cache: none beyond an ordinary tail append.
75
75
 
76
76
  **Exports.** `/client` exports the plugin body (`apply`/`inject`) and the `MessageToolsRemote` type; the host export is the `MessageToolsService` class plus wire types under `/types`.
77
77
 
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/message-tools/README.en.md
5
- packages/message-tools/README.en.md: bd272494cd2c60cb8845549496c103da8c3826ea
6
- packages/message-tools/README.md: 594842e812e8c0e9eba3670926bcb09881576b57
5
+ packages/message-tools/README.en.md: 637cb08cdcd8afe6c4fb08eda8a9cef2e6ff2d54
6
+ packages/message-tools/README.md: 00f218f3c6d6a2511dfb5d31e4328a7453822806
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  - **每条用户消息都有操作行**——用户气泡与已吸入轮次的 steering 消息带复制、编辑、撤回操作。
14
14
  - **就地编辑**——内联编辑框带真实可用的模型 chip;保存后从该处重新生成,已编辑的气泡可再次编辑。
15
- - **真撤回,不是打标记**——消息及其后整个尾部离开模型上下文,折叠为可展开的「已撤回 N 条消息」分隔线。
15
+ - **真撤回,不是打标记**——消息及其后整个尾部离开模型上下文,折叠为可展开的「已撤回 N 条消息」分隔线;展开即可只读回放被撤回的用户原文、随消息发出的图片与助手文本(纯图片消息同样计入 N)。
16
16
  - **草稿回填**——撤回成功后把原文回填到 composer 草稿,绝不自动发送。
17
17
  - **恢复到尾部**——「恢复到对话末尾」把用户消息逐字重放、助手文本重放为「已恢复」组;工具调用永不重放。
18
18
 
@@ -38,15 +38,15 @@ dsh plugin --profile web remove @khorsheed/dsh-client-message-tools
38
38
 
39
39
  ## Compatibility
40
40
 
41
- - npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):✅ 完整——适配 0.1.5-rc.1 的 format v2/v3(`assistant/attempt` 取代 `assistant/chunk`;surfaceOp replace 字段 `start`/`end` 更名 `startSeq`/`endSeq`),全量构建测试通过;minHost 前移至 0.1.5-rc.1,旧宿主请停留在旧发布线。
42
- - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.5-rc.1)
41
+ - npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):✅ 完整——适配 0.1.5-rc.1 的 format v2/v3(`assistant/attempt` 取代 `assistant/chunk`;surfaceOp replace 字段 `start`/`end` 更名 `startSeq`/`endSeq`),全量构建测试通过;minHost 前移至 0.1.5-rc.1,旧宿主请停留在旧发布线。Session V4 的生产者归属 source(kind `message-tools`)在 0.1.5 宿主同样合法落盘:0.1.5 的 `user/message` 准入只要求非空 kind 字符串。
42
+ - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.7-rc.1)——Session V4 适配:写侧改生产者归属 source(kind `message-tools`,V4 原生准入拒绝退役的 `kind: 'plugin'` 包装),读侧兼认新 kind、`plugin:message-tools`(V3→V4 迁移形态)与 V3 包装存量(0.1.5 宿主原位读取);`conversation.chat.node` rc.1 契约(hookContext/inject 新形状、match 事件 `SessionEventLike` 并集)复验通过;ui-primitives 图标改名(`Icon*Outline14/16` → `Icon*OutlineMedium`)跟进。
43
43
 
44
44
  **版本线对照**:0.2.0 之后的首个发布起支持宿主 `0.1.5-rc.1` 及以后;宿主 `0.1.2-rc.1` 请停留在 `0.2.0`,宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 请停留在 0.1.x 发布线(末版 `0.1.0`)。
45
45
 
46
46
  ## 已知限制
47
47
 
48
48
  - **全区间隐藏依赖一个未文档化的 DOM 属性**——上游若移除它,隐藏退化为仅渲染器层(用户消息仍被隐藏),`console.warn` 一次,绝不报错。
49
- - **编辑仅支持文本**——原消息的图片附件不会带入重发。
49
+ - **编辑与撤回回填只带文本**——原消息的图片附件不会带入重发或回填的输入框草稿;图片仍留在分隔线的展开回放里,可以自行另发(宿主没有"文本 + 图片一起设置草稿"的公开 API,图片只能作为一次新上传重新登记)。
50
50
  - **恢复是尾部重放,不是原位修复**——助手文本以带框架的用户角色消息重放,工具调用/结果永不重放;撤回区间始终留在模型上下文之外,恢复入口只在撤回分隔线上。
51
51
  - **context 与排队消息不在范围内**——context 消息沿用官方渲染器(无操作行);仍在排队的消息由官方队列条带编辑/移除。
52
52
 
@@ -57,13 +57,13 @@ dsh plugin --profile web remove @khorsheed/dsh-client-message-tools
57
57
 
58
58
  **架构。** 浏览器半部分以官方用户消息渲染器的视觉克隆(`conversation.chat.node` 槽位、key `user` 与 `steering`、priority -1)替换默认渲染并新增操作行,同时注册四个 `ConversationNodeDefinition`:撤回投影为可展开的「已撤回 N 条消息」分隔线(N = 区间隐藏的用户可见消息数),编辑替换投影为带「已编辑」徽标的原位气泡,恢复条目投影为「已恢复」组里的重放行。Host 半部分是 `messageTools` Typert Remote 服务(`withdraw` / `edit` / `restore`);客户端经 `ctx.remote.$mount` 自行挂载生成的 Remote contribution,无需改动任何核心包。
59
59
 
60
- **编辑**即原位替换:host 追加一条 `user/message` replacement,其内容就是编辑后的新文本(区间覆盖目标消息及 surface 尾部——编辑旧消息就是放弃其后的一切),随后经 `agent.followup` 投递一条极简插件来源触发消息启动重新生成。模型上下文里新文本只出现一次;旧内容留在日志里作审计。会话有运行中的轮次时先取消它并等待完全落定——落定指被取消轮次的收尾(工具结果、`turn/end`)全部落盘,而不是 `running` 翻转;等待有界,迟迟不落定的轮次以「编辑失败」拒绝,取消失败则不编辑。支持编辑链:已编辑气泡可再次编辑,目标是上一个 replacement 的 seq。编辑框的模型 chip 与 composer、/model 弹层共享按会话的 `ModelDirectory`(`ctx.get('modelDirectories')`,可选服务)——组合里没有 ui-model-selection 就不渲染 chip——切换经 host 校验后作用于重新生成的轮次。
60
+ **编辑**即原位替换:host 追加一条 `user/message` replacement,其内容就是编辑后的新文本(区间覆盖目标消息及 surface 尾部——编辑旧消息就是放弃其后的一切),随后经 `agent.followup` 投递一条极简生产者来源触发消息启动重新生成。模型上下文里新文本只出现一次;旧内容留在日志里作审计。会话有运行中的轮次时先取消它并等待完全落定——落定指被取消轮次的收尾(工具结果、`turn/end`)全部落盘,而不是 `running` 翻转;等待有界,迟迟不落定的轮次以「编辑失败」拒绝,取消失败则不编辑。支持编辑链:已编辑气泡可再次编辑,目标是上一个 replacement 的 seq。编辑框的模型 chip 与 composer、/model 弹层共享按会话的 `ModelDirectory`(`ctx.get('modelDirectories')`,可选服务)——组合里没有 ui-model-selection 就不渲染 chip——切换经 host 校验后作用于重新生成的轮次。
61
61
 
62
- **撤回**是真撤回,不是打标记:host 追加一条 `user/message` surface replacement(与 compaction 同一机制),区间覆盖目标消息及之后的所有 surface 节点,该区间由此离开 `session.surface`,不再进入模型上下文;与编辑同款的 cancel-and-settle 编排保证流式内容不会落到 replacement 之后。事件为插件来源,`sourceEventSeqs` 引用每一个被遮蔽节点,方法返回前经 `SessionStore.flush` 落盘——它本身就是审计轨迹。不引入新事件类型:harness 之外的类型无法携带 `ignorable: true`,持久化的未知类型会让 session-persistence 重载时拒绝整个日志。撤回成功后把目标原文回填到 composer 草稿(空草稿直接填入、非空换行追加、落 info 提示)——绝不自动发送,失败则不回填;编辑路径不触发回填。
62
+ **撤回**是真撤回,不是打标记:host 追加一条 `user/message` surface replacement(与 compaction 同一机制),区间覆盖目标消息及之后的所有 surface 节点,该区间由此离开 `session.surface`,不再进入模型上下文;与编辑同款的 cancel-and-settle 编排保证流式内容不会落到 replacement 之后。事件为生产者来源(source kind `message-tools`),`sourceEventSeqs` 引用每一个被遮蔽节点,方法返回前经 `SessionStore.flush` 落盘——它本身就是审计轨迹。不引入新事件类型:harness 之外的类型无法携带 `ignorable: true`,持久化的未知类型会让 session-persistence 重载时拒绝整个日志。撤回成功后把目标原文回填到 composer 草稿(空草稿直接填入、非空换行追加、落 info 提示)——绝不自动发送,失败则不回填;编辑路径不触发回填。
63
63
 
64
- **投影与隐藏。** 插件的 Definition 把 replacement 认领为锚定在其 seq 上的分隔线节点。隐藏分两层:被遮蔽的用户渲染器对区间内用户消息渲染为空;一张动态样式表把 `data-chat-flow-key`(`ChatNodeSeat.tsx`)锚点落在区间内的聊天行一律隐藏,覆盖助手步骤、工具调用、turn 尾部。隐藏器在首个非空规则集探测这个未文档化属性,探测落空进入有界重试(MutationObserver 加截止时限),避免聊天区尚未挂载时误停用;只有窗口耗尽仍探不到行才停用——`console.warn` 一次,退化为仅渲染器隐藏——且 observer 在停用后仍存活,行晚到会重新生效,绝不报错。分隔线可就地展开只读回放撤回区间(用户原文加助手文本,从实时节点存储折叠)并提供「恢复到对话末尾」;行已掉出加载窗口的区间显示「撤回的内容不在当前已加载的历史中」。
64
+ **投影与隐藏。** 插件的 Definition 把 replacement 认领为锚定在其 seq 上的分隔线节点。隐藏分两层:被遮蔽的用户渲染器对区间内用户消息渲染为空;一张动态样式表把 `data-chat-flow-key`(`ChatNodeSeat.tsx`)锚点落在区间内的聊天行一律隐藏,覆盖助手步骤、工具调用、turn 尾部。隐藏器在首个非空规则集探测这个未文档化属性,探测落空进入有界重试(MutationObserver 加截止时限),避免聊天区尚未挂载时误停用;只有窗口耗尽仍探不到行才停用——`console.warn` 一次,退化为仅渲染器隐藏——且 observer 在停用后仍存活,行晚到会重新生效,绝不报错。分隔线可就地展开只读回放撤回区间(用户原文与随消息发出的图片、加助手文本,从实时节点存储折叠;图片交给官方附件画廊槽位 `renderMessageImages` 渲染,组合里没有它时退化为纯文本回放而不报错)并提供「恢复到对话末尾」;行已掉出加载窗口的区间显示「撤回的内容不在当前已加载的历史中」。
65
65
 
66
- **恢复**是整个被撤回区间的尾部重放,不是原位修复:surface 折叠是位置性的——被替换区间只接续成一个节点(`applySurfacePlan`)——区间无法回到模型上下文原位,其模型侧隐藏也永不回退。host 沿撤回 replacement 自身划定的日志区间 `[start, seq)` 把可重放内容按原始顺序逐条追加到尾部:用户消息逐字重放(编辑替换的内容就是最后一次编辑的新文本,恢复时即以此为准),每条助手回复的文本以带框架的插件来源用户消息重放——`assistant/message` 无法携带插件来源,且轨迹不允许在 step 之外追加助手消息,因此角色保真由框架 `(以下是先前被撤回、现随恢复放回的助手回复)` 承担,UI 上不显示该框架。未落成 `assistant/message` 的中断尝试从其 `assistant/attempt` 事件内嵌的紧凑流合并重放(只有 reasoning 时保留 reasoning)。工具调用/结果永不重放:配对无法重新进入,副作用不可重放。重放行渲染为「已恢复」组——用户气泡带完整操作行,助手文本走官方 `MarkdownText`——分隔线在存在引用该区间的存活恢复行期间显示「已恢复」徽标;再次撤回这些恢复行会清掉徽标并重新启用恢复操作(恢复事件始终留在日志里)。
66
+ **恢复**是整个被撤回区间的尾部重放,不是原位修复:surface 折叠是位置性的——被替换区间只接续成一个节点(`applySurfacePlan`)——区间无法回到模型上下文原位,其模型侧隐藏也永不回退。host 沿撤回 replacement 自身划定的日志区间 `[start, seq)` 把可重放内容按原始顺序逐条追加到尾部:用户消息逐字重放(编辑替换的内容就是最后一次编辑的新文本,恢复时即以此为准),每条助手回复的文本以带框架的生产者来源用户消息重放——`assistant/message` 只能携带 model 来源,且轨迹不允许在 step 之外追加助手消息,因此角色保真由框架 `(以下是先前被撤回、现随恢复放回的助手回复)` 承担,UI 上不显示该框架。未落成 `assistant/message` 的中断尝试从其 `assistant/attempt` 事件内嵌的紧凑流合并重放(只有 reasoning 时保留 reasoning)。工具调用/结果永不重放:配对无法重新进入,副作用不可重放。重放行渲染为「已恢复」组——用户气泡带完整操作行,助手文本走官方 `MarkdownText`——分隔线在存在引用该区间的存活恢复行期间显示「已恢复」徽标;再次撤回这些恢复行会清掉徽标并重新启用恢复操作(恢复事件始终留在日志里)。
67
67
 
68
68
  **为什么投影层做不到(彻底修复所需的上游 seam)。** 组装器对每个事件运行所有已注册 Definition 的 `match`,没有否决机制(`conversation-assembler.ts:370`),且 `match(event)` 只能读当前事件;被遮蔽事件在日志里仍保持 `surfaceOp: 'append'`(区间元数据只存在于 replacement 自身),因此依然命中各内置 Definition——内置 Definition 排除的是 *replacement* 事件,不是被遮蔽事件。节点的 `visibility` 只能由产出它的 Definition 设置,组装器禁止把已物化节点撤回为 null,节点 key 又与其 Definition 的 kind 绑定——插件既无法翻转也无法冒充官方节点。遮蔽其余 `conversation.chat.node` key 同样行不通:官方组件没有导出,`command`/`turn-tail`/`tool-call` 声明了子槽位而遮蔽无法重复声明(`tool-call` 还持有 key 空间无界的按工具名 keyed 的 `tool.call.toolview` 槽位)。官方 compaction 流水线在设计上就做了同样选择——被替换的区间保留在 transcript 里。
69
69