240xu/dsh-message-ops

240xu/dsh-message-ops

DSH 会话消息级回滚、单条删除与重放式恢复;操作以 append-only surface replace 实现,带确认提示和运行中会话保护。需 Node.js 23.5+。

会话与消息

@240xu/dsh-message-ops

DSH web 插件:消息回滚 + 删除 + 恢复。只补 dsh 官方没有的能力。

0.9.0 是破坏性变更:移除了本插件的分支与 Markdown 导出。
两者 dsh 官方都已有(session/fork 与 /api/session.export),详见下方「为什么不重复造轮子」。

10 秒速查

dsh plugin --profile web add @240xu/dsh-message-ops
# 重启 dsh web 后,会话头部点 Message ops
  • 能做什么:回滚到任意消息、只删一条、恢复;分支调用官方 session/fork
  • 不能做什么:Markdown 导出(请用官方 /api/session.export)
  • 卸载:dsh plugin --profile web remove @240xu/dsh-message-ops

官方没有、本插件提供的

能力 · 官方 dsh 0.2.0-rc.2 · 本插件 消息回滚(遮蔽该条及其后全部内容) · ❌ 没有。SurfaceOp 只有 append\ · replace,没有解除遮蔽的操作;undo 仅指草稿编辑器的 Ctrl+Z · ✅ 单条删除 · ❌ 没有。官方 README 明文 *"sessions can be archived but never deleted"* · ✅ 恢复(撤销一次回滚/删除) · ❌ 没有 · ✅

为什么不重复造轮子(0.9.0 的取舍)

  • 分支 → 官方有完整实现:RPC session/fork(atSeq 任意)+ SessionStore.fork()
  • UI 消息级按钮 + 官方埋点就叫 branch_session_click。

本插件原先还有一个磁盘实现(applyBranch)作为「官方失败时的回落」,但它语义是错的: 删掉了 isSeeded(官方 fork 设 true)、不写 inheritedEventCount、 也不补 fork closers → 产出的子会话边界可能悬空。已删除,失败时直接提示停止运行中的会话。

  • Markdown 导出 → 官方 /api/session.export 提供 ZIP 归档(原始 JSONL + 附件,面向迁移/复现)。

格式确实不重叠,但本插件的导出零 UI 入口(实测路由可用但前端从不调用),等于死代码。已删除。

用法

  • 会话头部的 Message ops 按钮 → 右侧 OpsPanel 抽屉(消息 / 回收站双 tab);

旧 OpsDialog 仅 window.__MOPS_LEGACY_DIALOG === true 时回退

  • 助手消息 hover 行的 Revert to here / Quote to composer
  • 用户消息 hover 行的 Revert to here
  • 输入框上方的回撤贴条:展开后可逐条 Restore(消息级恢复入口)
  • 侧栏会话行的 ... 菜单

面板流程:选一条消息 → 回滚 / 仅删除 / 分支 → 破坏性操作二次确认; 整会话删除在回收站 tab,需输入会话标题确认。

安装

零 npm 依赖(只用 node:fs / node:zlib / node:path / node:url;zstd 压缩走 Node 内建 zlib.zstdCompressSync,需 Node ≥ 23.5,Termux 与 Windows 通用)。

# Termux / Linux
dsh plugin --profile web add @240xu/dsh-message-ops

# Windows
dsh plugin --profile web add @240xu/dsh-message-ops
# 或本地目录:
dsh plugin --profile web add file:C:/path/to/dsh-message-ops

重启 DSH web 实例后生效。

安全设计

  • 回滚/删除不重写日志:通过一条承载 surfaceOp: replace 的 system 事件实现,

约束由 DSH 引擎 assertProvenance 校验,违规如实上报(HTTP 409)。

  • 回滚/删除前必须二次确认;运行中的会话一律拒绝(提示先停止)。
  • 分支走官方 sessions.fork(要求先停止运行中的会话),插件不再写磁盘分支。
  • 插件 GET 与宿主 /api 同条件校验 BrowserAuth cookie;有 credentials 服务但

client-connection/browser-session 记录不可读时 401 fail-closed,无该服务的老宿主 保留旧围栏。

端点

