dsh-edit-turn 0.2.20 → 0.2.21
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 +27 -14
- package/lib/client.js +12 -5
- package/lib/index.js +486 -68
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-edit-turn
|
|
2
2
|
|
|
3
|
-
**DeepSeek Harness 的「编辑某一轮」插件 ——
|
|
3
|
+
**DeepSeek Harness 的「编辑某一轮」插件 —— 点消息旁的编辑按钮,就地改写。改提问:那一句就地替换,这一条下面的回复与后续对话原样不动;改回答:回答被替换,它后面的轮次先回退、再逐事件重放回来,顺序不变。** 改写走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新",被替换的内容从模型上下文里消失),**保存本身永不调用模型**。会话日志是 append-only 的,**原始字节一个都不改写**。
|
|
4
4
|
|
|
5
5
|
[中文](#中文) · [English](#english)
|
|
6
6
|
|
|
@@ -18,16 +18,17 @@ DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的
|
|
|
18
18
|
- 点击后在该消息下方就地展开编辑器,预填原文;
|
|
19
19
|
- 保存后:**只有这一条消息被替换**——旧措辞从模型上下文里消失,改后的内容占住它的位置(因此后续每一轮读到的都是改后的版本);
|
|
20
20
|
- **它下面的助手回复、以及之后的全部对话,原样保留**,不重跑、不清空;
|
|
21
|
-
-
|
|
21
|
+
- **模型回答也能这样改**:改写后的回答落在原回答的位置,它后面的轮次**先回退、再逐事件重放回原位**——改一句回答不会把后面的对话丢掉;
|
|
22
|
+
- 装了姊妹插件 **dsh-rerun-turn** 时,编辑器里会多一个「**重跑**」:先保存这次修改,再让它用改后的提示词重新生成这一轮(后续轮次同样逐事件重放回来,不丢)。
|
|
22
23
|
|
|
23
24
|
### 特性
|
|
24
25
|
|
|
25
26
|
- **官方 seam,不改日志** —— 回退 = 追加一条带 `surfaceOp: { op: 'replace', startSeq, endSeq }` 的替换事件。原始事件全部留在会话文件里,只是不再进入 `deriveMessages()`。与官方 `/compact` 用的是同一套契约。
|
|
26
27
|
- **改写内容就是载体本身** —— 提示词编辑落下的是一条 `user/message` 替换事件,内容就是你改后的那句话。它既让模型读到你改后的版本,也在转录里显示成你自己的气泡;平台不会把它当成新输入去回答(这一点踩过坑:**追加**一条 user 消息会触发平台自动回答,越改越多)。
|
|
27
28
|
- **块随消息一起改(0.2.19)** —— 改写一条带图/带文件的消息不再只留文字:**用户没碰过的块原样搬**(逐字节复制,attachmentId、文件名、尺寸一个不改),**删掉的块不写**,**新加的块**经平台的附件服务录用后写进载体。代码里没有任何"这是图片/这是文件"的判断,唯一的判定是"这个块里有文字吗",所以平台以后加的块类型自动适用。编辑器的非文字块显示成芯片(缩略图或名字 + 大小 + 删除按钮),另有一个「添加附件」入口;服务不在时退回旧行为(只改文字 + 继续显示那条警告)。
|
|
28
|
-
-
|
|
29
|
+
- **窗口最小化 + 尾部重放** —— 提示词编辑只遮蔽目标那一格(`shadowed = [target.seq]`);回复编辑因为新回答只能 append,窗口必须到末尾,**但被遮蔽的后续轮次不会消失**:它们会带着新 turn 号、新 messageId、原内容和 replay 标记逐事件重放回来(与 `dsh-rerun-turn` 的 infix splice 是同一套写法,两个仓库的 replay 输出有逐字段对照测试)。助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
|
|
29
30
|
- **保存永不调用模型** —— 保存只写上下文,不产生任何模型调用。"改完立刻重答"是另一件事,交给姊妹插件 **dsh-rerun-turn**(它遮蔽该轮、用表面上的提示词重新生成、再把后续轮次逐事件重放回来);本插件只在它装着时提供一个「重跑」按钮转发过去。
|
|
30
|
-
- **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs
|
|
31
|
+
- **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,**追加**一条带改写文本的助手消息,再把回退掉的后续轮次**逐事件重放**在它后面——模型读到的是「前缀 + 改写后的回答 + 原来的后续轮次」,对话可以继续。`source.editedBy` 记录改写文字由插件写入,重放事件另带 `replayBy` / `replayId` / `originalSeq`。
|
|
31
32
|
- **改过的那条还能再改** —— 它仍然显示在原来的位置(由插件渲染成气泡),悬停就有编辑按钮。
|
|
32
33
|
- **一次点击即执行** —— 保存后不再有二次确认(`confirm: true` 可恢复两步确认,确认步里会写明这次保存会丢弃什么)。编辑器本身已经是用户主动打开的动作,保存的后果在 README 与确认步里说明,输入框本身保持干净。
|
|
33
34
|
- **中英双语 UI**,跟随 DSH 当前语言。
|
|
@@ -59,7 +60,7 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
|
|
|
59
60
|
编辑器里,消息带的图片与文件**各显示成一个芯片**(缩略图或文件名 + 大小 + 删除按钮):不碰就原样保留,点删除就是不要它了;旁边还有一个「**添加附件**」(这条消息本来没有附件时也能用)。缩略图由本插件的只读路由按需取回;取不回来、或那些字节不是图片,芯片退回显示名字。**只有当这个部署没有附件存储时**,编辑器才会改回旧的那句话——「这条消息包含图片或文件附件,改写会丢弃它们,只保留文字」——并且不出现添加入口。
|
|
60
61
|
**一字不改就按保存,什么都不会发生。** 编辑器预填的就是这条消息**现在**的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器照常关闭、**一条提示都不显示**(0.2.20 起;这条提示此前压在输入框的占位文字上)。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。**「重跑」不受这条守卫影响**:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
**编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存」——这条回答被替换为你写的内容;它后面的轮次先从上下文里回退,再逐事件重放回原位(新 turn 号、新 messageId、原内容),顺序与原来一致,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
|
|
63
64
|
|
|
64
65
|
注意:回答里的**工具调用与思考过程**无法保留(替换后只留文字,编辑器会提示),因为工具调用的结果已经不再成立。
|
|
65
66
|
|
|
@@ -82,7 +83,7 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
|
|
|
82
83
|
一次编辑就是**一条官方替换事件**。读事件流(`sessionQuery.readSession`,回退到活动会话的 `snapshotEvents()`),用官方折叠规则算出当前 surface 顺序,定位目标消息的节点,然后:
|
|
83
84
|
|
|
84
85
|
- 提示词编辑:窗口 = **仅那一格**(`[target.seq]`),载体就是改后的文本;
|
|
85
|
-
- 回复编辑:窗口 = `surface[目标下标 .. 末尾]
|
|
86
|
+
- 回复编辑:窗口 = `surface[目标下标 .. 末尾]`(修正回答只能 append,所以窗口必须到末尾);先在日志里**开一个新轮次**(`turn/start` + `step/start`),载体是空 `developer/message`(空内容不投影给模型),随后把改写文本作为一条助手消息追加进这个轮次,再 `step/end` + `turn/end` 收尾;最后用 `buildReplayWrites` 把窗口里**后续轮次**的 surface 事件逐条重放回来(新 turn 号、新 messageId、`replayBy` / `replayId` / `originalSeq` 标记)。重放规则与 `dsh-rerun-turn` 共用一套:tool call 与 result 必须成对、没等到结果的调用连同它的广告一起丢、`TOOL_NOT_STARTED` 修复保持不带 `sourceEventSeqs`、system / developer / 静默载体 / 日志-only 记录一律跳过。
|
|
86
87
|
|
|
87
88
|
追加一条:
|
|
88
89
|
|
|
@@ -94,7 +95,7 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
|
|
|
94
95
|
})
|
|
95
96
|
```
|
|
96
97
|
|
|
97
|
-
落盘后等一次持久化检查点(`sessions.flush
|
|
98
|
+
落盘后等一次持久化检查点(`sessions.flush`),保证重启也能看到。回复编辑的这条重放**不调模型**:它只是把已经记录的事件以新身份再 append 一遍,所以保存仍然是"一次点击、零模型调用"。
|
|
98
99
|
|
|
99
100
|
**重跑不由本插件做。** "改完立刻重答"是姊妹插件 `dsh-rerun-turn` 的操作:它遮蔽该轮、用表面现在显示的提示词(也就是你改后的文本)重新生成,再把后续轮次逐事件重放回来。本插件只负责把编辑落地;编辑器里的「重跑」按钮仅在检测到它挂载时出现,点击后先保存、再调用它的 `POST /dsh-rerun-turn/apply`。它不在(例如桌面版没装)时,没有这个按钮。
|
|
100
101
|
|
|
@@ -132,14 +133,14 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
|
|
|
132
133
|
|
|
133
134
|
### 验证状态
|
|
134
135
|
|
|
135
|
-
本插件在 DSH `0.1.6-alpha.2`
|
|
136
|
+
本插件在 DSH `0.2.1-rc.2`(真实校验器与真实会话)以及 `0.1.6-alpha.2` / `0.1.7-rc.1` 上完成以下验证:
|
|
136
137
|
|
|
137
138
|
| 验证 | 命令 | 结果 |
|
|
138
139
|
|---|---|---|
|
|
139
140
|
| 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
|
|
140
141
|
| 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
|
|
141
|
-
| 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` |
|
|
142
|
-
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` |
|
|
142
|
+
| 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 85 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**回复编辑后的尾部重放(后续提问/回答/tool 调用与结果按新 turn 号回到修正回答之后,顺序正确、日志仍可加载)**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)** |
|
|
143
|
+
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 188 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
|
|
143
144
|
| 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议、块契约) | `npm run verify:client` | 184 项全部通过(含 12 条新的块契约检查:能力上报、服务现场探测、录用是唯一建块路径、录用先于 append、**两侧都不特判块类型**、块逐字节复制、文字归位、`dropped` 上报、只在无服务时警告、芯片靠 `error` 事件自证) |
|
|
144
145
|
| 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
|
|
145
146
|
| **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
|
|
@@ -225,6 +226,18 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
225
226
|
|
|
226
227
|
### 更新日志
|
|
227
228
|
|
|
229
|
+
**0.2.21** —— 回复编辑改成 infix splice:改一条中间的回答,后面的轮次不再被丢掉,而是重放回来(与 `dsh-rerun-turn` 同一套逻辑)。
|
|
230
|
+
|
|
231
|
+
- **为什么要改**:0.2.20 的回复编辑是「从这条回答到末尾」的整段回退——改第一条回答,第 2、3 轮就一起消失。所谓「任意位置编辑」此前只对用户消息成立;用户消息的改写本身就在原位,不需要重放。
|
|
232
|
+
- **改法**(宿主半侧):
|
|
233
|
+
1. `planRollback` 的回复窗口仍到末尾(修正回答只能 append,尾部必须先离开 surface),但额外算出 `replayFrom`(目标轮之后的下一个 `turn/start` 括号)与 `logTo`(窗口最后一个轮的 `turn/end`);
|
|
234
|
+
2. 新增 `buildReplayWrites`:把窗口里后续轮次的 surface 事件按新 turn 号、新 messageId 重放回去,并打 `replayBy` / `replayId` / `originalSeq` 标记。规则与 rerun 完全一致:tool call 与 result 成对、没等到结果的调用连同广告丢掉、`TOOL_NOT_STARTED` 修复保持不带 `sourceEventSeqs`、system/developer/静默载体与日志-only 记录一律跳过;`test/replay-parity.test.js` 把两个仓库的 builder 放在同一份日志上逐字段比对(sibling 不在时 skip);
|
|
235
|
+
3. `applyEdit` 在「开轮 → 空载体替换窗口 → 追加修正回答 → 收尾」之后同步重放,并把活循环的轮次计数器推到重放出来的最高轮号;整批只在最后 flush 一次,避免留下「影子已持久化、修正却不在」的中间态;
|
|
236
|
+
4. 派生顺序变成「前缀 + 改写后的回答 + 原来的后续轮次」,日志仍然 append-only。
|
|
237
|
+
- **测试**:`npm test` **189/189**、`npm run verify:contract` **85** 项、`npm run verify:client` **184** 项全绿;真实 `Session` 校验器上新增「改第一轮回答 → 后续轮次的提问、回答、tool call/result 全部重放回来且顺序正确、日志仍可加载」的用例;宿主集成测试改成断言重放标记与轮次号,并新增「改三段会话中间那一轮的回复,前后两轮都还在」;与 `dsh-rerun-turn` 的 `buildReplayWrites` 有逐字段对照测试。
|
|
238
|
+
- **真实会话验证**(只读、不写盘):在本机 4 个多轮会话上把整条 apply 路径跑在内存副本里——最多 11,013 个事件 / 130 轮,重放 687 条写入,全部被真实校验器接受、重新加载通过,改后的派生顺序是「前缀 + 改写回答 + 后续轮次」。
|
|
239
|
+
- **诚实说明**:重放是同步 append(不调模型,毫秒级),没有 rerun 那套跨分钟的后台恢复流程;如果中途某条写入被校验器拒绝,响应会带 `replay.error` 与已写入条数,已经落地的副本保留,不会回滚成「什么都没有」。
|
|
240
|
+
|
|
228
241
|
**0.2.20** —— 删掉「原样保存」时那条提示。只改提示,行为不变。
|
|
229
242
|
|
|
230
243
|
- **症状**:一字不改按保存,宿主本来就不写任何事件(0.2.18 的守卫),但客户端还会弹一条「内容没有变化,未做任何修改。」——而它在真机上是**压在输入框的占位文字上**渲染的(截图可见重叠)。这条提示既没有信息量(用户刚做的动作就是"什么都没做"),位置也不对。
|
|
@@ -415,7 +428,7 @@ This plugin adds it:
|
|
|
415
428
|
- clicking it opens an in-place editor below that message, pre-filled with the original text;
|
|
416
429
|
- saving **replaces that one message in place**: only the old wording leaves the model context, the revised text takes its place, and every later turn is answered against the new version;
|
|
417
430
|
- **the reply under it and the whole conversation after it stay exactly as they were** - nothing is re-run and nothing is cleared;
|
|
418
|
-
- model replies are editable too:
|
|
431
|
+
- model replies are editable too: the corrected answer is appended as a fresh reply and **everything that followed is replayed back behind it** - event by event, with fresh turn numbers and message ids - so a reply edit never drops the rest of the conversation;
|
|
419
432
|
- with the sister plugin **dsh-rerun-turn** installed, the editor also offers a "**Re-run**" button: it saves your change first, then has that plugin regenerate the turn from the revised prompt (replaying what followed, so nothing after it is lost).
|
|
420
433
|
|
|
421
434
|
### Features
|
|
@@ -423,11 +436,11 @@ This plugin adds it:
|
|
|
423
436
|
- **Official seam, log untouched.** A rollback appends one replacement event carrying `surfaceOp: { op: 'replace', startSeq, endSeq }`. Every original event stays in the session file; it simply stops entering `deriveMessages()`. This is the same contract `/compact` uses.
|
|
424
437
|
- **The revision is the carrier.** A prompt edit lands a `user/message` replacement whose content **is** your revised text: it updates the model context and shows in the transcript in place of the old wording. The platform does not answer it as a new input (that was tried: *appending* a user message makes the platform reply, and the next save added another copy). Reply edits stay silent too: their carrier is an empty `developer/message` - the format admits only system-prompt sources on `system/message`, and an empty plugin-owned system node is exactly what used to make sessions unreadable (see below).
|
|
425
438
|
- **A revised message carries every block it carried (0.2.19).** A block the user did not touch is copied verbatim (a structured clone of the original block object: attachment ids, names, sizes and every field a later platform adds), a block they removed is not written, and a newly picked payload is admitted through the platform's attachment store before anything is appended. Nothing in the module names a block type - the only block test is "does this block carry text" - so a block kind the platform adds later works with no change here. The editor draws one chip per non-text block (a thumbnail when its bytes are reachable, otherwise its name and size, plus a remove button) and offers an "Add an attachment" entry; with no store mounted it falls back to the old warning and no picker.
|
|
426
|
-
- **The smallest window that is correct.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface
|
|
439
|
+
- **The smallest window that is correct, with a faithful replay.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface (a correction can only be appended, so the tail must leave first), and then replays every later turn back behind the correction with fresh turn numbers, fresh message ids and a `replayBy` / `replayId` / `originalSeq` marker. The walk is the same one `dsh-rerun-turn` performs after a rerun, and a parity test compares the two builders write for write. An assistant message (carrying its own tool_use blocks) and the tool/result it produced always leave together, so a dangling call/result pair is impossible.
|
|
427
440
|
- **A rewritten message stays editable.** It still shows where the original stood, and hovering it offers the pencil again.
|
|
428
441
|
- **Saving never calls the model.** It only writes the context. Regenerating a turn after the edit is a different operation, owned by the sister plugin **dsh-rerun-turn** (it shadows the turn, regenerates from the prompt the surface now shows, and replays the turns that followed); this plugin only forwards to it, through the "Re-run" button, when it is mounted.
|
|
429
442
|
- **A save that changes nothing writes nothing** (0.2.18). The layer that decides what to write compares the draft with the live text **verbatim**, through the same parse the editor was prefilled from (`readMessageText`, the one behind `/state`’s `turns[].text`), and answers `unchanged: true` before appending anything: no replacement event, no synthetic turn, no appended answer, no loop-counter sync. The flag is not a refusal - the route still answers 200, and the editor still starts the re-run when that was the button the user pressed. Nothing is announced for it either (0.2.20 removed the notice this used to draw): the editor closes like any save, and the screen is exactly as it was.
|
|
430
|
-
- **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
|
|
443
|
+
- **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 correction is **appended** as a fresh reply - the model goes on treating it as its own - and the turns the rollback took leave the surface only to be **replayed** back behind it, in order, as fresh copies. `source.editedBy` records honestly that the plugin wrote those words; each replay event carries `replayBy`, `replayId` and `originalSeq`.
|
|
431
444
|
- **One click applies** - no second confirmation by default. The editor is already an explicit action the user opened; with `confirm: true` the confirmation step states what this save will discard, while the input box itself stays clean.
|
|
432
445
|
- **Bilingual UI** that follows the current DSH locale.
|
|
433
446
|
- **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`.
|
|
@@ -579,7 +592,7 @@ The transcript only builds rows for **append surface events** (ui-chat's user an
|
|
|
579
592
|
2. **The replacement event has the type of the target**: a `user/message` edit lands a `user/message` (a reply cannot be swapped in place — see 5);
|
|
580
593
|
3. **`sourceEventSeqs` always lists the whole window**: the official validator demands complete coverage; one missing node and the append is refused;
|
|
581
594
|
4. **Every replacement carries the semantic marker**: `source.editedBy === 'dsh-edit-turn'` — including the empty text-free `developer/message` carrier, so siblings can recognise rewrites by `editedBy` alone instead of inferring from the window shape;
|
|
582
|
-
5. **A reply edit is a multi-node rollback
|
|
595
|
+
5. **A reply edit is a multi-node rollback, an appended correction, and a replay of the tail**, not an in-place swap: the old reply row keeping no entry is **by design** (the new row carries it), and everything after it is re-appended with fresh identities and a replay marker. Do not add `sourceEventSeqs` to an `assistant/message` to keep the old row's entry — the official validator refuses it outright;
|
|
583
596
|
6. **The block list is an optional, forward-compatible parameter**: `POST /apply` accepts `parts` (`[{ keep } | { add }]`), and **omitting it is exactly the 0.2.18 behaviour** (a text-only carrier), with the response's `dropped` stating whether that save left non-text blocks behind. `GET /state` answers `capabilities: { attachments, preview }` for what this deployment can admit and read, and `turns[].blocks` describes each block as `{ index, type, name?, mediaType?, bytes?, width?, height?, preview }` - read by field presence, never by type, so a sibling can do the same and a block kind the platform adds later needs no change anywhere;
|
|
584
597
|
7. **The mapping is published**: `revisions[]` on `GET /dsh-edit-turn/state` gives `{ replacementSeq, startSeq, endSeq, shadowed }` (the field names the `POST /apply` response already used), and `hidden[]` lists `{ seq, turn, replacement }` per shadowed row. No sibling has to re-derive the ledger from the event stream.
|
|
585
598
|
|
package/lib/client.js
CHANGED
|
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
|
|
|
42
42
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
43
43
|
|
|
44
44
|
/** Keep in sync with package.json and lib/index.js. */
|
|
45
|
-
const PLUGIN_VERSION = '0.2.
|
|
45
|
+
const PLUGIN_VERSION = '0.2.21'
|
|
46
46
|
|
|
47
47
|
// --- copy -----------------------------------------------------------------
|
|
48
48
|
|
|
@@ -59,7 +59,7 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
'action.copied': '复制成功',
|
|
60
60
|
'editor.title': '编辑这条消息',
|
|
61
61
|
'editor.title-reply': '编辑这条回答',
|
|
62
|
-
'editor.placeholder': '
|
|
62
|
+
'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
|
|
63
63
|
'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
|
|
64
64
|
'editor.save': '保存',
|
|
65
65
|
'editor.rerun': '重跑',
|
|
@@ -71,7 +71,7 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
72
72
|
'editor.attach.add': '添加附件',
|
|
73
73
|
'editor.attach.remove': '移除这个附件',
|
|
74
|
-
'confirm.body': '
|
|
74
|
+
'confirm.body': '确认后会立即执行:只有这条消息被替换,后面的对话保持不变。原始会话日志不会改写。',
|
|
75
75
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
76
76
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
77
77
|
'error.session-not-found': '找不到该会话的日志。',
|
|
@@ -96,6 +96,7 @@ window.__ModuleLoader__.load({
|
|
|
96
96
|
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
97
97
|
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
98
98
|
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
99
|
+
'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
const en = {
|
|
@@ -106,7 +107,7 @@ window.__ModuleLoader__.load({
|
|
|
106
107
|
'action.copied': 'Copied',
|
|
107
108
|
'editor.title': 'Edit this message',
|
|
108
109
|
'editor.title-reply': 'Edit this reply',
|
|
109
|
-
'editor.placeholder': '
|
|
110
|
+
'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
|
|
110
111
|
'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
|
|
111
112
|
'editor.save': 'Save',
|
|
112
113
|
'editor.rerun': 'Re-run',
|
|
@@ -118,7 +119,7 @@ window.__ModuleLoader__.load({
|
|
|
118
119
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
119
120
|
'editor.attach.add': 'Add an attachment',
|
|
120
121
|
'editor.attach.remove': 'Remove this attachment',
|
|
121
|
-
'confirm.body': 'Confirming applies right away:
|
|
122
|
+
'confirm.body': 'Confirming applies right away: only this message is replaced; the conversation after it stays as it is. The original session log is never rewritten.',
|
|
122
123
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
123
124
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
124
125
|
'error.session-not-found': 'No session log was found for this id.',
|
|
@@ -143,6 +144,7 @@ window.__ModuleLoader__.load({
|
|
|
143
144
|
'error.attachment-unreadable': 'That attachment could not be read; try another file.',
|
|
144
145
|
'error.attachment-too-large': 'That file is too large to attach.',
|
|
145
146
|
'error.too-large': 'That revision was too large for the host to accept.',
|
|
147
|
+
'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
// Every error code the host can return travels through here. A code is
|
|
@@ -775,6 +777,11 @@ window.__ModuleLoader__.load({
|
|
|
775
777
|
// the only place the user can still be told (see applyEdit's
|
|
776
778
|
// `dropped`).
|
|
777
779
|
if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
|
|
780
|
+
// The correction landed, but replaying the turns behind it did not:
|
|
781
|
+
// the editor is already gone, so this is the only place left to say
|
|
782
|
+
// that the tail is missing from the context instead of silently
|
|
783
|
+
// hiding rows the user still expects.
|
|
784
|
+
if (data.applied === true && data.replay && data.replay.error) this.notify('replay-partial')
|
|
778
785
|
}
|
|
779
786
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
780
787
|
// from the prompt the surface NOW shows, so the revised wording has
|
package/lib/index.js
CHANGED
|
@@ -69,7 +69,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
69
69
|
export const name = PLUGIN_ID
|
|
70
70
|
|
|
71
71
|
/** Keep in sync with package.json and lib/client.js. */
|
|
72
|
-
export const PLUGIN_VERSION = '0.2.
|
|
72
|
+
export const PLUGIN_VERSION = '0.2.21'
|
|
73
73
|
|
|
74
74
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
75
75
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -237,6 +237,44 @@ export function turnIndex(events) {
|
|
|
237
237
|
return turnOf
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* One turn bracket: its number, log range, first prompt and reply count.
|
|
242
|
+
*
|
|
243
|
+
* A turn NUMBER is not unique in a log - a replay reuses numbers on fresh
|
|
244
|
+
* brackets - so planning must locate the bracket that CONTAINS an event rather
|
|
245
|
+
* than the first bracket carrying its number (the same rule dsh-rerun-turn
|
|
246
|
+
* learned in 0.1.23; this is the same walk).
|
|
247
|
+
*
|
|
248
|
+
* @param events - complete contiguous raw event log.
|
|
249
|
+
* @returns one entry per `turn/start`, in log order.
|
|
250
|
+
*/
|
|
251
|
+
export function turnSpans(events) {
|
|
252
|
+
const spans = []
|
|
253
|
+
let open = null
|
|
254
|
+
for (const event of events) {
|
|
255
|
+
if (event.type === 'turn/start') {
|
|
256
|
+
open = {
|
|
257
|
+
turn: event.data && event.data.turn,
|
|
258
|
+
startSeq: event.seq,
|
|
259
|
+
endSeq: null,
|
|
260
|
+
promptSeq: null,
|
|
261
|
+
replies: 0,
|
|
262
|
+
}
|
|
263
|
+
spans.push(open)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (open === null) continue
|
|
267
|
+
if (event.type === 'turn/end') {
|
|
268
|
+
open.endSeq = event.seq
|
|
269
|
+
open = null
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (event.type === 'user/message' && open.promptSeq === null) open.promptSeq = event.seq
|
|
273
|
+
if (event.type === 'assistant/message') open.replies += 1
|
|
274
|
+
}
|
|
275
|
+
return spans
|
|
276
|
+
}
|
|
277
|
+
|
|
240
278
|
/** The turn still awaiting its `turn/end`, or null when every turn closed. */
|
|
241
279
|
export function openTurn(events) {
|
|
242
280
|
let open = null
|
|
@@ -710,18 +748,24 @@ function resolveTarget(events, request) {
|
|
|
710
748
|
}
|
|
711
749
|
|
|
712
750
|
/**
|
|
713
|
-
* Turn one UI target into the canonical rollback window
|
|
751
|
+
* Turn one UI target into the canonical rollback window plus, for a reply, the
|
|
752
|
+
* replay range that puts the later turns back after the correction.
|
|
714
753
|
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
754
|
+
* A revised PROMPT is replaced in place: its window is the message alone, so
|
|
755
|
+
* the reply under it and everything after stay exactly where they were.
|
|
756
|
+
*
|
|
757
|
+
* A revised REPLY cannot be swapped in place - the format refuses
|
|
758
|
+
* `sourceEventSeqs` on an `assistant/message`, so a new reply can only be
|
|
759
|
+
* APPENDED. The window therefore runs from the addressed reply to the last
|
|
760
|
+
* surface node, and the correction is appended in a fresh turn. Everything the
|
|
761
|
+
* window took is replayed back behind the correction by
|
|
762
|
+
* {@link buildReplayWrites}: the same infix splice dsh-rerun-turn performs, with
|
|
763
|
+
* the user's text instead of a regenerated answer.
|
|
720
764
|
*
|
|
721
765
|
* @param events - complete contiguous raw event log.
|
|
722
766
|
* @param surfaceNodes - current surface seqs in model order.
|
|
723
767
|
* @param request - `{ seq?, messageId?, turn? }` (`turn` addresses a prompt only).
|
|
724
|
-
* @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments }`.
|
|
768
|
+
* @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments, replayFrom, logTo, turnStart, turnEnd }`.
|
|
725
769
|
* @throws {EditPlanError} with a stable code when the target cannot be planned.
|
|
726
770
|
*/
|
|
727
771
|
export function planRollback(events, surfaceNodes, request) {
|
|
@@ -737,7 +781,8 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
737
781
|
throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
|
|
738
782
|
}
|
|
739
783
|
// Two editable kinds. A prompt is rewritten in place and the conversation
|
|
740
|
-
// continues; a reply is replaced
|
|
784
|
+
// continues; a reply is replaced by an appended correction and the tail is
|
|
785
|
+
// replayed behind it.
|
|
741
786
|
const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
|
|
742
787
|
if (mode === null) {
|
|
743
788
|
throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
|
|
@@ -746,14 +791,54 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
746
791
|
// Two different windows, because the two edits mean different things.
|
|
747
792
|
//
|
|
748
793
|
// A revised PROMPT is replaced in place: only the message itself leaves the
|
|
749
|
-
// surface, so the reply under it and everything after stay exactly
|
|
750
|
-
// were - edit the wording, keep the conversation.
|
|
751
|
-
//
|
|
752
|
-
//
|
|
794
|
+
// surface, so the reply under it and everything after stay exactly where they
|
|
795
|
+
// were - edit the wording, keep the conversation.
|
|
796
|
+
//
|
|
797
|
+
// A revised REPLY cannot be a replacement carrier (the format refuses
|
|
798
|
+
// `sourceEventSeqs` on an assistant message), so the correction is appended
|
|
799
|
+
// at the tail and everything from the addressed reply to the end must leave
|
|
800
|
+
// the surface first. `buildReplayWrites` then puts every later turn back,
|
|
801
|
+
// event by event, with fresh turn numbers and message ids - the model reads
|
|
802
|
+
// prefix + corrected reply + the same later turns, in order.
|
|
753
803
|
const shadowed = mode === 'prompt' ? [target.seq] : surfaceNodes.slice(startIdx)
|
|
754
804
|
if (shadowed.length === 0 || shadowed[0] !== target.seq) {
|
|
755
805
|
throw new EditPlanError('not-editable', 'the target does not open a rollback window')
|
|
756
806
|
}
|
|
807
|
+
// The replay range: the log range the shadow covers, and the bracket where
|
|
808
|
+
// the later turns start. A reply in the last turn has neither, and the edit
|
|
809
|
+
// degenerates to the old tail-less rollback (there is nothing to replay).
|
|
810
|
+
let replayFrom = null
|
|
811
|
+
let logTo = null
|
|
812
|
+
let turnStart = null
|
|
813
|
+
let turnEnd = null
|
|
814
|
+
if (mode === 'reply') {
|
|
815
|
+
const spans = turnSpans(events)
|
|
816
|
+
// A closed bracket that CONTAINS the reply. The fallback (the last bracket
|
|
817
|
+
// that starts before it) only matters for a malformed log with a bracket
|
|
818
|
+
// that never closed; a valid log always takes the containment branch.
|
|
819
|
+
const owning = spans.find(
|
|
820
|
+
(entry) => entry.endSeq !== null && entry.startSeq <= target.seq && target.seq <= entry.endSeq,
|
|
821
|
+
) ?? spans.filter((entry) => entry.startSeq <= target.seq).at(-1)
|
|
822
|
+
if (owning !== undefined) {
|
|
823
|
+
turnStart = owning.startSeq
|
|
824
|
+
turnEnd = owning.endSeq
|
|
825
|
+
}
|
|
826
|
+
const lastShadowed = shadowed[shadowed.length - 1]
|
|
827
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
828
|
+
logTo = lastShadowed
|
|
829
|
+
for (let seq = lastShadowed + 1; ; seq += 1) {
|
|
830
|
+
const event = bySeq.get(seq)
|
|
831
|
+
if (event === undefined) break
|
|
832
|
+
// The window already ends on the last surface node: only log-only events
|
|
833
|
+
// (step/turn closing records) can follow it.
|
|
834
|
+
if (isSurfaceEvent(event)) break
|
|
835
|
+
logTo = seq
|
|
836
|
+
if (event.type === 'turn/end') break
|
|
837
|
+
}
|
|
838
|
+
const owningIndex = owning === undefined ? -1 : spans.indexOf(owning)
|
|
839
|
+
const nextSpan = owningIndex >= 0 ? spans[owningIndex + 1] : undefined
|
|
840
|
+
if (nextSpan !== undefined && nextSpan.startSeq <= logTo) replayFrom = nextSpan.startSeq
|
|
841
|
+
}
|
|
757
842
|
const { text, attachments } = readMessageText(target)
|
|
758
843
|
// The blocks themselves travel with the plan: the carrier is rebuilt from
|
|
759
844
|
// them, so no caller has to re-read the message to know what it carries.
|
|
@@ -778,7 +863,293 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
778
863
|
original: text,
|
|
779
864
|
attachments,
|
|
780
865
|
blocks,
|
|
866
|
+
// Reply-only replay range. `replayFrom` is the next turn's bracket (null when
|
|
867
|
+
// the edited reply is in the last turn); `logTo` is the end of the last turn
|
|
868
|
+
// the window shadowed. Both are computed from the ORIGINAL log and stay valid
|
|
869
|
+
// while the correction is appended after it.
|
|
870
|
+
replayFrom,
|
|
871
|
+
logTo,
|
|
872
|
+
turnStart,
|
|
873
|
+
turnEnd,
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// --- reply replay -----------------------------------------------------------
|
|
878
|
+
//
|
|
879
|
+
// A reply edit lands exactly like a rerun's infix splice: the shadow takes the
|
|
880
|
+
// old answer and every later turn off the surface, the correction is appended in
|
|
881
|
+
// a fresh turn, and this walk puts the later turns back as fresh copies. The
|
|
882
|
+
// algorithm is the one dsh-rerun-turn ships (`buildReplayWrites` there); keep the
|
|
883
|
+
// two in step - `test/replay-parity.test.js` compares their output on one log
|
|
884
|
+
// when the sibling repository is checked out.
|
|
885
|
+
|
|
886
|
+
/** Marker written into every replayed event's source. */
|
|
887
|
+
export const REPLAY_MARKER = PLUGIN_ID
|
|
888
|
+
|
|
889
|
+
/** Text that carries nothing a model could read (spaces, zero-width glue). */
|
|
890
|
+
const INVISIBLE_TEXT_RE = /^[\s\u200b-\u200f\u2060\ufeff]*$/
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Whether content blocks carry anything readable.
|
|
894
|
+
* @param content - the `content` array of a message.
|
|
895
|
+
* @returns true when at least one block is not invisible text.
|
|
896
|
+
*/
|
|
897
|
+
export function hasReadableContent(content) {
|
|
898
|
+
if (!Array.isArray(content)) return false
|
|
899
|
+
for (const block of content) {
|
|
900
|
+
if (!block || typeof block !== 'object') continue
|
|
901
|
+
if (block.type !== 'text') return true
|
|
902
|
+
if (typeof block.text !== 'string' || !INVISIBLE_TEXT_RE.test(block.text)) return true
|
|
903
|
+
}
|
|
904
|
+
return false
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Whether an event is a plugin's silent surface carrier rather than conversation.
|
|
909
|
+
*
|
|
910
|
+
* `dsh-delete-turn` writes one for a deletion and `dsh-rerun-turn` for a rerun's
|
|
911
|
+
* shadow - a `user/message` whose source kind is `plugin:<id>` and whose content
|
|
912
|
+
* is empty or invisible. A replay must retire it without a copy: it projects to
|
|
913
|
+
* no model message, so copying it would resurrect an empty user node.
|
|
914
|
+
*
|
|
915
|
+
* A rewritten prompt is NOT this shape: `dsh-edit-turn` keeps `kind: 'user'` and
|
|
916
|
+
* carries the user's wording, so it stays a real prompt and is copied.
|
|
917
|
+
*
|
|
918
|
+
* @param event - raw session event.
|
|
919
|
+
* @returns true for a text-free plugin replacement.
|
|
920
|
+
*/
|
|
921
|
+
export function isSilentPluginCarrier(event) {
|
|
922
|
+
if (!event || event.type !== 'user/message') return false
|
|
923
|
+
const data = event.data || {}
|
|
924
|
+
const source = data.source
|
|
925
|
+
if (!source || typeof source.kind !== 'string' || !source.kind.startsWith('plugin:')) return false
|
|
926
|
+
return !hasReadableContent(data.content)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Stamp one copied event's source with this plugin's replay markers. */
|
|
930
|
+
function replaySource(source, replayId, originalSeq) {
|
|
931
|
+
return {
|
|
932
|
+
...(source && typeof source === 'object' ? source : {}),
|
|
933
|
+
replayBy: REPLAY_MARKER,
|
|
934
|
+
replayId,
|
|
935
|
+
originalSeq,
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Build the replay writes for a reply edit's shadowed tail.
|
|
941
|
+
*
|
|
942
|
+
* Every shadowed event after the edited reply is copied verbatim except for:
|
|
943
|
+
* fresh turn numbers (the log requires them contiguous), fresh message ids,
|
|
944
|
+
* remapped `sourceEventSeqs` on tool results, dropped usage numbers and embedded
|
|
945
|
+
* streams, and the replay marker in each message source. System, developer and
|
|
946
|
+
* every log-only record are skipped: they describe the original run, not the
|
|
947
|
+
* conversation.
|
|
948
|
+
*
|
|
949
|
+
* Tool calls are copied only as complete pairs. A call whose result never landed
|
|
950
|
+
* (an interrupted turn) is dropped along with its advertisement in the copied
|
|
951
|
+
* assistant content, because the format refuses a turn that closes with an
|
|
952
|
+
* unresolved call. The inverse repair shape - a `TOOL_NOT_STARTED` result with no
|
|
953
|
+
* call event - is copied as-is and keeps its absent `sourceEventSeqs`: the
|
|
954
|
+
* validator accepts that repair only without one.
|
|
955
|
+
*
|
|
956
|
+
* @param events - complete contiguous raw event log (the pre-edit snapshot).
|
|
957
|
+
* @param plan - the planned window (`shadowed`, `replayFrom`, `logTo`).
|
|
958
|
+
* @param baseTurn - the first fresh turn number the replay may use.
|
|
959
|
+
* @param replayId - the operation identity recorded on every copy.
|
|
960
|
+
* @param startSeq - the seq the first write will land at (writes are appended
|
|
961
|
+
* back to back, so each predicted seq is `startSeq` + its offset).
|
|
962
|
+
* @param promptCopied - parity with dsh-rerun-turn's builder: when true, the
|
|
963
|
+
* plan's `promptSeq` is replayed too. A reply edit never shadows the prompt,
|
|
964
|
+
* so its own plans leave this false.
|
|
965
|
+
* @returns an ordered `{ type, data, surfaceOp?, sourceEventSeqs? }` write list.
|
|
966
|
+
*/
|
|
967
|
+
export function buildReplayWrites(events, plan, baseTurn, replayId, startSeq, promptCopied = false) {
|
|
968
|
+
const shadowedSet = new Set(plan.shadowed)
|
|
969
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
970
|
+
const turnOf = turnIndex(events)
|
|
971
|
+
const shadowedTurns = new Set()
|
|
972
|
+
for (const seq of plan.shadowed) {
|
|
973
|
+
const turn = turnOf.get(seq)
|
|
974
|
+
if (typeof turn === 'number') shadowedTurns.add(turn)
|
|
975
|
+
}
|
|
976
|
+
const included = (event) => {
|
|
977
|
+
if (isSurfaceEvent(event)) return shadowedSet.has(event.seq)
|
|
978
|
+
if (
|
|
979
|
+
event.type === 'turn/start' ||
|
|
980
|
+
event.type === 'turn/end' ||
|
|
981
|
+
event.type === 'step/start' ||
|
|
982
|
+
event.type === 'step/end' ||
|
|
983
|
+
event.type === 'tool/call'
|
|
984
|
+
) {
|
|
985
|
+
return typeof event.data.turn === 'number' && shadowedTurns.has(event.data.turn)
|
|
986
|
+
}
|
|
987
|
+
return false
|
|
988
|
+
}
|
|
989
|
+
const range = []
|
|
990
|
+
// `replayFrom` is this plugin's plan field; `logFrom` is the name the sibling
|
|
991
|
+
// builder uses, accepted here so the parity test can feed one plan object.
|
|
992
|
+
const walkFrom = typeof plan.replayFrom === 'number' ? plan.replayFrom : plan.logFrom
|
|
993
|
+
for (let seq = walkFrom; seq <= plan.logTo; seq += 1) {
|
|
994
|
+
const event = bySeq.get(seq)
|
|
995
|
+
if (event === undefined) continue
|
|
996
|
+
if (included(event)) range.push(event)
|
|
997
|
+
}
|
|
998
|
+
// Tool pairing facts for the whole range, so assistant content can be
|
|
999
|
+
// corrected before anything is written.
|
|
1000
|
+
const calledIds = new Set()
|
|
1001
|
+
const resultIds = new Set()
|
|
1002
|
+
const repairIds = new Set()
|
|
1003
|
+
for (const event of range) {
|
|
1004
|
+
if (event.type === 'tool/call') calledIds.add(event.data.callId)
|
|
1005
|
+
else if (event.type === 'tool/result') {
|
|
1006
|
+
resultIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
|
|
1007
|
+
const error = event.data.error
|
|
1008
|
+
if (error && error.name === 'ToolNotStartedError' && error.code === 'TOOL_NOT_STARTED' && event.sourceEventSeqs === undefined) {
|
|
1009
|
+
repairIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const healthyIds = new Set()
|
|
1014
|
+
for (const id of calledIds) if (resultIds.has(id)) healthyIds.add(id)
|
|
1015
|
+
for (const id of repairIds) if (!calledIds.has(id)) healthyIds.add(id)
|
|
1016
|
+
const pairedResultIds = new Set()
|
|
1017
|
+
for (const id of calledIds) if (resultIds.has(id)) pairedResultIds.add(id)
|
|
1018
|
+
for (const id of repairIds) if (!calledIds.has(id)) pairedResultIds.add(id)
|
|
1019
|
+
|
|
1020
|
+
const writes = []
|
|
1021
|
+
let predicted = startSeq
|
|
1022
|
+
let nextTurn = baseTurn
|
|
1023
|
+
const turnMap = new Map()
|
|
1024
|
+
const assistantSeqById = new Map()
|
|
1025
|
+
let lastAssistantSeq = null
|
|
1026
|
+
const seqOf = () => {
|
|
1027
|
+
const seq = predicted
|
|
1028
|
+
predicted += 1
|
|
1029
|
+
return seq
|
|
1030
|
+
}
|
|
1031
|
+
for (const event of range) {
|
|
1032
|
+
const data = event.data || {}
|
|
1033
|
+
if (event.type === 'turn/start') {
|
|
1034
|
+
turnMap.set(data.turn, nextTurn)
|
|
1035
|
+
writes.push({ type: 'turn/start', data: { turn: nextTurn } })
|
|
1036
|
+
seqOf()
|
|
1037
|
+
continue
|
|
1038
|
+
}
|
|
1039
|
+
if (event.type === 'turn/end') {
|
|
1040
|
+
writes.push({ type: 'turn/end', data: { turn: turnMap.get(data.turn), reason: data.reason } })
|
|
1041
|
+
seqOf()
|
|
1042
|
+
nextTurn += 1
|
|
1043
|
+
continue
|
|
1044
|
+
}
|
|
1045
|
+
if (event.type === 'step/start' || event.type === 'step/end') {
|
|
1046
|
+
writes.push({ type: event.type, data: { turn: turnMap.get(data.turn), step: data.step } })
|
|
1047
|
+
seqOf()
|
|
1048
|
+
continue
|
|
1049
|
+
}
|
|
1050
|
+
if (event.type === 'user/message') {
|
|
1051
|
+
if (event.seq === plan.promptSeq && promptCopied !== true) continue
|
|
1052
|
+
// A silent carrier standing where a prompt was is bookkeeping, not
|
|
1053
|
+
// conversation: retire it without a copy, exactly as rerun does.
|
|
1054
|
+
if (isSilentPluginCarrier(event)) continue
|
|
1055
|
+
writes.push({
|
|
1056
|
+
type: 'user/message',
|
|
1057
|
+
surfaceOp: 'append',
|
|
1058
|
+
data: {
|
|
1059
|
+
id: randomUUID(),
|
|
1060
|
+
role: 'user',
|
|
1061
|
+
content: data.content,
|
|
1062
|
+
source: replaySource(data.source, replayId, event.seq),
|
|
1063
|
+
},
|
|
1064
|
+
})
|
|
1065
|
+
seqOf()
|
|
1066
|
+
continue
|
|
1067
|
+
}
|
|
1068
|
+
if (event.type === 'assistant/message') {
|
|
1069
|
+
const message = data.message || {}
|
|
1070
|
+
const source = { ...(message.source || {}) }
|
|
1071
|
+
delete source.replayState
|
|
1072
|
+
const content = Array.isArray(message.content)
|
|
1073
|
+
? message.content.filter((block) => !(block && block.type === 'tool-call' && !healthyIds.has(block.id)))
|
|
1074
|
+
: []
|
|
1075
|
+
writes.push({
|
|
1076
|
+
type: 'assistant/message',
|
|
1077
|
+
surfaceOp: 'append',
|
|
1078
|
+
data: {
|
|
1079
|
+
turn: turnMap.get(data.turn),
|
|
1080
|
+
step: data.step,
|
|
1081
|
+
message: {
|
|
1082
|
+
id: randomUUID(),
|
|
1083
|
+
role: 'assistant',
|
|
1084
|
+
content,
|
|
1085
|
+
source: replaySource(source, replayId, event.seq),
|
|
1086
|
+
},
|
|
1087
|
+
stream: [],
|
|
1088
|
+
...(data.interrupted === true ? { interrupted: true } : {}),
|
|
1089
|
+
},
|
|
1090
|
+
})
|
|
1091
|
+
assistantSeqById.set(event.seq, predicted)
|
|
1092
|
+
lastAssistantSeq = predicted
|
|
1093
|
+
seqOf()
|
|
1094
|
+
continue
|
|
1095
|
+
}
|
|
1096
|
+
if (event.type === 'tool/call') {
|
|
1097
|
+
if (!pairedResultIds.has(data.callId)) continue
|
|
1098
|
+
writes.push({
|
|
1099
|
+
type: 'tool/call',
|
|
1100
|
+
data: { turn: turnMap.get(data.turn), step: data.step, callId: data.callId, name: data.name, arguments: data.arguments },
|
|
1101
|
+
})
|
|
1102
|
+
seqOf()
|
|
1103
|
+
continue
|
|
1104
|
+
}
|
|
1105
|
+
if (event.type === 'tool/result') {
|
|
1106
|
+
const callId = data.message && data.message.source ? data.message.source.callId : data.callId
|
|
1107
|
+
if (!pairedResultIds.has(callId)) continue
|
|
1108
|
+
const message = data.message
|
|
1109
|
+
const source = replaySource(message && message.source, replayId, event.seq)
|
|
1110
|
+
// The TOOL_NOT_STARTED repair is accepted only without sourceEventSeqs;
|
|
1111
|
+
// ordinary results remap theirs onto the copied assistant message.
|
|
1112
|
+
let sourceEventSeqs
|
|
1113
|
+
if (!repairIds.has(callId)) {
|
|
1114
|
+
const remapped = Array.isArray(event.sourceEventSeqs)
|
|
1115
|
+
? event.sourceEventSeqs.map((seq) => assistantSeqById.get(seq)).filter((seq) => typeof seq === 'number')
|
|
1116
|
+
: []
|
|
1117
|
+
if (remapped.length > 0) sourceEventSeqs = remapped
|
|
1118
|
+
else if (typeof lastAssistantSeq === 'number') sourceEventSeqs = [lastAssistantSeq]
|
|
1119
|
+
}
|
|
1120
|
+
const originalMessage = message || {}
|
|
1121
|
+
const rest = { ...originalMessage }
|
|
1122
|
+
delete rest.id
|
|
1123
|
+
delete rest.content
|
|
1124
|
+
delete rest.source
|
|
1125
|
+
// The interrupted-repair carrier must keep its contract-shaped id
|
|
1126
|
+
// (`interrupted-tool-result-<callId>-<n>`): the validator refuses a repair
|
|
1127
|
+
// whose id does not match, and a random id breaks it. The suffix is the
|
|
1128
|
+
// copy's own predicted seq, so the identity stays self-describing.
|
|
1129
|
+
const messageId = repairIds.has(callId) ? `interrupted-tool-result-${callId}-${predicted}` : randomUUID()
|
|
1130
|
+
writes.push({
|
|
1131
|
+
type: 'tool/result',
|
|
1132
|
+
surfaceOp: 'append',
|
|
1133
|
+
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
|
|
1134
|
+
data: {
|
|
1135
|
+
turn: turnMap.get(data.turn),
|
|
1136
|
+
step: data.step,
|
|
1137
|
+
message: {
|
|
1138
|
+
...rest,
|
|
1139
|
+
id: messageId,
|
|
1140
|
+
content: originalMessage.content ?? [],
|
|
1141
|
+
source,
|
|
1142
|
+
},
|
|
1143
|
+
...(data.error === undefined ? {} : { error: data.error }),
|
|
1144
|
+
...(data.meta === undefined ? {} : { meta: data.meta }),
|
|
1145
|
+
},
|
|
1146
|
+
})
|
|
1147
|
+
seqOf()
|
|
1148
|
+
continue
|
|
1149
|
+
}
|
|
1150
|
+
// system/message, developer/message and every log-only record are skipped.
|
|
781
1151
|
}
|
|
1152
|
+
return writes
|
|
782
1153
|
}
|
|
783
1154
|
|
|
784
1155
|
/**
|
|
@@ -1465,6 +1836,13 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1465
1836
|
// (it projects `lastTurn` from every turn/start it observes). A prompt edit
|
|
1466
1837
|
// does not: `user/message` carries the revision and needs no turn.
|
|
1467
1838
|
const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
|
|
1839
|
+
// One identity for the whole reply splice: every copy the replay appends
|
|
1840
|
+
// carries it, so a later reader (or a sibling plugin) can tell a replayed turn
|
|
1841
|
+
// from the one it retired without re-deriving anything.
|
|
1842
|
+
const replayId = plan.mode === 'reply' ? randomUUID() : null
|
|
1843
|
+
// Resolved BEFORE the appends so the loop counter can be re-pointed in the
|
|
1844
|
+
// same tick as the writes, not after an await that lets the loop start.
|
|
1845
|
+
const loopAgent = plan.mode === 'reply' ? await resolveLoopAgent(ctx, sessionId) : undefined
|
|
1468
1846
|
const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config, content)
|
|
1469
1847
|
if (plan.mode === 'reply') {
|
|
1470
1848
|
try {
|
|
@@ -1495,82 +1873,122 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1495
1873
|
} catch (error) {
|
|
1496
1874
|
throw new HttpError(409, 'stale', `the surface refused the rollback: ${String((error && error.message) || error)}`)
|
|
1497
1875
|
}
|
|
1498
|
-
const flush = await flushSession(ctx, session)
|
|
1499
1876
|
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1877
|
+
// A prompt edit saved here: the carrier that replaced the window IS the
|
|
1878
|
+
// revised prompt, so the model reads it as the user's wording from here on.
|
|
1879
|
+
// Nothing is prompted and nothing else needs writing.
|
|
1880
|
+
if (plan.mode === 'prompt') {
|
|
1881
|
+
const flush = await flushSession(ctx, session)
|
|
1882
|
+
return {
|
|
1883
|
+
kind: 'prompt',
|
|
1884
|
+
replacementSeq: replacement.seq,
|
|
1885
|
+
applied: true,
|
|
1886
|
+
shadowed: plan.shadowed,
|
|
1887
|
+
dropped,
|
|
1888
|
+
// What the carrier now carries, described exactly the way /state describes
|
|
1889
|
+
// it. The browser half adopts this for the row it re-draws before its own
|
|
1890
|
+
// refresh lands - without it a second save could not know which blocks are
|
|
1891
|
+
// still there and would rewrite the message text-only.
|
|
1892
|
+
blocks: blockSummaries(content, canPreviewAttachments(store)),
|
|
1893
|
+
...flush,
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Step two of a reply edit: the correction itself, inside the turn just
|
|
1898
|
+
// opened. An `assistant/message` may never carry `sourceEventSeqs`, so it can
|
|
1899
|
+
// never be a replacement carrier (checked against the real validator, not
|
|
1900
|
+
// assumed); it is appended in the same shape the agent loop records a reply,
|
|
1901
|
+
// and the model goes on seeing the edited text as its own answer.
|
|
1902
|
+
const correction = buildCorrection(plan, text, editTurn, 1)
|
|
1903
|
+
let appended
|
|
1904
|
+
try {
|
|
1905
|
+
appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
|
|
1906
|
+
// Close the step and the turn behind the corrected reply: the host anchors
|
|
1907
|
+
// a turn's tail (duration, action strip) at its last `turn/end`, and the
|
|
1908
|
+
// format only reads a step message inside an open turn and step.
|
|
1909
|
+
session.append('step/end', { turn: editTurn, step: 1 })
|
|
1910
|
+
session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
const failedFlush = await flushSession(ctx, session)
|
|
1913
|
+
return {
|
|
1914
|
+
kind: 'reply',
|
|
1915
|
+
replacementSeq: replacement.seq,
|
|
1916
|
+
applied: false,
|
|
1917
|
+
applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
|
|
1918
|
+
shadowed: plan.shadowed,
|
|
1919
|
+
dropped,
|
|
1920
|
+
...failedFlush,
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Step three: put every later turn back behind the correction, as fresh
|
|
1925
|
+
// copies. This is the same infix splice dsh-rerun-turn performs after a
|
|
1926
|
+
// regenerated answer - shadow the tail, write the new reply, replay what
|
|
1927
|
+
// followed - with the user's text taking the place of the model call.
|
|
1928
|
+
let replay = null
|
|
1929
|
+
if (typeof plan.replayFrom === 'number' && typeof plan.logTo === 'number') {
|
|
1930
|
+
const writes = buildReplayWrites(events, plan, editTurn + 1, replayId, session.seq)
|
|
1931
|
+
let replayed = 0
|
|
1932
|
+
let replayError = null
|
|
1512
1933
|
try {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
} catch (error) {
|
|
1520
|
-
return {
|
|
1521
|
-
kind: 'reply',
|
|
1522
|
-
replacementSeq: replacement.seq,
|
|
1523
|
-
applied: false,
|
|
1524
|
-
applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
|
|
1525
|
-
shadowed: plan.shadowed,
|
|
1526
|
-
dropped,
|
|
1527
|
-
...flush,
|
|
1934
|
+
for (const write of writes) {
|
|
1935
|
+
const options = {}
|
|
1936
|
+
if (write.surfaceOp !== undefined) options.surfaceOp = write.surfaceOp
|
|
1937
|
+
if (write.sourceEventSeqs !== undefined) options.sourceEventSeqs = write.sourceEventSeqs
|
|
1938
|
+
session.append(write.type, write.data, options)
|
|
1939
|
+
replayed += 1
|
|
1528
1940
|
}
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
replayError = String((error && error.message) || error)
|
|
1943
|
+
}
|
|
1944
|
+
const playedMax = writes.reduce((max, write) => {
|
|
1945
|
+
const turn = write.data && typeof write.data.turn === 'number' ? write.data.turn : 0
|
|
1946
|
+
return turn > max ? turn : max
|
|
1947
|
+
}, 0)
|
|
1948
|
+
replay = {
|
|
1949
|
+
id: replayId,
|
|
1950
|
+
replayed,
|
|
1951
|
+
total: writes.length,
|
|
1952
|
+
turns: writes.reduce((count, write) => count + (write.type === 'turn/start' ? 1 : 0), 0),
|
|
1953
|
+
...(replayError === null ? {} : { error: replayError }),
|
|
1954
|
+
}
|
|
1955
|
+
// The replayed turns are closed in the log but were never opened by the
|
|
1956
|
+
// live loop: re-point its idle counter at the highest one, synchronously.
|
|
1957
|
+
const loopTurn = syncLoopTurn(loopAgent, playedMax > editTurn ? playedMax : editTurn)
|
|
1958
|
+
if (loopTurn === 'unavailable') {
|
|
1959
|
+
noteRequest({ kind: 'reply', sessionId, ok: replayError === null, loopTurn, turn: editTurn, replayId, replayed })
|
|
1529
1960
|
}
|
|
1530
1961
|
const corrected = await flushSession(ctx, session)
|
|
1531
|
-
// The synthetic turn this edit opened is invisible to the live loop's own
|
|
1532
|
-
// counter; without the sync its next run reuses that number and the log
|
|
1533
|
-
// fails its next load. Only a reply edit opens a turn - a prompt edit does
|
|
1534
|
-
// not, and needs nothing.
|
|
1535
|
-
const loopAgent = await resolveLoopAgent(ctx, sessionId)
|
|
1536
|
-
const loopTurn = syncLoopTurn(loopAgent, editTurn)
|
|
1537
|
-
// `loopTurn` is the diagnostic the request ring records; the field is DELIBERATELY
|
|
1538
|
-
// not called `code` - the static checks read `code:` as host error codes and
|
|
1539
|
-
// must not start demanding a user-visible message for an internal sync state.
|
|
1540
|
-
const syncNote = loopTurn
|
|
1541
|
-
if (syncNote === 'unavailable') {
|
|
1542
|
-
noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn: syncNote, turn: editTurn })
|
|
1543
|
-
}
|
|
1544
1962
|
return {
|
|
1545
1963
|
kind: 'reply',
|
|
1546
1964
|
replacementSeq: replacement.seq,
|
|
1547
1965
|
appendedSeq: appended.seq,
|
|
1548
1966
|
applied: true,
|
|
1549
1967
|
loopTurn,
|
|
1968
|
+
replay,
|
|
1550
1969
|
shadowed: plan.shadowed,
|
|
1551
1970
|
dropped,
|
|
1552
1971
|
...corrected,
|
|
1553
1972
|
}
|
|
1554
1973
|
}
|
|
1555
1974
|
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1975
|
+
// No later turn to replay: the correction is the last answer in the log.
|
|
1976
|
+
// The synthetic turn still moved the loop's counter; sync it the same way.
|
|
1977
|
+
const loopTurn = syncLoopTurn(loopAgent, editTurn)
|
|
1978
|
+
if (loopTurn === 'unavailable') {
|
|
1979
|
+
noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn, turn: editTurn, replayId })
|
|
1980
|
+
}
|
|
1981
|
+
const corrected = await flushSession(ctx, session)
|
|
1562
1982
|
return {
|
|
1563
|
-
kind: '
|
|
1983
|
+
kind: 'reply',
|
|
1564
1984
|
replacementSeq: replacement.seq,
|
|
1985
|
+
appendedSeq: appended.seq,
|
|
1565
1986
|
applied: true,
|
|
1987
|
+
loopTurn,
|
|
1988
|
+
replay: null,
|
|
1566
1989
|
shadowed: plan.shadowed,
|
|
1567
1990
|
dropped,
|
|
1568
|
-
|
|
1569
|
-
// it. The browser half adopts this for the row it re-draws before its own
|
|
1570
|
-
// refresh lands - without it a second save could not know which blocks are
|
|
1571
|
-
// still there and would rewrite the message text-only.
|
|
1572
|
-
blocks: blockSummaries(content, canPreviewAttachments(store)),
|
|
1573
|
-
...flush,
|
|
1991
|
+
...corrected,
|
|
1574
1992
|
}
|
|
1575
1993
|
}
|
|
1576
1994
|
|
|
@@ -1597,7 +2015,7 @@ function registerTool(ctx) {
|
|
|
1597
2015
|
defineTool({
|
|
1598
2016
|
name: 'edit_turn_targets',
|
|
1599
2017
|
description:
|
|
1600
|
-
'List the user turns of a DeepSeek Harness session that the edit-turn plugin can rewrite
|
|
2018
|
+
'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text. A prompt is replaced where it stands; a reply correction is appended and every later turn is replayed back behind it, with no model call. Read-only: it changes nothing.',
|
|
1601
2019
|
parameters: {
|
|
1602
2020
|
sessionId: {
|
|
1603
2021
|
type: 'string',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Edit any
|
|
3
|
+
"version": "0.2.21",
|
|
4
|
+
"description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands, a model reply is corrected and every later turn is replayed back behind it - the same infix splice dsh-rerun-turn performs after a rerun, with no model call. The append-only session log is never rewritten.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|