dsh-dupguard 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  本文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.4.0] - 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - **围栏代码块内放宽检测**(`skipCodeBlocks` 默认开启 + `codeBlockMultiplier` 默认 `3`,均可在设置页调整):
10
+ 代码块内的重复串(生成的测试夹具、表格、ASCII 图、内嵌数据)改用「阈值 × 倍数」判定,
11
+ 不再被误判为复读;块内的失控复读仍会被兜住(默认连续 30 次即截停)。倍数范围 1–100,
12
+ 设为 1 等价于不放宽;关闭开关则块内与块外同样严格;
13
+ - 围栏识别按 CommonMark:起始行为行首 ≤3 个空格 + 连续 ≥3 个 `` ` `` 或 `~`(其后为 info string),
14
+ 结束行需同字符且不短于起始长度、其后仅空白;未闭合围栏延续到该块结束;
15
+ 围栏标记被切进多个增量(如 `"``"` + ``"`js"``)时仍能识别;行内代码与缩进代码块不受影响;
16
+ - 截停日志新增 `code-block=true(放宽阈值命中)` 标记,便于区分命中来源。
17
+
18
+ ### Changed
19
+
20
+ - 窗口缺口提示与宿主告警改为按「阈值 × 代码块倍数 × 最大单元长度」计算(关闭放宽时倍数按 1 计),
21
+ 设置页提示文本同步显示倍数。
22
+
23
+ ### Fixed
24
+
25
+ - 长围栏不再被短围栏提前关闭:围栏长度取起始行的**完整** run,```` ```` ```` 开启的代码块不会被
26
+ 内部的 ``` 行关闭(此前一见 3 个反引号就进入并记长度为 3)。
27
+
28
+ ## [1.3.1] - 2026-09-05
29
+
30
+ ### Fixed
31
+
32
+ - **白名单按「单个字符」匹配的语义两端不一致**:设置页会把整串输入(如 `ab`)当成一个条目,
33
+ 而检测按单个字符比对,该条目永远不会生效。现在设置页把一次输入拆成单个字符逐个加入
34
+ (忽略其中的空白),宿主对存量或手改的多字符、空、非字符串条目在运行时丢弃并打印告警;
35
+ 白名单比对同时改为按 Unicode 码点进行,emoji 等代理对字符可作为单个条目正常生效。
36
+ - **未改动的数值输入在失焦时也会写入**,把默认值写进用户层(`settings.yaml` 里因此出现
37
+ `threshold: 10`、`maxUnitLength: 80`、`detectionWindow: 8192` 这类无意义条目)。现在值与
38
+ 宿主一致时跳过写入。
39
+ - **跨字段约束只在「最大单元」一行提示**:从「最小单元」一侧提交违规值时只能拿到宿主拒绝后的
40
+ 泛化「保存失败」。现在两行都给出具体错误并在本地拦截写入。
41
+ - 截停收尾遇到未知块类型(协议新增 `ContentBlock` 类型)时会静默按 tool-call 兜底,
42
+ 现补充一次性宿主告警,便于定位协议漂移。
43
+
5
44
  ## [1.3.0] - 2026-09-05
6
45
 
7
46
  ### Added
package/README.md CHANGED
@@ -9,6 +9,17 @@
9
9
  [![CI](https://github.com/zqh260619/dsh-dupguard/actions/workflows/ci.yml/badge.svg)](https://github.com/zqh260619/dsh-dupguard/actions/workflows/ci.yml)
10
10
  [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-0969da)](https://github.com/topics/dsh-plugin)
11
11
 
12
+ **兼容性 / Compatibility**
13
+
14
+ - **DSH**:宿主 API 自 `0.1.1-rc.1` 起可用;当前版本在 **0.1.5-rc.2 / 0.1.6-alpha.2** 上实测通过,
15
+ 更早版本在 `0.1.1-rc.1` / `0.1.2-rc.1` 上实测通过。下面「一条命令的 bundle 安装」需要
16
+ **DSH ≥ 0.1.2-rc.1**(bundle 自动纳管与 `dsh.bundle` 约定从该版本起提供)。
17
+ - **Node**:≥ 20(与 DSH 一致,不支持 Node 18)。
18
+
19
+ Host APIs work since DSH `0.1.1-rc.1`; the current version is verified on **0.1.5-rc.2 / 0.1.6-alpha.2**
20
+ (earlier releases were verified on `0.1.1-rc.1` / `0.1.2-rc.1`). The one-command bundle install below
21
+ needs **DSH ≥ 0.1.2-rc.1**. Node ≥ 20 (matching DSH; Node 18 is unsupported).
22
+
12
23
  触发后,已生成的内容会**正常提交为助手消息**,本轮对话干净结束——不会报错、不会丢弃输出、不会污染会话日志。
13
24
 
14
25
  When triggered, the already-generated text is committed as a normal assistant message and the turn ends cleanly — no errors, no lost output, no session-log pollution.
@@ -27,10 +38,12 @@ When triggered, the already-generated text is committed as a normal assistant me
27
38
  「重复守卫」分节,可视化编辑白名单与全部检测参数(阈值、最小/最大单元长度、检测窗口、空白与
28
39
  reasoning、工具参数开关)并持久化(`dsh-dupguard` 设置命名空间),修改即时生效;窗口小于
29
40
  阈值 × 最大单元长度时给出「窗口长度需要提高」提示。
41
+ - **代码块内放宽**:围栏代码块(``` / ~~~)内改用「阈值 × 倍数」(默认 3×)判定,生成的代码、
42
+ 测试夹具、表格、ASCII 图不会被误判为复读;块内的失控复读仍会被兜住(默认连续 30 次即截停)。
30
43
  - **零配置开箱即用**:默认配置即可用;全部检测参数均可在设置页按需调整。
31
44
  - **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js` + `lib/client.js`),行为一致、CI 防漂移。
32
45
  - **内置 DSH 兼容补丁**(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
33
- 修复 DSH ≤ 0.1.2-rc.1 的 preset standing-mount 多代并存冲突(见下文"已知限制")。
46
+ 修复 DSH ≤ 0.1.6-alpha.2 仍未修复的 preset standing-mount 多代并存冲突(见下文"已知限制")。
34
47
 
35
48
  ---
36
49
 
@@ -63,7 +76,9 @@ dsh plugin --profile web add dsh-dupguard
63
76
  `{ id: dupguard, name: dsh-dupguard }`。**无需手改任何 YAML。**
64
77
 
65
78
  - 验证:`dsh --profile web --dump-config` 末尾应出现 `dupguard` 行;loader 日志出现
66
- `apply plugin dupguard`。
79
+ `apply plugin dupguard`;宿主日志另有本插件的自检行
80
+ `[dupguard] 常驻插件 apply 开始` 与 `[dupguard] 已注册设置命名空间 dsh-dupguard(检测参数可在设置页动态调整)`,
81
+ 出现后设置面板的「重复守卫」分节即可编辑白名单与全部参数。
67
82
  - 升级:`dsh plugin --profile web update dsh-dupguard`;
68
83
  卸载:`dsh plugin --profile web remove dsh-dupguard`(依赖与 bundles 层一并移除)。
69
84
 
@@ -83,6 +98,42 @@ dsh plugin --profile web add dsh-dupguard
83
98
  ⚠️ 两种方式**不要同时使用**:loader 对重复 entry id 直接抛
84
99
  `duplicate loader entry id: dupguard`。从手工方式切换到 bundle 方式时,请删除手工 `insert` 项。
85
100
 
101
+ **3. 临时禁用(不卸载)**:在 profile 的用户补丁层里按 id 关掉该行即可:
102
+
103
+ ```yaml
104
+ # $DSH_HOME/profiles/web/cordis.patch.yml
105
+ - id: dupguard
106
+ name: dsh-dupguard
107
+ disabled: true
108
+ ```
109
+
110
+ 用户补丁层在 bundle 层之后应用,profile 的 `patchReload` 默认为 `live`——**保存即卸载该插件行,无需重启**;
111
+ 改回 `disabled: false`(或删除该条)即恢复。这条 id 定位的补丁对两种安装方式都适用(bundle 层插入的行、
112
+ 或你自己 insert 的行都会被它覆盖)。禁用后宿主半边(截停与 `dsh-dupguard` 设置命名空间)一并卸载,
113
+ 全部检测参数回落到代码默认值(含白名单;`settings.yaml` 中的取值保留),重复输出不再被截停;由于 DSH 的
114
+ 客户端模块图按**活动 loader 行**生成,**刷新页面**后设置面板里的「重复守卫」分节会消失。注意进程内的
115
+ standing-mount 兼容补丁不会随禁用撤销,要彻底干净需重启 DSH。彻底卸载请用上面的
116
+ `dsh plugin --profile web remove dsh-dupguard`(依赖与 bundles 条目一并移除)。
117
+
118
+ **Temporarily disable (without uninstalling)**: turn the row off by id in the profile's user patch layer:
119
+
120
+ ```yaml
121
+ # $DSH_HOME/profiles/web/cordis.patch.yml
122
+ - id: dupguard
123
+ name: dsh-dupguard
124
+ disabled: true
125
+ ```
126
+
127
+ The user layer applies after every bundle layer and the profile's `patchReload` defaults to `live`, so
128
+ saving unloads the row **without a restart**; set it back to `false` (or delete the entry) to re-enable.
129
+ The id-targeted patch works for both install styles (a row inserted by the bundle layer or by your own
130
+ `insert` entry). The host half (guard + the `dsh-dupguard` settings namespace) unloads with it — every
131
+ detection parameter falls back to the code default (whitelist included) while `settings.yaml` keeps your
132
+ values, and nothing is stopped any more. DSH builds its Client module graph from **live loader rows**, so
133
+ the "Dupguard" settings section disappears after a page refresh. The process-resident standing-mount
134
+ patch is not undone by disabling; restart DSH if you need a completely clean process. To remove it
135
+ entirely, use `dsh plugin --profile web remove dsh-dupguard`.
136
+
86
137
  **本地开发**:未发布/调试时,`name` 也可直接用 `file:` URL 指向仓库内的
87
138
  [`lib/index.js`](lib/index.js)(CJS 导出 `{ name, apply }`,与 loader 的
88
139
  `unwrapExports` 兼容,零构建)。
@@ -126,21 +177,23 @@ and persisted to `settings.yaml`; the dynamic build uses the constants.
126
177
  | `threshold` | `10` | 触发阈值:同一字符串连续重复 ≥ 该值时停止 / stop when the same string repeats ≥ this many times |
127
178
  | `minUnitLength` | `1` | 最小重复单元长度 / minimum repeating-unit length (`1` also catches single-char loops like `aaaaaaaaaa`) |
128
179
  | `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
