dsh-purge
YuJunZhiXue/dsh-purge在设置页编辑 prompt-inject.md、管理 AGENTS.md 或 CLAUDE.md 规则集、检查插件更新,并对本机包做可回滚的应用或还原。
Edit any user turn in DeepSeek Harness and re-run it: an inline editor rolls the conversation back through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.
DeepSeek Harness 的「编辑某一轮」插件 —— 点消息旁的编辑按钮,就地改写。改提问:那一句就地替换,这一条下面的回复与后续对话原样不动。改回答:可以只改正文,也可以只改思维链;改动通过官方 plugin record + message projection 就地生效,这一轮不挪位置、后面的轮次不重放、日志只多一条小记录。 提示词走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新");回复块走宿主公开的 appendPluginRecord + registerMessageProjection,不是磁盘改日志、不是内部 hook。保存本身永不调用模型。会话日志是 append-only 的,原始字节一个都不改写。宿主没有投影能力时自动退回 0.2.21 的重放路径。
中文 · English
DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的问题,会一直留在模型上下文里污染后续每一轮。官方只有整段摘要式的「压缩」(/compact),没有「改一下我刚才那句话然后重来」这条路——而这恰恰是 ChatGPT / Claude 里最常用的动作。
这个插件把它补上:
surfaceOp: { op: 'replace', startSeq, endSeq } 的替换事件。原始事件全部留在会话文件里,只是不再进入 deriveMessages()。与官方 /compact 用的是同一套契约。user/message 替换事件,内容就是你改后的那句话。它既让模型读到你改后的版本,也在转录里显示成你自己的气泡;平台不会把它当成新输入去回答(这一点踩过坑:追加一条 user 消息会触发平台自动回答,越改越多)。assistant/message 携带 sourceEventSeqs(真实校验器已验证),所以回复不能走 surface-replace。0.3.0 改用官方为这种情况准备的另一条正门:appendPluginRecord(session, 'plugin:dsh-edit-turn/patch', data) 追加一条 ignorable 的插件记录,再用 registerMessageProjection 注册一个纯函数投影,在折叠时改写目标 seq 的模型可见消息。surface 一个节点都不动,所以没有窗口、没有重放、没有合成轮次;日志每次编辑只多一条几百字节的记录。原始 assistant/message 的字节原样保留。text、reasoning / thinking)各自出现在编辑器的块选择器里。只改选中的那一块:工具调用、其他块、后面的轮次都不动。思维链被改写时会去掉原来的 signature / thinkingSignature(签名对应旧文本,留着会被 provider 拒绝)。转录里正文块就地显示投影后的文字;思维链折叠时在回复下方出现一条「思维链已改写(模型可见)」的注记。appendPluginRecord 或 registerMessageProjection 时(例如旧版本),回复编辑自动退回 0.2.21 的「回退 + 追加修正 + 尾部重放」路径;replyMode: replay 可以强制走旧路径,replyMode: patch 则在缺少投影能力时直接报错而不是悄悄降级。重放代码与 dsh-rerun-turn 的 builder 仍有逐字段对照测试。confirm: true 可恢复两步确认,确认步里会写明这次保存会丢弃什么)。编辑器本身已经是用户主动打开的动作,保存的后果在 README 与确认步里说明,输入框本身保持干净。--dshet-panel)而不是借用主题的表面色变量。皮肤的本意就是让表面半透明、把插画透出来,而它只会给自己的元素补可读背景,插件类名不在其中;借用皮肤变量的面板会变成全透明,文字直接压在插画上。暗色分支走官方属性 body[data-ds-dark-theme](与 dsh-client-ui-theme 及多个官方 UI 包一致),并用 backdrop-filter 与皮肤融合。Host 与 Origin。dsh plugin --profile web add dsh-edit-turn从本地目录安装(开发用):
dsh plugin --profile web add /path/to/dsh-edit-turn安装后重启该 profile 对应的 DSH 进程即可生效。
dsh-rerun-turn 时旁边还有一个「重跑」:先保存,再让那一轮用改后的内容重新生成(后续轮次不丢)。编辑器是平台输入框那样的一个圆角盒子:输入区在上、取消与主按钮在右下,聚焦时整圈描边高亮;随文字长高(空的时候不留空白),没有标题栏、没有常驻说明、没有拖拽手柄。键盘也和输入框一致:Enter 保存、Shift+Enter 换行、Esc 取消(确认步里 Esc 是退回上一步)。输入法优先:组合中的 Enter 与"候选选词的 Enter"(isComposing 或 keyCode 229,平台自己的输入框也是这两条一起挡)都不会被当成保存,组合期间也不重排输入框——此前少挡了 229 那半,拼音选词按回车会直接保存/关闭,看起来就是"打不了中文"。
编辑器里,消息带的图片与文件各显示成一个芯片(缩略图或文件名 + 大小 + 删除按钮):不碰就原样保留,点删除就是不要它了;旁边还有一个「添加附件」(这条消息本来没有附件时也能用)。缩略图由本插件的只读路由按需取回;取不回来、或那些字节不是图片,芯片退回显示名字。只有当这个部署没有附件存储时,编辑器才会改回旧的那句话——「这条消息包含图片或文件附件,改写会丢弃它们,只保留文字」——并且不出现添加入口。 一字不改就按保存,什么都不会发生。 编辑器预填的就是这条消息现在的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器照常关闭、一条提示都不显示(0.2.20 起;这条提示此前压在输入框的占位文字上)。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。「重跑」不受这条守卫影响:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
术语:编辑器里的「思维链」「正文」「全部」三个界面叫页;「全部」页里的「思维链」「正文」条目叫子页(也叫块)。页与子页指的是同一份内容,所以在任一页里改动、切到别的页再切回来,改过的内容都还在(只有关掉编辑器才会丢);子页之间也是同一份草稿。
编辑模型的回答:悬停任意一条模型回复,同样会出现编辑入口。编辑器上方多一个页选择器:一条回复同时带思维链和正文时,可以选「思维链」「正文」页,或切到「全部」页一次看完整轮。改完点「保存」——记录只追加一条 plugin:dsh-edit-turn/patch,投影在模型下一次折叠时生效;这条回答仍在原来的位置,后面的轮次原样不动,工具调用和其他块也不动。转录里正文会就地显示改后的文字;思维链被改时会出现一条注记,展开就能看到模型实际读到的内容。
在退回重放路径的宿主上(或显式 replyMode: replay),行为仍是 0.2.21 的:整条回答被替换,后面的轮次回退再逐事件重放;这种模式下只能改正文,工具调用与思维链无法保留(编辑器会提示)。
写在 profile 里即可覆盖默认值:
- id: dsh-edit-turn
config:
confirm: false # 默认 false:一次点击即执行;置 true 才要求二次确认
replyMode: auto # auto | patch | replay
editorStyle: frame # frame | bubble | center字段 · 默认 · 含义 confirm · false · 客户端保存后是否先要求一次确认。默认关闭(一次点击即执行:编辑器本身已是用户主动打开的动作;设为 true 时确认步里会写明这次保存会丢弃什么); replyMode · auto · 回复编辑怎么落地。auto:宿主有 plugin record + message projection 就用就地补丁,否则退回重放;patch:强制就地补丁,缺能力时加载/保存直接报错;replay:始终使用 0.2.21 的回退 + 重放路径。提示词编辑不受影响,始终走 surface-replace。 editorHeight · fill · 编辑器高度。fill(默认):普通块按一轮最大,「全部」时铺满对话区到输入框之间的整段高度;max:一轮最大;content:跟着当前块走。设置页可切换。 editorPlacement · overlay · 编辑器位置。overlay:悬浮在视口内,按当前块取框;inline:按页面坐标铺在消息上、跟随页面滚动,高度按内容展开;legacy:整行旧版,套整条 assistant 行。浮层 / 整行旧版 / 居中默认夹紧在对话区内:上不越过对话区顶部、下不压到输入框,长内容框内滚动;inline 保持页面坐标与页面滚动,不夹。 editorStyle · frame · 编辑器外观。frame:一圈描边套住整条消息行,正文输入框落在消息文字上、按钮在右上角(参考图);bubble:编辑框精确等于消息气泡的 rect;center:居中大面板。插件页 /#/panel/plugins/dsh-edit-turn/dsh-edit-turn 现在有「编辑器样式」下拉(和 dsh-delete-turn 同一套 plugins.row.config 槽位),选择会记住;编辑器对话框里不再放样式下拉,样式只从这里设置。
一次编辑读事件流(sessionQuery.readSession,回退到活动会话的 snapshotEvents()),用官方折叠规则算出当前 surface 顺序,定位目标消息的节点,然后走两条路之一:
A. 提示词编辑(以及旧宿主的回复编辑):一条官方替换事件。
[target.seq]),载体就是改后的文本;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 记录一律跳过。追加一条:
// 旧宿主上的回复编辑;提示词编辑的载体就是改后的那条 user/message
session.append('developer/message', { turn, step, message: { role: 'developer', content: [] } }, {
surfaceOp: { op: 'replace', startSeq, endSeq },
sourceEventSeqs: shadowed, // 完整覆盖:官方校验要求列出全部被遮蔽的节点
})B. 回复块就地补丁(0.3.0,优先):一条 plugin record + 一个 projection。
appendPluginRecord(session, 'plugin:dsh-edit-turn/patch', {
version: 1,
targets: [{ seq, blockIndex, kind: 'text' | 'reasoning', text }],
})
ctx.sessions.registerMessageProjection({
type: 'plugin:dsh-edit-turn/patch',
project(event, context) {
// 目标仍在 surface 上、源事件是 assistant/message;
// 以 context.messages.get(seq) 为 base 叠加,返回 Map<seq, message>
},
})appendPluginRecord 写入的是一条 ignorable: true 的日志事件,不碰 surface;投影在每次折叠会话时把目标 seq 的模型可见消息改写。没有窗口、没有替换、没有重放,后面的轮次连 turn 号都不变。多条记录按 seq 顺序叠加:先改正文、再改思维链,两个补丁都在。落盘后同样等一次 sessions.flush,所以重启、重新打开会话也还在。投影的生命周期跟着宿主进程:注册后即使插件被热重载也会被复用;卸载插件并重启宿主后投影不存在,记录被跳过,模型与 UI 都回到记录里的原文。只卸载不重启时,模型上下文仍按已注册的投影读取(UI 已没有编辑入口)。这是 0.3.0 与 surface 路径的取舍,适合「改一下再继续」的日常使用;需要永久改写时仍可用提示词编辑。
落盘后等一次持久化检查点(sessions.flush),保证重启也能看到。两条路径都不调模型:重放只是把已经记录的事件以新身份再 append 一遍,投影是纯函数,所以保存仍然是"一次点击、零模型调用"。
重跑不由本插件做。 "改完立刻重答"是姊妹插件 dsh-rerun-turn 的操作:它遮蔽该轮、用表面现在显示的提示词(也就是你改后的文本)重新生成,再把后续轮次逐事件重放回来。本插件只负责把编辑落地;编辑器里的「重跑」按钮仅在检测到它挂载时出现,点击后先保存、再调用它的 POST /dsh-rerun-turn/apply。它不在(例如桌面版没装)时,没有这个按钮。
文本没变就不写(0.2.18)。 决定「写什么」的那一层在落任何事件之前,把请求里的文本与当前活节点的文本做逐字比较(readMessageText——/state 的 turns[].text / replies[].text 用的就是它,所以编辑器预填的文本与这里的比较对象天然同源)。相同则直接返回 { applied:false, unchanged:true, shadowed:[] }:不落替换、不 flush、不开轮次、不动活循环的轮次计数器。比较不 trim:只有空白差异(哪怕多一个尾随空格)也算真修改,走原来的写入路径,行为与本版之前完全一致。unchanged 是给调用方的标志而不是拒绝——路由照常 200 返回,编辑器「重跑」的链式调用照常发起;所以这道守卫只拦得住「写」,拦不住用户要的重跑。客户端读到这个标志只做一件事:不把 no-op 当成 applied:false 的失败(否则会误报「已回退,但替换没有落地」);它不显示任何提示——0.2.20 删掉了那条「内容没有变化,未做任何修改。」,原样保存就是编辑器照常关闭、屏幕上不多一个字。
块随消息一起改(0.2.19)。 载体不再写死成一个 text 块,而是照提交的块列表重建:列表里只有两种条目——{ keep: }(原文里那一块,原样搬)与 { add: {...} }(用户刚选的字节,录用后写进载体)。"原样搬"是 structuredClone 出来的原文块对象,逐字节一致;删掉的块就是不在列表里,因此不写;新文字落在原文第一个文字块的位置,其余块维持相对顺序(planBlockLayout / assembleBlocks)——真实日志里 text → image 与 image → text 两种顺序都常见,所以不重排。整个模块里只有一处块判断(isTextBlock:这个块里有文字吗),别的什么类型都不认识。录用走平台公开的附件服务:ctx.get('attachments')(dsh-attachment 的 AttachmentStore,dsh-acp 用的同一个 seam;另接受 attachment / attachment-local 两个包名拼写)的 saveImage / saveFile,每次请求现场探测;服务不在就退回只写文字,响应带 dropped: true,一个引用都不会写进日志。录用发生在任何 session.append 之前:被拒(超限、字节非法)时返回 400 attachment-refused,日志与轮次一个字节都不动。
为什么不做文件截断? 因为那不是官方能力。dsh-session-persistence-jsonl 只暴露残帧崩溃修复用的 truncateTornTail,正常路径下「Committed events are never rewritten」;运行中的宿主把会话放在内存 append-only 日志 + 投影缓存里,改磁盘不会让它重新读取。截断还会破坏 sourceEventSeqs 的 [start,end] 压缩表示与多帧 zstd 结构。surface-replace 是官方为这件事准备的机制。
插件接口
类型 · 名称 · 说明 路由 · GET /dsh-edit-turn/state?sessionId= · 可编辑轮次、已遮蔽行账本(hidden + revisions)、surface、忙碌状态;mode 是 patch 或 replay;patches[] 是已落地的就地补丁;回复项带 blocks[](index / kind / 当前投影 text / original / patched) 路由 · POST /dsh-edit-turn/apply · 提示词:{ sessionId, seq \ · messageId \ · turn, text, parts? };就地回复:再加 blockIndex + kind(text / reasoning)。写入改后内容;parts 是要保留/新增的块列表([{ keep } \ · { add: { data, mediaType?, name? } }],缺省 = 老客户端的纯文字载体);响应含 applied / shadowed / replacementSeq / dropped / blocks;改后文本与当前活文本一字不差且块列表与原消息相同时什么都不写,响应改为 applied:false + unchanged:true + shadowed:[](original 是它比较的那份文本)。这是一个标志,不是拒绝:调用方自己决定下一步(编辑器的「重跑」正是靠它继续发起重跑) 路由 · GET /dsh-edit-turn/attachment?sessionId=&seq=&index= · 只读:该会话日志确实引用过的那个块的原始字节(芯片缩略图用);回环 + 同源 + 只认本会话引用过的引用,取不到就 404(芯片退回显示名字)。它只用于显示,改写路径完全不经过它 工具 · edit_turn_targets · 只读:列出该会话当前可编辑的轮次、回复与每条回复的可编辑块(供 agent 自查) 前端 · conversation.input.overlay · 每会话控制器:编辑入口、就地编辑器、被遮蔽行的隐藏账本
转录只为 append surface 事件建行(ui-chat 的 user/assistant 定义都以 isAppendSurfaceEvent 为 match 条件),所以一次就地编辑之后,那一行永远锚在原来的 seq / messageId 上——行里没有任何字段告诉别的插件"我现在在 seq N",而模型上下文里的活节点是替换事件。任何按 surface 校验的入口(例如兄弟插件 dsh-delete-turn 的删除按钮)只看原 seq 就已经死了,是否继续提供入口需要沿替换链找到活节点。为此外部消费者可以依赖:
planRollback 的 mode === 'prompt' 只遮蔽目标那一格(shadowed = [target.seq]),不会顺手回退到末尾;user/message 落下的就是 user/message(回复编辑不换类型就换不了位,见第 5 条);source.editedBy === 'dsh-edit-turn'——包括那个不含文本的空 developer/message 载体,兄弟插件按 editedBy 识别即可,不必靠推断窗口形状;/state.patches 可查);旧宿主才是「多节点回退 + 追加新回复」。任何情况下都不要为了保住旧行往 assistant/message 上加 sourceEventSeqs——官方校验会直接拒;POST /apply 多了一个 parts([{ keep } | { add }]);不传就是 0.2.18 的行为(纯文字载体),响应里的 dropped 会如实说明这次保存有没有丢下非文字块。GET /state 的 capabilities: { attachments, preview } 说明这个部署能不能录用/读取块;turns[].blocks 逐块给出 { index, type, name?, mediaType?, bytes?, width?, height?, preview },且只按字段有无读取、不按类型分支——兄弟插件按同样的方式读即可,平台加新块类型不需要任何人改;GET /dsh-edit-turn/state 的 revisions[] 给出 { replacementSeq, startSeq, endSeq, shadowed }(字段名与 POST /apply 响应一致),hidden[] 每题一格列出 { seq, turn, replacement }。跨插件不必再从事件流自行推导。`source.kind` 的行为(这里修过一个坑):prompt 编辑落地的那条 user/message 保持 `source.kind === 'user'`,来历记在 source.editedBy 上。原因:平台里有近二十处按 source.kind === 'user' 认定「这是人类提问」——dsh-session-turn-outline(轮次导轨与轨迹视图)、dsh-client-ui-trajectory、dsh-client-ui-chat、inbox 的 steering 过滤、会话列表的 lastPromptAt。载体站在用户的位置上,改掉这个 kind 会让这些消费者看不见这一轮的提问:导轨空掉、turnOutline 的 prompt 变成空串、轨迹视图把提问归到「上下文」而不是「用户」。(0.2.15 及更早就是这样。)
保持 kind === 'user' 不会让它变成「被回答的新输入」:入队根本不看 kind——inbox 投影的 reducer 只处理 agent/inbox/spliced(dsh-agent-loop),而该事件只由显式的 agent splice 写入;跳过新输入的那些扫描判的是 surfaceOp === 'append',而载体是 surfaceOp: { op: 'replace' }。
本插件在 DSH 0.2.1-rc.2(真实校验器与真实会话)以及 0.1.6-alpha.2 / 0.1.7-rc.1 上完成以下验证:
验证 · 命令 · 结果 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) · npm run probe:loaded [端口] · 通过:/state 返 400、/apply 返 405 —— 这两个状态码只有本插件会返回 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) · GET /dsh-edit-turn/debug · 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found 官方 append 与读取契约(真实校验器,进程内) · npm run verify:contract · 96 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、连续两次回退都被接受、编辑模型回答的完整机制被接受、回复编辑后的尾部重放(后续提问/回答/tool 调用与结果按新 turn 号回到修正回答之后,顺序正确、日志仍可加载)、跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`),以及就地补丁:一条 ignorable 记录、surface 不变、去掉投影后原文回来、后续轮次与 tool 结果不动、第二条记录可叠加 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) · npm test · 227 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 dsh-rerun-turn builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 quote-card 块的通用性、录用与拒绝、/state 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议、块契约) · npm run verify:client · 184 项全部通过(含 12 条块契约检查:能力上报、服务现场探测、录用是唯一建块路径、录用先于 append、两侧都不特判块类型、块逐字节复制、文字归位、dropped 上报、只在无服务时警告、芯片靠 error 事件自证) 实机前端产物校验(运行中的 DSH 是否在下发当前代码) · npm run verify:live -- --token-file ~/path/to/dsh.log · 全部通过(含宿主节点形状锚点) 真实浏览器渲染冒烟(CDP 驱动已开着的标签页,只读) · npm run verify:ui · 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、被回滚的用户行一定显示替代文本(消息不会凭空消失)、替代气泡一定排在时间/复制之上(顺序与正常消息一致)、改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)、回复铅笔一定排在动作条最左(复制之前,已现场确认)、气泡上没有 .dshet-floating 压着文字、切走再切回入口仍在 真实 profile 安装 / 补丁合成 / 启动 / 工具契约 / 路由守卫 · npm run verify:profile · 全部通过 真实浏览器端到端(agent-browser 驱动 Chrome 打开运行实例) · 手动 · 已完成:模型回答行出现「编辑这条回答」、点开编辑器预填真实回复原文、取消后网络层 0 个 apply 请求。截图见 docs/reply-editor-in-browser.png
verify:live 针对正在运行的实例:用 DSH 启动时打印的 token 换取鉴权 cookie,读启动页里的客户端模块组,把含本插件的那一组下载下来,断言插件自己的标记确实在其中。它证明的是「浏览器刷新后会拿到当前代码」,而不是「源码看起来没问题」——前端改动后这是唯一能确认已生效的自动手段。
verify:ui 直接驱动一个已经开着的 Chrome 标签页(--remote-debugging-port,默认 9333):先缓存击穿地重载,断言浏览器此刻拿到的字节就是刚改的字节,再读 DOM 与 console——每个 assistant 单元格都渲染出了东西(没有被 abdicate 留下的空占位)、回复铅笔落在平台自己的动作条里、被回滚的行丢掉消息但保住时间/复制/删除、并且每一条被折叠的用户消息都立着本插件的替代气泡(这条正是「保存后消息不见了」的现场判据:行可以折叠,但不能折叠到无处显示替代文本)、而且这颗气泡排在时间/复制的上方(顺序与平台画一条正常消息时一致,否则时间戳看起来像属于下一条)、改写后那颗铅笔排在还活着的那条动作条的末尾、气泡里不再留笔(把 message 文本那一层留给文字,铅笔跟着行本身走)、回复铅笔排在动作条最左、压在平台自己画的复制按钮之前(那一格是 display:contents,DOM 序只能排第二,靠 flex order 才能争到第一位,现场按布局算 x 而不是数节点)、气泡上没有 .dshet-floating 压着文字、切走再切回会话入口仍在。它只读:从不打开编辑器、从不点保存、从不发 /apply。(这两条铅笔落位的断言已在真机重载后跑通,三处视图——原始会话、切走的会话、切回后——都确认了。)
verify:profile 会另起独立端口 + 独立 DSH_HOME 的沙箱实例,只按 pid 结束自己启动的进程,绝不触碰你正在用的 DSH。也可以手动指定:
DSH_BIN=/path/to/dsh/lib/bin.js PORT=4123 DSH_HOME=/tmp/dsh-edit-turn-home bash tools/verify-dsh-plugin.sh尚未验证的一环(诚实说明):verify:ui 覆盖的是「渲染成什么样」(只读),真正按下保存会发 /apply 并回退会话——这一步刻意留给手动端到端(见上表最后一行与 docs/reply-editor-in-browser.png),冒烟脚本永远不碰。另外本机没有 React / Playwright,所以 verify:ui 走的是裸 CDP 而不是框架。但纯点击路径本身已被自动覆盖:test/client.dom.test.js 用一个可读的 DOM 实现把真实的 OverlayEntry 跑起来,断言「点动作 → 编辑器出现并预填 → 点保存 → 进入确认步骤 → 点确认 → 发出正确请求 → 编辑器关闭」以及各失败分支的文案。这个测试是有意义的:把重绘标记改回旧写法时,其中 6 项会失败。
「按下保存之后那一屏」同样已被覆盖,因为这里出过一次真事故:保存成功后旧消息行被折叠,而替身气泡从未建起,整条消息在界面上消失(截图见 docs/,服务端状态其实早已回退成功)。三个用例分别钉住它的一半成因——①保存响应一到,替代气泡必须立刻出现(把紧随其后的 /state 卡住不返回也得过,证明不靠刷新兜底)②保存必须重新向宿主要一次 /state ③有替换记录却还没有替换文本时,行不得折叠。把 lib/client.js 里对应的三处修复逐一还原,这三个用例各自恰好失败一次,恢复后当时 94 项全绿(现 105 项)。
「气泡排在时间/复制上面」同样已被覆盖:替代气泡原先插在被折叠行的后面,行里剩下的时间戳与复制按钮于是排到了改写文本的上方,看起来像属于下一条消息。现在气泡插进行内、动作条之前(placeRevisionBubble),同时 collapseRowContent 的豁免名单加上它自己——气泡一旦进到行里,清空行内容的那一步就必须放过它;而没有动作条可留的行会整行消失,此时气泡改插到行前面,否则会跟着行一起被藏起来。两个用例分别钉住这两种情形(94 → 95 项):把定位那一处改回「插在行后」,恰好 4 个用例同时失败。verify:ui 加了现场断言「气泡必须在动作条之上」,静态检查加 2 条,verify:live 加 1 个下发标记。
「两支铅笔该落在哪儿」同样已被覆盖,这是按你给的规范改的:用户行那颗笔排在动作条的最右边(时间、复制、以及兄弟插件后插进来的按钮之后),回复行那颗笔排在动作条的最左边(复制按钮之前)。两处都不是"换个插入点"能办到的,各自卡在一个宿主限制上:折叠行原先把笔塞进替代气泡的字外留白里(要悬停改写文本才看得见,也够不着"复制");回复笔受宿主注册的 slot 约束,DOM 上必然排在平台自己画的复制按钮之后。现在的做法是:①renderRevision 把改写后的笔交给那条活下来的动作条(injectRowAction(row, editTarget, …)),同时把它从气泡里摘掉,只有行彻底没有条可留时才退回气泡字外留白;applyDom 在行可见时只在没有替代气泡站位时才拆掉行上的笔——否则会把上一行刚放进去的笔拆走。②回复笔靠新增的 .dshet-reply-action 加 flex order:-1 冲到最左,因为那一格宿主是 display:contents,DOM 序永远只能第二,只有顺序属性改得动;用户笔同理——兄弟插件(dsh-delete-turn)会在任意时刻把垃圾桶追加进同一条动作条,DOM 插入序保不住右端,所以 .dshet-action-host 带 order:9,无论谁后插都在它左边。颜色也一并对齐平台:.dshet-action 改用平台的 --dsw-alias-label-tertiary(悬停 --dsw-alias-label-primary,留一个等于默认主题取值的兜底),不再用自己的蓝灰墨色——此前它比旁边的复制/垃圾桶更蓝,看起来像另一个控件;实测两者计算色都是 rgb(129, 133, 140)。测试上:原"铅笔在字外留白"的用例改写为「铅笔并进那条活下来的条的末尾、气泡里不再留笔」,无条用例补上"留白里仍有笔",回复 entry 用例补 class 断言;静态检查加 4 条钉住这一批写法(含「气泡按内容收窄」),另加 1 条钉住 order:9,verify:live 加 2 个下发标记;verify:ui 的「行末尾的笔」断言改成按布局量右边缘(DOM 序不算数)并已在真机重载后跑通——现场那个用户条是 时间 → 复制 → 垃圾桶 → 笔,笔在最右(原始会话/切走的会话/切回后三处确认)。三处新写法各做过反向复现:把「笔交给动作条」「.dshet-reply-action class」「applyDom 的站位守卫」逐一还原,各有一个用例失败(守卫那处失败在「笔并进条末尾」用例上——它恰好也钉住了"刚放好又被拆走"这条回归)。当前 npm run check 105 项 + 契约 + 静态全绿。
开发前置:npm test 与两个 verify:* 需要 @deepseek-ai/dsh-session 与 @deepseek-ai/dsh-tools 可解析。本插件自身不依赖它们(宿主半部只 import schemastery 与 dsh-tools),测试需要一个装了 DSH 的 node_modules:
ln -s /path/to/dsh-install/node_modules ./node_modulesdsh-rerun-turn 的「重跑」。sessionController.fork({ sessionId, atSeq }),尚未实现。409 session-not-active。409 busy。session-not-found,本插件因此在其中完全不工作(表现为没有编辑入口)。新写的会话是 v4,正常。dsh-client-ui-trajectory)读的是同一份投影数据,本版没有接管它:在那里复制一条被改写过的消息,仍会拿到改写前的文本。先分清是"插件没加载"还是"加载了但没渲染"——两者的现场完全不同,别猜。
npm run probe:loaded [端口](默认 3080)。返回 400/405 说明宿主半部在跑;返回 404 说明插件根本没加载。dsh-edit-turn (dsh-edit-turn): failed to import这行的原因几乎总是依赖解析失败,最常见的是:插件以 link: 方式安装(开发目录直接挂进 profile),而插件目录的 node_modules 是个指向某处 npx 缓存的软链——缓存被 npx 清理后软链变死链,静态 import 直接抛错,整个插件消失。修法是在插件目录里真实安装依赖:
cd <插件目录> && rm -f node_modules && npm install --omit=dev --legacy-peer-deps注意区分两类依赖:宿主提供的 peer(dsh-tools、dsh-settings)由 DSH 加载器解析,可以不装;普通 dependencies(如 schemastery)必须能解析到,加载器不会替你找。
curl "http://127.0.0.1:<端口>/dsh-edit-turn/debug"记录里有 state 且 replies 大于 0 → 数据到了客户端,问题在渲染;一条记录都没有 → 客户端半部没跑起来(看浏览器控制台)。
verify:ui 遇到这种情况会注记并跳过该项,不会误报。session-not-found,控制台里出现 SessionFormatError):这可能是 0.2.0–0.2.3 的回复编辑留下的坏日志。用仓库里的修复工具扫描并用官方加载器验证、备份后截断:node tools/repair-session.mjs ~/.dsh/sessions/<项目目录> --dry-run
node tools/repair-session.mjs ~/.dsh/sessions/<项目目录>截断前的内容全部留在同目录的 *.corrupt-*.bak 里;修复后重启 DSH(或桌面版)即可重新打开该会话。
「点了保存没反应」先看这里:记录里有没有 apply。没有 → 点击丢在浏览器半边(此前的成因是宿主在 mousedown/mouseup 之间重建了行,按钮已改为 pointerdown 激活);有 apply 但 ok:false → 宿主拒绝了,code 就是原因(busy/stale/...)。
replyMode: replace(把回复也走 surface 替换)试过:像 dsh-chat-thinking-editor 那样 append 一条新的 assistant/message、用 replace op 遮蔽旧的那条,让宿主原生渲染。当前宿主(0.2.1-rc.2)的 surface 校验直接拒绝,两条独立规则:
session event "assistant/message" carries an invalid replace surfaceOp —— replace op 的键必须是 {op, startSeq, endSeq}(对方的代码用 {start, end})assistant/message embeds its source stream and cannot carry sourceEventSeqs,而同一校验又要求 surface replace: sourceEventSeqs must include every shadowed surface node; missing N —— 两条互相矛盾 ⇒ assistant 消息不能被另一条 assistant 消息替换conversation.chat.reasoning.body 槽位渲染