dsh-edit-turn 0.1.0 → 0.2.0

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
@@ -25,7 +25,10 @@ DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的
25
25
  - **默认零上下文污染** —— 替换事件的载体是一个**空的 `system/message`**。官方格式文档里空的后置 system 节点是「dormant,不投影成任何消息」,所以回退后模型看到的上下文,和「对话真的停在那一点」完全一致,不会多出任何标记文本。
26
26
  - **轮边界安全** —— 遮蔽窗口右端固定为日志最后一个 surface 节点,左端固定为目标消息节点,因此助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
27
27
  - **重跑走官方准入路径** —— `ctx.sessionController.prompt()` 是唯一的口径:它会自己 resume 冷会话,并恰好开一个新轮次。
28
+ - **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,再**追加**一条带改写文本的助手消息——模型会把改写后的内容当成自己说过的话,对话可以继续。`source.editedBy` 会如实记录这段文字由插件写入。
29
+ - **一次点击即执行** —— 保存后不再有二次确认。编辑器本身已经是用户主动打开的动作,面板里也写明了保存会丢弃哪些内容;想恢复两步确认可在 profile 里一行开启(`confirm: true`)。
28
30
  - **中英双语 UI**,跟随 DSH 当前语言。
31
+ - **皮肤友好** —— 编辑器面板自带不透明表面(`--dshet-panel`)而不是借用主题的表面色变量。皮肤的本意就是让表面半透明、把插画透出来,而它只会给**自己的**元素补可读背景,插件类名不在其中;借用皮肤变量的面板会变成全透明,文字直接压在插画上。暗色分支走官方属性 `body[data-ds-dark-theme]`(与 `dsh-client-ui-theme` 及多个官方 UI 包一致),并用 `backdrop-filter` 与皮肤融合。
29
32
  - **可配置载体**:万一某个 DSH 版本对空 system 节点处理不同,一行配置即可切回短标记载体。
30
33
  - **宿主路由只限本机回环**,并校验 `Host` 与 `Origin`。
31
34
 
@@ -46,12 +49,15 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
46
49
  ### 使用
47
50
 
48
51
  1. 把鼠标移到你想改的那条**用户消息**上,点右侧的编辑图标(铅笔)。
49
- 2. 消息下方展开编辑器,原文已预填。改完点「保存并重跑」。
50
- 3. 出现确认步骤(默认开启):确认后会从模型上下文中移除这条消息之后的一切,并重新跑这一轮。
51
- 4. 编辑器与该轮之后的转录行一起消失,新提示词与新回复出现在下方。
52
+ 2. 消息下方展开编辑器,原文已预填。改完点「保存并重跑」——**一次点击即执行**:这条消息之后的一切从模型上下文中移除,并立刻重新跑这一轮。
53
+ 3. 编辑器与该轮之后的转录行一起消失,新提示词与新回复出现在下方。
52
54
 
53
55
  编辑器里如果提示「这条消息包含图片或文件附件」,说明改写只保留文字,附件会被丢弃。
54
56
 
57
+ **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存替换」——这条回答被替换为你写的内容,它之后的内容一并移除,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
58
+
59
+ 注意:回答里的**工具调用与思考过程**无法保留(替换后只留文字,编辑器会提示),因为工具调用的结果已经不再成立。
60
+
55
61
  ### 配置
56
62
 
57
63
  写在 profile 里即可覆盖默认值:
@@ -68,7 +74,7 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
68
74
  |---|---|---|
69
75
  | `carrier` | `system/message` | 替换事件的载体类型。`system/message` = 空节点,不投影成模型消息;`user/message` = 短标记文本,会作为一条用户消息进入上下文(等价插件在生产中用的是这种形状,作为兜底)。 |
70
76
  | `markerText` | 一段说明文字 | `carrier: user/message` 时的载体文本。 |
71
- | `confirm` | `true` | 客户端保存后是否先要求一次确认。 |
77
+ | `confirm` | `false` | 客户端保存后是否先要求一次确认。默认**关闭**(一次点击即执行:编辑器本身已是用户主动打开的动作,面板里也写明了保存的后果);设为 `true` 可恢复两步确认。 |
72
78
 
73
79
  ### 工作原理
74
80
 
@@ -108,11 +114,13 @@ await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', conten
108
114
 
109
115
  | 验证 | 命令 | 结果 |
110
116
  |---|---|---|
