dsh-file-history
A DeepSeek Harness plugin that backs up a file before an agent's write/edit overwrites it.
Per-project .dsh-backup/ (filename + timestamp, last N generations), a file_history tool to list / diff /
restore single files or a whole turn, a top-bar panel that shows whether it is alive, and a per-project switch.
If a backup cannot be made, the write is refused (fail-closed). 中文说明见下。
DeepSeek Harness 的「改前自动备份」插件:让 agent 改文件这件事从"靠自觉备份"变成"harness 兜底"。
- 任何
write/edit覆盖已存在文件之前,自动把原文逐字节备份进项目内的.dsh-backup/; - 备份文件名 = 源文件名 + 时间戳 + 原扩展名,按源文件路径镜像存放;
- 每个源文件只保留最近 5 轮(
maxGenerations,可就地调整),另加天数与容量两层兜底; - 备份失败就拦下这次写入(fail-closed),不会出现"没备份却照样改坏";
- 模型侧有
file_history工具(list/show/restore/revert_turn),系统提示里也常驻几行说明,
agent 知道备份系统的存在、位置与恢复方式; - 运行状况标志:
.dsh-backup/_dsh-file-history/status.json(时间戳每次备份/还原刷新)。
兼容性
| dsh | settings 接口 | 状态 |
|---|---|---|
| 0.1.5-rc.x | settings.register() / .get() |
✅ 老路径 |
| 0.1.7+(含 0.2.x) | 无 register/get:Config + volatile 字段 + settings.mutate(profileEntryId, ops) |
✅ 0.5.0 起 |
插件在运行时用 typeof ctx.settings.register === 'function' 自动分代,同一份代码两代都能跑:
- 0.1.5:注册命名空间,开关写
file-history命名空间; - 0.2.x:导出
Config = fileHistorySettingsSchema,读ctx.fiber.config(volatile 字段.get()实时值),
写settings.mutate(, ops)(条目 id 从configEditor解析)。 - 客户端面板路由在 0.2.x 上会等
webServer就绪后再挂载(不把webServer放进inject,
否则 headless profile 会一直 pending)。
> 0.4.0 在 dsh 0.2.1-rc.5 上会因为 ctx.settings.register is not a function 启动即失败(整条插件不激活)。
> 升级到 0.5.0 即可;无需改 cordis.patch.yml。
为什么需要它
harness 原生只有 dsh-atomic-write(写入原子)和 dsh-fs-observation-policy(先读后写)——
原子不等于可撤销。文件被覆盖或删掉之后,唯一的补救是让模型重读上下文再写一遍:
既烧 token,又可能二次改错。本插件把原文留在工具层,回滚变成一次工具调用(或一次文件复制)。
备份长什么样
<项目根>/.dsh-backup/
├── src/lib/nested.20260917-095500.ts # 子目录按源路径镜像
├── src/lib/nested.20260917-095500.ts.json # 该备份的元数据
├── demo.20260917-095500.txt
├── demo.20260917-095500.txt.json
└── _dsh-file-history/
├── status.json # ★ 运行状况标志
└── manifest.jsonl # 快照/还原/回退审计流水
~/.dsh/file-history/<工作区>/<文件签名>/<时间戳>/content # 仅“项目外文件”的兜底目录
恢复方式三选一:file_history(action="restore", path=...)、file_history(action="revert_turn")(整轮),
或直接把人能看懂的备份文件复制回原位。
目录
| 文件 | 作用 |
|---|---|
lib/index.js |
宿主半边:tools/pre-execute 备份 + file_history 工具 + 系统提示公告 + 保留策略 + 客户端 RPC 路由 |
lib/client.js |
客户端半边:右上角「备份」状态芯片 + 悬停面板(实时流水 / 统计 / 总开关) |
package.json |
插件清单(dsh.client 段声明浏览器半边,依赖 @deepseek-ai/cordis、@deepseek-ai/schemastery) |
install.ps1 |
安装/卸载到指定 dsh profile(写 node_modules + 登记 cordis.patch.yml,自动备份原文件) |
selftest.mjs |
离线自测:假 ctx 驱动已安装副本,70 项断言(宿主加载路径、schema 契约、客户端 RPC、项目级开关、0.1.5/0.2.x 双模式) |
host-contract-check.mjs |
真依赖契约检查:在 profile 内加载真实 schemastery/timer 语义,12 项断言 |
monitor.ps1 |
命令行观测面板:一次看清 / -Watch 实时盯梢 / -Scan 扫全机 |
after-restart-check.ps1 |
重启后体检:插件是否已加载、备份目录是否已创建 |
verify.ps1 |
端到端验证:起一个独立 headless dsh,让真实 agent 改坏再还原 |
e2e/patch.yml |
端到端验证用的临时 patch |
可视化观测:右上角状态芯片
装上客户端半边后,会话右上角(「时间戳开 / 记忆库开」那排旁边)会多一个备份芯片:
- 灯色:🟢 最近 2 分钟内有备份 · 🔵 已启用但暂时没动作 · 🔴 有错误(含最近一次失败原因)· ⚪ 读不到状态。
- 芯片文字:当前占用(现存备份的总大小,会随 5 代清理降下来),不是历史累计次数;悬停提示里另给「份数 / 累计次数」。
- 点击展开面板:最后活动时间 / 备份次数 / 保留代数 / 占用 · 统计 / 最近一次备份与还原的完整路径 / 实时流水(每 2 秒刷新,格式
时间 · 动作 · 文件名,鼠标悬停看备份文件路径)/ 一键开关。 - 面板关着时每 30 秒轮询一次(只更新灯色,开销可忽略);开着时每 2 秒。
- 数据来源与命令行面板完全一致:
status.json+manifest.jsonl,通过宿主路由POST /dsh-file-history/api读取。 - 同一个开关也出现在「设置 → 通用」里(与记忆库的入口一致)。
宿主接口契约(踩过坑,务必按这个写)
这两条是 2026-09-17 两次真实加载失败的根因,已写进 host-contract-check.mjs 做回归:
ctx.settings.register(ns, schema, options)的schema必须是 schemastery Schema,不能是工厂函数。
宿主dsh-settings的resolve()是schema(mergeLayers(base, section))—— 把合并后的配置对象
当参数调用 schema。写成(s) => s.object({...})时s就是那个普通配置对象,
于是s.boolean()不存在 →TypeError: s.boolean is not a function(插件一加载就炸)。
正确写法(与dsh-memory-db一致):import s from '@deepseek-ai/schemastery'+s.object({...})。- 用到
ctx.setTimeout/ctx.timeout就必须inject: ['tools','settings','timer']。
这些方法由cordis-plugin-timer通过ctx.mixin混入,没 inject 会报
cannot get property "timer" without inject;另外ctx.setTimeout已 deprecated,统一用ctx.timeout。 - settings 接口分两代(0.1.7 是分界线):0.1.5 有
register()/get();0.1.7+ 没有,
改为「导出Config(schemastery schema,字段标volatile)+ 读ctx.fiber.config+
写settings.mutate(profileEntryId, ops)」。硬写老接口会TypeError让整条插件不激活。
安装
方式 A:克隆仓库 + 安装脚本(最直观)
git clone https://github.com/cuteG41cute/dsh-file-history.git
cd dsh-file-history
powershell -ExecutionPolicy Bypass -File install.ps1 -Profile web -Force
# 然后重启 dsh 服务 / 桌面版窗口
方式 B:pnpm 直接装(不落源码)
dsh plugin --profile web add github:cuteG41cute/dsh-file-history
# 然后在 profile 的 cordis.patch.yml 里加:
# - insert:
# - id: dsh-file-history
# name: 'dsh-file-history'
# inject: [tools, settings, timer, agents]
# 最后重启 dsh 服务
> 也可以 npm 方式:npm publish 之后 dsh plugin --profile web add dsh-file-history。
重启是必须的:宿主侧 @deepseek-ai/cordis-plugin-hmr 在 dsh-base 里 disabled: true,
改 cordis.patch.yml 不会热加载新插件(客户端插件 HMR 是另一回事);客户端面板还多一步刷新页面。
卸载:powershell -ExecutionPolicy Bypass -File install.ps1 -Remove(已产生的备份不会被删除)。
自测与验证
# 1) 装依赖(只有跑测试需要)
npm install
# 2) 离线自测,秒级,70 项断言(假 ctx,含 0.2.x 设置接口)
node selftest.mjs
# 3) 真依赖契约检查(仓库内或已安装目录都能跑;找不到 schemastery 会回退到 DSH profile 里那份)
node host-contract-check.mjs
# 4) 重启后的体检 + 端到端(起独立实例,不动正在运行的 3080 服务)
powershell -ExecutionPolicy Bypass -File after-restart-check.ps1
powershell -ExecutionPolicy Bypass -File verify.ps1
注意:本机 PowerShell 7(pwsh)不在 PATH 上,脚本一律用系统自带的
powershell.exe(Windows PowerShell 5.1)执行。
所有 .ps1 必须带 UTF-8 BOM:Windows PowerShell 5.1 对无 BOM 的脚本按 ANSI(GBK) 解码,
脚本里的中文注释/字符串会变成乱码并直接把语法搞坏(Unexpected token)。
用编辑器保存时选「UTF-8 with BOM」,或命令行补一次:
$f = 'monitor.ps1'
$text = [System.IO.File]::ReadAllText($f, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText($f, $text, [System.Text.UTF8Encoding]::new($true))
另外:用 Start-Process 传带空格的路径会被参数拆分吃掉一段,
所以 monitor.ps1 支持用环境变量 FH_PROJECT 指定项目目录。
配置(~/.dsh/settings.yaml → file-history 段,改动热生效)
开关是项目级的:enabled 是全局默认,projects 是逐项目覆盖(键=项目目录绝对路径,小写规范化)。
生效值 = 项目覆盖 > 全局默认。右上角面板和「设置 → 通用」里都能直接切当前项目,并能在两种状态间来回。
file-history:
enabled: true # 全局默认开关(没有单独设置过的项目用它)
projects: # 逐项目覆盖:{ "<项目目录>": true | false }
c:\work\my-proj: false
maxFileBytes: 2097152 # 超过只记指纹(默认 2MB)
maxGenerations: 5 # 每个源文件保留最近几代(默认 5 轮)
maxAgeDays: 30 # 备份保留天数
maxProjectBytes: 536870912 # 单个项目备份区容量上限(默认 512MB)
announceInPrompt: true # 把备份系统写进系统提示
gitignoreBackups: true # 自动把 .dsh-backup/ 加进项目 .gitignore
面板里的三个操作(对应 RPC set-enabled / reset-project):
| 操作 | 效果 |
|---|---|
| 关闭/开启本项目备份 | 写 projects[<当前项目>],只影响这个项目 |
| 改为跟随全局默认 | unset projects[<当前项目>],回到跟随 enabled |
| 全局默认:开/关 → 切换 | 写 enabled,只影响没有单独设置过的项目 |
关键实现决策
- 不改写工具参数:
tools/pre-execute无法改写exec.arguments,所以不做"挡下写入再由插件代写"——
那会丢掉 harness 原生的 read-before-write 校验与展示层 diff 卡片。这里只做旁路备份 + 校验,原工具照常执行。 - fail-closed:备份读失败/并发修改 →
deny本次写入,并把原因回给模型。 - 同轮分组用
rootCallId:一次助手消息里的多次write/edit共享rootCallId,revert_turn按它成组回退;
同一文件多代时回退到本轮最早那代(即本轮开始时的样子)。 - 备份逐字节保存:不做行尾/编码转换,
restore是原样写回。 - 代数上限就地执行:每次备份后立刻把该源文件收敛到最近 N 代(不是只靠小时级 sweep),短时间连改也不会堆积。
- 自身免疫 + git 卫生:
.dsh-backup/内的文件不再被备份;从工作区向上找最近的 git 根
(工作区常是仓库的子目录),把带相对路径的条目追加进那个.gitignore。 - 项目外文件不污染无关目录:落到用户主目录的兜底区,由同一个
file_history工具统一索引。 - 按宿主真实契约写:见上文「宿主接口契约」——schema 用
s.object(...)而不是工厂函数,
定时器用ctx.timeout且声明inject: ['tools','settings','timer']。离线自测只覆盖调用约定,
真依赖语义由host-contract-check.mjs在 profile 内保证。
技能与排查
同机已安装配套技能:~/.dsh/skills/dsh-file-history/SKILL.md(含完整行为规则、排障表、file_history 用法)。