dsh-dupguard 1.3.0 → 1.3.1

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,22 @@
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.3.1] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - **白名单按「单个字符」匹配的语义两端不一致**:设置页会把整串输入(如 `ab`)当成一个条目,
10
+ 而检测按单个字符比对,该条目永远不会生效。现在设置页把一次输入拆成单个字符逐个加入
11
+ (忽略其中的空白),宿主对存量或手改的多字符、空、非字符串条目在运行时丢弃并打印告警;
12
+ 白名单比对同时改为按 Unicode 码点进行,emoji 等代理对字符可作为单个条目正常生效。
13
+ - **未改动的数值输入在失焦时也会写入**,把默认值写进用户层(`settings.yaml` 里因此出现
14
+ `threshold: 10`、`maxUnitLength: 80`、`detectionWindow: 8192` 这类无意义条目)。现在值与
15
+ 宿主一致时跳过写入。
16
+ - **跨字段约束只在「最大单元」一行提示**:从「最小单元」一侧提交违规值时只能拿到宿主拒绝后的
17
+ 泛化「保存失败」。现在两行都给出具体错误并在本地拦截写入。
18
+ - 截停收尾遇到未知块类型(协议新增 `ContentBlock` 类型)时会静默按 tool-call 兜底,
19
+ 现补充一次性宿主告警,便于定位协议漂移。
20
+
5
21
  ## [1.3.0] - 2026-09-05
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -128,7 +128,7 @@ and persisted to `settings.yaml`; the dynamic build uses the constants.
128
128
  | `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
129
129
  | `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后),需 ≥ 阈值 × 最大单元长度 / rolling detection window in chars (after whitespace removal); must be ≥ threshold × max unit length |
130
130
  | `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 |
131
+ | `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
132
  | `monitorReasoning` | `true` | 是否检测思考文本(思考中的复读同样消耗 token,默认截停;只检测可见输出时置 `false`)/ also guard reasoning (thinking) text — on by default; set `false` to guard visible output only |
