Skip to content

dsh-dialog-fence

Verified

dsh-dialog-fence · v0.14.0 · MIT · Web UI

对话围栏:一套对所有对话生效的通用规则 + 可选的按对话追加的专属规则,全部存在一个位置可自定义的 JSON 文件里,以固定顺序注入运行时上下文快照(不破坏前缀缓存、纯追加、压缩后仍存活)。每条规则右侧有独立开关可随时单独停用;还可以勾选「不使用围栏的对话」让若干对话整体跳过围栏(规则保留,取消勾选即恢复)。按标准 DSH bundle 安装,出现在「插件 → 已安装」,可用官方安装器装卸;规则可在设置页、包详情页与会话浮层里编辑。 / A dialogue fence: a shared rule li

Install

dsh plugin add dsh-dialog-fence

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-dialog-fence / 对话围栏

给 DSH(DeepSeek Harness)的每一次对话设定边界与围栏:一套随时增删、下一轮即生效的行为约束规则。 全部规则存在一个 JSON 文件里——用任何编辑器直接改 JSON,或者直接在设置页里逐条编辑。 文件放在哪可以自己定,设置页一键「打开文件夹」就能看到它。 不破坏前缀缓存 · 不膨胀上下文 · 不影响其它对话 · 与 billion-context 压缩共存。

Set the bounds and fence of every DSH (DeepSeek Harness) conversation: one list of behavioral rules you can add or drop at any time, effective from the next turn. Everything lives in one JSON file — edit the JSON in any editor, or edit rule by rule on the Settings page. The file can live wherever you want; one click on "Open folder" in Settings shows it to you. Breaking no prefix cache · inflating no context · touching no other conversation · coexisting with billion-context compaction.

两层规则,叠加注入(Two layers, injected on top of each other):

  • 通用规则(fence.json 里的 rules 数组)—— 对每一个对话都生效,包括新开的对话。设置页里主要就是编辑这一层。

  • 会话规则(fence.json 里的 sessions.<会话 id>.rules)—— 只叠加到某一个对话,可选,通常在会话右上角的「围栏」浮层里维护。

  • Shared rules (the rules array in fence.json) — apply to every conversation, including new ones. This is the layer the Settings page is mostly about.

  • Session rules (the sessions.<session id>.rules map in fence.json) — opt-in, stacked onto one conversation only; usually maintained from the fence panel in the conversation's top-right corner.

适用形态:dsh CLI(web / headless / acp 等 profile) 与 DSH Desktop。 Works in dsh CLI (web / headless / acp profiles) and DSH Desktop.


英文概览 / English overview

The same thing in one screen, for readers who would rather not read Chinese first.

What it is. A DSH plugin that keeps a short list of behavioral rules per conversation and puts them in front of the model on every turn. Two layers stack: rules shared by every conversation, plus optional rules that only apply to one session. Both live in a single JSON file you can point anywhere.

Why not just put the rules in the system prompt. Because that rewrites the system prompt every time a rule changes, and the whole prefix cache is lost. Writing the rules as an extra user message every turn is safe but never reclaimed — a few dozen turns later it is thousands of tokens of dead weight. And billion-context's own acp_rule feature only stores rules, it never injects them (its RULES_USAGE_PROMPT is defined with zero references in 0.1.202).

What this plugin does instead. It registers one runtime-context section at a fixed order (118) and renders the rules into the Current runtime context snapshot. When the rendered text has not changed the snapshot is not re-projected, so nothing is appended and no tokens are spent. When it does change, one message is appended — never a prefix rewritten. The snapshot header makes billion-context classify it as durable/runtime-context, so the latest copy survives compaction while older copies are still collectable.

Where you edit it. A Settings menu entry, the bundle's own page under Plugins → Installed, the legacy Plugins → Built-in tab, and a floating panel in the conversation's header — five surfaces, one component, one JSON file, no state fork. The panel closes when you click outside it. Since 0.13.0 every single rule also carries its own on/off switch, so one rule can be paused without deleting it; a newly added rule starts enabled, and paused rules take no number in the rendered list. Since 0.14.0 you can additionally tick any number of conversations in an exclusion list: those conversations skip the fence entirely — shared and per-session rules alike — while their rules stay in the file and come back the moment you untick them (nothing is ticked by default, so every conversation keeps using the fence).

Install. Through the official installer (Plugins → Add plugin, give it the package path or github:ILoveHAKU/dsh-dialog-fence), or dsh plugin --profile <name> add <spec>. A file:/// mount via scripts/install.mjs also works for development but does not show up under Installed.

Tests. Eight zero-dependency suites: smoke (host logic), route (HTTP API), ruletoggle (per-rule switches), client (bundle shape), render (real React first frame), opensource (pre-publication leak scan), release (publish channel), docs (this bilingual rule). npm test runs them in order. None of them touch your live rules file.

Docs. Design & implementation covers the mechanism and the rejected alternatives; the operations manual covers day-to-day use, how to change the plugin, troubleshooting and incident records. Both are in Chinese — the code and the code comments are the English source of truth.


它解决什么问题 / What problem it solves

大模型每轮都会忘掉「这次对话的规矩」。常见的补丁方式都有代价:

做法 代价
把规矩写进系统提示词 内容一变,系统提示词就变 → 前缀缓存整体失效(本机 workbuddy / opencode-go 路由未声明 systemPromptUpdate: 'in-history',会触发 head 重写)
每轮追加一条 user 消息 纯追加安全,但永不回收,几十轮后吃掉几千 token
用压缩插件的「规则」功能 billion-context 0.1.202 的 acp_rule 只存不注(源码里 RULES_USAGE_PROMPT 有定义零引用),plugin 模式 compress.rules 亦不生效