129
- | `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后),需 ≥ 阈值 × 最大单元长度 / rolling detection window in chars (after whitespace removal); must be ≥ threshold × max unit length |
180
+ | `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后),需 ≥ 阈值 × 代码块倍数 × 最大单元长度 / rolling detection window in chars (after whitespace removal); must be ≥ threshold × code-block multiplier × max unit length |
181
+ | `skipCodeBlocks` | `true` | 围栏代码块(``` / ~~~)内放宽检测:块内改用「阈值 × 代码块倍数」判定,避免生成的代码/夹具/表格被误杀;置 `false` 则块内与块外同样严格 / relax detection inside fenced code blocks; set `false` to apply the normal threshold inside them too |
182
+ | `codeBlockMultiplier` | `3` | 代码块内的阈值倍数,范围 1–100;越大越少误杀正常代码,但也越晚兜住代码块里的失控复读(1 = 不放宽)/ threshold multiplier inside code blocks, range 1–100 (1 = no relaxation) |
130
183
  | `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
131
- | `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown 表格分隔行(连字符与竖线)不参与重复统计 / whitelist of characters ignored during detection, so Markdown table separators don't count |
184
+ | `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown 表格分隔行(连字符与竖线)不参与重复统计。条目必须**是单个字符**(按 Unicode 码点匹配,emoji 也算一个);设置页一次输入多个字符会逐个加入,多字符/空条目在运行时被丢弃并告警 / whitelist of characters ignored during detection, so Markdown table separators don't count. Entries must be a **single character** (matched per Unicode code point); the settings page splits multi-character input into individual entries, and invalid entries are dropped at runtime with a warning |
132
185
  | `monitorReasoning` | `true` | 是否检测思考文本(思考中的复读同样消耗 token,默认截停;只检测可见输出时置 `false`)/ also guard reasoning (thinking) text — on by default; set `false` to guard visible output only |
133
186
  | `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
134
- | `fixStandingMountConflict` | `true` | DSH ≤ 0.1.2-rc.1 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突(仅代码常量)/ idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.2-rc.1 standing-mount conflict (code constant only) |
187
+ | `fixStandingMountConflict` | `true` | DSH ≤ 0.1.6-alpha.2 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突(仅代码常量)/ idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.6-alpha.2 standing-mount conflict (code constant only) |
135
188
 
136
- **窗口约束 / Window constraint**:`detectionWindow` 必须 ≥ `threshold × maxUnitLength`;否则长度超过
137
- `floor(detectionWindow / threshold)` 的重复单元凑不满重复次数,无法被识别(例如窗口 80、阈值 10 时,
138
- 超过 8 字符的单元不再触发)。设置页在该约束被违反时显示
139
- 「⚠ 检测窗口长度需要提高:至少 N(当前 M = 阈值 × 最大单元)」的提示,宿主日志同时打印一条告警;
140
- 该约束**只提示不拒绝写入**,便于按需权衡内存占用与可识别单元长度。
189
+ **窗口约束 / Window constraint**:`detectionWindow` 必须 ≥ `threshold × codeBlockMultiplier × maxUnitLength`
190
+ (关闭代码块放宽时倍数按 1 计);否则长度超过 `floor(detectionWindow / 最严格阈值)` 的重复单元凑不满重复
191
+ 次数,无法被识别(例如窗口 80、阈值 10、倍数 3 时,超过 2 字符的单元不再触发)。设置页在该约束被违反时
192
+ 显示「⚠ 检测窗口长度需要提高:至少 N(当前 M = 阈值 × 倍数 × 最大单元)」的提示,宿主日志同时打印一条
193
+ 告警;该约束**只提示不拒绝写入**,便于按需权衡内存占用与可识别单元长度。
141
194
 
142
195
  The settings page shows a "detection window is too small" hint (and the host logs a matching warning)
143
- whenever `detectionWindow < threshold × maxUnitLength`; the write is still accepted.
196
+ whenever `detectionWindow < threshold × codeBlockMultiplier × maxUnitLength`; the write is still accepted.
144
197
 
145
198
  ---
146
199
 
@@ -156,9 +209,9 @@ Listens to the `llm/stream` waterfall (wraps every streaming model call) and ret
156
209
  ### 2. 检测算法 / Detection
157
210
 
158
211
  - 按块索引(`chunk.index`)分别累积文本,多块交替输出互不干扰;
159
- - 去空白后做**尾部连续重复检测**:文本以某个单元(长度 1..80)连续重复 ≥ 阈值结尾即触发。
160
- 模型一旦复读,重复必然在尾部,因此尾部检测即可实时捕获所有循环,同时避免全窗口词频的误报
161
- (如正常中文里高频的"的")。
212
+ - 去空白后做**尾部连续重复检测**:文本以某个单元(长度 `minUnitLength`..`maxUnitLength`,默认 1..80)
213
+ 连续重复 ≥ `threshold` 次结尾即触发。模型一旦复读,重复必然在尾部,因此尾部检测即可实时捕获所有
214
+ 循环,同时避免全窗口词频的误报(如正常中文里高频的"的")。
162
215
 
163
216
  Tails-only consecutive-run detection on the whitespace-stripped buffer: catches every loop in real time
164
217
  without the false positives of whole-window frequency counting.
@@ -211,10 +264,15 @@ default), Markdown table separator rows and horizontal rules (whitelisted by def
211
264
  │ ├── index.js # npm/组合常驻形式(package.json main 入口,含设置集成)
212
265
  │ └── client.js # 浏览器端设置页(ModuleLoader 格式,dsh.client 入口)
213
266
  ├── tests/
214
- │ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings 集成
215
- │ ├── client.test.js # 设置页组件测试:最小 React/DSH 桩驱动写路径
216
- │ └── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
217
- ├── .github/workflows/ci.yml # GitHub Actions:Node 20/22/24
267
+ │ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings 集成(74 项)
268
+ │ ├── client.test.js # 设置页组件测试:最小 React/DSH 桩驱动写路径(16 项)
269
+ │ ├── stress-host-adversarial.js # 压力:边界/协议交错/热更新 churn/畸形输入
270
+ │ ├── stress-host-throughput.js # 压力:吞吐/内存/200 路并发/参数极值(METRIC 指标)
271
+ │ ├── stress-client-ui.js # 压力:设置页高频交互、乱序应答、挂载泄漏
272
+ │ ├── stress-real-invariant.mjs # 压力:真实 DSH llm-invariant + BlockAssembler 端到端校验
273
+ │ ├── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
274
+ │ └── experiment-inspect-patch.mjs # 诊断实验:standing-mount 补丁的多代并存行为
275
+ ├── .github/workflows/ # ci.yml(Node 20/22/24 跑 npm test)+ publish.yml(v* 标签发布 npm)
218
276
  ├── cordis.patch.yml # bundle 补丁层(dsh.bundle.patch:插入宿主行)
219
277
  ├── package.json
220
278
  ├── CHANGELOG.md
@@ -225,20 +283,49 @@ default), Markdown table separator rows and horizontal rules (whitelisted by def
225
283
  ## 测试 / Tests
226
284
 
227
285
  ```bash
228
- npm test # 两个测试文件
229
- node tests/detector.test.js # 检测端到端(48 项)
230
- node tests/client.test.js # 设置页组件(6 项)
286
+ npm test # 功能测试(两个文件)
287
+ node tests/detector.test.js # 检测端到端(74 项)
288
+ node tests/client.test.js # 设置页组件(16 项)
231
289
  ```
232
290
 
233
- 同一套 15 项用例分别驱动两个入口(`plugin/host.js` 经 `new Function` 求值、`lib/index.js` 经
291
+ 同一套用例分别驱动两个入口(`plugin/host.js` 经 `new Function` 求值、`lib/index.js` 经
234
292
  `require` 加载),覆盖:透传完整性、各类复读形态、阈值边界、协议闭合、上游 `return()` 调用、
235
- 默认不检测 reasoning/工具参数、未闭合工具调用块的闭合、多次调用状态隔离等。`client.test.js`
236
- 用最小 React 与 DSH 客户端桩驱动设置页组件,断言写入走 `settingsScope` 控制器
293
+ 默认不检测 reasoning/工具参数、未闭合工具调用块的闭合、多次调用状态隔离、设置 schema 与热更新等。
294
+ `client.test.js` 用最小 React 与 DSH 客户端桩驱动设置页组件,断言写入走 `settingsScope` 控制器
237
295
  (`set`/`unset`)而非已移除的 `connection.api`。CI 在 Node 20/22/24 上运行
238
296
  (与 DSH 一致,不支持 Node 18)。
239
297
 
240
- The same 15-test suite drives both entries, guarding against drift between the two forms. CI runs on
241
- Node 20/22/24 (matching DSH; Node 18 is not supported).
298
+ ### 压力测试 / Stress suites
299
+
300
+ ```bash
301
+ npm run stress # 依次运行下列四套
302
+ node tests/stress-host-adversarial.js # 边界/周期重叠/分块不变性/协议交错/围栏代码块/设置 churn/畸形输入
303
+ node tests/stress-host-throughput.js # 吞吐、最坏情况扫描、命中延迟、内存、200 路并发、参数极值
304
+ node tests/stress-client-ui.js # 设置页 500 条白名单、1000 次混合操作、写应答乱序、churn、挂载泄漏
305
+ node tests/stress-real-invariant.mjs # 用真实 DSH 的 llm-invariant 与 BlockAssembler 校验截停收尾
306
+ ```
307
+
308
+ 后一套使用本机安装的 `@deepseek-ai/dsh-llm`(依次探测 `$DSH_LLM_DIR`、`$DSH_INSTALL`、
309
+ `$DSH_HOME`、全局 npm 安装),找不到时打印 SKIP 并跳过,因此可安全地在任意环境运行。
310
+
311
+ 实测参考(Windows / Node 24,默认参数除注明外):
312
+
313
+ | 指标 | 实测 |
314
+ | --- | --- |
315
+ | 增量吞吐(窗口 8192) | 1 字符增量 1.9 µs/增量(533 chars/ms);1KB 增量 11 µs/增量(92k chars/ms) |
316
+ | 增量吞吐(窗口 1 MiB) | 4 字符增量 33 µs/增量(118 chars/ms)——每增量重写整个窗口,大窗口显著变慢 |
317
+ | 最坏情况扫描(近失配周期文本) | 2.7 µs/增量 |
318
+ | 命中延迟 | 阈值 10 时消费 12 字符即截停(尾部即时判定) |
319
+ | 5,000,000 字符长流 | 堆增长约 32 MB(完整文本 + 检测窗口),无额外无界增长 |
320
+ | 200 路并发 | 136 ms;1 路复读被截停,其余 199 路完整透传 |
321
+ | 设置页 500 条白名单 | 渲染 0.3 ms;逐个删除 500 次 = 500 次写入 |
322
+ | 设置页 1000 次混合操作 | 24 ms、887 次写入,终态与控制器快照一致 |
323
+ | 500 次挂载/卸载 | subscribe 1000 / dispose 999(余 1 为当前挂载),无监听器泄漏 |
324
+
325
+ The same suite drives both entries. Stress suites cover host throughput/memory/concurrency, adversarial
326
+ protocol interleavings, client UI churn, and an end-to-end check against the installed DSH
327
+ `llm-invariant` validator plus the real `BlockAssembler`; `tests/stress-real-invariant.mjs` skips itself
328
+ when DSH is not installed. CI runs on Node 20/22/24 (matching DSH; Node 18 is not supported).
242
329
 
243
330
  ---
244
331
 
@@ -247,8 +334,17 @@ Node 20/22/24 (matching DSH; Node 18 is not supported).
247
334
  - 停止时若恰有未闭合的工具调用块(顺序输出块的适配器几乎不可能),该块会按已累积参数闭合并可能被执行。
248
335
  - 服务端停止依赖适配器在流关闭时中止底层请求的语义(已验证 `dsh-llm-deepseek`;自定义适配器需自查)。
249
336
  - 阈值语义为 `>= threshold`:第 10 次重复出现时即停止。
250
-
251
- ### DSH 运行期间编辑 preset 后的 standing-mount 冲突(DSH ≤ 0.1.2-rc.1 缺陷,本插件已内置补丁)
337
+ - **代码块放宽只覆盖围栏代码块**:行内代码(`` `x` ``)与缩进代码块(4 空格)仍按普通阈值判定;
338
+ 模型忘记闭合围栏时,其后内容一律按代码块(放宽)处理,且**代码块内的失控复读要到「阈值 × 倍数」
339
+ 才会被截停**(默认 30 次),这是「避免误杀生成的代码」的代价——把倍数调小可获得更早的兜底。
340
+ - 检测窗口上限 1,048,576 字符:每个增量都要重写一次缓冲,成本随窗口线性增长——缓冲填满后
341
+ 1 MiB 窗口约 0.13 ms/增量,实测 4 字符增量的平均值为 33 µs/增量(含缓冲填充期)。默认 8192 无感
342
+ (1.9 µs/增量,模型侧毫秒级的 token 间隔下可忽略)。
343
+ - 若手工编辑 `settings.yaml` 写入非法值(如 `minUnitLength > maxUnitLength`),DSH 会在注册时拒绝该
344
+ 命名空间,本插件捕获后仅打印错误日志并整体回落到代码默认值(检测功能不受影响,设置页显示默认值)。
345
+ - 协议新增 `ContentBlock` 类型时,截停收尾对未知块类型只能按 tool-call 兜底并打印一次性告警。
346
+
347
+ ### DSH 运行期间编辑 preset 后的 standing-mount 冲突(DSH ≤ 0.1.6-alpha.2 缺陷,本插件已内置补丁)
252
348
 
253
349
  **现象**:对某个会话执行模型选择等操作时报
254
350
  `resume failed ... preset ... failed to mount ... Host Cordis inspect provider "Service" is already registered`,
@@ -264,8 +360,8 @@ while the process lives")。`tool-cordis` 在每次挂载时向**进程全局*
264
360
  **本插件的修复(默认开启)**:`apply` 时把 `cordisInspect.register` **幂等化**——同 id 已有注册时
265
361
  共享既有注册并返回 no-op disposer,多代并存不再冲突。补丁进程内常驻(卸载本插件后仍生效,
266
362
  重启后由本插件重新安装;HMR 重载不会叠加)。依赖 `cordisInspect.providers` 为可读 Map
267
- (rc.6 / rc.7 / 0.1.1-rc.1 / 0.1.2-rc.1 实测如此);0.1.2-rc.1 上游仍留有
268
- "reclaim the superseded generation" 的 TODO,缺陷未修复,故默认开启。
363
+ (rc.6 / rc.7 / 0.1.1-rc.1 / 0.1.2-rc.1 / 0.1.5-rc.2 / 0.1.6-alpha.2 实测如此);0.1.6-alpha.2 上游
364
+ 仍留有 "reclaim the superseded generation" 的 TODO,缺陷未修复,故默认开启。
269
365
  DSH 升级修复后可将 `CONFIG.fixStandingMountConflict` 置为 `false` 关闭。
270
366
 
271
367
  **仍建议的操作纪律**:运行期间编辑已挂载 preset 后重启 DSH(补丁消除的是报错,旧代残留的
@@ -279,7 +375,7 @@ its process-global Host inspect providers (`Service` …) twice and every retry
279
375
  patches it by default**: `cordisInspect.register` is made idempotent (a same-id registration shares
280
376
  the existing one and gets a no-op disposer), so coexisting generations no longer collide. The patch
281
377
  is process-resident (survives plugin unload, reinstalled on restart; HMR reload does not stack it);
282
- set `CONFIG.fixStandingMountConflict` to `false` once a fixed DSH ships. Still open in 0.1.2-rc.1:
378
+ set `CONFIG.fixStandingMountConflict` to `false` once a fixed DSH ships. Still open in 0.1.6-alpha.2:
283
379
  upstream carries a TODO to reclaim superseded generations.
284
380
 
285
381
  ## License
package/lib/client.js CHANGED
@@ -24,9 +24,9 @@ window.__ModuleLoader__.load({
24
24
  nav: '重复守卫',
25
25
  title: '重复输出守卫',
26
26
  intro: '模型输出中同一字符串连续重复达到阈值次数时自动截停。以下参数修改后即时生效并持久化(settings.yaml)。',
27
- list: '忽略字符(白名单)',
27
+ list: '忽略字符(白名单,按单个字符匹配)',
28
28
  empty: '白名单为空:所有字符都参与重复统计。',
29
- addPlaceholder: '输入要忽略的字符',
29
+ addPlaceholder: '输入要忽略的字符(可多个)',
30
30
  add: '添加',
31
31
  params: '检测参数',
32
32
  threshold: '触发阈值(连续重复次数)',
@@ -36,9 +36,13 @@ window.__ModuleLoader__.load({
36
36
  maxUnitLength: '最大重复单元长度',
37
37
  maxUnitLengthHint: '可识别的最长重复单元(清洗后字符数,空白与白名单字符不计)。范围 {min}–{max}。',
38
38
  detectionWindow: '检测窗口(字符)',
39
- detectionWindowHint: '检测缓冲保留的字符数。范围 {min}–{max};需 ≥ 阈值 × 最大单元长度,否则长单元凑不满重复次数。',
39
+ detectionWindowHint: '检测缓冲保留的字符数。范围 {min}–{max};需 ≥ 阈值 × 代码块倍数 × 最大单元长度,否则长单元凑不满重复次数。',
40
+ codeBlockMultiplier: '代码块内阈值倍数',
41
+ codeBlockMultiplierHint: '围栏代码块(``` / ~~~)内的重复按「阈值 × 本倍数」判定。范围 {min}–{max};越大越不容易误杀正常代码,但也越晚兜住代码块里的失控复读(1 = 不放宽)。',
40
42
  stripWhitespace: '忽略空白字符',