133
133
  | `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
134
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) |
@@ -247,6 +247,11 @@ Node 20/22/24 (matching DSH; Node 18 is not supported).
247
247
  - 停止时若恰有未闭合的工具调用块(顺序输出块的适配器几乎不可能),该块会按已累积参数闭合并可能被执行。
248
248
  - 服务端停止依赖适配器在流关闭时中止底层请求的语义(已验证 `dsh-llm-deepseek`;自定义适配器需自查)。
249
249
  - 阈值语义为 `>= threshold`:第 10 次重复出现时即停止。
250
+ - 检测窗口上限 1,048,576 字符:窗口越大,每个增量都要重写一次缓冲(实测 1Mi 窗口约 0.13 ms/增量),
251
+ 极端长输出下会有可感知的额外开销;默认 8192 无此问题。
252
+ - 若手工编辑 `settings.yaml` 写入非法值(如 `minUnitLength > maxUnitLength`),DSH 会在注册时拒绝该
253
+ 命名空间,本插件捕获后仅打印错误日志并整体回落到代码默认值(检测功能不受影响,设置页显示默认值)。
254
+ - 协议新增 `ContentBlock` 类型时,截停收尾对未知块类型只能按 tool-call 兜底并打印一次性告警。
250
255
 
251
256
  ### DSH 运行期间编辑 preset 后的 standing-mount 冲突(DSH ≤ 0.1.2-rc.1 缺陷,本插件已内置补丁)
252
257
 
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: '触发阈值(连续重复次数)',
@@ -51,6 +51,7 @@ window.__ModuleLoader__.load({
51
51
  errInteger: '请输入整数。',
52
52
  errRange: '取值范围 {min}–{max}。',
53
53
  errCross: '不能小于最小重复单元长度(当前 {min})。',
54
+ errCrossMin: '不能大于最大重复单元长度(当前 {max})。',
54
55
  windowWarn: '⚠ 检测窗口长度需要提高:至少 {need}(当前 {current} = 阈值 {threshold} × 最大单元 {maxUnit})。当前窗口下超过 {effective} 字符的重复单元无法识别。',
55
56
  loading: '加载中…',
56
57
  remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,检测参数将使用默认值。',
@@ -60,9 +61,9 @@ window.__ModuleLoader__.load({
60
61
  nav: 'Dupguard',
61
62
  title: 'Repetition Guard',
62
63
  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)',
64
+ list: 'Ignored characters (whitelist, matched per character)',
64
65
  empty: 'Whitelist is empty: every character counts.',
65
- addPlaceholder: 'Character to ignore',
66
+ addPlaceholder: 'Characters to ignore (one or more)',
66
67
  add: 'Add',
67
68
  params: 'Detection parameters',
68
69
  threshold: 'Threshold (consecutive repeats)',
@@ -87,6 +88,7 @@ window.__ModuleLoader__.load({
87
88
  errInteger: 'Enter an integer.',
88
89
  errRange: 'Allowed range {min}–{max}.',
89
90
  errCross: 'Must not be smaller than the minimum unit length (currently {min}).',
91
+ errCrossMin: 'Must not be larger than the maximum unit length (currently {max}).',
90
92
  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.',
91
93
  loading: 'Loading…',
92
94
  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.',
@@ -281,7 +283,10 @@ window.__ModuleLoader__.load({
281
283
  const value = parsed[field.key]
282
284
  if (!Number.isInteger(value)) return t('errInteger')
283
285
  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 })
286
+ // 跨字段约束在两侧都提示:否则从「最小单元」一侧提交违规值时,
287
+ // 只能拿到宿主拒绝后的泛化「保存失败」。
288
+ if (crossInvalid && field.key === 'maxUnitLength') return fmt(t('errCross'), { min: parsed.minUnitLength })
289
+ if (crossInvalid && field.key === 'minUnitLength') return fmt(t('errCrossMin'), { max: parsed.maxUnitLength })
285
290
  return null
286
291
  }
287
292
 
@@ -326,8 +331,15 @@ window.__ModuleLoader__.load({
326
331
  return
327
332
  }
328
333
  const value = parsed[field.key]
329
- dirty.current[field.key] = true
330
334
  setErrors((prev) => ({ ...prev, [field.key]: null }))
335
+ // 值未变化时不写入:避免仅仅聚焦/失焦就把默认值写进用户层,
336
+ // 污染 settings.yaml 并让「恢复默认」失去意义。
337
+ if (snapshotValue()[field.key] === value) {
338
+ dirty.current[field.key] = false
339
+ setForm({ ...currentForm(), [field.key]: String(value) })
340
+ return
341
+ }
342
+ dirty.current[field.key] = true
331
343
  setForm({ ...currentForm(), [field.key]: String(value) })
332
344
  runWrite(() => controller.set(field.key, value), () => snapshotValue()[field.key] === value)
333
345
  }
@@ -353,11 +365,20 @@ window.__ModuleLoader__.load({
353
365
  }
354
366
 
355
367
  const add = () => {
356
- const ch = draft.trim()
357
- if (ch.length === 0) return
368
+ const text = draft.trim()
369
+ if (text.length === 0) return
358
370
  setDraft('')
359
- if (shown.ignoredChars.indexOf(ch) !== -1) return
360
- commitList([...shown.ignoredChars, ch])
371
+ // 白名单按单个字符匹配:一次输入多个字符时逐个加入(忽略输入中的空白),
372
+ // 否则整串会被当成一个条目,永远不会命中。
373
+ const next = [...shown.ignoredChars]
374
+ let changed = false
375
+ for (const ch of text) {
376
+ if (/\s/.test(ch)) continue
377
+ if (next.indexOf(ch) !== -1) continue
378
+ next.push(ch)
379
+ changed = true
380
+ }
381
+ if (changed) commitList(next)
361
382
  }
362
383
  const remove = (ch) => commitList(shown.ignoredChars.filter((item) => item !== ch))
363
384
 
package/lib/index.js CHANGED
@@ -78,6 +78,8 @@ const CONFIG = {
78
78
  // 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成
79
79
  // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
80
80
  // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
81
+ // 条目必须是单个字符(按 Unicode 码点匹配,emoji 也算一个);
82
+ // 多字符条目永远不会命中,运行时会丢弃并告警。
81
83
  ignoredChars: ['-', '|'],
82
84
  // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
83
85
  // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
@@ -129,12 +131,11 @@ function stripWhitespace(text) {
129
131
  return text.replace(/\s+/g, '')
130
132
  }
131
133
 
132
- /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。 */
134
+ /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。按 Unicode 码点逐个比对。 */
133
135
  function stripIgnoredChars(text, ignored) {
134
136
  if (ignored.length === 0) return text
135
137
  let out = ''
136
- for (let i = 0; i < text.length; i++) {
137
- const ch = text[i]
138
+ for (const ch of text) {
138
139
  if (ignored.indexOf(ch) === -1) out += ch
139
140
  }
140
141
  return out
@@ -147,6 +148,46 @@ function sanitizeDelta(delta, config) {
147
148
  return piece
148
149
  }
149
150
 
151
+ /**
152
+ * 未知块类型只会在 DSH 协议新增 ContentBlock 类型时出现:此时没有合法的合成
153
+ * block-end,只能按 tool-call 兜底并告警一次,便于定位协议漂移。
154
+ */
155
+ let warnedUnknownBlockType = false
156
+ function warnUnknownBlockType(blockType) {
157
+ if (warnedUnknownBlockType) return
158
+ warnedUnknownBlockType = true
159
+ console.warn(
160
+ '[dupguard] 遇到未知块类型 ' + JSON.stringify(blockType) +
161
+ ':截停收尾只能按 tool-call 兜底,请升级插件以匹配新的 DSH 协议。'
162
+ )
163
+ }
164
+
165
+ /**
166
+ * 白名单按「单个字符」(Unicode 码点)匹配:多字符或空条目永远不会命中,
167
+ * 属于无效配置。此处丢弃并告警,避免设置页显示了一个永远不生效的条目。
168
+ */
169
+ function normalizeIgnoredChars(raw, fallback) {
170
+ if (!Array.isArray(raw)) return [...fallback]
171
+ const kept = []
172
+ const dropped = []
173
+ for (const entry of raw) {
174
+ if (typeof entry !== 'string') {
175
+ dropped.push(String(entry))
176
+ continue
177
+ }
178
+ // [...entry] 按码点拆分:emoji 等代理对字符算一个字符。
179
+ if ([...entry].length === 1) kept.push(entry)
180
+ else dropped.push(entry)
181
+ }
182
+ if (dropped.length > 0) {
183
+ console.warn(
184
+ '[dupguard] 白名单条目必须是单个字符,已忽略无效条目:' + JSON.stringify(dropped) +
185
+ '(白名单按字符匹配,多字符条目不会生效)。'
186
+ )
187
+ }
188
+ return kept
189
+ }
190
+
150
191
  /**
151
192
  * 为一次 llm/stream 调用创建守卫。
152
193
  * 每次模型调用都会新建一份状态,互不干扰。
@@ -241,6 +282,7 @@ function createStreamGuard(options, runtime) {
241
282
  } else if (b.blockType === 'reasoning') {
242
283
  block = { type: 'reasoning', text: b.text }
243
284
  } else {
285
+ if (b.blockType !== 'tool-call') warnUnknownBlockType(b.blockType)
244
286
  block = {
245
287
  type: 'tool-call',
246
288
  id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index,
@@ -386,9 +428,7 @@ function applySettingsToRuntime(runtime, value) {
386
428
  return fallback[key]
387
429
  }
388
430
  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]
431
+ runtime.ignoredChars = normalizeIgnoredChars(source.ignoredChars, fallback.ignoredChars)
392
432
  runtime.threshold = pickInt('threshold')
393
433
  runtime.minUnitLength = pickInt('minUnitLength')
394
434
  runtime.maxUnitLength = pickInt('maxUnitLength')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dupguard",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",