dialog-fence 的做法:把规则注入到 运行时上下文快照(runtime context) 这一段里。

<围栏位置>/fence.json                  ← 单文件:{ enabled, rules: [...], sessions: { <id>: { rules: [...] } } }
   └─ systemPrompt.context({ name:'dialog-fence:rules', order:118 })
        └─ 通用规则 + 启用会话的专属规则 → 拼成一段文本,全都没有就返回空串
             └─ agent-loop: joinContextSections() → "Current runtime context. ..."
                  └─ RuntimeContextProjection.project()
                       ├─ 文本与上次相同  → return(一条都不追加,零 token)
                       └─ 文本变化        → 追加一条新 user 消息(纯 append)

三件事因此同时成立:

  1. 零缓存破坏:从不碰系统提示词;历史里只有「追加」,没有任何前缀被重写。
  2. 零无谓消耗:文本不变时快照不落盘,同一条规则不会每轮重发。
  3. 压缩后仍存活:快照头是 Current runtime context,被 billion-context 的 dshDurableToolPath 归为 durable/runtime-context,自动进 protectedLatestTools(只保护最新一份,旧快照可被压缩),围栏常驻且不膨胀。

隔离性:通用规则为空、且当前会话没有启用专属规则时渲染空字符串,而 renderContextSections() 会 .filter(s => s.text.length > 0) —— 空串 = 零 token、零注入、零副作用。(总开关关掉时,两层一起静音。)


五处界面 / Five places to edit it

界面 位置 能力
设置页 设置左栏 → 「对话围栏」(与「通知提醒」「模型」同级) 主入口:总开关 + 通用规则全文的可视化编辑(逐条多行编辑框,可换行)+ 新增/删除/清空 + 某个对话的专属规则(下拉选对话,默认选中当前对话;逐条编辑/新增/清空;可单独停用该对话)+ 打开文件夹 + 更改围栏文件位置
插件详情页配置区 左侧「插件」→ 已安装 → dsh-dialog-fence → 详情页(包描述与行之间) 同一份配置界面
会话按钮 会话右上角工具区「围栏」,带规则条数徽标 打开本会话规则浮层
规则浮层 右下角面板(点击面板外部自动关闭) 与设置页同一套四张卡片(紧凑版):总开关 + 通用规则编辑 + 任意对话的专属规则(下拉默认停在该对话) + 打开文件夹;另有顶部「关闭」
插件页标签页 设置 → 内置插件 → 「对话围栏」一页 同一份配置界面(老入口,仍在)

五处界面写的是同一个 JSON 文件:面板改完 → 文件落盘 → 下一轮装配时重新读取 → 生效。没有任何状态分叉。 会话按钮上的数字就是真实注入条数(通用 + 本对话专属),不是只数会话层。

设置页的对话下拉里,选项文字用对话名称(重命名过就用你起的名字,否则用项目目录名; 真的用过但没起名字的对话显示「未命名对话 N」)——任何情况下都不会把 session-<uuid> 显示出来。 当前对话会标注「(当前)」并默认选中,所以「给现在这个对话加一条专属规则」不需要先选。

建了但一句话都没说过的空对话不会出现在下拉里(0.12.4 起)。这类对话在宿主侧带 blank: true 且没有标题,宿主会把它的显示名退化成项目目录名,于是下拉里会冒出一串 重复的 harness / workspace;官方侧边栏本来就是把这类行整行藏掉的,这里保持一致。 Conversations that were created but never used are not listed (since 0.12.4): they carry blank: true and no title, so the host falls back to the workspace folder name and the picker would fill up with repeated harness / workspace entries.

为什么卡片在「已安装」而不是「官方」:plugins.item 槽位的语义被写死为「官方分组里的一张卡片」, 改 id / order 都改不到别处;plugins.bundle.config 才属于「组合包自己的配置」, 以 npm 包名 为 key 渲染在该 bundle 详情页的描述与行之间。 0.5.0 起本插件按组合包安装(profile dependencies + dsh.profile.bundles + dsh.bundle.patch 三件齐备), 因此它出现在「已安装」分组,装与卸都走插件页右上角「添加插件」那套官方流程。


安装 / Install

推荐走官方插件安装器(装在「已安装」分组里,可一键卸载)。改完必须完全退出并重启 DSH(宿主半边不走 dsh-hmr 热重载)。

方式 A:官方安装器(推荐,出现在「已安装」分组)/ Way A: the official installer (recommended, lands under Installed)

包内已带 dsh.bundle.patch: ./cordis.patch.yml、exports、icon 与 dsh.client 声明,三件套齐备(dependencies + dsh.profile.bundles + bundle patch),因此官方安装器直接认它。

图形界面:左侧「插件」→ 右上角「添加插件」→ 在输入框填本包的绝对路径:

C:\Users\<你>\AppData\Roaming\dsh-desktop\harness\dialog-fence

点安装。成功后卡片出现在「已安装」分组(不是「官方」分组),点进去的详情页里就有围栏配置区。 卸载同理:进该包详情页点「卸载」,卡片消失、loader 行一并移除。

命令行:

dsh plugin --profile web add "C:\Users\<你>\AppData\Roaming\dsh-desktop\harness\dialog-fence"

dsh plugin add 装完后,dsh-plugin-manager 的 reconcile() 会自动把声明了 dsh.bundle 的新依赖追加进 profile 的 dsh.profile.bundles 并保存 manifest —— 不需要手工改 profiles/<profile>/package.json。

⚠️ 重要:本地路径安装是「复制」,不是「链接」

DSH Desktop 的安装后端(profileBundlePackageBackend)会把整个源码目录复制 到 generation 快照里,再在那儿 pnpm add:

