dsh-redo
imzl-zl/dsh-redo
给你发出的每条消息加上「重新生成」和「编辑重发」:重新生成一键用同样的文字重新回答,不经过输入框;编辑重发就地撤回这条消息及其之后的内容——从时间线和模型上下文里一起消失,不新建会话也不开新分支——再把文字和图片放回输入框,改完发送就从那一条重写对话。带文件(非图片)的消息两个操作置灰。
安装
dsh plugin --profile web add github:imzl-zl/dsh-redoREADME
这是什么
DSH(DeepSeek Harness)是一个 append-only 的会话系统:消息一旦发出去,原生没有"改一下再发"的入口。 dsh-redo 把这件事补上——选中任意一条自己发过的消息,改文字、删图、加图,或者原样再来一次, 模型上下文会原地回到那条消息之前并重新生成,而不是开一个新会话。
适合这些场景:
- 消息发错了(错字、贴错内容、图配错)→ 改完重发。
- 回答不满意 / 渠道抽风 → 原样重发,不必等失败才能点。
- 想换一个问法继续同一段对话 → 从任意一条消息重写,后面的内容作废。
功能
- 重新生成:任意一条用户消息都能重发,不限于失败的消息——一键,不经过输入框。
- 编辑重发=点一下就地撤回:那条消息连同它的回答立刻从对话里消失,不再回来;文字和图片原样回到 底部输入框,改完正常按发送就开始新的问答。用的就是原生 composer,不是插件自己的弹窗。
- 图片跟着走:存的图读回字节、重新挂成草稿,发送时一起上送;文件附件除外(见下)。
- 旧内容真正离开上下文:被重写的区间由
surface replace遮蔽,旧消息和它之后的回答不再进入模型请求。 - 整轮从时间线撤掉:被重写的那一轮——你的消息、回答、工具卡、用量行——当场消失,新消息落在同一个位置,
看起来就像这条消息原地重新生成。
/redo命令卡也一起隐藏。刷新后依然如此:区间是从会话日志的surface replace读回来的,不靠内存。 - 原生操作行:时间、复制、重新生成、编辑重发排在一起,尺寸、圆角、间距、悬停行为对齐原生。
- 破坏性操作有确认:撤回中段消息前会提示「之后的 N 轮对话会离开上下文」;输入框里本来有内容时再问一句是否替换。
- 不会误触发:撤回只影响你点的那条消息及其之后;其余会话、其余窗口不受影响。
- 生成中禁用:回答还在生成时两个按钮置灰,先停止再重发——不会把重发塞进输入框的排队区。
宿主端也照样挡:turn 还开着时
/redo直接拒绝、一个事件都不写,所以即便按钮没来得及置灰(另一窗口正在 跑同一个会话),也不会把日志写成下次读不出来的样子。
不支持
- 文件附件:prompt 的文件通道只接受本次会话上传换来的 receipt,客户端没有"按引用读取旧文件"的 RPC, 所以带文件的消息两个按钮置灰,而不是静默丢内容。
- 太久以前的撤回不留痕:区间是从事先加载的会话窗口里读的。一次重写如果发生在很早以前、已经落到 窗口之外,刷新后那批旧消息会重新出现——模型上下文仍然是对的,丢的只是界面上的「撤掉」。
- 命令行手动输入
/redo也会被藏:命令卡按「它撤回的目标是否已被遮蔽」判断,插件发起的和你手敲的 记录长得一样,所以一起隐藏。 - 账本里仍留着原文:会话日志是只追加的,撤回追加一条
replace,不删原来那行。对模型和界面都已生效, 但dump-session或任何回放仍能看到你撤回掉的那条文本。
安装
需要 DSH >= 0.2.0-rc.1 < 0.3.0,并在 Web GUI 中使用。
从 npm 装(发布物已含构建好的 lib/,无需自己 build):
dsh plugin --profile <profile> add dsh-redo
dsh --profile <profile> --dump-config | grep redo # 应看到 "== dsh-redo" 层
dsh --profile <profile>
或者从源码装(改代码、跟进未发布提交时用):
git clone https://github.com/Imzl-zl/dsh-redo.git
cd dsh-redo
npm install
npm run build
dsh plugin --profile <profile> add <本仓库绝对路径>
装完重启 GUI。想先拿独立 profile 试,可以用:
dsh --profile redo-test --from-default-profile web --dump-config
dsh plugin --profile redo-test add <本仓库绝对路径>
dsh --profile redo-test --no-open --port 3111
使用
- 鼠标移到任意一条自己发的消息上,气泡下方出现操作行。
- 点「重新生成」原样重发:一键,不经过输入框。
- 点「编辑重发」:这条消息立刻从对话里撤掉,文字和图片回到底部输入框。该问的会先问 (中段消息说明之后还有几轮会跟着离开;输入框本来不是空的问是否替换)。
- 在输入框里改完(增删图片、改文字都行),正常按发送——这一次发送就是在重写那段对话,新的那条落在 被撤掉的位置。撤掉这件事不设期限,草稿留着多久都行。
编辑不弹插件自己的对话框:内容进的是原生 composer,@ 引用、斜杠命令、图片预览、Esc 撤销全是原生行为。
武装这一步仍然会往时间线写一条 /redo <seq> 命令记录(成功、无输出),但发送完成、区间落定后,
这张卡和它所在的那段对话会一起被撤掉,所以正常使用时看不到它。
带文件(非图片)的消息两个按钮置灰,因为客户端没有按引用读回旧文件的 RPC。
工作原理
两个半区,各干一件小事。插件不改上游源码,只使用公开扩展点。
宿主半(src/index.ts)
两条入口,同一个追加:
- 撤回(编辑):客户端调
/redo <seq>(ctx.commands.register注册的宿主命令),命令当场写五个事件 ——turn/start→step/start→ 空 content 的system/message(surfaceOp: replace target..surface 尾) →step/end→turn/end。没有消息进 inbox,这个 turn 不会被请求模型;日志坐标精确,冷读不会报错。 点一下,消息就从上下文里消失了。已有 turn 还开着时命令拒绝执行(一个事件都不写),见下一条。 - 重新生成:客户端发送的 prompt 把目标写进
requestId(redo:<seq>:<uuid>),agent/pre-step从认领到的消息上读回来,agent/request在已经打开的 step 里写同样的替换。
为什么撤回要绕一道命令:composer 提交时的 requestId 由 beginSubmission 内部铸(randomUUID()),
BeginSubmissionInput 不接受外部注入,所以没有钩子能在提交那一刻插手。命令是客户端唯一能主动让宿主「现在就把这段删掉」的公开通道。
为什么命令要自己开一个 turn:system/message 只能落在已打开的 turn+step 内,而点击发生在 turn 之外。
turn 边界由宿主说了算:命令不扫日志、不自己算 turn 号,而是读 ctx.sessionProjections.stateOf(session,'turnBoundary')
——和 agent loop、日志读取器同一个折叠。没有打开的 turn 才写,编号取 lastTurn + 1;有打开的 turn 就拒绝。
写盘前宿主不校验 turn 生命周期,所以这条不变式只能插件自己守,否则下一次加载历史会整份拒读
(turn/start does not open the expected turn)。
为什么必须是空 system/message:它是唯一同时满足「不产生模型消息」和「Chat 时间线不渲染」的节点类型;
空 user/message 会被 deriveEventMessage 原样投影给 provider,空 developer/message 会多一行空上下文。
浏览器半(src/client/index.tsx)
- 接管 keyed
conversation.chat.node的user单元,气泡本体仍走官方conversation.chat.user.content工厂。 - 操作行走原生
MessageIconActions的度量与 token,自己补回时间与复制。 - 回填:先调命令撤回,再
inputActions.setDraft(text)写文字;存的图在调命令之前用session.readAttachment读回字节、包成File、经conversation.createDrafts挂成草稿,最后由inputActions.addAttachments(ids)加进输入框——顺序反过来的话,读图失败会连消息一起丢。 - 区间不靠客户端记账:每次撤回都是一条
surfaceOp: { op: 'replace', startSeq, endSeq }追加,插件直接 从会话自己的事件窗口(binding.eventSource)把区间读回来(src/client/intervals.ts)。刷新后依然生效。 - 撤掉的是整轮,不只是气泡:插件只接管了
user单元,回答、工具卡、过程行、用量行都由官方 cell 渲染, 所以被遮蔽的那一行挂载后,会把它所在 Turn 的容器行(data-chat-turn)一并隐藏,MutationObserver补上 晚到的行。插件为撤回开的那个空 turn 也会留下「已完成」和用量行,deriveRetractionTurns按「有 replace、 没有 user 消息」把它认出来一起隐藏。/redo命令卡不在任何 Turn 里,按「它撤回的目标是否已被遮蔽」识别。 都只作用在同一个 Chat flow 内,分屏不会串号。 intervals.ts是纯函数:区间推导 + 撤回 turn 推导 + 判定,test/intervals.test.ts直接锁它。
兼容性
| 项目 | 说明 |
|---|---|
| DSH | >= 0.2.0-rc.1 < 0.3.0,实测 0.2.1-rc.2 / 0.2.1-rc.3 |
| 平台 | Web GUI(浏览器半只要求 shell 模块表里的 react) |
| 扩展点 | keyed slot conversation.chat.node、agent/pre-step、agent/request、ctx.commands |
| 测试 | Node 24(node --test 直接跑 TypeScript 源码,不走构建) |
已验证
自动化(npm test,28 项,跑在 src/ 上,不需要构建):宿主半锁「撤回命令写出的五个事件」顺序与坐标
(turn/step 精确、turn 号取 turnBoundary 的 lastTurn + 1、不留 open step)、sourceEventSeqs 完整覆盖
被遮蔽的 surface 尾、受保护的节点 0 拒绝改写、替换必须是空 system 而非 user、非法参数/越界目标/非人类消息
全被拒且不碰日志;turn 还开着时撤回被拒且不写任何事件;turnBoundary 缺失时报错而不是猜编号。
最后两项把插件写出的日志交给上游 assertReleasedV4Relationships 冷读——「关着的 turn 写出来必须可读」
「开着的 turn 不许写」由宿主判定,不由插件自证。重试路线覆盖 plan 只消费一次、step 作用域、畸形 requestId
不重写。客户端半锁区间推导与「撤回 turn」识别。CI 依次跑 typecheck → test → build。
变异检验:收窄 endSeq / 让 sourceEventSeqs 少一个节点 / 去掉未关闭 turn 的门禁 / turn 号写成
lastTurn + 2,每一种都会立刻有测试变红(前两类 1 项,后两类 2–3 项,含上游校验器那项)。
诊断脚本:node scripts/check-sessions.mjs <目录或日志> 用上游校验器逐份冷读并指出第一处坏事件;
node scripts/verify-retract-safe.ts <日志> 在一份真实日志上试一次撤回(关着的 turn 必须写出可读日志,
开着的 turn 必须一个事件都不写)。
手工(packaged 0.2.1-rc.2 GUI,redo-test profile + 本地 Cherry 网关):
- 20:1x 三条路径:文字编辑重发(模型上下文只剩改后文本,追问「能看到几条用户消息」答 2 而非 4)、图片编辑 重发(粘贴的图回填成草稿再发,模型仍能读到图)、一键重新生成。
- 22:0x 整轮撤掉:重试最后一条 / 编辑 / 中段重试,被重写的那一轮(消息、回答、过程行、用量行)连同
redo命令卡一起消失,新的一轮落在同一位置。 - 22:5x 点击即撤回:点「编辑重发」后 1.8 秒内时间线只剩 0 行,composer 拿到原文;日志里是
turn/start→step/start→replace 8..18→step/end→turn/end。重启实例冷读该会话正常, 无SessionPersistenceCorruptionError,接着发消息正常开下一个 turn。
排查现场用:
node scripts/dump-session.mjs <session.v4.jsonl.zstd>
开发
npm install
npm test # node --test,直接跑 TypeScript 源码
npm run build # lib/index.js + lib/client.js
npm run typecheck
node scripts/dump-session.mjs <session.v4.jsonl.zstd> # 解码会话日志排查
产物 lib/ 不入库,克隆后需要先构建。宿主半只依赖 node:crypto;浏览器半只要求 shell 模块表里的
react,运行时不会装出第二份框架实例。
问题与建议欢迎开 issue / PR。
变更记录与发版
每个版本都记在 CHANGELOG.md,并标注它是针对哪个 DSH 版本构建的。
发版是 tag 驱动的,不需要任何人手上有 token:
npm version patch # 先跑 typecheck + 测试,再改版本号并打 tag
# 把 CHANGELOG 的 Unreleased 段落改成这个版本号
git push --follow-tags
.github/workflows/release.yml 会核验 tag 与 package.json 一致,然后通过 npm trusted publishing
(OIDC) 发布并附 provenance。这个工作流文件被 npm 侧的 trusted publisher 配置按路径绑定,重命名或挪位置
会让发布在 403 上失败——改路径时要去 npmjs.com 同步改一次。