方法 · 路径 · 说明 GET · /api/message-ops/messages?sessionId= · 消息级列表(磁盘真相 + 可见性标注 + running 状态);&view=compact 只回 seq/role/snippet/time/visible/shadowed(体积约 −54%) GET · /api/message-ops/search?sessionId=&q=&role=&limit= · 服务端全文检索(含已回滚消息之外的全部文本),返回 query 对齐片段 GET · /api/message-ops/text?sessionId=&seq= · 单条消息全文(引用按钮按需取) GET · /api/message-ops/session/trash · 回收站列表(自动清理过期条目) POST · /api/message-ops/revert · {sessionId, seq} 回滚到该条(含) POST · /api/message-ops/delete · {sessionId, seq} 仅遮蔽该条 POST · /api/message-ops/restore · {sessionId, seq} 回滚恢复:seq 为某次 revert/delete 标记事件的 seq POST · /api/message-ops/session/delete · {sessionId, confirmTitle} 整会话移入回收站(7 天保留;运行中 409) POST · /api/message-ops/session/restore · {trashId} 从回收站恢复目录与投影/工作区元数据 POST · /api/message-ops/session/purge · {trashId} 永久删除(唯一 destructive 操作)

0.11.3 · 区间编码、回填落盘与会话损坏修复

  • `sourceEventSeqs` 区间语义修正(数据正确性):宿主用 encodeSeqRanges 把连续

≥3 个 seq 无损压缩成 [start, end] 闭区间,读盘时 decodeSeqRanges 再展开。 0.11.2 的 flat(Infinity) 会把 [[3, 6]] 变成 3, 6,丢掉 4、5,写出的 marker 被引擎判 missing 4, 5。改为 expandSourceSeqs() 按闭区间展开并保持首次出现顺序。

  • `postamble` 幂等(会话损坏 P0):旧实现成功路径与 catch 各写一次

noticeWindowPostamble;postamble/flush 自身抛错时 catch 再写一遍,落成 step/end,turn/end,step/end,turn/end,宿主读盘直接判 step/end does not match an open turn and step,整份会话变 corrupt。 改为 closeWindow() 单点 + closed 标志。

  • composer 回填落盘(「回滚成功但输入框是空的」):宿主 inputActions.setDraft()

只改编辑器、不写 localStorage;紧随其后的 openSession() 会用 readConversationDraft() 从旧草稿重读并覆盖。回填后补 persistDraft()。 对话框入口同理。

  • `lastRevertable` 排除 `shadowed`:只判 visible 时,回滚刚落盘、索引未刷新的

窗口里会再次选中同一 seq → 409 surface replace: start seq N not found in surface。 /undo 成功后同时就地标记 seq >= target 行为 shadowed。

0.11.2 · 合成 turn 竞态与历史日志兼容修复

  • 修复回滚在 live 会话中可能产生重复 turn:opsCommit / opsRestore 在取得

live session 后改用 session.log 重算 turn 窗口,不再信任可能已过期的磁盘快照。

  • 合成 turn 成功闭合后对齐宿主 idle agent 的 lastTurn,防止下一条真实消息复用同一 turn。
  • planNoticeWindow 遇到「turn 已开但 step 未开」直接 409,不再嵌套非法合成 turn。
  • applySurfaceReplace / buildMarkerEvent 对 sourceEventSeqs 去重和整数校验(0.11.3 起改为正确展开区间),

区间成员不再丢失。

  • 旧日志中的区间式 sourceEventSeqs([[start,end]])在 0.11.3 已确认是宿主合法编码,

无需维护脚本;tool/result replacement 非 content 字段仍需按需修复。

  • 契约测试在无本地 sessionfix 夹具时自动跳过,不再硬依赖个人绝对路径。

0.11.0 · 方向 A 面板 + 会话回收站

  • src/client.js 新增右侧功能主义抽屉 OpsPanel(560px,≤760px 全屏):

消息/回收站双 tab、200ms 防抖搜索、角色过滤、含已回滚开关、46px 固定行高 虚拟列表、/text 详情、回滚/仅删除/分支、整会话删除(输入标题确认)。 旧 OpsDialog 保留为 window.__MOPS_LEGACY_DIALOG === true 回退。

  • GET /messages?view=compact 为面板默认载荷;GET /search 提供 500 字符

上限的服务端全文检索(LRU 8 corpus 缓存,按 logPath mtime+size 失效)。

  • src/session-trash.js:删除 = 目录 rename + 投影行/工作区/归档/置顶元数据