harness/profiles/.generations/live/dsh-dialog-fence+<版本>+<hash>/node_modules/dsh-dialog-fence/

所以改完 harness/dialog-fence/ 里的源码,已安装的那份不会跟着变, 必须在插件页卸载再装一次(或重跑上面那条命令)。 规则数据不在这里:默认数据目录固定为 <DSH_HOME>/dialog-fence/rules, 与 generation 快照无关,升级换代不会丢规则(见下面「直接编辑规则文件」)。

想要「改一行就生效」的开发体验,用方式 B。

方式 B:file:/// 挂载(开发用,不进「已安装」清单)/ Way B: a file:/// mount (development only, never listed under Installed)

# 装到默认 profile(本机 DSH Desktop 用的 profile 叫 web)
node scripts/install.mjs install

# 指定 profile / DSH_HOME;--dry-run 只查看不落盘
node scripts/install.mjs install --profile headless --home "C:\Users\你\AppData\Roaming\dsh-desktop\harness" --dry-run

# 卸载(只删插入行,规则文件保留)
node scripts/install.mjs uninstall

脚本往 <DSH_HOME>/profiles/<profile>/cordis.patch.yml 末尾追加一段带标记的 insert 块,改写前自动备份成 cordis.patch.yml.<时间戳>.bak:

# >>> dsh-dialog-fence (managed by scripts/install.mjs — do not edit by hand) >>>
- insert:
    - id: dialog-fence
      name: "file:///C:/.../dialog-fence/index.js"
# <<< dsh-dialog-fence <<<

若本机没有 node(DSH Desktop 自带的是 Electron),用: $env:ELECTRON_RUN_AS_NODE="1"; & "D:\DSH Desktop\DSH Desktop.exe" scripts\install.mjs status

这种方式是引用源码目录,改 client.js 刷新页面即可、改 index.js 重启即可,无需重装。 代价:file:/// 插入行不属于 profile bundle,因此

  • 不会出现在插件页「已安装」分组(插件管理清单只列 profile 里真实安装的包);
  • Desktop 日志可能出现 [desktop] profile inconsistency: the patch layer inserts file:///…, which is not installed——只是提示,不影响加载,同一份日志下一行就是 [dialog-fence] ready。

两种方式不要同时用。同时装会有两条 loader 行重复注册槽位。 从方式 B 切到方式 A 之前,先 node scripts/install.mjs uninstall。

验证安装 / Verify the install

# 1) 无宿主测试(语法 + 行为,不会碰线上规则目录)
npm test
# 或者只洗手动指定的那几套
node test/smoke.mjs && node test/route.mjs && node test/ruletoggle.mjs && node test/client.mjs

# 2) 重启后在任意会话里
/fence status

重启并开启围栏后,下一轮的运行时上下文里会出现:

Current runtime context. This snapshot supersedes earlier runtime-context snapshots.

...(沙箱策略 / 审批策略)...

Dialog fence (user-set rules binding this conversation; a newer direct user instruction wins on conflict):
1. 你的第一条规则

看到这段就说明生效了。


用法 / Usage

可视化面板(web / DSH Desktop)/ The visual editor (web / DSH Desktop)

重启后:

  • 设置左栏 → 「对话围栏」:主入口,直接编辑通用规则(对每个对话生效)。每条占一行:序号 + 正文 + 「改」「删」 + 启停滑块(贴在最右端);点「改」就地变成多行编辑框(0.9.0 起,可换行),Ctrl+Enter 保存 / Esc 取消。底部编辑框新建一条,另有「清空全部」,右侧实时显示 注入 N 字符 ≈ M token。顶部还有 「打开文件夹」 与 「更改位置」。下半部分还有 「某个对话的额外规则」:下拉里按名字选对话(默认已选中当前对话),下面就是那个对话的专属规则列表与新建框 —— 和浮层下半部分是同一份数据、同一套操作(0.10.0 起)。这一块是两行:第一行「对话」+ 下拉,第二行「停用/启用该对话」按钮 + N/40 条专属规则 计数(0.12.1 起,此前挤在一行会把中文压成逐字换行)。
  • 会话右上角出现「围栏」按钮,数字是真实注入条数(通用 + 本对话专属;高亮 = 已启用)。点开浮层,里面渲染的就是设置页那四张卡片(紧凑版式),只是外面套了一层标题栏与「关闭」;下拉默认停在「点开它的这条对话」上,所以浮层里同样能给任意一条对话配专属规则(0.12.0 起)。点浮层外部任意位置即可关闭(0.10.0 起)。
  • 两处都能单独停用某一条对话的围栏(设置页的下拉旁也有「停用该对话 / 启用该对话」,0.12.0 起此前只有浮层有)。
  • 所有改动下一轮生效,无需重启。

三处配置入口(渲染的是同一个组件,内容和行为完全一致):

  • 设置左栏 → 「对话围栏」:一级菜单项,和「通知提醒」「模型」并排。
  • 左侧「插件」→ 已安装 → dsh-dialog-fence:点进该包详情页,配置区在包描述与行之间。
  • 设置 → 内置插件 → 「对话围栏」标签页:老入口,保留。

