dsh-plugin-redact
v0.1.1
Published
In-place redaction and row-level rollback for DSH session logs (session.vN.jsonl.zstd), usable from inside dsh-tui.
Maintainers
Readme
dsh-plugin-redact
在 dsh-tui 里,原地脱敏 / 回退 DSH 会话日志(session.vN.jsonl.zstd)。
不需要开新会话、不需要删整条会话、不需要把日志搬家:它在原文件上把指定的字节改写掉,并且让日志仍然能被读取端正常打开。
| 项 | 值 |
|---|---|
| 包名 | dsh-plugin-redact |
| 版本 | 0.1.0 |
| 许可 | MIT |
| 运行环境 | Node.js >= 22.15.0(依赖 zlib.zstdCompressSync / zstdDecompressSync) |
| TUI 内命令 | /redact list\|nodes\|pick\|scan\|hide\|plan\|apply\|verify\|purge\|rollback\|undo |
| 离线 CLI | dsh-redact inspect\|paths\|verify\|plan\|apply\|cut\|graph |
| 硬依赖 | 只消费宿主 commands 服务(inject: ['commands']),不发布任何服务;/redact pick 另需 TUI 行的 tuiDialogs 并且要显式打开 allowDialogs: true(默认关闭,见 §2.5) |
0. 它解决什么问题
一段工具结果、一条命令输出、一次网页抓取——只要它进了会话,就会被持久化批次写进 session.vN.jsonl.zstd,从此长期躺在磁盘上:可能长期留在会话历史里被反复读回、被后续上下文继续引用、被索引或导出。内容本身未必是"密钥":它可能是法律上不该留存的材料,可能是一段凭证,也可能只是错的、过时的、不该继续影响后续判断的信息——判断标准由你定,本工具只负责"把已经落盘的这些字节删掉或改写掉,并且不把日志弄坏"。
它做两件事:改写(把命中的文本/字段换掉,或把整行清空)和剪除(删掉末尾若干行,或在显式重编号的前提下删掉中间行)。改完之后,会话还能照常被打开、回放、继续追加。
还要分清四件不同的事——它们由四个子命令分别负责:
/redact hide—— 立刻把命中的工具结果从当前会话的模型视野里遮蔽掉(追加surfaceOp: {op:'replace'}替换节点)。下一轮请求起模型就看不到它,不需要重启会话。磁盘字节不变。 定位方式有三种:plan.json里的find、/redact nodes列出的序号、或日志行号(见 §3.1)。想先看到完整列表再挑就用/redact pick:它在 TUI 自己的面板里多行列出节点、用方向键选择,不受 200 格单行限制。面板里每条给的是序号 / 日志行号 / 轮次 / 工具名 / 体积,并在条目下方附上该次工具调用的命令行(受argsCells控制,可截断或彻底关掉)——这六类信息合起来才够你认出"是哪一条"(见 §3.2)。工具结果的正文永远不显示。 ⚠️pick默认关闭(allowDialogs: false):模态对话框会让 TUI 让出聊天键盘,approval 面板挂起时必然卡住界面(见 §2.5)。要启用得显式配置;不启用时用/redact nodes看编号 +/redact hide <序号> --commit,功能完全不受影响。/redact apply—— 真正重写磁盘上的日志字节(原地改写,行数不变)。它要求该会话没有被占用(否则必须显式--allow-live),所以更适合在会话关闭后用离线 CLI 做。/redact rollback <轮数>—— 撤回已经发生的对话:按turn/end边界把最后 N 轮整体截断掉。这是"后缀删除",也是最安全的一种删除。/redact undo—— 撤销我自己的脱敏:从最新的隔离备份恢复(装回前先校验备份:帧可解码、头部合法、seq密集、引用合法、无残帧;不合格就拒绝并指出可改用的更早备份)。
它们互补:hide 先止损(内容不再进入后续上下文)→ 会话关闭后 apply 把字节真正抹掉 → 出错了用 undo 从隔离备份回退。只做 hide 的话,原文仍然完整地留在磁盘上。
1. ⚠️ 先理解这一条:删除中间行 = 整份日志打不开
[!WARNING] DSH 读取端有一条不可违背的约束:每一行的
seq必须等于它的行号减一(0-based,第 1 行是头部行,不参与)。读取端解码时会逐行校验if (event.seq !== eventCount) throw ...。因此:删除中间任意一行,都会让整份日志被判为损坏而拒绝打开——不是"少一行",是"整个会话打不开"。
反过来,原地改写是安全的:行数、行序、
type、seq、time、交叉引用全部不变,对读取端完全透明。
所以脱敏的主力手段是三种原地改写,删除只是补充:
| 操作 | 做什么 | 结构影响 | 什么时候用 |
|---|---|---|---|
| substitutions | 在每个字符串值里替换命中的子串(可用 path / lines 限定范围) | 无:行数/seq/type 不变 | 同一段文字散落在多行多处(含工具结果、meta、参数回显),想一次性抹掉 |
| setFields | 把某一行上某个 JSON 路径的值整个换掉 | 无 | 已经用 paths 定位到确切位置,只想改那一处,其余保持原样 |
| blankLines | 把这些行里所有字符串值都换成占位符(默认 [已移除]) | 无:type/seq/time/id 等结构键不动 | 整行内容都要作废、但必须保留这一行(事件计数、turn 结构、工具调用配对不能少) |
| dropLines | 删除这些行 | ⚠️ 有:行号会变 | 只在末尾后缀上安全(引用一律向后指)。删中间行必须同时给 renumber: true |
| renumber | 允许中间行删除:重编号所有 seq,并重映射所有向后引用 | ⚠️ 大 | 确实要"这一行不存在"、且能接受重写 seq。遇到悬空引用(有行引用了被删的行)时仍然会拒绝执行 |
只想抹掉内容时,优先用前三种;dropLines 是"确实要让这一行消失"才用的手段。
2. 安装
包有两种装法。方式 A 是常规做法(包声明了 dsh.bundle,装完自动成为该 profile 的一层);方式 B 用于你自己手写行、或不想让它进 bundles 列表的场合。
2.1 方式 A:作为 bundle 安装(推荐)
# 从本地目录安装(相对路径先锚定到当前目录,再装进 profile)
dsh plugin --profile <name> add ./dsh-plugin-redact
# 或从 npm / git 安装
dsh plugin --profile <name> add dsh-plugin-redact
dsh plugin --profile <name> add github:<you>/dsh-plugin-redact#<commit>dsh plugin --profile <name> <args...> 会先确保 profile 存在,然后把参数转发给 pnpm、工作目录就是 profile 目录——所以 add / remove / why / update 等 pnpm 动词都可用,并且要求 pnpm 在 PATH 上。因为本包声明了:
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }安装成功后它会进入该 profile 的 dsh.profile.bundles,其 cordis.patch.yml 作为一层被应用(- insert: 一行 id: dsh-redact)。
2.2 方式 B:作为普通 profile 行挂载
编辑 <DSH_HOME>/profiles/<name>/cordis.patch.yml(这个文件才是用户层;同目录的 cordis.yml 是自动生成的空根,不要手改):
# 顶层 YAML 数组;没有 id 的 insert 会把行追加到顶层
- insert:
- id: dsh-redact
name: dsh-plugin-redact # 裸包名:从 <profile>/node_modules 解析
config:
root: !!js dshHomePath('sessions')
cacheRoot: !!js dshHomePath('storages')
placeholder: '[已移除]'两条必须注意的解析规则:
行里的包名必须能从 profile 的
node_modules解析到。裸包名由 Node 从 profile 目录开始向上查找(<profile>/node_modules、<DSH_HOME>/profiles/node_modules等),所以要么先dsh plugin --profile <name> add ./dsh-plugin-redact,要么把包装进 profile 的依赖里。仅仅把包放在任意别的目录、然后用名字引用是找不到的。Windows 上写绝对本地路径必须是
file://URL。加载器用动态import()解析name;直接写C:\...\index.js会被当成协议名c:,报ERR_UNSUPPORTED_ESM_URL_SCHEME。用pathToFileURL生成,并且要指到入口文件(不是目录,目录会报ERR_UNSUPPORTED_DIR_IMPORT):node -p "require('node:url').pathToFileURL('C:/Users/me/dsh-plugin-redact/index.js').href" # => file:///C:/Users/me/dsh-plugin-redact/index.js也可以写相对路径(
./dsh-plugin-redact/index.js),它相对包含它的那个文件(即 profile 目录)解析。
2.3 配置项
| 字段 | 默认值 | 说明 |
|---|---|---|
| root | <DSH_HOME>/sessions | 会话日志根目录,布局为 <root>/<项目键>/<会话 id>/session.vN.jsonl.zstd |
| cacheRoot | <DSH_HOME>/storages | 派生缓存根,purge 只在这里删文件 |
| placeholder | [已移除] | blankLines 清空一行时写入的占位符(substitutions 的 replace 默认是空字符串,即直接删除) |
| argsCells | 120 | 显示工具调用命令行的显示格预算(CJK 算 2 格)。命令行是"认出这是哪一条"的关键,但它自己也可能含敏感查询词,所以留了这个开关:0 = 完全不显示命令行(清单文件与面板里一点痕迹都不留,连 … 都不会有)。只影响 nodes full 写出的清单与 pick 选项的 description;nodes 的单行通知从不显示命令行,不受它影响 |
| dialogTimeoutMs | 15000 | pick 两个对话框的 timeoutMs(毫秒)。它是"面板没被渲染时,界面最多不可操作多久"的上界(见 §2.5);0 = 彻底禁用对话框:pick 直接返回错误,不会调用对话框服务。默认值曾经是 120000(2 分钟),那是错误赌注——用户实际踩到过这个卡死 |
| allowDialogs | false | pick 的模态对话框总开关,默认关闭(理由见 §2.5):把 promise 停进 TuiDialogStore 会让聊天键盘被无条件让出(输入框停用),而面板唯一的挂载点会被 approval 面板无提示压掉、approval 又没有超时——「有审批挂着时敲 pick」必然锁住键盘,连 Ctrl+C 都退不出,只能等超时。设 true 才启用 pick;关着时 pick 回一条说明并让你改用 nodes + hide <序号>。非布尔值直接抛错 |
<DSH_HOME> 未设置时取 ~/.dsh。包自带的 cordis.patch.yml 使用部署自带的 !!js dshHomePath(...) 助手,和官方 session-persistence-jsonl 行完全一致,因此跟着 DSH_HOME 走。
配置校验(在 apply() 里同步做,早失败早看见):
config:留空(YAML 传进null)或未提供(undefined)视同{},六个字段各自取默认值。这一条是刻意的:否则一个空config:会中止整个 profile 的启动。- 仍然拒绝:非对象(字符串、数组)、以及
root/cacheRoot/placeholder里任何非字符串或空字符串的值。错误长这样:dsh-redact: invalid config: expected an object、dsh-redact: invalid config: $.root must be a non-empty string。 - 两个数字字段必须是"非负整数":
argsCells/dialogTimeoutMs写成-1、1.5、'5000'、null都会在启动期抛错,且不注册命令:dsh-redact: invalid config: $.argsCells must be a non-negative integer、dsh-redact: invalid config: $.dialogTimeoutMs must be a non-negative integer。0是合法值,而且有明确含义(分别是"不显示命令行"与"禁用对话框")。 allowDialogs必须是布尔值:写成'true'、1、null都抛dsh-redact: invalid config: $.allowDialogs must be a boolean(不注册命令);不写就是默认的false。- 未知键只告警、不拒绝(与平台自身的 config 语义一致,避免一个手滑的键名打断启动):
dsh-redact: ignoring unknown config key(s): palceholder, extra。告警走ctx.logger.warn。 - 本包不导出 Schemastery 的
Config:以link:方式安装时,包自身的真实路径向上找不到node_modules,@deepseek-ai/schemastery不可解析(实测ERR_MODULE_NOT_FOUND)。所以上面这套等价校验是在apply()里手写的。
2.4 确认它挂上了
dsh --profile <name> --dump-config # 不用启动,直接打印合成后的配置在输出里应该能看到 id: dsh-redact 这一行,并标注它来自哪个层。(--dump-config 会顺带重写 profile 里那个空的 cordis.yml、并可能修复 profiles/node_modules 链接——两者都是幂等的。)
patchReload: live 的 profile 里,改动 cordis.patch.yml 会热生效、新命令也会通过 commands/change 立刻进入 TUI 的 / 菜单;但改动已经加载过的模块内容不会热更,需要重启(或换文件名)。首次创建插件文件不需要重启。
2.5 /redact pick 是可选项、默认关闭:要启用得同时做两件事(安装必读)
[!IMPORTANT]
pick默认关闭(allowDialogs: false),这不是保守,是根因结论。 把对话框的 promise 停进 TUI 的TuiDialogStore,会让聊天无条件让出键盘(Chat.js:2566:questionnaire / approval / plugin dialog 挂起时输入框停用);而对话框面板唯一的挂载点(Chat.js:3585)会被 approval 面板无提示压掉,approval 又没有超时——于是"有审批挂着时敲/redact pick"必然锁住键盘,连Ctrl+C都退不出(exitOnCtrlC: false),只能干等超时。唯一结构上安全的做法就是别把 promise 停进去,所以默认路径是/redact nodes看编号 →/redact hide <序号> --commit(功能上没有任何损失)。确实要用面板时,需要两件事同时成立:
- 配置
allowDialogs: true—— 模态对话框的总开关(默认false)。没打开时pick直接返回下面这条,连服务都不会碰:pick 的模态对话框默认关闭(它会让 TUI 键盘卡住,且 approval 挂起时必然复现)· 用 /redact nodes 看编号,再 /redact hide <序号> --commit · 确要启用请设 allowDialogs: true 并自行承担风险- 行级
inject: [commands, tuiDialogs]—— 消除启动时序上的"准入竞态"(下一段)。包内自带的cordis.patch.tui.yml把这一步和上面那一步都写好了。
为什么还需要行级 inject。 tuiDialogs 的运行时在 try/catch 之外还有一道准入守卫:调用方必须是一个已登记在册的活跃非 root 激活(@deepseek-harness-tui/dsh-tui 的 lib/types/dsh-adapter/dialogs.js:103-111:bindOwnerEffect(...) 绑定失败就立刻 pending.onAbort(),Promise 直接以取消值收场;这份登记由 TuiDialogRuntime 构造时的 compositionRoot(ctx) 建立,同文件 :185)。若本行早于第一个安装该 composition-root tracker 的 dsh-tui 适配器模块进入 ACTIVE,每次 select / confirm 都只写一条 logger warning 就返回——面板不弹,用户只看到"已取消"。加行级 inject 后,Cordis 会等该服务就绪再激活本行,时序问题消失(实测:最坏顺序下面板从 0/6 变成 6/6 正常)。
包内自带的那一层是 cordis.patch.tui.yml(随包发布,exports 里也能解析到 dsh-plugin-redact/cordis.patch.tui.yml),它把行级 inject 和 allowDialogs: true 都写在那一行里。启用 pick 时你要的那一行长这样:
# 启用 /redact pick 的那一行(行级 inject + 显式打开对话框)
- insert:
- id: dsh-redact
name: dsh-plugin-redact
inject: [commands, tuiDialogs]
config:
root: !!js dshHomePath('sessions')
placeholder: '[已移除]'
cacheRoot: !!js dshHomePath('storages')
allowDialogs: true(包内 cordis.patch.tui.yml 里还有大段注释,解释准入守卫、代价、以及为什么不要用 !!js 的 disabled 自禁用守卫来"自动二选一"——Entry.disabled 是实时 getter,判据会随挂载进度翻转,实测会让整个 boot 中止。)
三种用法,任选其一:
- 粘进你自己的 profile 补丁层(推荐,最直观):把上面那段
insert复制进<DSH_HOME>/profiles/<name>/cordis.patch.yml(包内文件与上面这段都已经把allowDialogs: true写在 config 里,照抄时别漏)。 - 用
--patch直接指到包内那个文件:
该文件的 config 四字段齐全(含dsh --profile <name> --patch "C:/path/to/node_modules/dsh-plugin-redact/cordis.patch.tui.yml" # Windows 绝对路径要 file:// URL:node -p "require('node:url').pathToFileURL('C:/.../cordis.patch.tui.yml').href"allowDialogs: true),所以这一种用法一步到位;--patch的多层会按顺序叠加。 - 照抄:只想要面板、不需要这个文件本身时,照抄上面那段即可。
三条实测确认的语义,能省掉不少返工:
- 行级
inject是"追加"而不是"替换":模块静态声明的commands仍然生效(cordis 用Inject.resolve(entry.options.inject, fiber.inject)合并),所以[commands, tuiDialogs]里的commands是重复声明、无害——不用纠结要不要二选一。 config是整行替换(patch 命中即整个 config 换掉,不是深合并),所以那一行里要写全你想生效的字段——这也是为什么allowDialogs: true必须写在这一行里,别指望它从别处"继承"过来。- 套用后可以用
dsh --profile <name> --dump-config确认这一行的inject与 config(见 §2.4)。
[!WARNING] 代价:只有在确定用 dsh-tui 前端、并且确实要那个面板时才套用这一层。 若你的 profile 里没有
tuiDialogs的提供者(非 TUI 前端、或 dsh-tui 那一行被 disable / 加载失败),本行会永远停在 PENDING(state 0),而启动器会把 PENDING 行升级为致命错误——整个 profile 起不来,不是单行静默失败。
那为什么包自带的默认层 cordis.patch.yml 故意不写这个 inject? 因为在非 TUI profile(例如 web)上 tuiDialogs 根本不存在:写进 inject 会让本行一直 PENDING,而 profile 启动把"依赖永远不满足的行"当致命错误——整个 profile 起不来。所以默认层保持优雅降级。
关闭(默认)与降级时的行为都写得很明确,不会静默失败:
allowDialogs !== true→ 上面那条"默认关闭"说明(kind: error),不碰对话框服务;- 开了
allowDialogs但dialogTimeoutMs: 0→对话框已被配置禁用(dialogTimeoutMs: 0)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件; - 开了
allowDialogs但前端没有该服务 →当前前端没有对话框服务 tuiDialogs · 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件; - 开了
allowDialogs、服务也在,但本行没被宿主接纳(准入竞态)→ 插件用耗时识别(人按 Esc 不可能在 50 毫秒内完成):
二次确认框同理:对话框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件确认框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact hide <序号> --commit
也就是说:关着或降级时 pick 从不静默失败——它会把"面板没弹"和"人真的取消了"(耗时长、回 已取消)分开报。nodes 尾部的 选择 /redact pick · 提示只在 allowDialogs: true 且 tuiDialogs 可用时才出现,免得向你推荐一条已经关掉的路径。
3. 使用
3.1 /redact 子命令
/redact 只用会话 id(--session <id>),不用日志路径;不写 --session 时默认目标就是当前会话。它在 / 菜单里的参数提示串是 [list|nodes|pick|scan|hide|plan|apply|verify|purge|rollback|undo] [plan.json] [--lines <n>] [--session <id>] [--commit]。
nodes / hide / pick 三个子命令直接读写活会话的内存 surface,所以它们只认当前会话,--session 指向别的会话会直接报错(而不是悄悄显示当前会话)。
| 子命令 | 语法 | 输出 / 行为 |
|---|---|---|
| list | /redact list | 列出会话(按体积降序),压成一行:共 N 个会话(按体积): + 若干段 <id 前 8 位> <体积>(人类可读写法,如 1.5MB / 805.4KB / 551B),段间用 | 分隔,当前会话在该段末尾标 (当前);按 200 显示格预算逐段收敛,放不下就接 …另M个,末尾固定提示 用 /redact nodes 看当前会话。⚠️ 会话多到把预算吃满时,连这个末尾提示本身也会被截成 用 /redact nodes …。无参数时默认执行它 |
| scan | /redact scan <plan.json> [--session <id>] | 只定位,单行:会话 <id 前 8 位> · 共 N 行 · 命中 M 行:行号列表 · 未改动任何文件。命中行号最多列 8 个,超出直接接 …共M(例:命中 14 行:2,3,4,5,6,7,8,9…共14);命中 0 行时没有 :行号列表 这一段。计划里带 substitutions[].lines 时多一段 · 已按 substitutions[].lines 限定。行数含头部行。⚠️ 它只用 substitutions[].find 作为探针:计划里只有 setFields/blankLines 时它无法定位,会报命中 0 行 |
| nodes | /redact nodes [页号\|full] | 列出当前会话 surface 上的 tool/result 节点,最新在前。每条是 [序号] 行号 轮T 工具 体积,例如只有一页时:共 3 个 tool/result(最新在前):[1] 7 轮3 tool-2 149B | [2] 5 轮2 tool-1 149B | [3] 3 轮1 tool-0 149B 全文 /redact nodes full · 隐藏 /redact hide <序号>。分页:按显示格预算切块(不是固定条数——固定条数会让"装不下的那几条"永远看不到);多页时 head 变成 共 N 个 tool/result(最新在前)第1/7页:,未到底接 续 /redact nodes 2(还有17条) ·,末页没有「续」只有 全文 … · 隐藏 …;只有一页时不写 第x/y页,head 直接以 : 收尾。序号跨页连续(20 个节点实测 7 页、序号 1..20 无缺口),所以任何一页看到的序号都能直接喂给 hide;页号越界自动落到最后一页。T 是 tool/result 事件自己的 data.turn(拿不到就写 -),工具名用该结果的 message.source.callId 回到 tool/call 事件里对出来(对不上显示 (未知工具));体积 ≥1KB 写作 12.3KB。nodes full(或 --full)把完整清单写到 %TEMP%\dsh-redact-nodes-<会话 id>.txt,每行形如 [1] 轮 3 tool-2 149B 行 7 {"cmd":"very-long-command-line-2","query":"SYNTHETIC-…"} → /redact hide 1 --commit(命令行经 oneLine() 压平并按 argsCells 截断;设 0 则整段省略、不留占位符),回复 完整清单已写入 <路径>(共 N 条,序号可直接 /redact hide <序号>)。正文永远不显示(识别元数据的完整说明见 §3.2);列表按会话记住,供 /redact hide <序号> 引用。⚠️ 显式拒绝别的会话:nodes 只能列出当前会话的节点(surface 是活会话的内存状态) · 其它会话请用 /redact apply 处理其日志。当 allowDialogs: true 且 tuiDialogs 可用时尾部多一段 选择 /redact pick · (两者缺一不提示) |
| pick | /redact pick | 在 TUI 面板里用方向键选要隐藏的节点——这是「看到完整列表并直接选中」的正解,不受 200 格单行限制(对话框由 TUI 自己的 chrome 渲染)。⚠️ 默认关闭:pick 的第一道判断是 allowDialogs(默认 false,见 §2.3/§2.5),关着时直接返回 pick 的模态对话框默认关闭(它会让 TUI 键盘卡住,且 approval 挂起时必然复现)· 用 /redact nodes 看编号,再 /redact hide <序号> --commit · 确要启用请设 allowDialogs: true 并自行承担风险,连对话框服务都不会碰;第二道是 dialogTimeoutMs: 0(回 对话框已被配置禁用(dialogTimeoutMs: 0)· …);第三道才是服务探测(回 当前前端没有对话框服务 tuiDialogs · …)。启用后仍然只给元数据:先 select(多行列表,每条 label 形如 [2] 行 5 轮 2 · tool-1 · 149B,该次调用的命令行作为这一条的 description 显示在下一行;argsCells: 0 时整条 description 都不设),再 confirm 二次确认(标题 隐藏 [2] 行 5 · tool-1 · 149B?,正文 隐藏后从下一轮请求起模型不再看到它(磁盘字节仍在)。该节点会被永久遮蔽,无法还原。,按钮 隐藏 / 取消),确认后执行与 hide 相同的 surface 替换。成功:已隐藏 [2] 行 5 · tool-1 · 下一轮请求起模型不再看到 · 磁盘字节仍在(会话关闭后用 apply 清理)。"面板没弹"与"人取消"是分开报的:人取消(Esc / 超时,插件侧耗时 ≥ 50ms)回 已取消;select 在 50ms 内返回取消说明宿主没接纳本行 → 对话框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件;确认框同理 → 确认框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact hide <序号> --commit(修法见 §2.5)。只作用于当前会话(pick 只作用于当前会话;其它会话请用 /redact apply 重写日志。)。一次最多列 60 条:超过时标题写明 选择要隐藏的节点(共 70 个,仅列最新 60 个;其余用 /redact hide <序号>),不超过时是 选择要隐藏的节点(共 N 个,最新在前)。两个对话框都带 timeoutMs: dialogTimeoutMs(默认 15000)。对话框调用本身抛错回 对话框调用失败:<原因>;选到不存在的 id 回 无效的选择:<id> |
| hide | /redact hide <序号\|plan.json> [--commit]、/redact hide --lines <行号> [--commit] | 只作用于当前会话,三种定位方式(见下):① <序号> 引用最近一次 /redact nodes 的第 N 条;② <plan.json> 找出 data.message 里含 substitutions[].find 的 tool/result 表面节点;③ --lines <行号> 按日志行号(就是 nodes 打印的那个行号)定位。不加 --commit 只试算(试算:将隐藏 N 个节点(seq 5)· 加 --commit 执行,或 试算:未命中任何 tool/result 节点;不写会话、不碰磁盘);加 --commit 才逐个追加替换节点,并报告 已隐藏 N 个节点 · 下一轮请求起模型不再看到 · 磁盘字节仍在(会话关闭后用 apply 清理)(一个都没命中则是 未命中任何 tool/result 节点,未做改动)。⚠️ 多个目标中途失败时会把已生效的部分如实报出来:第 2 个节点(seq 2)的消息形态不受支持(content 里没有可替换的文本块),未做改动 · 已有 1 个节点被永久遮蔽(seq 1),该变更已生效且不可撤销,建议重开会话。磁盘字节不变,从下一轮请求起生效 |
| plan | /redact plan <plan.json> [--session <id>] | 试算,不写任何文件(单行):试算 OK(未写盘) · <id8> · 行 删x/空x/改x/换x · 帧 留x/写x · 自检通过。统计段只在计数 >0 时才追加 /重编号x、/移除x(例:行 删0/空0/改0/换1 · 帧 留1/写3) |
| apply | /redact apply <plan.json> [--session <id>] [--allow-live] | 就地执行(单行):记录文件修订号 → 读盘 → 试算 → 复查修订号(期间被追加过就整单拒绝)→ 写隔离备份 → 落盘 → 清理派生缓存。成功输出把备份与「仍含原文」放在最前(渲染端只保留前 200 格):已脱敏 sess-bet · 备份 session.v3.jsonl.zstd.quarantine-<时间戳>(仍含原文,确认无误后自行删除) · 行 删0/空0/改0/换0 · 帧 留1/写1 · 缓存清理 0 · 另有 7 处需自行处理(见文档 安全模型);--allow-live 且目标是当前会话时多一段 · 重开会话后内存历史才更新。拒绝当前会话时把出路放在最前:拒绝改写在用会话 sess-alp(写句柄仍持有该日志) · 加 --allow-live 可在本会话内执行(之后需重开会话) · 或切到其它会话后执行 /redact apply <计划.json> --session sess-alp。--allow-live 时若日志尾部有残帧会拒绝执行(写句柄缓存着按改写前布局算出的截断偏移,可能造成静默损坏) |
| verify | /redact verify [--session <id>] | 单行自检。通过:✓ sess-alp · 帧 4 · 行 9 · 头部合法 · seq 密集 · 引用合法 · 读取端可打开;不通过:✗ <id8> · 帧 N · 行 N · 问题:<原因>(此时 kind 也是 error;引用类问题报 refs.reason,否则报残帧/seq gap/解析失败行数)。帧头损坏时返回 日志无法解析:<原因> 而不是抛异常 |
| purge | /redact purge [--session <id>] | 单行:已清理缓存 N 个 · <id8>;若还有本工具不动的副本,追加 · 仍有 N 处本工具不动:<第一个路径> 等(只列路径,不读内容)。删除失败不改变命令成败,只在计数后加 (失败 N:<第一个原因>) |
| rollback | /redact rollback <轮数> [--session <id>] [--commit] | 按轮回退(单行):数出日志里的 turn/end 边界,把最后 N 轮整体截断(省略轮数时按 1 轮)。默认只试算:会话 sess-rol · 共 3 轮 · 回退 1 轮 → 保留 2 轮 · 删 2 行(自第 6 行起截断) · 试算未写盘,加 --commit 执行;加 --commit 才落盘,后接 · 备份 <文件名>(仍含原文,确认无误后自行删除) · 缓存清理 N,再按是否当前会话给 · 重开会话后生效 或 · 下次打开该会话即为回退后状态。拒绝当前会话:拒绝截断在用会话 sess-rol · 加 --allow-live 可在本会话内执行(之后需重开会话) · 或切到其它会话后执行 rollback 1 --session sess-rol。至少要保留 1 轮;日志里没有 turn/end 时直接拒绝 |
| undo | /redact undo [--session <id>] [--commit] | 撤销上一次脱敏(单行):找出该日志最新的隔离备份。默认只试算:sess-bet · 备份 session.v3.jsonl.zstd.quarantine-<时间戳>(271B)· 当前 271B · 试算未写盘,加 --commit 恢复;加 --commit 才恢复,后接 · 已恢复(已校验) · 撤销前状态另存 <文件名> · 缓存清理 N(此前先把当前状态另存为 <log>.before-undo-<时间戳>)。装回前先校验备份:帧可解码、头部合法、seq 密集、引用合法、无残帧,且事件数不少于当前日志;不合格则拒绝并保持当前日志不动:拒绝恢复:备份 <文件名> 不合格(<原因>) · 当前日志未改动(185B) · 可改用更早的 .quarantine-* 或 .before-undo-* 备份手工恢复。拒绝当前会话:拒绝恢复在用会话 sess-u(写句柄仍持有该日志) · 加 --allow-live 可在本会话内执行(之后需重开会话) · 或切到其它会话后执行 /redact undo --session sess-u |
输出为什么是单行的(渲染约束)
dsh-tui 不会直接把命令返回的 text 显示出来:它先走 cleanRenderText(text, COMMAND_RESULT_CELLS),其中 COMMAND_RESULT_CELLS = 200(在 @deepseek-harness-tui/dsh-tui 里,常量在 lib/types/screens/Chat.js:120,调用点在同文件 :1082),实现在 lib/types/dsh-adapter/sanitize.js:
const flat = withoutAnsi.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/\s+/g, ' ').trim();
if (stringWidth(flat) <= maxCells)
return flat;
let out = '';
for (const ch of flat) {
if (stringWidth(out + ch) > maxCells - 1)
break;
out += ch;
}
return `${out}…`;两件事同时发生,而且都绕不过去:
- 所有空白被压平:
\n、\t、连续空格全部变成一个空格——多行文本到这里必然变成一行。 - 按 200 个显示格截断,末尾补
…。宽度是终端显示格,CJK 一个字算 2 格,所以中文输出实际上只有约 100 个汉字的位置。
这是硬约束,不是风格选择——渲染端不会因为某个命令想多打几行就放宽。所以本插件干脆自己先做同一件事:index.js 里实现了一份同规则的 clamp(s, 200)(同样"CJK 算 2 格、超出用 … 收尾"),并在命令注册处用 clampResult() 包住整个 handler:
handler: (invocation) => clampResult(handler(invocation)),也就是说,每条输出在离开 handler 之前就已经被收敛到 200 格以内,不必等渲染端来砍。所以现在所有子命令的正常输出都是单行,字段之间用 · 分隔,最重要的信息在最前(只有少数很长的报错分支内部还是换行拼的,见下)。
结论,请照这个前提使用本工具:
- 不要指望可读的多行排版。 任何超过 200 格的内容,尾巴一定被砍掉。连
apply/rollback的成功输出也可能在备份信息之后就被截断——隔离备份的文件名很长。(这也是成功文案把备份与「仍含原文,确认无误后自行删除」提到最前面的原因。) - 不要围绕"从通知里复制一长串东西"来设计操作。 通知是压平 + 截断过的,长路径、长行号既可能已经被截掉,也不好选中。这正是
hide <序号>存在的理由:跑完/redact nodes,隐藏最新那条只要敲/redact hide 1 --commit,不用从通知里复制任何字符。 list/nodes/scan会在预算内主动少列几条(…另N个/…共M):宁可少列,也不让关键的行号被截掉。- 少数很长的报错分支内部仍是用换行拼的字符串;它们一样会被
clamp到 200 格、并在渲染时压成一行,所以你在屏幕上看到的永远是单行。
[!TIP] 唯一的例外是
/redact pick,而且它正是为绕开这条约束而生。 对话框走的是 TUI 自己的面板(ctx.tuiDialogs),由 TUI 的 chrome 渲染、TUI 自己掌管键盘,不经过cleanRenderText——所以它可以多行、可以用方向键选、不会被压平或截断。要「在完整列表里挑一条」,用pick;要在单行通知里定位,用nodes+hide <序号>。两者互补,而且都不会显示工具结果的正文(只有序号/行号/轮次/工具/体积,以及pick里的命令行)。注意pick默认关闭(allowDialogs: false,见 §2.5),打开后才有这条例外。
细节(都来自实现,不是约定):
rollback与undo默认都是试算,必须显式--commit才落盘;hide同理。rollback/undo与apply一样都拒绝作用于当前正在使用的会话,除非显式--allow-live;而hide/pick恰好相反——它们只作用于当前会话,--session指向别的会话时直接报错,也不接受--allow-live。nodes是分页的,nodes full是导出。 它只列当前会话 surface 上的tool/result节点,最新在前(刚触发风控的几乎总是最近那条)。分页按显示格预算切块(不是固定条数)——固定条数会让「装不下的那几条」永远看不到;序号跨页连续,所以hide <序号>在任何一页看到的序号都能直接用。nodes full把完整清单写到%TEMP%\dsh-redact-nodes-<会话 id>.txt(用完整 id 做文件名,避免两个会话互相覆盖),每行都带→ /redact hide <n> --commit,命令行按argsCells截断(0= 完全不写命令行)。它不显示正文,也不暴露内部seq(seq只出现在hide的试算与半途失败文案里;清单文件与面板都只用行号/序号,因为那才是你能直接喂回命令的东西)。pick是「看到全部再选」的正解,但它默认关闭、且依赖 TUI 行。 判断顺序是allowDialogs(默认false→ 回"默认关闭"说明)→dialogTimeoutMs === 0→ctx.get('tuiDialogs')。它软探测该服务——默认不写进inject,所以缺这个服务时本插件不会被卡在等待态,只是pick返回降级提示;代价是启动时序最坏时面板可能静默不弹(插件用 <50ms 的耗时把它识别成专门的报错)。要真正用上面板,需要allowDialogs: true+ 行级inject两件事(见 §2.5)。服务契约见@deepseek-harness-tui/dsh-tui的lib/types/dsh-adapter/dialogs.d.ts:ctx.tuiDialogs是TuiDialogRuntime extends Service,提供select/confirm/input;每个方法都会校验请求(渲染路径上的不可信数据),请求不合法时只 warning + 返回取消值(undefined/false),绝不抛异常——「对话框不能把插件或 TUI 带下去」。该文件里DIALOG_DEFAULT_TIMEOUT_MS = 30000(未给signal与timeoutMs时的兜底);本插件对两个对话框都显式传timeoutMs: dialogTimeoutMs(默认 15000,原先的 120000 已废弃 —— 理由见 §2.3 与 §2.5)。select返回选中的id,取消时undefined;confirm返回布尔,取消时false;两者本插件都当作「已取消」——但耗时 < 50ms 的取消不算"人取消":那说明宿主没接纳本行(准入竞态),插件会回专门的报错,见 §2.5。另外该运行时对每个请求都有上限(TITLE_CELLS/LABEL_CELLS= 120 格、MESSAGE_CELLS= 400 格、MAX_OPTIONS= 100 条),所以插件自己先把标题压在 ~90 格、选项截到 60 条,避免被宿主静默裁掉。匹配文本写进
plan.json,不要写在命令行上。 命令注册时设了recordInput: false,所以rawInput不会进会话日志;但前端自己的输入历史(~/.dsh-tui/history.jsonl)不看这个开关,所以命令行上打出来的字仍可能留下。--session <id>是会话 id。同一个会话若同时存在session.v2.jsonl.zstd与session.v3.jsonl.zstd,只会命中版本号最高的那一份。目标会话找日志的顺序:遍历
<root>/<项目>/<id>/,取session.vN.jsonl.zstd中版本号最高者;找不到时报找不到会话 <id> 的日志。参数按
"..."/'...'/ 空白切分,外层引号会被剥掉——所以含空格的路径用引号包起来即可(/redact apply "C:\my plans\plan.json")。相对路径仍相对宿主进程的工作目录解析,建议一律用绝对路径。hide只处理tool/result节点(用户消息、助手消息不参与),替换文本固定用配置里的placeholder,所以计划里的replace对hide无效。定位方式有三种:<序号>—— 引用本会话最近一次/redact nodes的第 N 条,1就是最新那条。这份列表只存在内存里、按会话保存:没跑过nodes就hide 1会报还没有节点列表,请先运行 /redact nodes,序号越界报序号 99 超出范围(当前 1-2),宿主进程重启后失效。<plan.json>—— 把data.message序列化后做子串匹配,只认substitutions[].find(其余键一概不看)。--lines <行号>—— 按日志行号定位,也就是nodes打印的那个行号(第 1 行是头部行,事件行seq对应行号seq + 2)。支持行号串:--lines 7、--lines 3-5、--lines 3,5-7。这一种完全不需要 plan.json,所以原文一个字都不用落到任何文件里。- 三种都不给时报
用法:/redact hide <序号|plan.json> 或 /redact hide --lines <行号>;--lines必须带值(写成--lines --commit会被当成光杆旗标,等于没给)。--session指向别的会话时报hide 只作用于当前会话;其它会话请用 /redact apply 重写日志。
--allow-live必须写成光杆旗标。写成--allow-live yes会得到字符串'yes',不满足=== true的判断,仍然会被拒绝。未知子命令返回
未知子命令 <cmd>加用法;既没有agent也没有--session时报无法确定目标会话,请加 --session <id>。用法串本身也已经改成单行:用法:/redact list|nodes|pick|verify|purge|undo(可加 --session <id>) · scan|plan|apply <计划.json> · hide <序号|计划.json|--lines 行号> · rollback <轮数> · 写操作需 --commit;匹配文本写在计划文件里,别写命令行。
3.2 识别元数据:怎么认出「是哪一条节点」
用户最常问的一句话是"根本看不出来是哪条消息需要撤回或修改"。所以 nodes / nodes full / pick 现在都补齐了同一组识别元数据——序号 / 日志行号 / 轮次 / 工具名 / 体积 / 该次工具调用的命令行。工具结果的正文永远不出现(这是本工具的承诺),但这六项合起来足够你认出目标。
四个真实样例(全部来自合成夹具,不是真实会话;第 3、4 条例外——pick 要先设 allowDialogs: true 才会走到对话框):
1) /redact nodes 的单行通知(最新在前;每格 [序号] 行号 轮T 工具 体积)
共 3 个 tool/result(最新在前):[1] 7 轮3 tool-2 149B | [2] 5 轮2 tool-1 149B | [3] 3 轮1 tool-0 149B 全文 /redact nodes full · 隐藏 /redact hide <序号>
2) /redact nodes full 写出的清单文件(每行一条,末尾直接给出可复制的 hide 命令)
[1] 轮 3 tool-2 149B 行 7 {"cmd":"very-long-command-line-2","query":"SYNTHETIC-SEARCH-TERM-… → /redact hide 1 --commit
[2] 轮 2 tool-1 149B 行 5 {"cmd":"very-long-command-line-1","query":"SYNTHETIC-SEARCH-TERM-… → /redact hide 2 --commit
3) /redact pick 的选项:label 是识别信息,description 是命令行(面板里分两行显示)
[1] 行 7 轮 3 · tool-2 · 149B
{"cmd":"tool-2","q":"Q-2"}
4) /redact pick 的二次确认框(把要隐藏的那条再念一遍)
隐藏 [2] 行 5 · tool-1 · 149B?每个字段从哪来(都出自 index.js 的 collectNodes()):
| 字段 | 来源 | 拿不到时 |
|---|---|---|
| 序号 [N] | surface.nodes 里最新在前的位次,和 /redact hide <序号> 用的是同一套编号 | 一定有 |
| 行号 <行> | tool/result 事件的 seq + 2(第 1 行是头部行,事件 seq N = 日志行 N+2);--lines <行号> 认的就是它 | 一定有 |
| 轮次 <T> | tool/result 事件自己的 data.turn——用它和你界面上看到的"第几轮对话"对上 | 写作 - |
| 工具名 | 用 message.source.callId 回到 tool/call 事件取 data.name | (未知工具) |
| 体积 | JSON.stringify(event.data.message).length,人类可读(1.5KB / 551B) | 序列化失败时 ? |
| 命令行 | 同一个 tool/call 事件的 data.arguments(只有字符串形态才取,其它形态当作没有),经 oneLine():控制字符→空格、空白压成单行、再按 argsCells 显示格截断 | 整段省略(nodes full 不留占位符,pick 不设 description) |
怎么用:先看轮次缩小到"哪一轮",再看命令行确认是不是那个动作,最后用序号动手(/redact hide <序号> --commit),全程不用复制任何东西、也不会把原文带进命令历史。
[!TIP] 命令行是"认出这是哪一条"的关键,但它本身也可能含敏感查询词——所以有
argsCells这个开关:默认120显示格,调小更保守,设0完全不显示命令行(清单文件与面板里连…都不会有)。另外注意nodes的单行通知从来不含命令行(只有序号/行号/轮次/工具/体积),所以这个开关只影响nodes full的清单文件与pick的description。
[!NOTE] 轮次直接取
tool/result事件自己的data.turn;只有工具名与命令行需要回查同一callId的tool/call事件。另外,nodes full的清单行是"轮次在前、行号在后"(轮 3 tool-2 149B 行 7 …),而nodes通知里是"行号在前、轮次在后"([1] 7 轮3 tool-2 149B)——两处字段顺序故意不同:通知的 200 格预算更紧,行号是喂给--lines的第一手信息,所以排在前面。
3.3 plan.json 完整字段
同一份计划文件在 TUI 内 /redact 和离线 dsh-redact 之间完全通用。
| 键 | 类型 | 说明 |
|---|---|---|
| substitutions | [{ find, replace?, path?, lines? }] | find 必须是非空字符串(否则报错)。replace 省略时为 '',即直接删掉。path 是点分路径前缀,限定只改这棵子树下的字符串;lines 限定行范围。递归处理所有字符串值,但跳过受保护键 |
| setFields | [{ line, path, value }] | line 是 1-based 逻辑行号(不能是 1);path 必须已存在,否则报 第 N 行上找不到路径 ...;写入值可以是任意 JSON 值 |
| blankLines | 行号串,如 "830-834" | 把这些行里所有字符串值换成 blankPlaceholder(TUI 内由 placeholder 配置提供,默认 [已移除]) |
| dropLines | 行号串 | 删除这些行。后缀直接放行;中间行必须同时给 renumber: true,否则报错拒绝 |
| renumber | true / 省略 | 允许中间行删除:重编号 seq,并重映射 surfaceOp.startSeq/endSeq、sourceEventSeqs、data.shadowedSeqs、data.shadowedRange。引用到被删行时拒绝执行并提示改用 blankLines |
| keepTorn | true / 省略 | 默认 false:文件尾部若有残帧(写到一半的未完成帧),重写时会被丢弃(inspect 会用"尾部残帧起点"告诉你有没有)。置 true 则原样保留这段残字节 |
行号串格式:"812"、"812-830"、"812,900,1024-1100"(逗号分隔,可混用区间)。行号是 1-based 的逻辑 JSONL 行号,第 1 行是头部行。
受保护键(substitutions 会跳过、setFields 拒绝写入):type、seq、time、surfaceOp、sourceEventSeqs、toolCallId、callId、role、id。它们承载结构、顺序与交叉引用。
第 1 行(头部行)永远不能被删除、清空或改写。
[!WARNING] 计划里写错的键会被静默忽略(引擎只读上面这几个键)。一个拼错的
"substitions"不会报错,只会什么都不做,让你以为已经抹干净了。每次执行前都要先试算,执行后都要复验。
/redact hide <plan.json> 只使用 substitutions[].find(其余键一概不看),所以同一份计划可以既拿来 hide、又拿来 apply;而 hide --lines <行号> 与 hide <序号> 连计划文件都不需要。
3.4 一个可用的 plan.json 例子
下面全部是占位文本。真实使用时,把
find换成你要抹掉的那段原文——这份文件本身也是敏感文件,用完请自行删除。
{
"substitutions": [
{ "find": "PLACEHOLDER-SECRET-VALUE", "replace": "[已移除]" }
],
"setFields": [
{ "line": 812, "path": "data.message.content.0.text", "value": "[已移除]" }
],
"blankLines": "830-834"
}写这份文件的工作流:
- 先用
dsh-redact inspect <log> --match-file needle.txt找出命中行号(把原文写进needle.txt,不要写在命令行上;inspect只输出行号,不会回显文本)。 - 用
dsh-redact paths <log> --line 812看清该行的 JSON 结构——它只列路径与类型(data.message.content.0.text = string(len=26)),不列值。把它输出的路径填进setFields[].path。 - 只想按行粗暴作废就用
blankLines;想精确定点就用setFields;同一段文字散落多处就用substitutions。
3.5 离线 CLI
会话没有在运行时,用离线 CLI 更省事(.mjs 也可以直接用 node lib/engine.mjs <子命令> 运行;bin 里注册的名字是 dsh-redact)。它接受日志文件路径,不是会话 id。
| 子命令 | 语法 | 说明 |
|---|---|---|
| inspect | dsh-redact inspect <log> [--frames] [--match-file <f> \| --match <s>] | 结构统计:文件/字节数/完整帧数/尾部残帧起点/逻辑总行数/无校验和的帧数;--frames 追加逐帧表(帧号、字节区间、明文字节、行数、逻辑行区间);--match-file 或 --match 追加命中行数/行号/涉及帧。只输出行号与计数 |
| paths | dsh-redact paths <log> --line <n> [--no-keys] | 打印该行的 JSON 结构(路径 + 类型 + 长度),不打印值;--no-keys 隐藏对象的键名 |
| verify | dsh-redact verify <log> | 结构 + seq 密集性自检。退出码 0 = 正常,2 = 有问题 |
| plan | dsh-redact plan <log> --plan <plan.json> | 试算,不写文件 |
| apply | dsh-redact apply <log> --plan <plan.json> [--out <f>] [--apply] | 默认把结果写到 <log>.new(不动原文件);加 --apply 才先写隔离备份、再就地替换 |
| cut | dsh-redact cut <log> --drop <行号串> [--renumber] [--apply] | 删行的快捷方式。⚠️ 中间行必须加 --renumber,否则直接报错退出 |
| graph | dsh-redact graph <sessionsRoot> | 递归扫描该根目录下所有 session*.jsonl.zstd,只解头部帧,输出 id / parent / seeded / origin / depth / 文件路径——看清会话谱系,避免漏掉分叉出去的副本 |
每个子命令一个例子:
# 1) 结构 + 命中定位(不打印文本)
dsh-redact inspect "$LOG" --frames
dsh-redact inspect "$LOG" --match-file ./needle.txt
# 2) 看清某一行能改哪里
dsh-redact paths "$LOG" --line 812
# 3) 结构自检(有问题的文件不要动)
dsh-redact verify "$LOG"
# 4) 试算:只打印统计,不写任何文件
dsh-redact plan "$LOG" --plan ./plan.json
# 5) 执行:先落 <log>.new 供检查;确认后再 --apply 就地替换(会先写隔离备份)
dsh-redact apply "$LOG" --plan ./plan.json
dsh-redact apply "$LOG" --plan ./plan.json --apply
# 6) 删行:中间行必须显式重编号
dsh-redact cut "$LOG" --drop 812
dsh-redact cut "$LOG" --drop 812 --renumber --apply
# 7) 会话谱系
dsh-redact graph "$DSH_HOME/sessions"plan / apply / cut 的执行统计长这样(字段含义见 §1 与 §3.3):
试算结果(未写任何文件)
字节 原 / 新 : 532 / 539
保留帧(原字节) : 1
重写帧 / 移除帧: 3 / 0
删除行 / 清空行: 0 / 1
路径改写 / 替换: 1 / 2
重编号行数 : 0
自检 : 通过(JSON 全部可解析、头部合法、seq 密集)未被触碰的帧按原字节保留(连压缩数据都不重新压),只有被改动的帧会重新压缩;被改动的帧会带 zstd 校验和。
[!WARNING] 离线 CLI 与 TUI 内命令有两处行为差异,别搞混:
- 它不知道会话是否在运行,所以没有
--allow-live这道闸——请自己在会话关闭后再执行。- 它不会清理派生缓存(那是
/redact apply和/redact purge干的事)。用完记得/redact purge --session <id>,或手动删掉 §4 里列出的缓存文件。
3.6 两条推荐流程
会话没在跑(最安全,首选)
dsh-redact verify "$LOG" # 1. 先确认文件本身是好的
dsh-redact inspect "$LOG" --match-file needle.txt # 2. 定位命中行
dsh-redact plan "$LOG" --plan plan.json # 3. 离线试算
dsh-redact apply "$LOG" --plan plan.json # 4. 落 .new,人工看一眼
dsh-redact apply "$LOG" --plan plan.json --apply # 5. 就地替换(自动写隔离备份)
dsh-redact verify "$LOG" # 6. 复验
# 7. 删掉隔离备份(它仍含原文),再清理派生缓存会话正在跑(TUI 内)
/redact list # 1. 找到目标会话 id
/redact scan C:\plans\plan.json --session <id> # 2. 只定位,不改文件
/redact plan C:\plans\plan.json --session <id> # 3. 试算
/redact nodes # 4. 当前会话的 tool/result 列表(最新在前:序号 行号 轮次 工具名 体积;命令行用 nodes full 或 pick 看)
/redact hide 1 # 5. 试算:隐藏第 1 条(最新那条)——4 个 token,不用复制任何东西
/redact hide 1 --commit # 6. 真正遮蔽(下一轮请求起生效,无需重启)
/redact hide --lines 7 --commit # 或者按日志行号(7 就是 nodes 里打印的行号)
/redact hide C:\plans\plan.json --commit # 或者按 plan.json 里的 find 匹配(同样先试算再 --commit)
/redact apply C:\plans\plan.json --session <id> # 7. 给"没人在用"的会话真正重写磁盘字节
/redact verify --session <id> # 8. 复验
/redact purge --session <id> # 9. 清派生缓存(apply 已做过一次,可再确认)
/redact undo --session <id> # 10. 反悔:先试算,确认后加 --commit 从隔离备份恢复第 4~6 步是专门为"通知被压成一行"设计的:nodes 把序号、行号、轮次、工具名、体积压进一行;hide <序号> 只要一个数字就能指到那条节点(见 §3.1 的渲染约束)。要认出"是哪一条"——先用轮次对到你界面上的那一轮,再用 nodes full 或 pick 里的命令行确认动作,最后用序号动手(见 §3.2)。
要注意分工:对当前会话的 apply 一定会被拒绝(见 §4),所以"我正在用的这个会话"要分两步走——先用 hide --commit 立刻让模型看不到(字节仍在盘上),再在会话关闭后用离线 CLI(或 --allow-live + 重启)把字节真正抹掉。反过来,hide 只认当前会话:别的会话请直接用 apply。想撤回的是整轮对话而不是某段文本,用 rollback <轮数>;想撤回的是自己刚才那次脱敏,用 undo。
4. 安全模型
| 闸门 | 具体行为 |
|---|---|
| 绝不打印内容 | /redact 与 CLI 只输出行号与计数。inspect --match 只给命中行号;paths 只给路径、类型与长度;JSON 解析失败信息也经过清洗(只保留 position N,剥掉 Node 报错里附带的输入片段),避免错误信息把原文漏到终端或日志里 |
| 就地替换前先写隔离备份 | TUI 内:<log>.quarantine-<时间戳>(ISO 时间,: 和 . 换成 -);CLI --apply:同样规则。备份里仍是原文,确认无误后请自行删除它——本工具不会自动删 |
| 落盘方式 | 非活动会话:写隔离备份 → 写成 <log>.redact-tmp → fsync → rename 覆盖。活动会话(--allow-live):写隔离备份 → open('r+') → 写前复查文件修订号 → 先 ftruncate 再写(被打断只会留下"旧日志的前缀"=读取端容忍的短尾,而不是"新头+旧帧"混合体)→ 校验写入字节数 → fsync 原地覆盖——改名替换可能与此刻正在发生的追加句柄相撞(Windows 上尤其明显),而后端每批追加都是重新 open(path,'a') 写 EOF,覆盖写不会与它冲突 |
| 写失败时的隔离备份 | 目标文件一个字节都没动时,刚复制出来的隔离备份会被删掉(它只是原文的重复副本);一旦动过目标文件就保留备份,并在错误信息里点名它(此时它是唯一完整的原文) |
| 并发追加检测 | apply 与 rollback 在读取前记录文件修订号(size:mtimeMs:ctimeMs),落盘前再取一次;open 之后、写入之前再查一次;写完后还要求文件长度精确等于输出长度。任何一次对不上就整单拒绝(未执行:日志在本次读取之后被追加过(可能有并发写入)),以免覆盖掉新事件 |
| --allow-live 的额外闸门 | 活动会话改写时,若日志尾部存在残帧,直接拒绝:写句柄缓存着一个"按改写前布局算出的截断偏移",下次追加会按它截断,可能造成静默损坏。请关掉该会话再执行 |
| rollback 的边界保护 | 只在完整的 turn/end 边界上截断,且至少要保留 1 轮(日志里没有 turn/end 时直接拒绝);若截断会移除继承型 session/end-seed 标记(读取端会判定整份日志损坏),同样拒绝执行 |
| undo 先校验再原子安装 | 装回之前先校验选中的隔离备份(帧可解码、头部合法、seq 密集、引用合法、没有未完成残帧、事件数不少于当前日志);不合格就拒绝并指出可改用的更早备份。安装用"临时文件 + rename",而不是直接覆盖 |
| 拒绝改写在用的当前会话 | 目标是当前会话且没给 --allow-live 时,apply / rollback / undo 一律直接报错:它的写句柄仍持有该日志,就地替换可能让后续追加失败。报错信息给出安全做法,并要求 --allow-live 之后重开会话以重新加载历史 |
| 写盘前内部自检(总闸) | 输出前一遍解码回放读取端语义:JSON 全部可解析、头部合法、seq 密集、引用字段合法(sourceEventSeqs 的游程展开规则:end < 本行 seq、唯一、带区间时严格递增;surfaceOp 端点更早;session/title.messageSeqs 满足 dsh-session-title 的真实不变式)。任何一项不过就放弃输出(内部自检失败,已放弃输出),宁可不写也不写坏 |
| 引用字段清单是显式的 | renumber 只重映射读取端自己那份清单里的引用(surfaceOp、sourceEventSeqs、data.shadowedSeqs、data.shadowedRange、data.messageSeqs、data.sourceEventSeq)。盘上 sourceEventSeqs 的 [start,end] 游程会被展开成每个 seq 逐个映射后再按同一压缩器压缩;任何清单外的 *Seq/*Seqs 字段一律失败关闭(拒绝执行),宁可拒绝也不写出悬空引用 |
| 头部行与结构键受保护 | 第 1 行不能被删/清空/改写;type/seq/time/surfaceOp/sourceEventSeqs/toolCallId/callId/role/id 这些键不会被 substitutions 或 blankLines 动到,也不会被 setFields 写入 |
| 对话框默认关闭,因为"会让出键盘且可能没人能应答" | pick 的模态对话框默认关闭(allowDialogs: false):一旦它成为 TUI store 里的活动请求,聊天输入就交出键盘(Chat.js 的键盘守卫把 dialogSnapshot !== null 与 questionnaire / approval 面板同等对待,:698-701、:2562-2566,输入框停用);而面板渲染是有优先级的——有 approval 面板时对话框不显示但仍挂起(渲染分支里 approvalPanelNode !== null 优先于 dialogSnapshot,:3585),approval 又没有超时,exitOnCtrlC: false 意味着连 Ctrl+C 都退不出。于是"有审批挂着时敲 pick"必然锁键盘,只能等超时。插件因此默认根本不把 promise 停进 store(回一条说明让你用 nodes + hide <序号>);确实要用时才 allowDialogs: true,并把 timeoutMs 压到 15000(dialogTimeoutMs 可调、设 0 再禁用一层),准入竞态的修法见 §2.5 |
派生缓存清理(purge / apply 的一部分)
/redact purge --session <id>(/redact apply 结束时也会自动做一次)删除:
<cacheRoot>/session_projcache/sessions/<id>.json<cacheRoot>/session_projcache/sessions/<id>.json.bak.*
本工具故意不动的位置,需要你自己处理
purge 会把这些路径列出来(只列路径,不读内容),但不会替你改:
<DSH_HOME>/storages/session_projcache.json—— 旧版聚合缓存。删掉单会话缓存后它可能把内容回灌,建议一并删除。~/.dsh-tui/history.jsonl—— 前端输入历史。~/.dsh-tui/session-index.json—— 前端会话索引。%TEMP%/dsh-spill-*、%TEMP%/dsh-subprocess-*(POSIX 下是$TMPDIR)—— 溢写与子进程临时文件。
此外,任何已经离开本机的副本都不在范围内:写进文件的、导出成报告的、上传到别处的、已经发给模型服务商的——见 §6。
5. 验证:怎么确认真的抹干净了
三道检查,从"结构没坏"到"内容确实不在":
1)TUI 内
/redact verify --session <id>✓ <id 前 8 位> · 帧 3 · 行 6 · 头部合法 · seq 密集 · 引用合法 · 读取端可打开一行就是全部输出(见 §3.1 的渲染约束)。不通过时同一位置给的是:
✗ <id 前 8 位> · 帧 3 · 行 6 · 问题:<原因>(此时 kind 也是 error,所以通知会以错误色显示。<原因> 是读取端语义的判据:引用类问题直接报引用检查的理由,否则报尾部残帧起点 / seq 缺口 / 解析失败行数。)
顺带说明这一版的 verify 比早期多查一项:refs = verifyLog(buf) 会按读取端语义回放引用字段(含盘上游程形式),所以通过串里多了 引用合法。这修的是「引擎自检说没问题、真实读取端却打不开」的那类缺陷。
2)离线
dsh-redact verify "$LOG"输出字段:文件、完整帧数、带校验和的帧、尾部残帧起点、总行数、JSON 解析失败、seq 密集性、首行是合法头部、结论。退出码 0 = 结构自洽,2 = 存在问题——可以直接用在脚本里。
3)内容层面
用 inspect --match-file needle.txt 复扫一次:命中行数应当变成 0。它只回显行号,所以复验本身不会把原文再打出来。
"seq 密集性"是什么意思
会话日志是一串各自独立、各自带校验和的 Zstandard 帧的简单拼接:第 0 帧只有 1 行头部行({"type":"session",...}),之后每帧是一次持久化追加批次,含 1..N 行 JSONL 事件,一行一个事件。
seq 密集指:除第 1 行(头部行)外的每一行,其 seq 恰好等于 行号 - 1(0-based 计数)。这是读取端最硬的一条约束——它逐行累加 eventCount 并断言 event.seq === eventCount,一旦不连续就判定整份日志损坏。
verify 打出 · seq 密集 · 引用合法 · 读取端可打开 的含义就是"这些不变量成立,读取端能正常打开"。它不保证内容已经不在了——内容是否还在,要用第 3 步的命中复扫确认。
仓库里的自测
五份脚本,全部只用合成日志(假内容,不碰任何真实会话),分别覆盖不同层次:
| 脚本 | 断言 | 覆盖什么 |
|---|---|---|
| test/handler.selftest.mjs | 248(脚本自己打印 N/M 通过) | 插件 handler 层:命令注册元数据(含 hint 串)、list/nodes/pick/scan/hide/plan/apply/verify/purge/rollback/undo 的输出与副作用、--session 与 --allow-live、各类错误路径、list 的单行 + 200 显示格预算(含 (当前) 标记、…另N个、人类可读体积)、nodes 的分页/格预算/序号连续/越界落末页/--full 导出/拒绝别的会话、识别元数据(每条都带「轮 T」、与节点账本一一对账;nodes full 每行含轮次/行号/命令行;argsCells 三态:默认 120 / 自定义 / 0 完全不显示且不留占位符;控制字符被压成空格)、pick(allowDialogs 默认 false 时拒绝且一次都不调用对话框、nodes 也不推荐 pick;打开后覆盖有/无 tuiDialogs、select 与 confirm 的请求形状、人取消 vs 面板没弹的 <50ms 判据、dialogTimeoutMs 自定义与 0、无效 id / 非数字 id、抛错、60 条上限、argsCells=0 时不给 description)、config 校验(null/undefined/非对象/空串/未知键;argsCells 与 dialogTimeoutMs 的负数、小数、字符串、null;allowDialogs 的非布尔全部拒绝)、hide 的试算与 --commit(断言试算文案 将隐藏 N 个节点、surfaceOp/sourceEventSeqs 形状、正文已换占位符、不删磁盘字节)、嵌套 blankLines、跨帧全局替换、后缀删行、中间删行、renumber 的引用重映射与悬空引用拒绝(断言抛出 RedactError)、头部帧原字节保留 |
| test/bugfix-regression.mjs | 54(脚本自己打印 N/M 通过) | 回归守卫(夹具用真实 Session + 真实编解码器生成,判据含真实 JsonlSessionPersistence.open()):夹具本身必须能被真实后端打开、tool/result 必须是真实消息形状;非法计划与中间行删除只返回错误、进程存活(BUG-1/BUG-1b)、损坏帧返回错误(BUG-2)、scan 定位且不泄漏原文、rollback 的试算/执行/保留边界/留隔离备份且输出可打开、undo 的查找/执行/留撤销前快照/恢复后可打开/无备份时报错、apply 脱敏后输出仍可打开且事件数不变、三者对当前会话的拒绝,以及会话内隐藏全线(真实消息形状) |
| test/hardening.mjs | 78(脚本自己打印 N/M 通过) | 红队报告 F1–F10 的回归套件,判据是真实读取端:旧夹具形状被真实后端拒绝(元发现)、sourceEventSeqs 游程的展开→映射→重新压缩及其"区间中间被删"拒绝、session/title.messageSeqs 重映射 + 真实 dsh-session-title 不变式复核、清单外 *Seq 字段的失败关闭、总闸对读取端会拒绝的输出逐条拦截、undo 对截断/损坏备份的拒绝与原子安装、live 改写的顺序/修订号复查/并发追加不被吞、缓存清理失败不改变命令成败、写失败时隔离备份的删除或点名、2 万行 renumber 不再爆栈、hide 半途失败与形态不支持 |
| test/preflight.mjs | 无断言(冒烟) | 模块可导入、name/inject/apply 形状、命令能注册(打印 hint 与 recordInput)、list 能跑通,以及未知会话 / 未知子命令 / 缺计划文件 / 无 agent / 会话无 surface 时 hide 的报错文案 |
| session-surgery/selftest.mjs | 34(34/34 通过) | 引擎 / CLI 主路径:帧扫描与逐帧统计、命中定位且不回显原文、paths 不打印值、三种原地改写生效且行数/帧数/seq 不变、未触碰帧原字节保留、中间行删除无 renumber 时被拒、带 renumber 时放行、后缀删除直接放行、拒绝删除/清空头部行、拒绝改写结构键 seq、损坏文件被拒、verify 正常退出、plan 试算不写文件 |
# 在 dsh-plugin-redact/ 里
node test/handler.selftest.mjs # 248/248 通过(退出码 0)
node test/bugfix-regression.mjs # 54/54 通过(退出码 0)
node test/hardening.mjs # 78/78 通过(退出码 0);需要真实 DSH 安装(DSH_REDACT_DSH_LIB 可覆盖路径)
node test/preflight.mjs # 冒烟检查,只打印观察结果
# 引擎 / CLI 那份在隔壁目录
cd ../session-surgery
node selftest.mjs # 34/34 通过本轮实测的输出末两行(handler.selftest.mjs 会把被测文件的字节数与 sha256 前 12 位一并打印,方便确认"通过的到底是哪一版"):
248/248 通过
被测 index.js:59330B sha256:e1b0ee081042bugfix-regression.mjs 与 hardening.mjs 会把真实 DSH 读取端当作判据:前者用真实 Session/sessionFormatCatalog 造夹具再用真实 JsonlSessionPersistence.open() 复核,后者还额外调用真实 dsh-session-title 不变式。只看引擎自己的自检不足以发现读取端语义缺陷——旧夹具写成 role:'tool'(真实形状是 role:'user' + source:{kind:'tool',callId}),所以那套绿色的断言曾经放过 sourceEventSeqs 游程与 session/title.messageSeqs 两类缺陷。
五份都需要 Node >= 22.15(zstd API),且都不需要 DSH 在运行。handler.selftest.mjs 会把 DSH_HOME 指向 mkdtemp 出来的临时目录(夹具跑完即删),并打印被测 index.js 的字节数与 sha256 前 12 位,方便你确认"通过的到底是哪一版";它还会把 process.exit 换成抛异常作为安全网(现在这条路径已经不再触发——引擎改为抛 RedactError,它同时把这一点作为"探针"记录下来,见 §6)。
表里没有单列的 test/real-reader.mjs 不是套件而是助手模块:它导出真实 Session / sessionFormatCatalog / JsonlSessionPersistence 的加载与夹具工具(writeSessionLog / backendOpen / titleInvariantError),由 bugfix-regression.mjs 与 hardening.mjs 引用。直接 node test/real-reader.mjs 不会打印任何断言结果——这是它的正常表现,不是失败。找不到真实 DSH 包时它会抛错而不是跳过(判据不能降级),可用 DSH_REDACT_DSH_LIB 指向 @deepseek-ai 目录。
test/ 里 handler.selftest.mjs 与 preflight.mjs 在 package.json 的 files 列表里(它们不需要真实 DSH 安装,所以随 npm 包发布,消费者可以自己跑一遍复核);bugfix-regression.mjs、hardening.mjs、real-reader.mjs 只在源码仓库里(判据依赖真实 DSH 读取端,找不到会抛错而不是跳过)。package.json 没有配置 scripts,请直接 node <文件> 运行。
6. 局限(诚实清单)
- 范围只有会话日志与它的派生缓存。 覆盖对象是
<DSH_HOME>/sessions/**/session.vN.jsonl.zstd与purge列出的缓存;其他一概不管。 - 只处理"版本号最高"的那一份日志。 同一会话若留有
session.v2.jsonl.zstd等旧版本文件,它们不会被改写,原文仍在。请用list/graph确认没有旧版本或分叉副本后,自行处理或删除。 - 已经离开本机的内容收不回来。 已经被写进文件、导出成报告、上传到别处、或已经作为请求发给模型服务商的内容,本工具无法撤回。它只能修复本机会话日志里的副本。
- 它不阻止内容进入。 这是事后修复工具:内容先落盘,之后才被抹掉。互补的做法是预防型策略插件——在工具结果持久化之前就替换掉它(见下)。
apply对当前会话是"原地覆盖",不是改名替换。 加--allow-live时它走open('r+')→ 写入 →ftruncate→fsync就地覆盖(改名替换可能与正在追加的句柄相撞,Windows 上尤其明显);不加则对当前会话一律拒绝。即便如此,运行中的进程内存里仍持有旧文本,它后续发给 provider 的请求也仍带着它——成功输出会明确提醒你重开会话,而且已经发出去的请求收不回来。当前会话的正确姿势仍然是先用hide --commit(立刻生效、不用重启、不删字节),字节留到会话关闭后再用离线 CLI 抹。- 引擎级拒绝现在抛
RedactError,不再process.exit(早期版本的缺陷已修复)。 早期实现用"打印到 stderr + 退出进程"表示致命错误,那会让/redact plan|apply直接杀掉宿主进程;现在die()抛RedactError,插件那层try/catch把它变成普通的未执行:<原因>错误结果,离线 CLI 则由main()顶层统一转成 stderr + 退出码 1(对外行为不变)。仓库里的test/bugfix-regression.mjs(BUG-1 / BUG-1b / BUG-2)与test/handler.selftest.mjs的探针就是针对这条回归的守卫。 renumber会重写结构。 它会改动所有行的seq并重映射向后引用;引用了被删行的行会被判为悬空引用而拒绝执行——那种情况请改用blankLines。- 残帧默认丢弃。 尾部未写完的帧默认在重写时被丢掉;要保留得在计划里写
"keepTorn": true。先看inspect的"尾部残帧起点"。 - 未触碰的帧原字节保留,被改动的帧会重新压缩——因此文件大小会变(可能变大),这是正常的,不代表还有原文。
- TUI 内的路径要用引号包住空格(参数按
"..."/'...'/ 空白切分,外层引号会被剥掉),相对路径相对宿主进程的 cwd 解析——建议用绝对路径。 - 需要 Node >= 22.15(zstd API)。
- 隔离备份是"福也是祸":它让你能回退,但它仍然包含原文。忘了删,等于没脱敏。
hide不是脱敏。 它只追加surfaceOp替换节点把内容移出模型视野,原文仍然完整保存在日志里(它自己的成功输出也会提醒这一点)。它只处理tool/result节点(用户消息、助手消息不参与),替换文本固定用配置里的placeholder(所以计划里的replace对hide无效);定位方式有三种——<plan.json>的substitutions[].find、/redact nodes给的<序号>、或--lines <日志行号>。后两种与find无关,因此不需要把原文写进任何文件。scan遵守substitutions[].lines:只报限定区间内、且apply真的会改的行,输出里会注明· 已按 substitutions[].lines 限定。verify遇到帧头损坏的日志会返回日志无法解析:<原因>而不再抛异常,但要复核这类文件仍建议用离线dsh-redact verify——它以退出码 2 报告问题。rollback依赖turn/end事件。 日志里没有turn/end就完全无法按轮回退(会直接拒绝),而且至少要保留 1 轮——它不能把整个会话清空。截断会碰到继承型session/end-seed标记时也会拒绝。- 通知永远是一行、最多 200 个显示格,而且插件自己也按这个额度裁剪。 渲染端(dsh-tui)会压平换行并按 200 格截断——见 §3.1;本插件在
clampResult()里用同一套规则先裁一遍,所以每条输出在离开 handler 时就已经 ≤200 格。这是硬约束,不是风格选择:中文一个字占 2 格,约 100 个汉字就把额度用光。唯一的例外是/redact pick:它走 TUI 自己的对话框面板,不经过这条渲染路径,所以是多行、可方向键选择的——这也正是它存在的理由。后果要心里有数:apply/rollback的成功输出带着很长的隔离备份文件名,尾巴完全可能被截掉;本版已把备份与「仍含原文,确认无误后自行删除」挪到最前面,但别把"没看到提醒"当成"没有备份"——备份就在日志旁边,purge/undo的输出也会告诉你它的名字。同理,list在会话很多时连末尾的用 /redact nodes 看当前会话提示都会被截断,nodes因此才要分页。不要围绕"从通知里复制一长串东西"来设计操作:要隐藏最新那条,直接敲/redact hide 1 --commit,或者用/redact pick。 hide <序号>依赖内存里的节点列表。 序号引用的是本会话最近一次/redact nodes的结果,它只存在宿主进程内存里、按会话保存:还没跑过nodes(或进程重启过)就必须先重新nodes,否则报还没有节点列表,请先运行 /redact nodes。不想依赖这份列表,就用hide --lines <行号>(行号你自己知道的话)、hide <plan.json>,或直接用pick(它自己重新收集节点,不依赖这份列表)。- 少数很长的报错分支内部仍是换行拼的字符串。 成功路径与短报错都是单行,但"拒绝改写/截断/恢复当前会话"、"拒绝
--allow-live:残帧"、"未知子命令"、"缺计划文件"这几条依然用\n拼接;它们同样先被clamp到 200 格、再被渲染端压成一行,所以屏幕上仍是一行,只是长句的尾巴可能被砍掉。 pick默认关闭,并且依赖 TUI 行的对话框服务。 第一道闸是allowDialogs(默认false):关着时pick直接返回pick 的模态对话框默认关闭(它会让 TUI 键盘卡住,且 approval 挂起时必然复现)· 用 /redact nodes 看编号,再 /redact hide <序号> --commit · 确要启用请设 allowDialogs: true 并自行承担风险,不碰任何服务,其余子命令完全不受影响。打开后才走软探测ctx.get('tuiDialogs')(默认不写进inject,所以缺这个服务也不会把插件卡在等待态):没有该服务时回当前前端没有对话框服务 tuiDialogs · 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件;被dialogTimeoutMs: 0关掉时回对话框已被配置禁用(dialogTimeoutMs: 0)· …。想真正用上面板,需要allowDialogs: true和行级inject两件事(见 §2.5);只开前者、不加inject时,"准入竞态"会让select/confirm在 50ms 内返回取消值——插件用耗时把这种情况单独报出来(对话框没有弹出(0ms 内直接返回取消)…/确认框没有弹出(…)),不会静默失败。另外服务契约(@deepseek-harness-tui/dsh-tui的lib/types/dsh-adapter/dialogs.d.ts)承诺请求不合法时只 warning + 返回取消值、绝不抛异常;本插件对两个对话框都显式给timeoutMs: dialogTimeoutMs(默认 15000),超时按「已取消」处理。pick一次只能选最新 60 条。 这是插件自己的上限(宿主侧的MAX_OPTIONS是 100,插件留了余量):超过时select.title会写明选择要隐藏的节点(共 70 个,仅列最新 60 个;其余用 /redact hide <序号>),但第 61 条及更早的节点无法通过对话框选中(options的 id 是1..60,它们对应的永远是最新的 60 条)。要处理更早的节点,用nodes <页号>翻到对应页再hide <序号>,或nodes full导出后照着文件里的序号hide。nodes full写出的清单文件不会自动清理。 它落在%TEMP%\dsh-redact-nodes-<会话 id>.txt(POSIX 下是$TMPDIR),文件名用完整会话 id 做安全化处理,所以不同会话不会互相覆盖。文件里是轮次、工具名、体积、日志行号,以及该次工具调用的命令行(命令行受argsCells控制,设0则完全不写)——不含工具结果正文,也不含内部seq;但会话 id、工具名与命令行本身仍可能算元数据(命令行尤其可能含查询词),用完请自行删除。本工具不会替你删,也不会在会话结束时清理。- 对话框是唯一不受 200 格规则约束的输出,但它默认关着、而且有自己的预算。 其余所有子命令的输出都在 handler 里被
clamp()先裁到 200 显示格(见 §3.1),而pick的对话框由 TUI 自己的 chrome 渲染,走的是另一套限制:标题与选项 label 各 120 格、确认框正文 400 格、一次最多 100 个选项(dialogs.js的TITLE_CELLS/LABEL_CELLS/MESSAGE_CELLS/MAX_OPTIONS)。所以插件把选择框标题压在 ~90 格、选项截到 60 条、命令行按argsCells截断——都是为了让宿主不要静默裁掉你需要的识别信息。而且这条路径默认关闭(allowDialogs: false,理由见 §2.5):不打开时你看到的全是那条单行说明,识别信息仍然从nodes/nodes full拿。 - 识别信息本身也是元数据。
nodes/nodes full/pick会显示轮次、工具名、体积与工具调用的命令行(argsCells可关)。这些都不是工具结果的正文,但命令行可能含敏感查询词——要更保守就把argsCells调小或设0。
互补做法:预防型策略插件(拦截工具结果)
本工具是事后的。想从源头减少落盘,需要另一个插件挂在工具结果持久化之前。可用的缝是 tools/execute(围绕派发的 waterfall:(exec, next) => Promise<ToolExecutionResult>,包装器返回替换后的结果),或 tools/post-execute((exec, result, next) => Promise<PostToolDecision>,可以接受 / 替换 / 阻断已归一化的结果)。这类插件的现成例子是社区插件 dsh-secret-redactor(挂工具结果做掩码),它自己的 README 也承认持久化日志里仍是原始值——这正是"预防"与"修复"必须分工的原因。
实现这个缝时有一条容易写错的关键点:要替换结果里的规范值 value,而不能只替换 content。因为成功结果的 content 与 meta 都是从 value 经工具自己的投影函数(render / presentationMeta)派生出来的,而 meta 会原样持久化到 tool/result 事件里;只掩码 content 的话,原文会从持久化的 meta 里漏出来。反过来,替换 value 会让两个投影一起重新生成。注意这个接口把二者设成了互斥:同一次决策里同时给 value 和 content 会直接抛 TypeError。
(这一段描述的是 DSH 的工具结果管线本身,不是本包的功能——本包不注册任何工具结果钩子。请以你所用 DSH 版本的类型定义为准。)
7. 与现有生态的关系:为什么还要一个
DSH 的插件生态里,"历史"类插件不少,但没有一个能改写已落盘的会话日志内容。以下都是真实存在的插件(名字取自社区调研,见仓库中的 dsh-tui-redaction-plugin-survey.md):
| 插件 | 它实际做什么 | 为什么不是"原地脱敏" |
|---|---|---|
| dsh-easyrewrite | 归档原会话,用"截断到目标消息之前"的同名新会话替换,并自动重发编辑后的文本(仅 Web UI) | 原日志只是被归档,没有被擦除;不做子串/工具结果级别的清除 |
| dsh-conversation-rewind | 追加一条 SurfaceOp 替换标记,把消息从转录与后续模型上下文中隐藏(仅 Web UI) | 只是从上下文里移除,磁盘上原样保留 |
| dsh-message-edit | 每次编辑/重试都创建一个新的会话版本(仅 Web UI) | 明确"不原地改写 Session 事件;历史是 append-only";旧会话全部保留 |
| dsh-undo | /undo 追加 surface/rewind 事件、/redo 追加 surface/restore | 追加式设计:消息节点、ID、工具调用关联、日志事件全部保留 |
| dsh-session-cleaner | 整会话:归档并物理删除日志目录;单条消息:surface 替换 | 消息级"原始事件仍保留在日志与人类转录中";整会话删除不可逆、无备份 |
| dsh-sess | 用 ctx.sessionPersistence.locate() 永久删除冷会话(仅 Web UI) | 只能整会话删除,没有消息级操作,也没有取消归档 |
| @anionex/dsh-turn-rewind、dsh-shadow-rewind | 恢复项目文件快照 / 分叉出新会话 | 作用于文件与分支,不作用于日志内容 |
| dsh-secret-redactor | 挂工具结果做掩码,遮住模型看到的那份文本 | 其 README 明说持久化日志仍存原始规范值,"日志级脱敏还在路线图上" |
| dsh-telemetry-redactor | 只脱敏外发遥测的那一份 | "它从不改写规范会话日志" |
| dsh-recall、dsh-session-index、@deepseek-ai/dsh-session-health | 检索 / 索引 / 多帧 zstd 日志的帧级诊断 | 全是只读 |
结论(也是调研的结论):这些插件里每一个都在保留日志——append-only 是生态常态。能改变磁盘字节的只有"整会话删除",而且不可逆、无备份;消息级的都只做 surface 替换。"打开 session.vN.jsonl.zstd,把指定的某段内容改写掉,同时保证日志仍可被读取端正常打开,并留下可回退的备份"——这件事没有现成插件做。
另外注意:dsh-tui 自带的 /rewind 也不是脱敏。它走的是 session fork:找到目标消息所属轮次的开始事件,分叉出一个分支会话、回放该边界之前的历史,然后把原消息放回输入框。原 session.vN.jsonl.zstd 仍然在磁盘上。 它改变的是"接下来看到什么",不是"磁盘上留着什么"——两者互补,不能互相替代。
本插件的定位就是补上这块:行级、原地、可复验、带隔离备份的日志内容脱敏,并且能从 TUI 里发起。 它刻意不做整会话删除(那有别的插件)、不做上下文级别的 rewind(TUI 原生已有)。
顺带说明:/redact hide 的机制(追加 surfaceOp 替换节点,把内容移出模型视野)与 dsh-conversation-rewind 属于同一类做法,但本包把它刻意做窄了——只针对 tool/result 节点、没有分支树 UI,并把它定位成"先止损"的第一步,配上一个真正改写磁盘字节、可回退的 apply。
8. 发布到 npm / 让别人装上
8.1 名字与 scope
官方文档没有规定命名与 scope。社区惯例是无 scope 的
dsh-*(如dsh-easyrewrite、dsh-shadow-rewind、dsh-telemetry-redactor),或 owner scope 的@owner/dsh-*。不要假定你能用
@deepseek-ai这个 scope。 已见到的该 scope 包都指向官方或官方相邻的发布者。发布前先占名,避免撞名:
npm view dsh-plugin-redact # 404 说明名字可用
8.2 发布前必须做的检查
files必须包含运行时真正会用到的文件,且dsh.bundle.patch指向的文件必须在包内可解析。本包的files已经列全:"files": ["index.js", "cordis.patch.yml", "cordis.patch.tui.yml", "lib/engine.mjs", "bin/dsh-redact.mjs", "test/handler.selftest.mjs", "test/preflight.mjs", "README.md", "README.zh.md", "LICENSE"]index.js—— 插件入口(export const name/export const inject/export function apply)cordis.patch.yml——dsh.bundle.patch指向的层(默认层:只inject: ['commands']、allowDialogs保持默认false,非 TUI 前端也能启动)cordis.patch.tui.yml—— TUI 变体层:行级inject: [commands, tuiDialogs]+ config 里allowDialogs: true(启用pick所需的全部;用法与代价见 §2.5)。exports里也解析得到:dsh-plugin-redact/cordis.patch.tui.ymllib/engine.mjs—— 引擎,被index.js相对引用bin/dsh-redact.mjs—— 离线 CLI(bin.dsh-redact)test/handler.selftest.mjs、test/preflight.mjs—— 两份不需要真实 DSH 安装的自测脚本,随包发布(见 §5)- 两份 README、以及
LICENSE(MIT 全文;本轮新增,此前files与包内都缺它)
test/bugfix-regression.mjs、test/hardening.mjs与助手模块test/real-reader.mjs故意只在仓库里、不进files:它们把真实 DSH 读取端当判据(需要本机装了@deepseek-ai/*),发布出去对消费者没有意义(找不到就抛错)。包里不要放进测试夹具、plan.json、needle.txt之类的敏感残留(自测脚本自己只造合成夹具、落在mkdtemp临时目录,所以脚本本身可以安全发布)。打包预览,确认没有漏文件、没有把不该发的带进去:
npm pack --dry-run本轮实测(
[email protected]):11 个文件、137.0 kB 打包体积 / 398.1 kB 解包体积,文件清单为LICENSE、两份 README、bin/dsh-redact.mjs、cordis.patch.tui.yml、cordis.patch.yml、index.js、lib/engine.mjs、package.json、test/handler.selftest.mjs、test/preflight.mjs。同批的姊妹包[email protected]是 10 个文件 / 61.9 kB——两个包现在都带LICENSE。engines.node保持>=22.15.0(zstd API 要求);peerDependencies(@deepseek-ai/cordis、@deepseek-ai/dsh-commands)在本包里是可选的(peerDependenciesMeta标了optional),不给消费者制造安装负担。包里不要包含测试夹具、
plan.json、needle.txt之类的敏感残留。
8.3 发布
npm login
npm publish # 无 scope 包
npm publish --access public # scoped 包(@you/dsh-plugin-redact)首次发布必须显式公开- 发布到 registry 不是必需的:官方文档支持 npm 包、
pnpm pack出来的 tarball、以及 git 依赖三条路——也就是说,直接dsh plugin --profile <name> add ./dsh-plugin-redact或add github:you/dsh-plugin-redact#<commit>也能分发。 - 走 git 分发时,pnpm ≥10 会拒绝执行依赖的
prepare,需要消费者把 pnpm 打印出来的那个 key 抄进 profile 的pnpm-workspace.yaml的allowBuilds再重试;发布 npm 包(把文件预先准备好)可以完全避开这件事。 - 版本号请遵守 semver 并按需
npm version patch|minor;消费者用dsh plugin --profile <name> update升级。
8.4 消费者怎么装
dsh plugin --profile <name> add dsh-plugin-redact # 从 npm
dsh plugin --profile <name> add ./dsh-plugin-redact # 本地目录
dsh plugin --profile <name> add github:you/dsh-plugin-redact#<commit> # commit 固定
dsh --profile <name> --dump-config # 确认 dsh-redact 行出现离线 CLI 随包安装(bin.dsh-redact),在 profile 目录里可用 npx dsh-redact 或直接调用该入口文件。用 dsh-tui 前端、并且想用 /redact pick 面板的话,装完按 §2.5 套用包内的 cordis.patch.tui.yml 即可(它同时给了行级 inject: [commands, tuiDialogs] 和 config 里的 allowDialogs: true);不套用也能用,pick 只会回一条"默认关闭"的说明并让你走 nodes + hide <序号>。非 TUI 前端不要套用那一层(会让整个 profile 起不来)。
8.5 分发时的三条提醒
- DSH 没有官方插件 registry。 官方只文档化了 npm / tarball / git 三种分发方式,并明确"不要求发布到 registry";也不存在官方提交表单或审核步骤。社区目录(如 cordis.run 及其自动生成的 Awesome 列表、
dsh-plugin-verify验证仓库、dshfind.com / dsh.so,以及dsh-tui-ecosystem+dsh-ecosystem-spec准入规范)都是社区自建、各自有各自流程的,不代表官方背书。 - 第三方插件以受信任的宿主代码运行,不在沙箱里。 装一个插件就等于用它跑在你机器上的权限执行它的代码;它能读写你的会话日志——本插件正是靠这一点工作的。请像审查任何本机程序一样审查它,发布时也请把你的权限需求写清楚(本包默认层只
inject: ['commands'],TUI 变体层额外声明tuiDialogs(见 §2.5),两者都不发布服务、不联网,读写的是你自己指定的root/cacheRoot)。 - README 里不要放真实敏感内容。 示例、issue、截图、测试夹具都可能长期留在公共记录里——这也是本工具存在的原因。
9. 许可
MIT。