快照写 manifest.json;恢复拒绝覆盖已存在目录(409);purge 才真正删除。 恢复校验 manifest.sessionId 与 rel(必须是 sessions 根下两层、且与 id 匹配), delete 回滚失败时保留回收站唯一副本,不再无条件删除。

  • 无标题会话取标题只解压首个 zstd header frame(readSessionHeader),损坏的正文

不会阻塞列表;marker 恢复进度改为单次索引扫描。

0.2.4 UI GAP(ux-scout dsh-ui-spec G-M1)

  • S6 违规修复:回滚/删除成功后不再 900ms 裸 location.reload()——改为

devkit 标准 toast(window.__dshDevkit.toast 探测,无 devkit 时降级为对话框 内完成文案)+ done 态新增「刷新页面」按钮由用户手动刷新;与 branch 路径 (refreshList 不刷新页面)行为拉齐。

0.2.3 格式兼容(compat-audit + 用户实测紧急修复)

  • P0 旧单帧 `session.jsonl.zstd` 读取必崩:readSessionFile 旧实现把帧 0

整段 JSON.parse——旧单帧格式整个文件是一帧、解压出多行 NDJSON,直接抛异常, 与「旧格式兼容」声明矛盾。现改为逐行扫描统一路径:全部帧解压后按行扫描, 首个 type:"session" 行作 header、其余行作事件,v3 多帧 / v4 多帧 / 旧单帧 三种格式一条代码路径(撕裂行仍按完整前缀语义跳过)。

  • P0 v4 会话格式(用户实测 3080 服务暴露):DSH 已升级 v4 持久化

(session.v4.jsonl.zstd,结构同 v3,仅文件名与 version 数字不同)。 findSessionDirs 探测序列改为 v4 → v3 → 旧单帧(此前只找 v3,最新会话 全部 "session log not found");readSessionFile 不做 version 硬校验; applyBranch 分支写回沿用读到的原格式版本(session.v.jsonl.zstd), 不再写死 v3。

  • dedup:listMessages 内联文本提取收敛到 session-file.messageText

单点(ops-core 转出保持导入面兼容),消除双实现漂移面。

  • 回归:40 项测试全绿,含旧单帧/ v4 fixture 往返、listMessages、

findSessionDirs 三格式发现与 v4 分支写回;真实 v4 会话日志已实测通过。

0.2.2 前端收尾(评审 M1/M2)

  • M2 观察范围收窄:侧栏行菜单注入的 MutationObserver 仍观察

document.body(菜单由宿主 React 动态渲染,安装期无稳定锚点),但回调改为 精确过滤——只有新增节点本身是(或包含)[role=menu] 时才调度探测;聊天流 渲染、流式 chunk 等海量 mutation 零探测成本;同帧多次命中合并为一次探测。

  • M1 分批渲染:消息列表先渲 50 条,顶部「显示更多(剩余 N 条)」按钮每次

渐进展开 50 条(上限仍为最近 200 条),避免 200 行单选列表一次性进 DOM; 打开新会话时分页重置。

0.2.1 评审修复(架构评审 arch-review)

  • P0 信任围栏:全部 6 条 /api/message-ops/* 路由接入三层信任判定

(回环 Host 挡 DNS rebinding + sec-fetch-site: cross-site 拒绝 + Origin 同源校验),非回环/跨站请求一律 403。写操作(revert/delete/branch/restore) 另要求 Content-Type: application/json(text/plain 绕预检的洞 → 415)。 为何不引入一次性 CSRF token:浏览器对所有 POST(含 text/plain)都附带 Origin 头,Origin 同源校验已覆盖跨站 POST;自定义头天然触发预检、与 Origin 校验等价——token 只增加握手复杂度而不增加安全性,围栏已足够(论证见 src/ops-core.js 的 isJsonContentType 注释)。

  • P1 写端 replace 拼写前向兼容:applySurfaceReplace 写入时先按当前

运行时 {startSeq,endSeq} 形状写;若引擎报 invalid replace surfaceOp (validateNext 在事件入 log 前抛出,无半写风险)自动降级 {start,end} 重试并记住拼写。当前与未来 dsh cohort 都不炸;读端双拼写兼容已统一收敛到 session-file.readReplaceOp 单点(computeShadowed / planRestore 共用)。

  • P1 大日志让出:新增 readSessionFileAsync,逐帧解压每 8 帧

setImmediate 让出事件循环;messages/export/restore 路由改走该路径。 帧数超过 500 阈值的 export 在 Markdown 末尾追加耗时提示(partial 语义, 内容完整无截断)。