三处都有:

  • 总开关(关掉后所有对话立即零注入,规则文件原封不动)
  • 通用规则的完整可视编辑(新增 / 改 / 删 / 清空)
  • 每条规则一个独立的启停滑块(0.13.0 起):点一下就把那一条暂时停用 / 恢复启用, 停用期间它不参与注入、编号也不占位;新加的规则默认就是启用的,不用再手动打开。 滑块固定在规则行的最右端(序号 → 正文 → 改 / 删 → 滑块),不挡正文的视线。
  • 某个对话的专属规则的完整可视编辑:下拉选对话 → 该对话的规则逐条「改」「删」→ 底部新建框 → 「添加专属规则」/「清空该对话的专属规则」(0.10.0 起,与浮层下半部分一致)
  • 「不使用围栏规则的对话」多选名单(0.14.0 起):勾上的对话整段跳过围栏 —— 通用规则和专属规则都不注入, 但它已配置的规则一条不删,取消勾选立刻原样恢复。默认一条都不勾(= 所有对话都使用围栏)。 另有「全部不使用围栏」与「恢复全部使用围栏」两个一键动作。名单是一列可滚动的复选行,名字单行省略, 勾再多条也只会让这一列自己纵向变长,不会把旁边的按钮或上面几张卡片挤变形。
  • 围栏文件当前的绝对路径 + 「打开文件夹」按钮(在资源管理器里选中它)
  • 「更改位置」:填一个新的绝对路径(或以 .json 结尾的目录)即可把围栏文件搬过去;随后原目录留一个 location.json 指针,删掉它就回到默认位置

面板与这三处配置页都只在 Web 形态出现(CLI / TUI / headless 没有 connection 服务,插件会自动跳过路由注册,/fence 命令照常可用)。

斜杠命令(所有形态)/ Slash commands (every form)

命令 作用
/fence 查看状态(启用与否、条数、实际注入文本、token 估算、围栏文件路径)
/fence on / /fence off 启用 / 停用本会话的专属规则(不影响通用规则);后面跟序号则只操作那一条(/fence off 2 = 停用第 2 条,0.13.0 起)
/fence add <规则文本> 追加一条本会话专属规则(默认启用;重复文本不会重复添加)
/fence rm <序号> 删除本会话第 N 条
/fence clear 清空本会话规则(保留启用状态)
/fence reset 彻底删除本会话记录(等于从未配置过)
/fence dir 打印围栏文件路径(含位置指针)
/fence mute / /fence unmute 让本会话整段不使用围栏(通用 + 专属都不注入)/ 恢复使用(0.14.0 起);后面跟会话 id 则操作那一个
/fence muted 列出所有「不使用围栏」的会话(0.14.0 起)
/fence open 在资源管理器里打开并选中围栏文件
/fence file <绝对路径> 更改围栏文件位置(现有规则一起搬过去)
/fence switch on / switch off 通用总开关
/fence all 查看通用规则(对每个对话都生效)
/fence all add <规则文本> 追加一条通用规则(默认启用)
/fence all rm <序号> 删除第 N 条通用规则
/fence all on <序号> / all off <序号> 启用 / 停用第 N 条通用规则(0.13.0 起)
/fence all clear 清空通用规则
/fence help 帮助

直接编辑围栏文件(推荐给「随时配置和维护」)/ Edit the fence file directly (the hands-on route)

默认位置 <DSH_HOME>/dialog-fence/fence.json(可用环境变量 DIALOG_FENCE_FILE 直接指定文件,或 DIALOG_FENCE_HOME 指定基目录;也可以在设置页「更改位置」)。同目录下的 location.json 是指针文件,{ "file": "<绝对路径>" }。

{
  "version": 3,
  "enabled": true,                       // 总开关;false = 所有对话零注入
  "rules": [                             // 通用规则:对每个对话都生效,数组顺序即注入顺序
    "结论必须给出可复现证据(命令 / 文件:行号 / 实测输出三者至少其一);给不出就明说「未验证」。",
    "改文件前先读该文件;改配置或数据前先备份,并给出精确回滚路径。"
  ],
  "ruleEnabled": [true, false],          // 与 rules 等长:false = 那一条暂时停用(0.13.0 起)
  "mutedSessions": ["session-<uuid>"],   // 这些对话整段不使用围栏(0.14.0 起);空数组 = 全都使用
  "sessions": {                          // 会话专属规则:只叠加到该会话
    "session-<uuid>": {
      "enabled": true,
      "rules": ["本对话额外的一条规则"],
      "ruleEnabled": [true]
    }
  }
}

ruleEnabled 是可选的:不写、写短了、整段删掉都等于「全部启用」,老文件直接拿来用即可。 它与 rules 一一对应(同位下标),删掉某条规则时这一位会被同步删掉,不会错位。 面板里的滑块、/fence off <序号>、HTTP 的 toggleRule 动作改的都是这个字段。

mutedSessions 也是可选的:不写 / 空数组 = 所有对话都使用围栏(默认行为,与 0.13.x 完全一致)。 名单里的会话 通用规则与专属规则都不注入,而它自己的 sessions.<id>.rules 一条都不会被删 —— 取消豁免立刻原样恢复。列表里的 id 一律会被消毒(只保留字母数字与 -_.)、去重、排序, 所以同一组多选无论勾选顺序如何,落盘内容都一样。面板的多选区、/fence mute、 HTTP 的 mute 动作({action:'mute', sessions:[...], muted:true|false},一次可传多个)改的都是这个字段。

注入顺序:先 rules 数组 1..N,再把该会话启用的 sessions.<id>.rules 接在后面编号。总开关 enabled: false 时两层一起静音。 会话被列进 mutedSessions 时,两层对这一条对话一起静音(等同于「这条对话没有围栏」)。

  • 直接改 rules 数组 → 所有对话下一轮生效(包括新开的),无需任何开关登记。
  • 直接改 sessions.<id>.rules → 该会话自动启用并生效(即使没写 enabled)。
  • ruleEnabled 里标 false → 那一条不参与注入,且不占编号(0.13.0 起);不写这个字段 = 全部启用。
  • mutedSessions 里出现该会话 id → 这条对话整段零注入(0.14.0 起);不写这个字段 = 全都使用围栏。
  • 手改坏了 JSON → 不抛异常、不清空文件,本轮降级为零注入,控制台留一条 warn。修好文件即恢复。
  • 上限:每层最多 40 条;渲染总上限 2400 字符;单条上限 2000 字符。