41
43
  stripWhitespaceHint: '检测前移除所有空白(含换行),使「重复 重复」「重复\\n重复」这类带分隔的复读也能识别。',
44
+ skipCodeBlocks: '放宽代码块内的检测',
45
+ skipCodeBlocksHint: '开启后围栏代码块(``` / ~~~)内改用放宽阈值,避免生成的代码、测试夹具、表格、ASCII 图被误判为复读;行内代码与缩进代码块不受影响。',
42
46
  monitorReasoning: '检测思考内容(reasoning)',
43
47
  monitorReasoningHint: '思考中的复读同样消耗 token,默认一并检测。',
44
48
  monitorToolArguments: '检测工具调用参数',
@@ -51,7 +55,8 @@ window.__ModuleLoader__.load({
51
55
  errInteger: '请输入整数。',
52
56
  errRange: '取值范围 {min}–{max}。',
53
57
  errCross: '不能小于最小重复单元长度(当前 {min})。',
54
- windowWarn: '⚠ 检测窗口长度需要提高:至少 {need}(当前 {current} = 阈值 {threshold} × 最大单元 {maxUnit})。当前窗口下超过 {effective} 字符的重复单元无法识别。',
58
+ errCrossMin: '不能大于最大重复单元长度(当前 {max})。',
59
+ windowWarn: '⚠ 检测窗口长度需要提高:至少 {need}(当前 {current} = 阈值 {threshold} × 代码块倍数 {multiplier} × 最大单元 {maxUnit})。当前窗口下超过 {effective} 字符的重复单元无法识别。',
55
60
  loading: '加载中…',
56
61
  remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,检测参数将使用默认值。',
57
62
  unavailable: '设置服务不可用:未读取到「重复守卫」的设置数据。请确认 DSH 为 npm 常驻版挂载(宿主日志应包含「已注册设置命名空间 dsh-dupguard」),并刷新页面重试。',
@@ -60,9 +65,9 @@ window.__ModuleLoader__.load({
60
65
  nav: 'Dupguard',
61
66
  title: 'Repetition Guard',
62
67
  intro: 'Generation stops when the same string repeats consecutively up to the threshold. Changes below take effect immediately and persist to settings.yaml.',
63
- list: 'Ignored characters (whitelist)',
68
+ list: 'Ignored characters (whitelist, matched per character)',
64
69
  empty: 'Whitelist is empty: every character counts.',
65
- addPlaceholder: 'Character to ignore',
70
+ addPlaceholder: 'Characters to ignore (one or more)',
66
71
  add: 'Add',
67
72
  params: 'Detection parameters',
68
73
  threshold: 'Threshold (consecutive repeats)',
@@ -72,9 +77,13 @@ window.__ModuleLoader__.load({
72
77
  maxUnitLength: 'Maximum repeating-unit length',
73
78
  maxUnitLengthHint: 'Longest repeating unit recognized, in characters after cleaning (whitespace and whitelisted characters are removed). Range {min}–{max}.',
74
79
  detectionWindow: 'Detection window (characters)',
75
- detectionWindowHint: 'Characters retained in the detection buffer. Range {min}–{max}; must be >= threshold x max unit length, otherwise long units never reach the repeat count.',
80
+ detectionWindowHint: 'Characters retained in the detection buffer. Range {min}–{max}; must be >= threshold x code-block multiplier x max unit length, otherwise long units never reach the repeat count.',
81
+ codeBlockMultiplier: 'Code-block threshold multiplier',
82
+ codeBlockMultiplierHint: 'Inside fenced code blocks (``` / ~~~) a repeat must reach threshold x this multiplier. Range {min}–{max}; higher means fewer false stops on generated code, but runaway loops inside code are caught later (1 = no relaxation).',
76
83
  stripWhitespace: 'Ignore whitespace',
77
84
  stripWhitespaceHint: 'Remove all whitespace (newlines included) before detection, so separated repeats are still recognized.',
85
+ skipCodeBlocks: 'Relax detection inside code blocks',
86
+ skipCodeBlocksHint: 'With this on, fenced code blocks (``` / ~~~) use the relaxed threshold, so generated code, fixtures, tables and ASCII art are not mistaken for repetition loops. Inline code and indented code blocks are unaffected.',
78
87
  monitorReasoning: 'Monitor reasoning text',
79
88
  monitorReasoningHint: 'Repeats inside reasoning burn tokens too, so they are monitored by default.',
80
89
  monitorToolArguments: 'Monitor tool-call arguments',
@@ -87,7 +96,8 @@ window.__ModuleLoader__.load({
87
96
  errInteger: 'Enter an integer.',
88
97
  errRange: 'Allowed range {min}–{max}.',
89
98
  errCross: 'Must not be smaller than the minimum unit length (currently {min}).',
90
- windowWarn: '⚠ Detection window is too small: needs at least {need} (currently {current} = threshold {threshold} x max unit {maxUnit}). Units longer than {effective} characters cannot be detected with the current window.',
99
+ errCrossMin: 'Must not be larger than the maximum unit length (currently {max}).',
100
+ windowWarn: '⚠ Detection window is too small: needs at least {need} (currently {current} = threshold {threshold} x code-block multiplier {multiplier} x max unit {maxUnit}). Units longer than {effective} characters cannot be detected with the current window.',
91
101
  loading: 'Loading…',
92
102
  remoteHint: 'Settings editing requires a local connection: open the DSH page through the local address (127.0.0.1) and retry. Remote browsers have the settings channel disabled and detection keeps its defaults.',
93
103
  unavailable: 'Settings unavailable: no data for the Dupguard section was received. Verify the npm build is mounted (the host log should contain "已注册设置命名空间 dsh-dupguard") and refresh the page.',
@@ -140,18 +150,21 @@ window.__ModuleLoader__.load({
140
150
  // ---------------------------------------------------------------------
141
151
  const NUMERIC_FIELDS = [
142
152
  { key: 'threshold', min: 2, max: 1000 },
153
+ { key: 'codeBlockMultiplier', min: 1, max: 100 },
143
154
  { key: 'minUnitLength', min: 1, max: 4096 },
144
155
  { key: 'maxUnitLength', min: 1, max: 8192 },
145
156
  { key: 'detectionWindow', min: 64, max: 1048576 },
146
157
  ]
147
- const BOOL_FIELDS = ['stripWhitespace', 'monitorReasoning', 'monitorToolArguments']
158
+ const BOOL_FIELDS = ['stripWhitespace', 'skipCodeBlocks', 'monitorReasoning', 'monitorToolArguments']
148
159
  const DEFAULTS = {
149
160
  ignoredChars: ['-', '|'],
150
161
  threshold: 10,
162
+ codeBlockMultiplier: 3,
151
163
  minUnitLength: 1,
152
164
  maxUnitLength: 80,
153
165
  detectionWindow: 8192,
154
166
  stripWhitespace: true,
167
+ skipCodeBlocks: true,
155
168
  monitorReasoning: true,
156
169
  monitorToolArguments: false,
157
170
  }
@@ -281,18 +294,26 @@ window.__ModuleLoader__.load({
281
294
  const value = parsed[field.key]
282
295
  if (!Number.isInteger(value)) return t('errInteger')
283
296
  if (value < field.min || value > field.max) return fmt(t('errRange'), { min: field.min, max: field.max })
284
- if (field.key === 'maxUnitLength' && crossInvalid) return fmt(t('errCross'), { min: parsed.minUnitLength })
297
+ // 跨字段约束在两侧都提示:否则从「最小单元」一侧提交违规值时,
298
+ // 只能拿到宿主拒绝后的泛化「保存失败」。
299
+ if (crossInvalid && field.key === 'maxUnitLength') return fmt(t('errCross'), { min: parsed.minUnitLength })
300
+ if (crossInvalid && field.key === 'minUnitLength') return fmt(t('errCrossMin'), { max: parsed.maxUnitLength })
285
301
  return null
286
302
  }
287
303
 
288
- // 窗口缺口提示:窗口 < 阈值 × 最大单元长度时,更长的单元无法凑满重复次数。
289
- const shortfall = numericValid && !crossInvalid && parsed.detectionWindow < parsed.threshold * parsed.maxUnitLength
304
+ // 窗口缺口提示:窗口 < 最严格阈值 × 最大单元长度时,更长的单元无法凑满重复次数。
305
+ // 代码块内使用放宽阈值(阈值 × 倍数),因此窗口需按更严格的一侧计算。
306
+ const strictThreshold = shown.skipCodeBlocks === true
307
+ ? parsed.threshold * parsed.codeBlockMultiplier
308
+ : parsed.threshold
309
+ const shortfall = numericValid && !crossInvalid && parsed.detectionWindow < strictThreshold * parsed.maxUnitLength
290
310
  ? {
291
- need: parsed.threshold * parsed.maxUnitLength,
311
+ need: strictThreshold * parsed.maxUnitLength,
292
312
  current: parsed.detectionWindow,
293
313
  threshold: parsed.threshold,
314
+ multiplier: shown.skipCodeBlocks === true ? parsed.codeBlockMultiplier : 1,
294
315
  maxUnit: parsed.maxUnitLength,
295
- effective: Math.floor(parsed.detectionWindow / parsed.threshold),
316
+ effective: Math.floor(parsed.detectionWindow / strictThreshold),
296
317
  }
297
318
  : null
298
319
 
@@ -326,8 +347,15 @@ window.__ModuleLoader__.load({
326
347
  return
327
348
  }
328
349
  const value = parsed[field.key]
329
- dirty.current[field.key] = true
330
350
  setErrors((prev) => ({ ...prev, [field.key]: null }))
351
+ // 值未变化时不写入:避免仅仅聚焦/失焦就把默认值写进用户层,
352
+ // 污染 settings.yaml 并让「恢复默认」失去意义。
353
+ if (snapshotValue()[field.key] === value) {
354
+ dirty.current[field.key] = false
355
+ setForm({ ...currentForm(), [field.key]: String(value) })
356
+ return
357
+ }
358
+ dirty.current[field.key] = true
331
359
  setForm({ ...currentForm(), [field.key]: String(value) })
332
360
  runWrite(() => controller.set(field.key, value), () => snapshotValue()[field.key] === value)
333
361
  }
@@ -353,11 +381,20 @@ window.__ModuleLoader__.load({
353
381
  }
354
382
 
355
383
  const add = () => {
356
- const ch = draft.trim()
357
- if (ch.length === 0) return
384
+ const text = draft.trim()
385
+ if (text.length === 0) return
358
386
  setDraft('')
359
- if (shown.ignoredChars.indexOf(ch) !== -1) return
360
- commitList([...shown.ignoredChars, ch])
387
+ // 白名单按单个字符匹配:一次输入多个字符时逐个加入(忽略输入中的空白),
388
+ // 否则整串会被当成一个条目,永远不会命中。
389
+ const next = [...shown.ignoredChars]
390
+ let changed = false
391
+ for (const ch of text) {
392
+ if (/\s/.test(ch)) continue
393
+ if (next.indexOf(ch) !== -1) continue
394
+ next.push(ch)
395
+ changed = true
396
+ }
397
+ if (changed) commitList(next)
361
398
  }
362
399
  const remove = (ch) => commitList(shown.ignoredChars.filter((item) => item !== ch))
363
400
 
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@ const LIMITS = {
31
31
  minUnitLength: { min: 1, max: 4096 },
32
32
  maxUnitLength: { min: 1, max: 8192 },
33
33
  detectionWindow: { min: 64, max: 1048576 },
34
+ codeBlockMultiplier: { min: 1, max: 100 },
34
35
  }
35
36
 
36
37
  /**
@@ -52,7 +53,9 @@ function loadSettingsSchema() {
52
53
  minUnitLength: z.number().min(LIMITS.minUnitLength.min).max(LIMITS.minUnitLength.max).step(1).default(CONFIG.minUnitLength),
53
54
  maxUnitLength: z.number().min(LIMITS.maxUnitLength.min).max(LIMITS.maxUnitLength.max).step(1).default(CONFIG.maxUnitLength),
54
55
  detectionWindow: z.number().min(LIMITS.detectionWindow.min).max(LIMITS.detectionWindow.max).step(1).default(CONFIG.detectionWindow),
56
+ codeBlockMultiplier: z.number().min(LIMITS.codeBlockMultiplier.min).max(LIMITS.codeBlockMultiplier.max).step(1).default(CONFIG.codeBlockMultiplier),
55
57
  stripWhitespace: z.boolean().default(CONFIG.stripWhitespace),
58
+ skipCodeBlocks: z.boolean().default(CONFIG.skipCodeBlocks),
56
59
  monitorReasoning: z.boolean().default(CONFIG.monitorReasoning),
57
60
  monitorToolArguments: z.boolean().default(CONFIG.monitorToolArguments),
58
61
  })
@@ -78,7 +81,17 @@ const CONFIG = {
78
81
  // 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成
79
82
  // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
80
83
  // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
84
+ // 条目必须是单个字符(按 Unicode 码点匹配,emoji 也算一个);
85
+ // 多字符条目永远不会命中,运行时会丢弃并告警。
81
86
  ignoredChars: ['-', '|'],
87
+ // 围栏代码块(``` / ~~~)内的重复检测放宽:代码里的重复串(生成的测试夹具、表格、
88
+ // ASCII 图、内嵌数据等)大多属于正常内容,按普通阈值会误杀。开启后块内改用
89
+ // threshold × codeBlockMultiplier 判定:既能放过正常代码,又能兜住真正的失控复读。
90
+ // 置 false 则关闭该行为(块内与块外同样严格);倍数置 1 等价于不放宽。
91
+ // 局限:只识别围栏代码块,行内代码(`x`)与缩进代码块(4 空格)仍按普通阈值判定;
92
+ // 模型忘记闭合围栏时,其后内容都按代码块(放宽)处理。
93
+ skipCodeBlocks: true,
94
+ codeBlockMultiplier: 3,
82
95
  // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
83
96
  // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
84
97
  // 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
@@ -129,12 +142,11 @@ function stripWhitespace(text) {
129
142
  return text.replace(/\s+/g, '')
130
143
  }
131
144
 
132
- /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。 */
145
+ /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。按 Unicode 码点逐个比对。 */
133
146
  function stripIgnoredChars(text, ignored) {
134
147
  if (ignored.length === 0) return text
135
148
  let out = ''
136
- for (let i = 0; i < text.length; i++) {
137
- const ch = text[i]
149
+ for (const ch of text) {
138
150
  if (ignored.indexOf(ch) === -1) out += ch
139
151
  }
140
152
  return out
@@ -147,6 +159,194 @@ function sanitizeDelta(delta, config) {
147
159
  return piece
148
160
  }
149
161
 
162
+ /**
163
+ * 流式围栏代码块过滤器(每个文本块一份状态)。
164
+ *
165
+ * 目标:把增量切成「普通文本 / 代码块内文本」两段,供检测按不同阈值判定——
166
+ * 代码块内的重复大多是正常内容(生成的测试夹具、表格、ASCII 图、内嵌数据),
167
+ * 按普通阈值会误杀,故块内使用放宽阈值。
168
+ *
169
+ * 规则按 CommonMark 的围栏代码块:
170
+ * - 起始行:行首最多 3 个空格 + 连续 ≥3 个 ` 或 ~(其后为 info string);
171
+ * - 结束行:行首最多 3 个空格 + 同字符且不短于起始长度的连续 run,其后仅允许空白;
172
+ * - 未闭合的围栏一直延续到该块结束。
173
+ * 增量切分安全:围栏标记被切进多个 delta(如 "``" + "`js")时靠行首缓冲继续判定。
174
+ * 局限:不识别行内代码(`x`)与缩进代码块(4 空格),它们按普通文本处理。
175
+ */
176
+ function createFenceFilter() {
177
+ // 围栏字符 run 的长度上限:超过即按上限记录并进入代码块,避免无界缓冲。
178
+ const MAX_FENCE_RUN = 64
179
+ let atLineStart = true
180
+ let head = '' // 行首缓冲:最多 3 个空格 + 可能的围栏字符 run
181
+ let headSpaces = 0
182
+ let headChar = ''
183
+ let fence = null // { char, len }:已进入代码块
184
+ let closeLine = '' // 代码块内当前行(用于判定结束行)
185
+ let closeTooLong = false
186
+
187
+ const runLength = () => head.length - headSpaces
188
+ const resetHead = () => {
189
+ head = ''
190
+ headSpaces = 0
191
+ headChar = ''
192
+ }
193
+
194
+ /** 当前行是否为结束行(同字符、不短于起始长度、其后仅空白)。 */
195
+ function isClosingLine(line) {
196
+ const body = line.replace(/[ \t]+$/, '')
197
+ const indent = body.length - body.replace(/^ {0,3}/, '').length
198
+ const run = body.slice(indent)
199
+ if (run.length < fence.len) return false
200
+ for (let i = 0; i < run.length; i++) {
201
+ if (run[i] !== fence.char) return false
202
+ }
203
+ return true
204
+ }
205
+
206
+ return {
207
+ /** 当前是否处于围栏代码块内(诊断/测试用)。 */
208
+ inside: () => fence !== null,
209
+ /**
210
+ * 消费一个增量,返回按「是否位于代码块内」切分的片段序列。
211
+ * 片段按原始顺序首尾相接即为完整增量,调用方据此分别用普通/放宽阈值检测。
212
+ * @param delta - 文本增量。
213
+ * @returns {Array<{ text: string, code: boolean }>} 片段序列(可能为空数组)。
214
+ */
215
+ push(delta) {
216
+ // 快路径:不含围栏字符与换行的增量不可能改变围栏状态。
217
+ if (fence !== null && delta.indexOf(fence.char) === -1 && delta.indexOf('\n') === -1) {
218
+ return [{ text: delta, code: true }]
219
+ }
220
+ if (fence === null && !atLineStart &&
221
+ delta.indexOf('`') === -1 && delta.indexOf('~') === -1 && delta.indexOf('\n') === -1) {
222
+ return [{ text: delta, code: false }]
223
+ }
224
+ const runs = []
225
+ let buffer = ''
226
+ let bufferCode = fence !== null
227
+ const emit = (text, code) => {
228
+ if (text.length === 0) return
229
+ if (buffer.length > 0 && code !== bufferCode) {
230
+ runs.push({ text: buffer, code: bufferCode })
231
+ buffer = ''
232
+ }
233
+ bufferCode = code
234
+ buffer += text
235
+ }
236
+ for (let i = 0; i < delta.length; i++) {
237
+ const ch = delta[i]
238
+ if (fence !== null) {
239
+ // 代码块内:只判定结束行,内容整体按代码发射。
240
+ if (ch === '\n') {
241
+ const closing = isClosingLine(closeLine)
242
+ emit(ch, true)
243
+ closeLine = ''
244
+ closeTooLong = false
245
+ if (closing) {
246
+ fence = null
247
+ atLineStart = true
248
+ resetHead()
249
+ bufferCode = false
250
+ }
251
+ continue
252
+ }
253
+ if (!closeTooLong) {
254
+ closeLine += ch
255
+ if (closeLine.length > fence.len + 8) closeTooLong = true
256
+ }
257
+ emit(ch, true)
258
+ continue
259
+ }
260
+ if (atLineStart) {
261
+ if (ch === ' ' && headChar === '' && headSpaces < 3) {
262
+ head += ch
263
+ headSpaces++
264
+ continue
265
+ }
266
+ if ((ch === '`' || ch === '~') && (headChar === '' || headChar === ch)) {
267
+ headChar = ch
268
+ head += ch
269
+ // 不能一见 3 个就进入:起始行的完整 run 才是围栏长度(```` 不能被 ``` 关闭)。
270
+ if (runLength() >= MAX_FENCE_RUN) {
271
+ emit(head, true)
272
+ fence = { char: ch, len: runLength() }
273
+ closeLine = ''
274
+ closeTooLong = false
275
+ atLineStart = false
276
+ resetHead()
277
+ }
278
+ continue
279
+ }
280
+ if (headChar !== '' && runLength() >= 3) {
281
+ // 起始行(含行首空格与围栏符)整体属于代码块,长度取完整 run。
282
+ emit(head, true)
283
+ fence = { char: headChar, len: runLength() }
284
+ closeLine = ''
285
+ closeTooLong = false
286
+ atLineStart = false
287
+ resetHead()
288
+ emit(ch, true)
289
+ continue
290
+ }
291
+ // 判定不是围栏起始:行首缓冲按普通文本交还。
292
+ emit(head, false)
293
+ resetHead()
294
+ atLineStart = ch === '\n'
295
+ emit(ch, false)
296
+ continue
297
+ }
298
+ emit(ch, false)
299
+ if (ch === '\n') {
300
+ atLineStart = true
301
+ resetHead()
302
+ }
303
+ }
304
+ if (buffer.length > 0) runs.push({ text: buffer, code: bufferCode })
305
+ return runs
306
+ },
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 未知块类型只会在 DSH 协议新增 ContentBlock 类型时出现:此时没有合法的合成
312
+ * block-end,只能按 tool-call 兜底并告警一次,便于定位协议漂移。
313
+ */
314
+ let warnedUnknownBlockType = false
315
+ function warnUnknownBlockType(blockType) {
316
+ if (warnedUnknownBlockType) return
317
+ warnedUnknownBlockType = true
318
+ console.warn(
319
+ '[dupguard] 遇到未知块类型 ' + JSON.stringify(blockType) +
320
+ ':截停收尾只能按 tool-call 兜底,请升级插件以匹配新的 DSH 协议。'
321
+ )
322
+ }
323
+
324
+ /**
325
+ * 白名单按「单个字符」(Unicode 码点)匹配:多字符或空条目永远不会命中,
326
+ * 属于无效配置。此处丢弃并告警,避免设置页显示了一个永远不生效的条目。
327
+ */
328
+ function normalizeIgnoredChars(raw, fallback) {
329
+ if (!Array.isArray(raw)) return [...fallback]
330
+ const kept = []
331
+ const dropped = []
332
+ for (const entry of raw) {
333
+ if (typeof entry !== 'string') {
334
+ dropped.push(String(entry))
335
+ continue
336
+ }
337
+ // [...entry] 按码点拆分:emoji 等代理对字符算一个字符。
338
+ if ([...entry].length === 1) kept.push(entry)
339
+ else dropped.push(entry)
340
+ }
341
+ if (dropped.length > 0) {
342
+ console.warn(
343
+ '[dupguard] 白名单条目必须是单个字符,已忽略无效条目:' + JSON.stringify(dropped) +
344
+ '(白名单按字符匹配,多字符条目不会生效)。'
345
+ )
346
+ }
347
+ return kept
348
+ }
349
+
150
350
  /**
151
351
  * 为一次 llm/stream 调用创建守卫。
152
352
  * 每次模型调用都会新建一份状态,互不干扰。
@@ -168,6 +368,7 @@ function createStreamGuard(options, runtime) {
168
368
  blockType,
169
369
  text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口
170
370
  stripped: '', // 去空白后的滚动窗口:仅用于检测
371
+ fence: createFenceFilter(), // 围栏代码块状态(skipCodeBlocks 开启时使用)
171
372
  toolCallId: undefined,
172
373
  toolCallName: undefined,
173
374
  toolCallArguments: '',
@@ -177,12 +378,25 @@ function createStreamGuard(options, runtime) {
177
378
  return b
178
379
  }
179
380
 
180
- /** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
381
+ /**
382
+ * 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。
383
+ *
384
+ * 增量会先按围栏代码块切成片段:块外按 threshold 判定,块内按
385
+ * threshold × codeBlockMultiplier 判定(放宽,避免误杀正常代码)。
386
+ */
181
387
  function feedText(b, delta) {
182
388
  b.text += delta
183
- const piece = sanitizeDelta(delta, runtime)
184
- b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
185
- return findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength)
389
+ const codeThreshold = Math.max(runtime.threshold, runtime.threshold * runtime.codeBlockMultiplier)
390
+ const runs = runtime.skipCodeBlocks ? b.fence.push(delta) : [{ text: delta, code: false }]
391
+ for (const run of runs) {
392
+ const piece = sanitizeDelta(run.text, runtime)
393
+ if (piece.length === 0) continue
394
+ b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
395
+ const threshold = run.code ? codeThreshold : runtime.threshold
396
+ const hit = findRepeatedTail(b.stripped, threshold, runtime.minUnitLength, runtime.maxUnitLength)
397
+ if (hit !== null) return { unit: hit.unit, count: hit.count, span: hit.span, code: run.code }
398
+ }
399
+ return null
186
400
  }
187
401
 
188
402
  /** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
@@ -241,6 +455,7 @@ function createStreamGuard(options, runtime) {
241
455
  } else if (b.blockType === 'reasoning') {
242
456
  block = { type: 'reasoning', text: b.text }
243
457
  } else {
458
+ if (b.blockType !== 'tool-call') warnUnknownBlockType(b.blockType)
244
459
  block = {
245
460
  type: 'tool-call',
246
461
  id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index,
@@ -273,7 +488,8 @@ function createStreamGuard(options, runtime) {
273
488
  '[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model +
274
489
  ' unit=' + JSON.stringify(stopped.unit) +
275
490
  ' repeat>=' + String(stopped.count) +
276
- ' span=' + String(stopped.span) + 'chars'
491
+ ' span=' + String(stopped.span) + 'chars' +
492
+ (stopped.code === true ? ' code-block=true(放宽阈值命中)' : '')
277
493
  )
278
494
  yield* closingChunks()
279
495
  // 提前 return:for-await 会调用上游 iterator.return(),
@@ -331,7 +547,9 @@ function settingsBase() {
331
547
  minUnitLength: CONFIG.minUnitLength,
332
548
  maxUnitLength: CONFIG.maxUnitLength,
333
549
  detectionWindow: CONFIG.detectionWindow,
550
+ codeBlockMultiplier: CONFIG.codeBlockMultiplier,
334
551
  stripWhitespace: CONFIG.stripWhitespace,
552
+ skipCodeBlocks: CONFIG.skipCodeBlocks,
335
553
  monitorReasoning: CONFIG.monitorReasoning,
336
554
  monitorToolArguments: CONFIG.monitorToolArguments,
337
555
  }
@@ -342,20 +560,29 @@ function createRuntime() {
342
560
  return settingsBase()
343
561
  }
344
562
 
563
+ /** 代码块内实际使用的最严格阈值(未开启代码块放宽时等于 threshold)。 */
564
+ function effectiveCodeThreshold(runtime) {
565
+ if (runtime.skipCodeBlocks !== true) return runtime.threshold
566
+ return runtime.threshold * runtime.codeBlockMultiplier
567
+ }
568
+
345
569
  /**
346
- * 检测窗口是否小于「阈值 × 最大单元长度」。
570
+ * 检测窗口是否小于「最严格阈值 × 最大单元长度」。
347
571
  *
348
572
  * 窗口是清洗后保留的字符数:一个长度 p 的单元需要 p × threshold 个字符才可能
349
- * 凑满阈值,因此 p 超过 floor(窗口 / 阈值) 的重复单元无法被识别。返回缺口描述
350
- * (含所需窗口)或 null。
573
+ * 凑满阈值,因此 p 超过 floor(窗口 / 阈值) 的重复单元无法被识别。代码块内使用
574
+ * 放宽阈值(threshold × codeBlockMultiplier),窗口需按该更严格的一侧计算。
575
+ * 返回缺口描述(含所需窗口)或 null。
351
576
  */
352
577
  function windowShortfall(runtime) {
353
- const needed = runtime.threshold * runtime.maxUnitLength
578
+ const threshold = Math.max(runtime.threshold, effectiveCodeThreshold(runtime))
579
+ const needed = threshold * runtime.maxUnitLength
354
580
  if (runtime.detectionWindow >= needed) return null
355
581
  return {
356
582
  needed: needed,
357
583
  current: runtime.detectionWindow,
358
- effectiveMaxUnit: Math.floor(runtime.detectionWindow / runtime.threshold),
584
+ threshold: threshold,
585
+ effectiveMaxUnit: Math.floor(runtime.detectionWindow / threshold),
359
586
  }
360
587
  }
361
588
 
@@ -363,11 +590,14 @@ function windowShortfall(runtime) {
363
590
  function warnShortWindow(runtime) {
364
591
  const short = windowShortfall(runtime)
365
592
  if (short === null) return
593
+ const relaxed = short.threshold !== runtime.threshold
366
594
  console.warn(
367
595
  '[dupguard] 检测窗口长度需要提高:至少 ' + String(short.needed) +
368
- '(= 阈值 ' + String(runtime.threshold) + ' × 最大单元长度 ' + String(runtime.maxUnitLength) +
596
+ '(= 阈值 ' + String(runtime.threshold) +
597
+ (relaxed ? ' × 代码块倍数 ' + String(runtime.codeBlockMultiplier) : '') +
598
+ ' × 最大单元长度 ' + String(runtime.maxUnitLength) +
369
599
  '),当前 ' + String(short.current) + ';超过 ' + String(short.effectiveMaxUnit) +
370
- ' 字符的重复单元将无法识别。请在设置页提高「检测窗口」,或降低阈值/最大单元长度。'
600
+ ' 字符的重复单元将无法识别。请在设置页提高「检测窗口」,或降低阈值/代码块倍数/最大单元长度。'
371
601
  )
372
602
  }
373
603
 
@@ -386,14 +616,14 @@ function applySettingsToRuntime(runtime, value) {
386
616
  return fallback[key]
387
617
  }
388
618
  const pickBool = (key) => (typeof source[key] === 'boolean' ? source[key] : fallback[key])
389
- runtime.ignoredChars = Array.isArray(source.ignoredChars)
390
- ? source.ignoredChars.filter((ch) => typeof ch === 'string')
391
- : [...fallback.ignoredChars]
619
+ runtime.ignoredChars = normalizeIgnoredChars(source.ignoredChars, fallback.ignoredChars)
392
620
  runtime.threshold = pickInt('threshold')
393
621
  runtime.minUnitLength = pickInt('minUnitLength')
394
622
  runtime.maxUnitLength = pickInt('maxUnitLength')
395
623
  runtime.detectionWindow = pickInt('detectionWindow')
624
+ runtime.codeBlockMultiplier = pickInt('codeBlockMultiplier')
396
625
  runtime.stripWhitespace = pickBool('stripWhitespace')
626
+ runtime.skipCodeBlocks = pickBool('skipCodeBlocks')
397
627
  runtime.monitorReasoning = pickBool('monitorReasoning')
398
628
  runtime.monitorToolArguments = pickBool('monitorToolArguments')
399
629
  warnShortWindow(runtime)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dupguard",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Real-time repetition guard for DeepSeek Harness (DSH): stops model generation when the same string repeats >=10 times in the streamed output. 实时检测 DSH 大模型流式输出中的重复内容,同一字符串重复十次以上立即停止生成。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -45,7 +45,8 @@
45
45
  "node": ">=20"
46
46
  },
47
47
  "scripts": {
48
- "test": "node tests/detector.test.js && node tests/client.test.js"
48
+ "test": "node tests/detector.test.js && node tests/client.test.js",
49
+ "stress": "node tests/stress-host-adversarial.js && node tests/stress-host-throughput.js && node tests/stress-client-ui.js && node tests/stress-real-invariant.mjs"
49
50
  },
50
51
  "publishConfig": {
51
52
  "access": "public"
package/plugin/host.js CHANGED
@@ -35,6 +35,15 @@ const CONFIG = {
35
35
  // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
36
36
  // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
37
37
  ignoredChars: ['-', '|'],
38
+ // 围栏代码块(``` / ~~~)内的重复检测放宽:代码里的重复串(生成的测试夹具、表格、
39
+ // ASCII 图、内嵌数据等)大多属于正常内容,按普通阈值会误杀。开启后块内改用
40
+ // threshold × codeBlockMultiplier 判定:既能放过正常代码,又能兜住真正的失控复读。
41
+ // 置 false 则关闭该行为(块内与块外同样严格);倍数置 1 等价于不放宽。
42
+ // 局限:只识别围栏代码块,行内代码(`x`)与缩进代码块(4 空格)仍按普通阈值判定;
43
+ // 模型忘记闭合围栏时,其后内容都按代码块(放宽)处理。
44
+ // 动态版无设置页,此处为代码常量;npm 常驻版可在设置页动态调整同名参数。
45
+ skipCodeBlocks: true,
46
+ codeBlockMultiplier: 3,
38
47
  // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
39
48
  // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
40
49
  // 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
@@ -103,6 +112,156 @@ function sanitizeDelta(delta, config) {
103
112
  return piece
104
113
  }
105
114
 
115
+ /**
116
+ * 流式围栏代码块过滤器(每个文本块一份状态)。
117
+ *
118
+ * 目标:把增量切成「普通文本 / 代码块内文本」两段,供检测按不同阈值判定——
119
+ * 代码块内的重复大多是正常内容(生成的测试夹具、表格、ASCII 图、内嵌数据),
120
+ * 按普通阈值会误杀,故块内使用放宽阈值。
121
+ *
122
+ * 规则按 CommonMark 的围栏代码块:
123
+ * - 起始行:行首最多 3 个空格 + 连续 ≥3 个 ` 或 ~(其后为 info string);
124
+ * - 结束行:行首最多 3 个空格 + 同字符且不短于起始长度的连续 run,其后仅允许空白;
125
+ * - 未闭合的围栏一直延续到该块结束。
126
+ * 增量切分安全:围栏标记被切进多个 delta(如 "``" + "`js")时靠行首缓冲继续判定。
127
+ * 局限:不识别行内代码(`x`)与缩进代码块(4 空格),它们按普通文本处理。
128
+ *
129
+ * 与 lib/index.js 中的同名实现保持一致(两个入口行为必须相同)。
130
+ */
131
+ function createFenceFilter() {
132
+ // 围栏字符 run 的长度上限:超过即按上限记录并进入代码块,避免无界缓冲。
133
+ const MAX_FENCE_RUN = 64
134
+ let atLineStart = true
135
+ let head = '' // 行首缓冲:最多 3 个空格 + 可能的围栏字符 run
136
+ let headSpaces = 0
137
+ let headChar = ''
138
+ let fence = null // { char, len }:已进入代码块
139
+ let closeLine = '' // 代码块内当前行(用于判定结束行)
140
+ let closeTooLong = false
141
+
142
+ const runLength = () => head.length - headSpaces
143
+ const resetHead = () => {
144
+ head = ''
145
+ headSpaces = 0
146
+ headChar = ''
147
+ }
148
+
149
+ /** 当前行是否为结束行(同字符、不短于起始长度、其后仅空白)。 */
150
+ function isClosingLine(line) {
151
+ const body = line.replace(/[ \t]+$/, '')
152
+ const indent = body.length - body.replace(/^ {0,3}/, '').length
153
+ const run = body.slice(indent)
154
+ if (run.length < fence.len) return false
155
+ for (let i = 0; i < run.length; i++) {
156
+ if (run[i] !== fence.char) return false
157
+ }
158
+ return true
159
+ }
160
+
161
+ return {
162
+ /** 当前是否处于围栏代码块内(诊断/测试用)。 */
163
+ inside: () => fence !== null,
164
+ /**
165
+ * 消费一个增量,返回按「是否位于代码块内」切分的片段序列。
166
+ * 片段按原始顺序首尾相接即为完整增量,调用方据此分别用普通/放宽阈值检测。
167
+ * @param delta - 文本增量。
168
+ * @returns {Array<{ text: string, code: boolean }>} 片段序列(可能为空数组)。
169
+ */
170
+ push(delta) {
171
+ // 快路径:不含围栏字符与换行的增量不可能改变围栏状态。
172
+ if (fence !== null && delta.indexOf(fence.char) === -1 && delta.indexOf('\n') === -1) {
173
+ return [{ text: delta, code: true }]
174
+ }
175
+ if (fence === null && !atLineStart &&
176
+ delta.indexOf('`') === -1 && delta.indexOf('~') === -1 && delta.indexOf('\n') === -1) {
177
+ return [{ text: delta, code: false }]
178
+ }
179
+ const runs = []
180
+ let buffer = ''
181
+ let bufferCode = fence !== null
182
+ const emit = (text, code) => {
183
+ if (text.length === 0) return
184
+ if (buffer.length > 0 && code !== bufferCode) {
185
+ runs.push({ text: buffer, code: bufferCode })
186
+ buffer = ''
187
+ }
188
+ bufferCode = code
189
+ buffer += text
190
+ }
191
+ for (let i = 0; i < delta.length; i++) {
192
+ const ch = delta[i]
193
+ if (fence !== null) {
194
+ // 代码块内:只判定结束行,内容整体按代码发射。
195
+ if (ch === '\n') {
196
+ const closing = isClosingLine(closeLine)
197
+ emit(ch, true)
198
+ closeLine = ''
199
+ closeTooLong = false
200
+ if (closing) {
201
+ fence = null
202
+ atLineStart = true
203
+ resetHead()
204
+ bufferCode = false
205
+ }
206
+ continue
207
+ }
208
+ if (!closeTooLong) {
209
+ closeLine += ch
210
+ if (closeLine.length > fence.len + 8) closeTooLong = true
211
+ }
212
+ emit(ch, true)
213
+ continue
214
+ }
215
+ if (atLineStart) {
216
+ if (ch === ' ' && headChar === '' && headSpaces < 3) {
217
+ head += ch
218
+ headSpaces++
219
+ continue
220
+ }
221
+ if ((ch === '`' || ch === '~') && (headChar === '' || headChar === ch)) {
222
+ headChar = ch
223
+ head += ch
224
+ // 不能一见 3 个就进入:起始行的完整 run 才是围栏长度(```` 不能被 ``` 关闭)。
225
+ if (runLength() >= MAX_FENCE_RUN) {
226
+ emit(head, true)
227
+ fence = { char: ch, len: runLength() }
228
+ closeLine = ''
229
+ closeTooLong = false
230
+ atLineStart = false
231
+ resetHead()
232
+ }
233
+ continue
234
+ }
235
+ if (headChar !== '' && runLength() >= 3) {
236
+ // 起始行(含行首空格与围栏符)整体属于代码块,长度取完整 run。
237
+ emit(head, true)
238
+ fence = { char: headChar, len: runLength() }
239
+ closeLine = ''
240
+ closeTooLong = false
241
+ atLineStart = false
242
+ resetHead()
243
+ emit(ch, true)
244
+ continue
245
+ }
246
+ // 判定不是围栏起始:行首缓冲按普通文本交还。
247
+ emit(head, false)
248
+ resetHead()
249
+ atLineStart = ch === '\n'
250
+ emit(ch, false)
251
+ continue
252
+ }
253
+ emit(ch, false)
254
+ if (ch === '\n') {
255
+ atLineStart = true
256
+ resetHead()
257
+ }
258
+ }
259
+ if (buffer.length > 0) runs.push({ text: buffer, code: bufferCode })
260
+ return runs
261
+ },
262
+ }
263
+ }
264
+
106
265
  /**
107
266
  * 为一次 llm/stream 调用创建守卫。
108
267
  * 每次模型调用都会新建一份状态,互不干扰。
@@ -122,6 +281,7 @@ function createStreamGuard(options) {
122
281
  blockType,
123
282
  text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口
124
283
  stripped: '', // 去空白后的滚动窗口:仅用于检测
284
+ fence: createFenceFilter(), // 围栏代码块状态(skipCodeBlocks 开启时使用)
125
285
  toolCallId: undefined,
126
286
  toolCallName: undefined,
127
287
  toolCallArguments: '',
@@ -131,12 +291,25 @@ function createStreamGuard(options) {
131
291
  return b
132
292
  }
133
293
 
134
- /** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
294
+ /**
295
+ * 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。
296
+ *
297
+ * 增量会先按围栏代码块切成片段:块外按 CONFIG.threshold 判定,块内按
298
+ * CONFIG.threshold × CONFIG.codeBlockMultiplier 判定(放宽,避免误杀正常代码)。
299
+ */
135
300
  function feedText(b, delta) {
136
301
  b.text += delta
137
- const piece = sanitizeDelta(delta, CONFIG)
138
- b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
139
- return findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
302
+ const codeThreshold = Math.max(CONFIG.threshold, CONFIG.threshold * CONFIG.codeBlockMultiplier)
303
+ const runs = CONFIG.skipCodeBlocks ? b.fence.push(delta) : [{ text: delta, code: false }]
304
+ for (const run of runs) {
305
+ const piece = sanitizeDelta(run.text, CONFIG)
306
+ if (piece.length === 0) continue
307
+ b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
308
+ const threshold = run.code ? codeThreshold : CONFIG.threshold
309
+ const hit = findRepeatedTail(b.stripped, threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
310
+ if (hit !== null) return { unit: hit.unit, count: hit.count, span: hit.span, code: run.code }
311
+ }
312
+ return null
140
313
  }
141
314
 
142
315
  /** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
@@ -227,7 +400,8 @@ function createStreamGuard(options) {
227
400
  '[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model +
228
401
  ' unit=' + JSON.stringify(stopped.unit) +
229
402
  ' repeat>=' + String(stopped.count) +
230
- ' span=' + String(stopped.span) + 'chars'
403
+ ' span=' + String(stopped.span) + 'chars' +
404
+ (stopped.code === true ? ' code-block=true(放宽阈值命中)' : '')
231
405
  )
232
406
  yield* closingChunks()
233
407
  // 提前 return:for-await 会调用上游 iterator.return(),