111
- | 官方 append 契约(真实校验器,进程内) | `npm run verify:contract` | 46 项通过:替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 system 载体不产生模型消息、**连续两次回退都被接受** |
112
- | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 63 项通过 |
113
- | 客户端半部静态检查(注册、i18n 完整性、样式、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
114
- | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过 |
117
+ | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
118
+ | 官方 append 契约(真实校验器,进程内) | `npm run verify:contract` | 61 项通过:替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 system 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受** |
119
+ | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 83 项通过 |
120
+ | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
121
+ | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
115
122
  | 真实 profile 安装 / 补丁合成 / 启动 / 工具契约 / 路由守卫 | `npm run verify:profile` | 全部通过 |
123
+ | **真实浏览器端到端**(agent-browser 驱动 Chrome 打开运行实例) | 手动 | 已完成:模型回答行出现「编辑这条回答」、点开编辑器预填真实回复原文、取消后网络层 0 个 apply 请求。截图见 `docs/reply-editor-in-browser.png` |
116
124
 
117
125
  `verify:live` 针对**正在运行的实例**:用 DSH 启动时打印的 token 换取鉴权 cookie,读启动页里的客户端模块组,把含本插件的那一组下载下来,断言插件自己的标记确实在其中。它证明的是「浏览器刷新后会拿到当前代码」,而不是「源码看起来没问题」——前端改动后这是唯一能确认已生效的自动手段。
118
126
 
@@ -132,7 +140,9 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
132
140
 
133
141
  ### 已知限制
134
142
 
135
- - **只支持人类输入的消息**。注入的上下文行、助手消息、系统提示词都不提供编辑入口。
143
+ - 可以编辑**你自己输入的消息**与**模型的回答**;注入的上下文行与系统提示词没有编辑入口。
144
+ - 编辑一条回答会把它替换为**纯文本**:该回答里的工具调用与思考过程会被移除(编辑器会提示),因为它们的结果已不再成立。
145
+ - 编辑一条用户消息会重跑那一轮;编辑一条模型回答只替换内容,**不会**重新问模型。
136
146
  - **编辑会丢弃该消息之后的全部轮次**(MVP 语义,和 ChatGPT 的编辑一致)。想保留原文形成分支,需要走 `sessionController.fork({ sessionId, atSeq })`,尚未实现。
137
147
  - **只改写文本**。消息里含图片/文件附件时,改写后只保留文字(编辑器会提示)。
138
148
  - **会话必须当前在 DSH 中打开**,否则返回 `409 session-not-active`。
@@ -181,7 +191,10 @@ This plugin adds it:
181
191
  - **Zero context pollution by default.** The replacement carrier is an **empty `system/message`**. The official format documents empty later system nodes as dormant, projecting to no message, so the context after an edit is exactly what it would be had the conversation really stopped there - no marker text is added.
182
192
  - **Turn-boundary safe.** The shadow window always ends at the last surface node and always opens at the addressed message, so an assistant message (which carries its own tool_use blocks) and the tool/result it produced are shadowed together. A dangling call/result pair is impossible.
183
193
  - **Official re-run.** `ctx.sessionController.prompt()` is the only prompt admission path; it resumes a cold Session itself and opens exactly one new turn.
194
+ - **The model's replies are editable too.** A reply cannot be swapped in place: the format refuses `sourceEventSeqs` on an `assistant/message` (verified against the real validator). The answer is rolled back together with everything after it, and the corrected text is **appended** as a fresh reply - the model goes on treating it as its own. `source.editedBy` records honestly that the plugin wrote those words.
195
+ - **One click applies** - no second confirmation. The editor is already an explicit action the user opened, and the panel states what saving discards; a profile can restore the two-step flow with `confirm: true`.
184
196
  - **Bilingual UI** that follows the current DSH locale.
197
+ - **Skin-friendly** - the editor paints its own opaque surface (`--dshet-panel`) instead of borrowing the theme's surface colours. A skin exists to make surfaces translucent so its artwork shows through, and it only compensates for *its own* elements; a plugin's class names are not on that list, so a panel that borrows those variables can end up fully transparent with text sitting straight on the art. The dark branch uses the official `body[data-ds-dark-theme]` hook (the same one `dsh-client-ui-theme` and several official UI packages use) and blends in with `backdrop-filter`.
185
198
  - **Configurable carrier**: if some DSH release treats empty system nodes differently, one config line restores the marker-text carrier.
186
199
  - **Loopback-only host routes** with `Host` and `Origin` validation.
187
200
 
@@ -202,13 +215,21 @@ Restart the DSH process that serves that profile to pick it up.
202
215
  ### Usage
203
216
 
204
217
  1. Hover the **user message** you want to change and click the pencil action.
205
- 2. An editor opens below it with the original text pre-filled. Click "Save and re-run".
206
- 3. A confirmation step appears (on by default). Confirming removes everything after this message from the model context and re-runs the turn.
207
- 4. The editor disappears together with the discarded rows; the new prompt and its reply appear below.
218
+ 2. An editor opens below it with the original text pre-filled. Click "Save and re-run" - **one click applies**: everything after this message leaves the model context and the turn runs again immediately.
219
+ 3. The editor disappears together with the discarded rows; the new prompt and its reply appear below.
208
220
 
209
221
  If the editor warns that the message carries attachments, the rewrite keeps the
210
222
  text only and drops them.
211
223
 
224
+ **Editing what the model said.** Hover any model reply and the same edit action
225
+ appears. Click "Save replacement" - the reply is replaced with your text,
226
+ everything after it is removed, and the model goes on treating your words as its
227
+ own, so the conversation can continue from there.
228
+
229
+ Note that **tool calls and reasoning inside that reply cannot survive** the
230
+ replacement (only text is kept, and the editor warns first): their results are no
231
+ longer valid once you have changed what the model said.
232
+
212
233
  ### Configuration
213
234
 
214
235
  ```yaml
@@ -223,7 +244,7 @@ text only and drops them.
223
244
  |---|---|---|
224
245
  | `carrier` | `system/message` | Event type carrying the replacement. `system/message` is an empty dormant node that projects to no model message. `user/message` is a short marker that does enter the context; an equivalent plugin runs that shape in production, so it is the fallback. |
225
246
  | `markerText` | an explanatory line | Carrier text when `carrier: user/message`. |
226
- | `confirm` | `true` | Whether the client asks for confirmation before applying. |
247
+ | `confirm` | `false` | Whether the client asks for a second confirmation before applying. Off by default - one click applies, because the editor is already an explicit action the user opened and the panel states what saving discards. Set it to `true` to restore the two-step flow. |
227
248
 
228
249
  ### How it works
229
250
 
@@ -274,11 +295,13 @@ Verified against DSH `0.1.6-alpha.2`, entirely **without model calls**:
274
295
 
275
296
  | Check | Command | Result |
276
297
  |---|---|---|
277
- | Official append contract against the real validator, in process | `npm run verify:contract` | 46 checks pass: the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty system carrier adds no model message, **two consecutive rollbacks are both accepted** |
278
- | Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 63 tests pass |
279
- | Browser-half static checks (registration, i18n completeness, styles, three-way version sync, wire contract) | `npm run verify:client` | all pass |
280
- | Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass |
298
+ | Is the plugin actually mounted in a running instance? (read-only route-guard probe, no token needed) | `npm run probe:loaded [port]` | pass: `/state` answers 400 and `/apply` answers 405 - status codes only this plugin produces |
299
+ | Official append contract against the real validator, in process | `npm run verify:contract` | 61 checks pass: the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty system carrier adds no model message, **two consecutive rollbacks are both accepted**, **the whole reply-editing mechanism is accepted** |
300
+ | Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 83 tests pass |
301
+ | Browser-half static checks (registration, i18n completeness, styles, skin legibility, three-way version sync, wire contract) | `npm run verify:client` | all pass |
302
+ | Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass, **including anchors on the host's node shapes** |
281
303
  | Real profile: install, patch composition, boot, tool contract, route guards | `npm run verify:profile` | all pass |
304
+ | **Real browser, end to end** (agent-browser drives Chrome at a running instance) | manual | done: an edit action appears on model reply rows, the editor opens pre-filled with the real reply, and cancelling leaves zero `apply` requests. See `docs/reply-editor-in-browser.png` |
282
305
 
283
306
  `verify:live` targets an instance that is **already running**: it exchanges the
284
307
  token DSH printed on boot for an auth cookie, reads the client module groups out
@@ -317,7 +340,9 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
317
340
 
318
341
  ### Known limitations
319
342
 
320
- - **Human messages only.** Injected context rows, assistant messages and the system prompt offer no edit entry.
343
+ - **Human prompts and model replies** are both editable; injected context rows and the system prompt offer no edit entry.
344
+ - **Editing a reply replaces it with plain text**: the tool calls and reasoning inside it are removed (the editor warns first), because their results are no longer valid.
345
+ - **Editing a user message re-runs that turn; editing a model reply does not ask the model again** - it only replaces the text.
321
346
  - **An edit discards every later turn** (the MVP semantic, matching ChatGPT's edit). Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
322
347
  - **Text only.** A message carrying image or file attachments keeps its text and drops them (the editor warns first).
323
348
  - **The session must be open in DSH**, otherwise the route answers `409 session-not-active`.
package/lib/client.js CHANGED
@@ -30,22 +30,28 @@ window.__ModuleLoader__.load({
30
30
  const ROUTE_PREFIX = '/dsh-edit-turn'
31
31
 
32
32
  /** Keep in sync with package.json and lib/index.js. */
33
- const PLUGIN_VERSION = '0.1.0'
33
+ const PLUGIN_VERSION = '0.2.0'
34
34
 
35
35
  // --- copy -----------------------------------------------------------------
36
36
 
37
37
  const zh = {
38
38
  'action.edit': '编辑这条消息并重跑',
39
+ 'action.edit-reply': '编辑这条回答',
39
40
  'editor.title': '编辑这条消息',
40
- 'editor.placeholder': '修改后确认,会话将回退到这条消息之前并重新跑这一轮。',
41
+ 'editor.title-reply': '编辑这条回答',
42
+ 'editor.placeholder': '修改后保存,会话将回退到这条消息之前并重新跑这一轮。',
43
+ 'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
41
44
  'editor.save': '保存并重跑',
42
- 'editor.review': '确认回退',
45
+ 'editor.save-reply': '保存替换',
46
+ 'editor.review': '确认执行',
43
47
  'editor.cancel': '取消',
44
- 'editor.pending': '正在回退并重跑…',
48
+ 'editor.pending': '正在执行…',
45
49
  'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
50
+ 'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
46
51
  'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
47
- 'confirm.title': '确认回退并重跑?',
48
- 'confirm.body': '这条消息之后的全部内容会从模型上下文中移除,然后用你改写后的内容重新跑这一轮。原始会话日志保持不变。',
52
+ 'editor.hint-reply': '保存后,这条回答会被替换为你写的内容,它之后的内容一并移除。模型会把你写的内容当成自己说过的话,对话可以继续下去。原始会话日志不会改写。',
53
+ 'confirm.title': '确认执行?',
54
+ 'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
49
55
  'error.invalid': '请求无效,请刷新后重试。',
50
56
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
51
57
  'error.session-not-found': '找不到该会话的日志。',
@@ -57,22 +63,29 @@ window.__ModuleLoader__.load({
57
63
  'error.method': '请求方式不被接受,请刷新后重试。',
58
64
  'error.generic': '编辑失败,请重试。',
59
65
  'error.prompt': '已回退,但重跑没有启动。请手动发送改写后的内容。',
66
+ 'error.reply': '已回退,但替换没有落地。请重试。',
60
67
  'error.empty': '改写后的内容不能为空。',
61
68
  'error.internal': '服务器内部错误,请稍后重试。',
62
69
  }
63
70
 
64
71
  const en = {
65
72
  'action.edit': 'Edit this message and re-run',
73
+ 'action.edit-reply': 'Edit this reply',
66
74
  'editor.title': 'Edit this message',
67
- 'editor.placeholder': 'Confirm after editing: the conversation rolls back to just before this message and this turn runs again.',
75
+ 'editor.title-reply': 'Edit this reply',
76
+ 'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
77
+ 'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
68
78
  'editor.save': 'Save and re-run',
69
- 'editor.review': 'Confirm rollback',
79
+ 'editor.save-reply': 'Save replacement',
80
+ 'editor.review': 'Confirm',
70
81
  'editor.cancel': 'Cancel',
71
- 'editor.pending': 'Rolling back and re-running...',
82
+ 'editor.pending': 'Working...',
72
83
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
84
+ 'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
73
85
  'editor.hint': 'Saving removes everything after this message (including that turn\'s reply and tool calls) from the model context and starts again from here. The original session log is never rewritten.',
74
- 'confirm.title': 'Roll back and re-run?',
75
- 'confirm.body': 'Everything after this message leaves the model context, then this turn runs again from your revised text. The original session log stays untouched.',
86
+ 'editor.hint-reply': 'Saving replaces this reply with your text and removes everything after it. The model goes on treating your words as its own, so the conversation can continue from here. The original session log is never rewritten.',
87
+ 'confirm.title': 'Confirm?',
88
+ 'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
76
89
  'error.invalid': 'Invalid request; refresh and try again.',
77
90
  'error.session-not-active': 'This session is not open in DSH; open it first.',
78
91
  'error.session-not-found': 'No session log was found for this id.',
@@ -84,6 +97,7 @@ window.__ModuleLoader__.load({
84
97
  'error.method': 'The request method is not accepted; refresh and try again.',
85
98
  'error.generic': 'The edit failed; try again.',
86
99
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
100
+ 'error.reply': 'Rolled back, but the replacement did not land. Try again.',
87
101
  'error.empty': 'The revised message cannot be empty.',
88
102
  'error.internal': 'The host hit an internal error; try again shortly.',
89
103
  }
@@ -100,10 +114,24 @@ window.__ModuleLoader__.load({
100
114
 
101
115
  // --- style ----------------------------------------------------------------
102
116
 
117
+ // A skin is free to make the theme's surface colours translucent - that is
118
+ // the point of one: the artwork shows through, and the skin compensates by
119
+ // giving *its own* elements a readable background and a text halo. A plugin's
120
+ // classes are not in that list, so anything painted with --dsw-alias-bg-*
121
+ // can end up with no background at all and become unreadable over the art.
122
+ // (Seen in practice: a skin sets --dsw-alias-bg-base to a fully transparent
123
+ // colour at scrim 0, and --dsw-alias-bg-layer-1 to a ~50% tint.)
124
+ //
125
+ // So this panel paints its own opaque surface and switches on the official
126
+ // dark-theme hook `body[data-ds-dark-theme]`, which the theme and the official
127
+ // UI packages use. Theme variables are still used for *colours* (hue,
128
+ // semantic tones) - those survive a skin; it is alpha that does not.
103
129
  const CSS = [
104
- '.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;transition:background-color .12s,color .12s}',
105
- '.dshet-action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,inherit)}',
106
- '.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
130
+ ':root{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:rgba(16,24,40,.16);--dshet-ink:#0f1524;--dshet-ink-dim:#4b5872;--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:0 12px 32px rgba(9,18,40,.22);--dshet-accent:#4d6bfe;--dshet-on-accent:#fff}',
131
+ 'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-line:rgba(255,255,255,.18);--dshet-ink:#eef2f8;--dshet-ink-dim:#a6b1c6;--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:0 12px 32px rgba(0,0,0,.5)}',
132
+ '.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dshet-ink-dim);cursor:pointer;transition:background-color .12s,color .12s}',
133
+ '.dshet-action:hover:not(:disabled){background:var(--dshet-chip);box-shadow:inset 0 0 0 1px var(--dshet-line);color:var(--dshet-ink)}',
134
+ '.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
107
135
  '.dshet-action:disabled{cursor:default;opacity:.4}',
108
136
  '.dshet-action svg{width:15px;height:15px}',
109
137
  '.dshet-action-host{display:inline-flex;align-items:center;justify-content:center}',
@@ -112,23 +140,23 @@ window.__ModuleLoader__.load({
112
140
  '.dshet-row:hover .dshet-floating,.dshet-floating:focus-within{opacity:1}',
113
141
  '.dshet-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
114
142
  '[data-dshet-hidden="1"]{display:none!important}',
115
- '.dshet-editor{margin:8px 0 4px;padding:12px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.28));border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 2px 10px rgba(0,0,0,.06)}',
116
- '.dshet-editor-title{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}',
117
- '.dshet-editor textarea{width:100%;min-height:84px;max-height:46vh;resize:vertical;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.32));border-radius:8px;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:14px;line-height:22px}',
118
- '.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
119
- '.dshet-editor textarea:disabled{opacity:.6}',
120
- '.dshet-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:19px}',
121
- '.dshet-warn{margin:8px 0 0;color:var(--dsw-alias-state-warning-primary,#c47b12);font-size:12px;line-height:19px}',
122
- '.dshet-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:12px;line-height:19px}',
143
+ '.dshet-editor{margin:8px 0 4px;padding:12px;border:1px solid var(--dshet-line);border-radius:12px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink)}',
144
+ '.dshet-editor-title{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;font-size:13px;font-weight:600;color:var(--dshet-ink)}',
145
+ '.dshet-editor textarea{width:100%;min-height:84px;max-height:46vh;resize:vertical;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:14px;line-height:22px}',
146
+ '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
147
+ '.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
148
+ '.dshet-editor textarea:disabled{opacity:.65}',
149
+ '.dshet-note{margin:8px 0 0;color:var(--dshet-ink-dim);font-size:12px;line-height:19px}',
150
+ '.dshet-warn{margin:8px 0 0;color:var(--dsw-alias-state-warning-primary,#a2650a);font-size:12px;line-height:19px}',
151
+ '.dshet-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#c93a31);font-size:12px;line-height:19px}',
123
152
  '.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:12px}',
124
- '.dshet-btn{padding:6px 14px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.32));border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
125
- '.dshet-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}',
126
- '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
153
+ '.dshet-btn{padding:6px 14px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
154
+ '.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
155
+ '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
127
156
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
128
- '.dshet-btn-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff)}',
129
- '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,#4d6bfe);filter:brightness(1.08)}',
130
- '.dshet-notice{margin:4px 0;padding:6px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.08));color:var(--dsw-alias-state-error-primary,#d54941);font-size:12px;line-height:19px}',
131
- '.dshet-spacer{flex:1 1 auto}',
157
+ '.dshet-btn-primary{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent));background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));color:var(--dsw-alias-label-primary-foreground,var(--dshet-on-accent))}',
158
+ '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
159
+ '.dshet-notice{margin:4px 0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dsw-alias-state-error-primary,#c93a31);font-size:12px;line-height:19px}',
132
160
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
133
161
  ].join('')
134
162
 
@@ -187,6 +215,7 @@ window.__ModuleLoader__.load({
187
215
  this.view = Object.freeze({
188
216
  hidden: new Map(),
189
217
  editable: new Map(),
218
+ replies: new Map(),
190
219
  editing: null,
191
220
  draft: '',
192
221
  confirming: false,
@@ -196,7 +225,9 @@ window.__ModuleLoader__.load({
196
225
  surfaceReady: false,
197
226
  loaded: false,
198
227
  loadError: false,
199
- confirmStep: true,
228
+ // Matches the host's default: one click applies. Only an explicit
229
+ // `confirm: true` in the profile turns the second step back on.
230
+ confirmStep: false,
200
231
  revision: 0,
201
232
  })
202
233
  }
@@ -243,9 +274,17 @@ window.__ModuleLoader__.load({
243
274
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
244
275
  if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
245
276
  }
277
+ // The model's replies are editable too, as a separate list: they are
278
+ // rewritten rather than re-run, so the editor words the consequence
279
+ // differently.
280
+ const replies = new Map()
281
+ for (const reply of Array.isArray(data.replies) ? data.replies : []) {
282
+ if (reply && typeof reply.seq === 'number') replies.set(reply.seq, reply)
283
+ }
246
284
  this.publish({
247
285
  hidden,
248
286
  editable,
287
+ replies,
249
288
  surfaceReady: true,
250
289
  loaded: true,
251
290
  loadError: false,
@@ -334,6 +373,13 @@ window.__ModuleLoader__.load({
334
373
  for (const entry of editable.values()) {
335
374
  if (hidden.has(entry.seq)) editable.delete(entry.seq)
336
375
  }
376
+ // A reply edit shadows its own reply too, so it leaves the editable
377
+ // replies here and re-enters through the next /state refresh (the
378
+ // appended correction is itself an editable reply).
379
+ const replies = new Map(this.view.replies)
380
+ for (const entry of replies.values()) {
381
+ if (hidden.has(entry.seq)) replies.delete(entry.seq)
382
+ }
337
383
  this.animateOnce = true
338
384
  this.editor = null
339
385
  this.editorDraft = ''
@@ -345,11 +391,13 @@ window.__ModuleLoader__.load({
345
391
  confirming: false,
346
392
  hidden,
347
393
  editable,
394
+ replies,
348
395
  failure: null,
349
396
  })
350
397
  // The rollback has landed, so the editor is gone with its row: a
351
- // failed re-run has to be reported somewhere that survives that.
352
- if (data.promptAccepted === false) this.notify('prompt')
398
+ // failed second half has to be reported somewhere that survives that.
399
+ if (data.kind === 'reply' && data.applied === false) this.notify('reply')
400
+ if (data.kind !== 'reply' && data.promptAccepted === false) this.notify('prompt')
353
401
  } catch {
354
402
  this.publish({ pending: false, failure: 'generic' })
355
403
  }
@@ -380,23 +428,44 @@ window.__ModuleLoader__.load({
380
428
 
381
429
  // --- row targets ----------------------------------------------------------
382
430
 
383
- // The editable target of a Chat view node, or null when the row is not a
384
- // human prompt the user can rewrite.
385
- function targetFor(node, editable, hidden) {
386
- if (!node || node.kind !== 'user') return null
431
+ // The editable target of a Chat view node, or null when the row stands for
432
+ // something that cannot be rewritten.
433
+ //
434
+ // Two kinds are offered: a human prompt (rewritten, then the turn re-runs)
435
+ // and a model reply (rewritten in place, discarding everything after it).
436
+ // A step row can span several surface nodes, and the reply seq sits on
437
+ // `finalNode` for an assistant step while a user row carries it on `data`,
438
+ // so both candidates are tried rather than assumed.
439
+ function targetFor(node, view) {
440
+ if (!node) return null
387
441
  const data = node.data || {}
388
- const seq = typeof data.seq === 'number' ? data.seq : node.anchorSeq
389
- if (typeof seq !== 'number') return null
390
- if (hidden.has(seq)) return null
391
- const entry = editable.get(seq)
392
- if (!entry) return null
393
- return {
394
- seq,
395
- turn: entry.turn,
396
- messageId: entry.messageId,
397
- text: typeof entry.text === 'string' ? entry.text : '',
398
- attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
442
+ const candidates = []
443
+ const push = (value) => {
444
+ if (typeof value === 'number' && !candidates.includes(value)) candidates.push(value)
445
+ }
446
+ if (node.kind === 'user') {
447
+ push(data.seq)
448
+ push(node.anchorSeq)
449
+ } else if (node.kind === 'assistant-step') {
450
+ push(data.finalNode && data.finalNode.seq)
451
+ push(node.anchorSeq)
452
+ } else {
453
+ return null
454
+ }
455
+ for (const seq of candidates) {
456
+ if (view.hidden.has(seq)) continue
457
+ const entry = view.editable.get(seq) || view.replies.get(seq)
458
+ if (!entry) continue
459
+ return {
460
+ seq,
461
+ mode: view.editable.has(seq) ? 'prompt' : 'reply',
462
+ turn: entry.turn,
463
+ messageId: entry.messageId,
464
+ text: typeof entry.text === 'string' ? entry.text : '',
465
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
466
+ }
399
467
  }
468
+ return null
400
469
  }
401
470
 
402
471
  // Every surface seq a row stands for, so one rollback can hide the rows it
@@ -491,7 +560,7 @@ window.__ModuleLoader__.load({
491
560
  }
492
561
 
493
562
  function injectRowAction(row, target, controller, t) {
494
- const label = t('action.edit')
563
+ const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
495
564
  let entry = rowActions.get(row)
496
565
  if (!entry) {
497
566
  const host = document.createElement('span')
@@ -551,8 +620,23 @@ window.__ModuleLoader__.load({
551
620
  ].join('|')
552
621
  }
553
622
 
623
+ // Every string the editor shows depends on what is being edited: a prompt is
624
+ // re-run, a reply is replaced. Keeping them in one place keeps the two modes
625
+ // from drifting apart in wording.
626
+ function editorTexts(t, target) {
627
+ const reply = target.mode === 'reply'
628
+ return {
629
+ title: t(reply ? 'editor.title-reply' : 'editor.title'),
630
+ placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
631
+ hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
632
+ warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
633
+ save: t(reply ? 'editor.save-reply' : 'editor.save'),
634
+ }
635
+ }
636
+
554
637
  function renderEditor(row, controller, view, t) {
555
638
  const target = view.editing
639
+ const texts = editorTexts(t, target)
556
640
  const marker = editorMarker(target, view)
557
641
  const existing = row.querySelector('.dshet-editor')
558
642
  if (existing && existing.dataset.dshetFor === marker) return
@@ -564,7 +648,7 @@ window.__ModuleLoader__.load({
564
648
 
565
649
  const title = document.createElement('p')
566
650
  title.className = 'dshet-editor-title'
567
- title.textContent = view.confirming ? t('confirm.title') : t('editor.title')
651
+ title.textContent = view.confirming ? t('confirm.title') : texts.title
568
652
  box.appendChild(title)
569
653
 
570
654
  const area = document.createElement('textarea')
@@ -572,20 +656,20 @@ window.__ModuleLoader__.load({
572
656
  area.value = controller.editorDraft
573
657
  area.disabled = view.pending || view.confirming
574
658
  area.spellcheck = false
575
- area.setAttribute('aria-label', t('editor.title'))
576
- if (!view.confirming) area.placeholder = t('editor.placeholder')
659
+ area.setAttribute('aria-label', texts.title)
660
+ if (!view.confirming) area.placeholder = texts.placeholder
577
661
  area.addEventListener('input', () => controller.setDraft(area.value))
578
662
  box.appendChild(area)
579
663
 
580
664
  const note = document.createElement('p')
581
665
  note.className = 'dshet-note'
582
- note.textContent = view.confirming ? t('confirm.body') : t('editor.hint')
666
+ note.textContent = view.confirming ? t('confirm.body') : texts.hint
583
667
  box.appendChild(note)
584
668
 
585
669
  if (target.attachments > 0) {
586
670
  const warn = document.createElement('p')
587
671
  warn.className = 'dshet-warn'
588
- warn.textContent = t('editor.warn.attachments')
672
+ warn.textContent = texts.warn
589
673
  box.appendChild(warn)
590
674
  }
591
675
  if (typeof view.failure === 'string' && view.failure !== '') {
@@ -618,7 +702,7 @@ window.__ModuleLoader__.load({
618
702
  ? t('editor.pending')
619
703
  : view.confirming
620
704
  ? t('editor.review')
621
- : t('editor.save')
705
+ : texts.save
622
706
  submit.disabled = view.pending
623
707
  submit.onclick = (event) => {
624
708
  event.preventDefault()
@@ -669,7 +753,7 @@ window.__ModuleLoader__.load({
669
753
  if (!node) continue
670
754
  const hidden = isRowHidden(view.hidden, seqsFor(node))
671
755
  setRowHidden(row, hidden, animate)
672
- const target = hidden ? null : targetFor(node, view.editable, view.hidden)
756
+ const target = hidden ? null : targetFor(node, view)
673
757
  if (target !== null) injectRowAction(row, target, controller, t)
674
758
  else removeRowAction(row)
675
759
  if (
package/lib/index.js CHANGED
@@ -48,7 +48,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
48
48
  export const name = PLUGIN_ID
49
49
 
50
50
  /** Keep in sync with package.json and lib/client.js. */
51
- export const PLUGIN_VERSION = '0.1.0'
51
+ export const PLUGIN_VERSION = '0.2.0'
52
52
 
53
53
  const ROUTE_PREFIX = '/dsh-edit-turn'
54
54
  const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -68,7 +68,12 @@ const DEFAULT_MARKER = '[dsh-edit-turn] The user revised an earlier message. Eve
68
68
  /** Deployment policy for the rollback. */
69
69
  export const Config = z.object({
70
70
  markerText: z.string().default(DEFAULT_MARKER),
71
- confirm: z.boolean().default(true),
71
+ /**
72
+ * Whether the client asks for a second confirmation before applying. Off by
73
+ * default: the editor is already an explicit action the user opened, and the
74
+ * panel states what saving does. Set it to `true` to require the extra step.
75
+ */
76
+ confirm: z.boolean().default(false),
72
77
  })
73
78
 
74
79
  // ---------------------------------------------------------------------------
@@ -214,6 +219,23 @@ export function isHumanPrompt(event) {
214
219
  return Boolean(data && data.source && data.source.kind === 'user')
215
220
  }
216
221
 
222
+ /**
223
+ * Whether an event is a reply the model produced.
224
+ *
225
+ * Editing one of these is a different operation from editing a prompt: the
226
+ * official append contract refuses to let an `assistant/message` carry
227
+ * `sourceEventSeqs` ("assistant/message embeds its source stream"), so a reply
228
+ * can never be the replacement carrier of a rollback. The rollback is therefore
229
+ * followed by appending the corrected text as a new assistant message, and the
230
+ * model goes on seeing it as its own answer.
231
+ *
232
+ * @param event - raw session event.
233
+ * @returns true for an assistant reply.
234
+ */
235
+ export function isAssistantReply(event) {
236
+ return Boolean(event) && event.type === 'assistant/message'
237
+ }
238
+
217
239
  /**
218
240
  * Read the plain text of a message payload, plus how many non-text parts it
219
241
  * carries. Editing is text-only, so a caller must be told when a rewrite would
@@ -223,7 +245,11 @@ export function isHumanPrompt(event) {
223
245
  */
224
246
  export function readMessageText(event) {
225
247
  const data = (event && event.data) || {}
226
- const blocks = Array.isArray(data.content) ? data.content : []
248
+ // `user/message` carries its message fields on `data`; the other three surface
249
+ // types nest them under `data.message`. Both shapes have to read the same way
250
+ // now that the model's replies are editable too.
251
+ const message = data.message && typeof data.message === 'object' ? data.message : data
252
+ const blocks = Array.isArray(message.content) ? message.content : []
227
253
  let text = ''
228
254
  let attachments = 0
229
255
  for (const block of blocks) {
@@ -255,7 +281,7 @@ export function rollbackLedger(events) {
255
281
  const event = bySeq.get(replacement.seq)
256
282
  const data = event && event.data
257
283
  const source = data && (data.source || (data.message && data.message.source))
258
- if (!source || source.kind !== 'plugin' || source.plugin !== PLUGIN_ID) continue
284
+ if (!(source && (source.kind === `plugin:${PLUGIN_ID}` || (source.kind === 'plugin' && source.plugin === PLUGIN_ID)))) continue
259
285
  for (const seq of replacement.shadowed) {
260
286
  const turn = turnOf.get(seq)
261
287
  hidden.push({ seq, turn: typeof turn === 'number' ? turn : null, replacement: replacement.seq })
@@ -331,8 +357,8 @@ function resolveTarget(events, request) {
331
357
  *
332
358
  * @param events - complete contiguous raw event log.
333
359
  * @param surfaceNodes - current surface seqs in model order.
334
- * @param request - `{ seq?, messageId?, turn? }`.
335
- * @returns `{ targetSeq, startSeq, endSeq, shadowed, turn, original, attachments }`.
360
+ * @param request - `{ seq?, messageId?, turn? }` (`turn` addresses a prompt only).
361
+ * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments }`.
336
362
  * @throws {EditPlanError} with a stable code when the target cannot be planned.
337
363
  */
338
364
  export function planRollback(events, surfaceNodes, request) {
@@ -347,8 +373,11 @@ export function planRollback(events, surfaceNodes, request) {
347
373
  if (target.seq === surfaceNodes[0]) {
348
374
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
349
375
  }
350
- if (!isHumanPrompt(target)) {
351
- throw new EditPlanError('not-editable', 'only messages you typed can be edited and re-run')
376
+ // Two editable kinds, two outcomes: rewriting a prompt re-runs the turn;
377
+ // rewriting a reply replaces the answer and lets the conversation continue.
378
+ const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
379
+ if (mode === null) {
380
+ throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
352
381
  }
353
382
  const startIdx = nodeIndex.get(target.seq)
354
383
  const shadowed = surfaceNodes.slice(startIdx)
@@ -357,13 +386,22 @@ export function planRollback(events, surfaceNodes, request) {
357
386
  }
358
387
  const { text, attachments } = readMessageText(target)
359
388
  const turn = turnOf.get(target.seq)
389
+ const data = target.data && typeof target.data === 'object' ? target.data : {}
390
+ const message = data.message && typeof data.message === 'object' ? data.message : data
391
+ const source = message.source && typeof message.source === 'object' ? message.source : null
360
392
  return {
361
393
  targetSeq: target.seq,
362
394
  messageId: messageIdOf(target),
363
395
  startSeq: shadowed[0],
364
396
  endSeq: shadowed[shadowed.length - 1],
365
397
  shadowed,
398
+ mode,
366
399
  turn: typeof turn === 'number' ? turn : null,
400
+ // The appended correction is keyed by turn:step in the client's reducer, so a
401
+ // missing step would make two corrections collide on "undefined:undefined".
402
+ step: typeof data.step === 'number' ? data.step : 1,
403
+ provider: source && typeof source.provider === 'string' ? source.provider : null,
404
+ model: source && typeof source.model === 'string' ? source.model : null,
367
405
  original: text,
368
406
  attachments,
369
407
  }
@@ -402,6 +440,42 @@ export function editableTurns(events, surfaceNodes) {
402
440
  return out
403
441
  }
404
442
 
443
+ /**
444
+ * Every model reply that can currently be rewritten.
445
+ *
446
+ * Replies with no text at all are skipped: a step that only produced tool calls
447
+ * has nothing to put in a text editor. A reply that carries tool calls *and*
448
+ * text is offered, because replacing it drops those calls along with it - the
449
+ * client warns about that, and the calls cannot survive a rewrite anyway.
450
+ *
451
+ * @param events - complete contiguous raw event log.
452
+ * @param surfaceNodes - current surface seqs in model order.
453
+ * @returns one entry per editable reply, in conversation order.
454
+ */
455
+ export function editableReplies(events, surfaceNodes) {
456
+ const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
457
+ const turnOf = turnIndex(events)
458
+ const head = surfaceNodes[0]
459
+ const out = []
460
+ for (const event of events) {
461
+ if (!isAssistantReply(event)) continue
462
+ if (!nodeIndex.has(event.seq)) continue
463
+ if (event.seq === head) continue
464
+ const { text, attachments } = readMessageText(event)
465
+ if (text.trim() === '') continue
466
+ const turn = turnOf.get(event.seq)
467
+ out.push({
468
+ seq: event.seq,
469
+ turn: typeof turn === 'number' ? turn : null,
470
+ messageId: messageIdOf(event) ?? null,
471
+ text,
472
+ // For a reply these are the non-text blocks: tool calls and reasoning.
473
+ attachments,
474
+ })
475
+ }
476
+ return out
477
+ }
478
+
405
479
  // ---------------------------------------------------------------------------
406
480
  // host service plumbing
407
481
  // ---------------------------------------------------------------------------
@@ -530,6 +604,7 @@ async function stateOf(ctx, sessionId, config) {
530
604
  edits: ledger.edits.length,
531
605
  surface: folded.nodes,
532
606
  turns: editableTurns(events, folded.nodes),
607
+ replies: editableReplies(events, folded.nodes),
533
608
  live: Boolean(live),
534
609
  busy: isBusy(events),
535
610
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
@@ -559,7 +634,7 @@ export function buildCarrier(plan, lastTurn, config) {
559
634
  id: randomUUID(),
560
635
  role: 'system',
561
636
  content: [],
562
- source: { kind: 'plugin', plugin: PLUGIN_ID },
637
+ source: { kind: `plugin:${PLUGIN_ID}` },
563
638
  },
564
639
  },
565
640
  }
@@ -570,7 +645,43 @@ export function buildCarrier(plan, lastTurn, config) {
570
645
  id: randomUUID(),
571
646
  role: 'user',
572
647
  content: [{ type: 'text', text: config.markerText }],
573
- source: { kind: 'plugin', plugin: PLUGIN_ID },
648
+ source: { kind: `plugin:${PLUGIN_ID}` },
649
+ },
650
+ }
651
+ }
652
+
653
+ /**
654
+ * The new assistant message that carries an edited reply.
655
+ *
656
+ * A reply cannot be swapped in place - the validator refuses `sourceEventSeqs`
657
+ * on an `assistant/message` - so the correction is appended as a fresh reply.
658
+ * `turn`/`step` are always set because the client keys a reply node by
659
+ * `turn:step`; leaving them out would make two corrections collide.
660
+ *
661
+ * `source.kind` stays `model` so the entry renders and projects exactly like an
662
+ * ordinary reply, while `editedBy` records honestly that the text was written by
663
+ * the plugin and not produced by the model.
664
+ *
665
+ * @param plan - the planned window, for the original's turn/step/provider.
666
+ * @param text - the replacement text.
667
+ * @returns `{ type, data }` for `session.append`, to be marked `surfaceOp: 'append'`.
668
+ */
669
+ export function buildCorrection(plan, text) {
670
+ const source = { kind: 'model', editedBy: PLUGIN_ID }
671
+ if (typeof plan.provider === 'string' && plan.provider !== '') source.provider = plan.provider
672
+ if (typeof plan.model === 'string' && plan.model !== '') source.model = plan.model
673
+ return {
674
+ type: 'assistant/message',
675
+ data: {
676
+ turn: typeof plan.turn === 'number' ? plan.turn : 0,
677
+ step: typeof plan.step === 'number' ? plan.step : 1,
678
+ message: {
679
+ id: randomUUID(),
680
+ role: 'assistant',
681
+ content: [{ type: 'text', text }],
682
+ source,
683
+ },
684
+ stream: [],
574
685
  },
575
686
  }
576
687
  }
@@ -627,11 +738,47 @@ async function applyEdit(ctx, sessionId, body, config) {
627
738
  }
628
739
  const flush = await flushSession(ctx, session)
629
740
 
630
- // Step two: admit the revised prompt. This is the only official prompt path,
631
- // so it also resumes a cold Session and starts exactly one new turn.
741
+ // Step two depends on what was edited.
742
+ //
743
+ // Rewriting a reply cannot swap the text in place - an `assistant/message` may
744
+ // never carry `sourceEventSeqs`, so it can never be a replacement carrier
745
+ // (checked against the real validator, not assumed). The correction is
746
+ // appended instead, in the same shape the agent loop records a reply, so the
747
+ // model goes on seeing the edited text as its own answer and the conversation
748
+ // continues from there. Nothing is re-run: the answer was fixed, the model was
749
+ // not asked again.
750
+ if (plan.mode === 'reply') {
751
+ const correction = buildCorrection(plan, text)
752
+ let appended
753
+ try {
754
+ appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
755
+ } catch (error) {
756
+ return {
757
+ kind: 'reply',
758
+ replacementSeq: replacement.seq,
759
+ applied: false,
760
+ applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
761
+ shadowed: plan.shadowed,
762
+ ...flush,
763
+ }
764
+ }
765
+ const corrected = await flushSession(ctx, session)
766
+ return {
767
+ kind: 'reply',
768
+ replacementSeq: replacement.seq,
769
+ appendedSeq: appended.seq,
770
+ applied: true,
771
+ shadowed: plan.shadowed,
772
+ ...corrected,
773
+ }
774
+ }
775
+
776
+ // A rewritten prompt is re-run through the only official prompt path, which
777
+ // also resumes a cold Session and starts exactly one new turn.
632
778
  const controller = ctx.get('sessionController')
633
779
  if (!controller || typeof controller.prompt !== 'function') {
634
780
  return {
781
+ kind: 'prompt',
635
782
  replacementSeq: replacement.seq,
636
783
  promptAccepted: false,
637
784
  promptError: 'sessionController.prompt unavailable',
@@ -651,6 +798,7 @@ async function applyEdit(ctx, sessionId, body, config) {
651
798
  )
652
799
  } catch (error) {
653
800
  return {
801
+ kind: 'prompt',
654
802
  replacementSeq: replacement.seq,
655
803
  promptAccepted: false,
656
804
  promptError: String((error && error.message) || error),
@@ -660,6 +808,7 @@ async function applyEdit(ctx, sessionId, body, config) {
660
808
  }
661
809
 
662
810
  return {
811
+ kind: 'prompt',
663
812
  replacementSeq: replacement.seq,
664
813
  promptAccepted: true,
665
814
  shadowed: plan.shadowed,
@@ -711,16 +860,24 @@ function registerTool(ctx) {
711
860
  if (!events) throw new Error(`no session log for ${sessionId}`)
712
861
  const nodes = surfaceOf(ctx, sessionId, events)
713
862
  const turns = editableTurns(events, nodes)
863
+ const replies = editableReplies(events, nodes)
714
864
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
715
865
  const shown = turns.slice(-limit)
866
+ const shownReplies = replies.slice(-limit)
716
867
  const lines = [
717
- `session ${sessionId}: ${turns.length} editable turn(s), ${isBusy(events) ? 'BUSY' : 'idle'}`,
868
+ `session ${sessionId}: ${turns.length} editable turn(s), ${replies.length} editable reply/replies, ${isBusy(events) ? 'BUSY' : 'idle'}`,
718
869
  ]
719
870
  for (const turn of shown) {
720
871
  const preview = turn.text.length > 160 ? `${turn.text.slice(0, 160)}...` : turn.text
721
872
  lines.push(`- turn ${turn.turn} (seq ${turn.seq}, ${turn.attachments} attachment(s)): ${preview}`)
722
873
  }
723
874
  if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
875
+ for (const reply of shownReplies) {
876
+ const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
877
+ lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s)): ${preview}`)
878
+ }
879
+ if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
880
+ lines.push('editing a turn re-runs it; editing a reply replaces its text and discards what follows.')
724
881
  return lines.join('\n')
725
882
  },
726
883
  }),
@@ -832,7 +989,7 @@ export function apply(ctx, config) {
832
989
  ? config.markerText
833
990
  : DEFAULT_MARKER,
834
991
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
835
- confirm: config && typeof config.confirm === 'boolean' ? config.confirm : true,
992
+ confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
836
993
  }
837
994
 
838
995
  const registerRoutes = (webServer, fiber) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Edit any user turn in DeepSeek Harness and re-run it: an inline editor on the message row rolls the conversation back to just before that turn through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,6 +39,7 @@
39
39
  "verify:client": "node tools/verify-client-static.mjs",
40
40
  "verify:live": "node tools/verify-live-client.mjs",
41
41
  "verify:profile": "bash tools/verify-dsh-plugin.sh",
42
+ "probe:loaded": "node tools/probe-loaded.mjs",
42
43
  "check": "npm run test && npm run verify:contract && npm run verify:client"
43
44
  },
44
45
  "dependencies": {