0.3.0–0.6.x 的「一条规则一个 txt」形态(rules/common/*.txt、rules/sessions/<id>/*.txt)会在首次启动时自动迁移成单个 fence.json,老文件原样保留不删。

为什么围栏文件默认不放在包目录里:0.5.0 起本插件可以作为组合包被官方安装器装进 profiles/.generations/live/<gen>/node_modules/dsh-dialog-fence/,那个目录每次升级 都会换成一份新的快照副本。宿主半边的 resolveDshHome() 会从插件所在目录向上找 第一个同时含 profiles/ 与 sessions/ 的祖先(即 DSH home),把基目录固定在那里, 于是开发态(<home>/dialog-fence)与组合包快照形态(<gen>/node_modules/dsh-dialog-fence) 指向同一个 <home>/dialog-fence/fence.json,换版本不丢规则。 你也可以用「更改位置」把它放到任何地方(例如 D:\我的围栏\fence.json)。

围栏文件的读写语义:

  • 改 rules 数组 → 所有对话立即生效(包括新开的),无需任何开关登记。
  • 改 sessions.<id>.rules → 该会话自动启用并生效(即使没写 enabled)。
  • 手改坏了 JSON → 不抛异常、不清空文件,本轮降级为零注入,控制台留一条 warn;修好即恢复。
  • 删掉某条 → 规则消失;数组顺序就是注入顺序。
  • 空白字符串不算规则(跳过)。

要点:

  • 通用规则与当前会话无关;会话规则的键是会话 id(/fence status 或 /fence dir 会打印当前会话 id)。
  • 单条 ≤ 2000 字符;每层最多 40 条;渲染总上限 2400 字符(超出会截断并注明 fence truncated: N/M rules shown)。
  • 规则里不要写 {{ }}:宿主的 interpolate() 遇到未注册变量会抛错。插件已用 try/catch 兜底降级为空,但内容会静默失效。
  • 文件写入请用 UTF-8。插件对 UTF-8 BOM 与 UTF-16LE 都做了兼容(历史事故:PowerShell 写坏过一次),但别依赖它。

与 billion-context(主动压缩)的关系 / Relationship with billion-context

本节记的是当时(0.1.202)的选型理由与实测结论,不是对 billion-context 现状的承诺。对方版本一变,下面这些行号级依据就可能失效——请以自己那份 dist/index.js 为准。

场景 结果
围栏消息 落在 Current runtime context 快照内 → durable/runtime-context → 自动进 protectedLatestTools:只保最新一份,旧快照照常被压缩
想要全历史硬保护 在 ~/.config/billion-context/billion-context.json 写 {"compress":{"protectedTools":["durable/*"]}},成为 HARD exclusion(永不折叠)
想改回自动模式 从 protectedTools / protectedLatestTools 里删掉 durable/*,billion-context 会重新自动追加 durable/* 到 protectedLatestTools

推论:不要发增量 delta,要发累积完整快照。protectedLatestTools 的语义是「同一虚拟路径只保最新一条」,发 delta 会让规则在半途丢失。


目录结构 / Repository layout

dialog-fence/
├── index.js            宿主半边:运行时上下文段 + /fence 命令 + HTTP 路由(零 import)
├── client.js           浏览器半边:会话按钮 + 浮层面板 + 设置页/插件详情配置区/插件标签页(懒 CJS 工厂,只 require react)
├── icon.svg            插件页图标
├── package.json        dsh.bundle.patch / dsh.client / icon 声明
├── cordis.patch.yml    bundle 安装层(方式 A 用)
├── scripts/install.mjs 一键安装器(方式 B 用,同时支持 dsh 与 DSH Desktop)
├── test/               smoke(宿主逻辑)/ route(HTTP API)/ ruletoggle(单条启停)/ sessionmute(整条对话豁免)/ client(bundle 形态)/ render(真 React 首帧)/ opensource(泄漏扫描)/ release(发布通道)/ docs(双语文案)
├── .github/            CI(test.yml)+ 自动分流(triage.yml)+ issue/PR 模板 + SECURITY.md
└── docs/               设计与实施方案、运维手册与变更记录

rules/ 是 0.3.0–0.6.x 的旧多 txt 目录,只在升级后首次启动时供迁移读取;一旦你的围栏已经是单个 JSON 文件,它就可以删。它也在 .gitignore 里,不会被提交。

开发与跑测试 / Development and tests

本包零运行时依赖:宿主半边只用 process.getBuiltinModule('node:fs' | 'node:path' | 'node:child_process'),浏览器半边只 require('react')(由 DSH 宿主提供)。所以没有 node_modules 要装,也没有 lockfile。

npm test            # 按顺序跑九套测试,任一失败即非零退出
npm run test:ruletoggle   # 只跑「每条规则单独启停」那一套
npm run test:sessionmute  # 只跑「整条对话不使用围栏」那一套
npm run test:opensource   # 只跑泄漏扫描(见下)
测试 覆盖什么 需要宿主吗
test/smoke.mjs 宿主逻辑:围栏文件解析/原子写、规则增删改、多行文本往返、上限与截断 不需要
test/route.mjs HTTP 路由:/api/dialog-fence 的 GET/POST、各 action、错误码 不需要
test/ruletoggle.mjs 单条规则的启用位:默认启用、停用后不注入且编号连续、落盘格式、删除时平移、老文件兼容(0.13.0 起) 不需要
test/sessionmute.mjs 整条对话不使用围栏:mutedSessions 落盘形状、多选一次提交、通用层与专属层一起静音、取消即恢复、脏 id 消毒、老文件兼容(0.14.0 起) 不需要
test/client.mjs client.js 的 bundle 形态:懒 CJS 工厂、槽位注册、源码级断言 不需要
test/render.mjs 用真 React 做服务端首帧渲染(renderToStaticMarkup),断言面板/设置页的 DOM 需要本机的 DSH 安装(找不到就打印 SKIP 并以 0 退出)
test/opensource.mjs 开源前泄漏扫描:真实姓名(含裸名)/姓氏形态/hostname/内部编号/真实 session id/绝对用户路径,文件与 commit message 都扫 不需要
test/release.mjs 发布通道静态检查:workflow 触发条件、机密注入红线、files 白名单不含本机数据 不需要
test/docs.mjs 中英双语规则:description、README 每个一/二/三级标题中英并列、开头摘要分中英、docs/ 结构 不需要

test/render.mjs 按下面的顺序找 React,找不到就 SKIP:

# 默认按 Windows 常见安装位置拼
# 也可以用环境变量指定(CI 上通常用不到,因为没有 DSH 安装)
export DSH_INSTALL="D:\\DSH Desktop"     # 其下有 resources/app.asar/node_modules
export DSH_ASAR="D:\\DSH Desktop\\resources\\app.asar\\node_modules"   # 或者直接给到 node_modules

如果你用的不是标准 Node(例如 DSH Desktop 自带的 Electron),用 ELECTRON_RUN_AS_NODE=1 跑: $env:ELECTRON_RUN_AS_NODE="1"; & "D:\DSH Desktop\DSH Desktop.exe" test\smoke.mjs

node_modules 不是可选项而是不存在:改完源码必须重启 DSH 才生效(dsh-hmr 不重载本插件的宿主半边),细节见运维手册 · 怎么改插件代码。

推送与发布(踩过的坑)/ Pushing and releasing (the traps)

改动含 .github/workflows/ 时,repo 权限的令牌推不上去:

refusing to allow an OAuth App to create or update workflow `.github/workflows/test.yml` without `workflow` scope

GitHub 对 workflow 文件单独要求一条权限。补法(device flow,令牌不经过命令行):

gh auth refresh -h github.com -s workflow

其它两条容易误判的现象:

  • git push 在 PowerShell 里成功后仍显示 [exit code: 1] —— git 把 To https://… 写 stderr,pwsh 当异常。判据取 git ls-remote origin 的实际 ref 值。
  • 重写历史后推的是重写后的新对象,--force-with-lease 会因远端仍是旧对象而拒绝,需 git push --force(先确认远端没有别人的提交);tag 要单独 git push --force origin --tags,因为「强制更新 tag」不是默认行为。
  • 推完之后从远端重新 clone 一份复验:git grep 工作树 + 全部历史、git tag 数量、五套测试。本地干净不等于远端干净。
  • git push --force 不会删掉旧 commit 对象。 只要知道旧 SHA 或旧 tag,raw.githubusercontent.com/<owner>/<repo>/<旧SHA>/<path> 和 codeload…/tar.gz/<旧tag> 仍能匿名取到内容。所以「历史重写 → force push」之后,验证判据必须是去取那个本该消失的 SHA、期望 404;只验「新 clone 里 grep 不到」是不够的(那只证明新历史干净)。GitHub 没有自助回收不可达对象的入口,真要清除只能删库重建或保持 private 找 Support。
  • 发布前用一条比扫描器更宽的检索对撞一次。 本仓库的 test/opensource.mjs 第一版 real-name 规则要求「名 + 姓」同时出现,于是文档里只写裸名的一句话被放过,整个发布流程(重写历史 → 重写 message → 删库重建 → 转 public → 配保护)因此做了两遍。规则与验证必须是两个独立实现、强度不同:扫描器越怕误报就越容易在「没想到的形态」上失守。放宽规则后记得做反向验证(造一个含敏感词的临时文件 → 确认变红 → 删掉 → 确认转绿),否则「规则太松」和「真的很干净」读数一样。

上架清单(awesome-dsh-plugin / 插件市场)/ Getting listed (awesome-dsh-plugin / market)

本插件已提交到社区清单 awesome-dsh-plugin: PR #9530,条目 data/plugins/ILoveHAKU__dsh-dialog-fence.yml,分类 dev。

收录后的市场可见性分两份目录,不要混:

目录 来源 是否收 GitHub-only 条目
https://awesome-dsh-plugin.com/plugins.json 直接由 data/plugins/*.yml 生成 收 ⇒ 收录后 dshmarket 的「插件市场」能搜到
https://awesome-dsh-plugin.com/index.json 由 data/npm-map.json + npm packument 生成 只收有 npm 包的条目(我们已发 dsh-dialog-fence,等 nightly probe 采集后进 index)

清单的硬门槛(都是 CI 自动查的,我们逐条实测过):

  • 一个插件一个 YAML,字段只允许 url / name / category / description / icon / tarball;两个 README 由脚本生成,不要手改(手改是「改到自己邻居条目」的常见事故源)。
  • package.json 必须有 dsh.bundle。只声明 dsh.client 的包会被判 declares only dsh.client — that alone is not installable,这是最常见的拒因。
  • 仓库要满 1 天(MIN_AGE_DAYS = 1),要有 dsh-plugin topic,描述必须属实(维护者会读源码核对,夸大是打回主因)。
  • 条目里的 icon: 只接受 GitHub 托管的 png/jpg/jpeg/webp(ICON_HOSTS 限定四个 GitHub 域),SVG 会被拒。所以 package.json 的 icon: "./icon.svg" 只对 DSH 自己的插件页有效,不能直接搬进清单条目;不写 icon 就回退作者头像。
  • description 里含 ": "(冒号+空格)必须加引号,否则 YAML 解析失败。

踩过的坑(可复现):官方 gate scripts/check-submission.mjs 可以在本地先跑,但它 import 的 scripts/lib/entries.mjs 依赖 js-yaml。从 %TEMP% 直接跑会 ERR_MODULE_NOT_FOUND;解法是在临时目录放 package.json({"type":"module"})并把 node_modules 做成指向 profiles/web/node_modules 的 Junction,再带上 GITHUB_TOKEN(gh auth token)与代理。提交流程用 GitHub contents API 更省事:先 POST /git/refs 建分支(PUT contents 不会自动建分支,会 404),再 PUT 条目文件,最后 gh pr create。

仓库 About 与逐条文案的双语规则 / Bilingual rule for the About box and every intro field

仓库右侧那句描述(About)不在仓库里,它是 git 之外的一份元数据,改它只有一条路:

gh api --method PATCH repos/ILoveHAKU/dsh-dialog-fence -f description="<中文句> / <English sentence>"

所以 test/docs.mjs 管不到它——那个测试只能检查仓库里的文件。改 About 之后要自己回读一次:

gh api repos/ILoveHAKU/dsh-dialog-fence --jq '.description'

写 --jq 时别在里面塞 |(PowerShell 会把它当管道),先落文件再 ConvertFrom-Json 更稳。

package.json 的 description 是另一回事:它进了仓库,也被 test/docs.mjs 钉住,而且一份字段喂三处—— DSH 插件页的卡片标题与描述(readPluginMeta 读 name / description / icon)、npm 页面、社区清单条目。 所以它必须同样中文优先。三处一起改,别只改一处。

新加的对外文案(含 issue/PR 模板、SECURITY、README 章节标题)一律中文在前、英文紧跟。 npm run test:docs 会在 CI 里挡住回退:标题缺英文、描述缺中文、开头没有英文摘要,都会红。

发布到 npm / Publishing to npm

打一个 v* tag 就会触发 .github/workflows/publish.yml, 也可以在该 workflow 上手动 Run workflow。

它不需要任何 token,也不需要在本机登录 npm。 用的是 npm 的 trusted publishing:GitHub Actions 用 OIDC 身份向 registry 换取一次性凭据,所以仓库里没有任何 NPM_TOKEN / _authToken。 test/release.mjs 会把这条红线钉住——workflow 里出现 secrets.、NPM_TOKEN 或字面 _authToken 就直接红。

长期规则:改完就发,两边不留版本差 / Standing rule: release on every change, no version drift

每一次改动,只要升了 package.json 的版本并推送到 GitHub,就必须同时推到 npm。 仓库和 npm 上的版本号不允许长期不一致——两边对不上时,「装哪个才是最新的」这个问题 没有正确答案,用户按 README 走 npm i 会拿到缺功能的旧版。

发版是三件事,一件都不能省:升版本 + 写 CHANGELOG → 提交推送 → 打 tag 推送。 只做完前两步不算发版,npm 上不会有任何变化。

核对两边是否同步(不需要凭据):

git describe --tags --abbrev=0                 # 仓库最新的 tag
node -p "require('./package.json').version"    # 仓库版本
npm view dsh-dialog-fence version              # registry 上的 latest

三条一致才算同步。若仓库领先于 npm(常见于「改完先提交、稍后才打 tag」), 补推对应 tag 即可,不需要重新提交代码。

一次性配置(只需一次,在 npmjs.com 网页上做,不碰本仓库)/ One-time setup (on npmjs.com, this repo stays untouched)

包 → Settings → Trusted Publisher → GitHub Actions,填:

字段 值
Organization or user ILoveHAKU
Repository dsh-dialog-fence
Workflow filename publish.yml
Environment 留空

填完的配置两天内必须完成第一次发布,否则会过期需要重配。

发版步骤 / Release steps

# 1. 改 package.json 的 version,写 CHANGELOG
# 2. 提交推送(CI 的 test job 会先跑一遍)
git add -A && git commit -m "release: v0.12.0" && git push

# 3. 打 tag 并推送 —— 这一步才真正发版
git tag -a v0.12.0 -m "v0.12.0"
git push origin v0.12.0

workflow 会:校验 tag 与 package.json 版本一致 → npm test → npm pack → npm publish <那份 tgz> → 把 tgz 作为 artifact 留档。先把测试跑红再上 registry 是不行的:npm 上的版本除非在 72 小时内,否则删不掉。

一条硬规则:发版一定要推 tag,不要用手动 Run workflow。

手动触发 checkout 的是分支 HEAD,不是 tag。如果 tag 打在 A 上、之后又推了 几个 commit 再手动触发,发出去的内容来自 HEAD 而不是 A —— 而 npm 的 provenance 会如实记下那个 HEAD,于是「tag 指向的东西」和「npm 上那份」不是同一份:

npm provenance: resolvedDependencies -> ...@<分支 HEAD>
git rev-list -n 1 v0.12.0            -> <tag 指向的 A>

后果很隐蔽:按 tag 重新打包会得到另一个 shasum,tag 从此证明不了发的是什么。 0.12.0 就踩过这个坑,代码文件全都一样、只有两个文档不同,光看功能发现不了。

确需手动重跑时,必须填 publish_ref(如 v0.12.0),workflow 会用 ref: inputs.publish_ref 检出并校验 HEAD 就是该 tag;不填时只打一条 warning 放行。

版本门槛(npm 官方文档原文):

Trusted publishing requires npm CLI version 11.5.1 or later and Node version 22.14.0 or higher.

workflow 里钉 Node 24 并 npm install -g npm@latest,就是为了稳过这条线。

本机没有凭据时不要硬发 / No local credentials? Do not force it

~/.npmrc 里如果只是 //registry.npmjs.org/:_authToken=(空值),本机 npm publish 会 401。这不是配置坏了,是故意没有存长期 token——走上面的 tag 流程即可。 npm publish --dry-run 不校验凭据,所以它能过并不代表能发布。

发布失败怎么查(两个最容易走错的坑)/ When a publish fails (the two traps)

坑 1:千万别给 setup-node 加 registry-url。 它会往 ~/.npmrc 写一行 _authToken=${NODE_AUTH_TOKEN},npm 看到有 token 就不再做 OIDC 交换, 错误信息却是误导性的 404 PUT https://registry.npmjs.org/<pkg>。正确做法是 不设 registry-url,在 npm publish 上显式带 --registry。

坑 2:OIDC token exchange error - package not found 不等于 npm 上没这个包。 当你看到:

npm http fetch POST 404 .../-/npm/v1/oidc/token/exchange/package/dsh-dialog-fence
npm verbose oidc Failed token exchange request with body message:
             OIDC token exchange error - package not found
npm error code ENEEDAUTH

先看同一份日志里 OIDC 环境自检 那一步:如果 ACTIONS_ID_TOKEN_REQUEST_URL 与 ..._TOKEN 都是 present,说明 OIDC 链路没问题。 这句话是 registry 返回的(CLI 只把响应体的 message 原样打印出来),

它指的是「本仓库 + 本 workflow 没有为该包配置 Trusted Publisher」,而非「包不存在」。 这一点做过对照实验:唯一变量是那个网页配置,配之前 POST .../token/exchange/... 返回 404 + ENEEDAUTH,配之后返回 201 + Successfully retrieved and set token,随后 PUT 202 发布成功。 包在两次之间都一直在 registry 上。

注意 Workflow 那一格填的是文件名 publish.yml,不是 workflow 的 name:。 /-/package/<pkg>/trust 接口未登录时返回 401,所以这件事只能在网页上核对。 配好后页面显示 Pending validation 是正常中间态,首次发布成功才转正。

读日志要读到最后一行:npm notice 的 tarball 清单很长,真正的结果是末尾的 npm info ok。发布成功后 registry 读缓存还有几分钟延迟,直接查 dist-tags 仍是 旧版本,加 ?ts=<时间戳> 破缓存才能看到新版本。

文档 / Documentation

已知限制 / Known limits

  • 宿主半边改动必须重启 DSH,dsh-hmr 不重载本插件(已实测)。
  • 面板与三处配置入口需要 Web 形态;CLI / TUI / headless 只有 /fence 命令。
  • 规则是「软约束」:它进入模型可见上下文并对模型产生强引导,但不是强制拦截。要硬拦截工具调用,请用 PreToolUse 钩子(本机已有 harness/fence/guard.mjs 做这类事)。
  • 与 workbuddy / opencode-go 等未声明 systemPromptUpdate 的路由完全兼容——正因为本插件从不改系统提示词。
  • 总开关控制的是「是否注入」,不等于卸载插件;要彻底停用请在插件页该包详情页里关掉开关或直接卸载。
  • 本地路径安装是复制进 generation 快照,改源码后必须重装(见「安装」一节的警告)。
  • 「打开文件夹」用系统文件管理器(Windows 上是 explorer /select,),只在有图形界面的形态下有意义;DIALOG_FENCE_NO_OPEN=1 可关掉(测试用)。0.8.0 起每次点击都会在按钮下方给出结果(已打开 / 未打开 + 原因 / 上一次启动失败的原因),不再「点了看不出有没有生效」。
  • Windows 上 explorer.exe /select,… 的退出码恒为 1,不可用作成功判据;判定只能靠「窗口是否真的出现」。因此本插件对「已打开」的陈述是「已发出打开请求」而非「已确认窗口可见」。
  • 改位置时不会覆盖已存在的目标文件:若目标已存在,直接切换过去(以目标内容为准)。

兼容性 / Compatibility

在 DSH Desktop 0.12.1 / harness 0.2.1-rc.2 上实测通过(五套测试全绿,插件在插件清单里 fiberPhase: active)。逐项核对过的契约:

依赖面 结论
五个客户端槽位(settings.section / settings.plugins.tab / plugins.bundle.config / conversation.session.header.utilities / shell.overlay) 全部存在,本插件注册项 active
connection.fetch.register / systemPrompt.context() / commands.register() 签名未变(text 仍支持函数)
CONTEXT_ORDER = 118 仍有效(落在 APPROVAL_POLICY:115 与 SUBAGENT_DELEGATION:120 之间;CONTEXT_ORDERS 是参考枚举、不是白名单)
useSessions 的 byId[id](title / displayTitle / retainedBy) 形状未变;sessionTitle 这个字段名在本版 0 命中

engines.dsh 声明为 ">=0.2.0 <0.3.0":0.x 期间次版本会带破坏性改动(0.12.1 就改过一次主题 token 名), 所以锁住次版本。注意版本不符合不会禁用插件——DSH 只打一行 [desktop] compatibility warning: ...(dsh-app-boot/lib/index.js:2362/1064 两处都只 report), 真正会 row.disabled = true 的只有「声明无法校验」那种异常。

dsh.compatibility.dshReleases 是死字段(全 asar 0 命中,没有代码读它),别再用它声明兼容性。

许可 / License

MIT