dsh-dupguard 1.0.2 → 1.1.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,27 @@
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.1.0] - 2026-08-30
6
+
7
+ ### Added
8
+
9
+ - 图形化设置页(npm 常驻版):新增 `lib/client.js`(`dsh.client` 浏览器端入口),在 DSH
10
+ 设置面板注册与「通用设置 / 模型 / 插件 / Agent 预设」并列的「重复守卫」分节,可视化
11
+ 编辑白名单(增删字符、恢复默认),样式使用 `--dsw-*` 主题变量与原有设置页一致;
12
+ - 设置持久化与热更新:host 端通过 settings 服务注册 `dsh-dupguard` 命名空间
13
+ (`ignoredChars` 字段,schemastery schema,新增 `@deepseek-ai/schemastery` 依赖),
14
+ 设置变更即时热生效(动态版保持零依赖,白名单固定取常量);
15
+ - 测试新增 settings 集成套件(默认 base、热更新、清空、恢复默认),共 48 项。
16
+
17
+ ## [1.0.3] - 2026-08-30
18
+
19
+ ### Fixed
20
+
21
+ - 思考(reasoning)复读守卫:`monitorReasoning` 默认改为开启,思考中同一字符串连续重复
22
+ 10 次以上即截停;修复开启后暴露的 reasoning 文本双重累积缺陷(`b.text` 与 `feedText`
23
+ 各加一次,导致闭合块文本翻倍);新增"关闭开关"变体套件验证 `monitorReasoning: false`
24
+ 时仍全量透传。测试套件双入口各 21 项,加变体共 44 项。
25
+
5
26
  ## [1.0.2] - 2026-08-27
6
27
 
7
28
  ### Fixed
package/README.md CHANGED
@@ -19,11 +19,14 @@ When triggered, the already-generated text is committed as a normal assistant me
19
19
 
20
20
  - **实时检测**:逐 token(`text-delta`)检测,复读出现即停,延迟为单个增量。
21
21
  - **多种复读形态**:单字符循环、词语循环、带空格/换行分隔的复读均能识别(默认去空白后检测)。
22
+ - **思考守卫**:默认同时检测 reasoning(思考)文本,思考中的复读同样会被截停(可通过 `monitorReasoning` 关闭)。
22
23
  - **真正的服务端停止**:提前关闭流迭代 → 适配器 `consumer.abort()` → 中断 HTTP 连接,模型在服务端停止生成。
23
24
  - **安全停止**:绝不 `abort()` agent 步骤信号;补发协议合规的 `block-end` + `finish(stop)`,消息正常提交。
24
- - **Markdown 表格友好**:默认忽略连字符与竖线(`ignoredChars` 白名单,可配置),表格分隔行与长分隔线不会被误判为复读。
25
- - **零依赖 / 零配置**:纯 JavaScript,无运行时依赖;默认配置开箱即用。
26
- - **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js`),行为一致、CI 防漂移。
25
+ - **Markdown 表格友好**:默认忽略连字符与竖线(`ignoredChars` 白名单),表格分隔行与长分隔线不会被误判为复读。
26
+ - **图形化设置页**(npm 常驻版):在 DSH 设置面板注册与「通用设置 / 模型 / 插件 / Agent 预设」并列的
27
+ 「重复守卫」分节,可视化编辑白名单并持久化(`dsh-dupguard` 设置命名空间),修改即时生效。
28
+ - **零配置开箱即用**:默认配置即可用;仅白名单可通过设置页调整。
29
+ - **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js` + `lib/client.js`),行为一致、CI 防漂移。
27
30
  - **内置 DSH 兼容补丁**(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
28
31
  修复 DSH ≤ 0.1.1-rc.1 的 preset standing-mount 多代并存冲突(见下文"已知限制")。
29
32
 
@@ -105,8 +108,8 @@ Edit the `CONFIG` block at the top of `plugin/host.js` / `lib/index.js` (both en
105
108
  | `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
106
109
  | `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后)/ rolling detection window in chars (after whitespace removal) |
107
110
  | `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
108
- | `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown 表格分隔行(连字符与竖线)不参与重复统计;需更严格检测时置 `[]` / whitelist of characters ignored during detection: Markdown table separators don't count as repetition; set `[]` for stricter detection |
109
- | `monitorReasoning` | `false` | 是否检测思考文本 / also guard reasoning (thinking) text — off by default, high false-positive risk |
111
+ | `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单(默认值):Markdown 表格分隔行(连字符与竖线)不参与重复统计;npm 常驻版可在设置页「重复守卫」分节中可视化修改并持久化(动态版固定取此常量)/ whitelist of characters ignored during detection (default): Markdown table separators don't count as repetition; the npm build exposes it in the settings page "Dupguard" with persistence (the dynamic build uses this constant) |
112
+ | `monitorReasoning` | `true` | 是否检测思考文本(思考中的复读同样消耗 token,默认截停;只检测可见输出时置 `false`)/ also guard reasoning (thinking) text — on by default; set `false` to guard visible output only |
110
113
  | `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
111
114
  | `fixStandingMountConflict` | `true` | DSH ≤ 0.1.1-rc.1 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突 / idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.1-rc.1 standing-mount conflict |
112
115
 
@@ -160,11 +163,12 @@ agent-loop commits the partial text as a normal assistant message.
160
163
  | 带空格复读 / space-separated | `hello hello hello ...` ×10 |
161
164
  | 逐行复读 / line repeats | `抱歉,我无法完成。` ×10 行 |
162
165
  | 前缀后循环 / loop after prefix | `好的,下面开始回答:` + `循环` ×10 |
166
+ | 思考复读 / reasoning loop | 思考中 `想` ×10(默认截停) |
163
167
 
164
168
  **不会触发 / Won't trigger**:正常文本中的高频词(检测只针对**连续**重复)、重复 9 次及以下、
165
- reasoning 与工具参数(默认关闭)、Markdown 表格分隔行与长分隔线(连字符与竖线在白名单中,默认忽略)。
166
- / high-frequency words in normal prose (consecutive runs only), ≤9 repeats, reasoning and tool args
167
- (off by default), Markdown table separator rows and horizontal rules (whitelisted by default).
169
+ 工具参数(默认关闭)、Markdown 表格分隔行与长分隔线(连字符与竖线在白名单中,默认忽略)。
170
+ / high-frequency words in normal prose (consecutive runs only), ≤9 repeats, tool args (off by
171
+ default), Markdown table separator rows and horizontal rules (whitelisted by default).
168
172
 
169
173
  ---
170
174
 
@@ -175,9 +179,10 @@ reasoning 与工具参数(默认关闭)、Markdown 表格分隔行与长分
175
179
  ├── plugin/
176
180
  │ └── host.js # 动态插件形式(cordis_define 的 code.host)
177
181
  ├── lib/
178
- │ └── index.js # npm/组合常驻形式(package.json main 入口)
182
+ │ ├── index.js # npm/组合常驻形式(package.json main 入口,含设置集成)
183
+ │ └── client.js # 浏览器端设置页(ModuleLoader 格式,dsh.client 入口)
179
184
  ├── tests/
180
- │ ├── detector.test.js # 端到端测试:15 项 × 2 入口(防漂移)
185
+ │ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings 集成
181
186
  │ └── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
182
187
  ├── .github/workflows/ci.yml # GitHub Actions:Node 18/20/22
183
188
  ├── package.json
package/lib/client.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * dupguard 浏览器端 bundle(单文件,经 window.__ModuleLoader__ 加载)。
3
+ *
4
+ * 在 DSH 设置面板注册一个与「通用设置 / 模型 / 插件 / Agent 预设」并列的
5
+ * 分节「重复守卫」:白名单(检测时忽略的字符)的可视化编辑界面。
6
+ * 数据经 settingsScope 绑定宿主 settings 服务的 "dsh-dupguard" 命名空间,
7
+ * 修改即时生效并持久化(settings.yaml)。
8
+ *
9
+ * 样式全部使用 --dsw-* 主题变量,跟随全局亮/暗主题,与原有设置页风格一致。
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-dupguard',
13
+ factory: (require) => {
14
+ var module = { exports: {} }
15
+ var exports = module.exports
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
17
+
18
+ const React = require('react')
19
+
20
+ const NS = 'dsh-dupguard'
21
+ const SETTINGS_NS = 'dsh-dupguard'
22
+
23
+ const zh = {
24
+ nav: '重复守卫',
25
+ title: '重复输出守卫',
26
+ intro: '模型输出中同一字符串连续重复 10 次以上时会自动截停。此处配置检测时忽略的字符(白名单):这些字符不参与重复统计,例如 Markdown 表格分隔行会大量使用连字符与竖线。',
27
+ list: '当前白名单',
28
+ empty: '白名单为空:所有字符都参与重复统计。',
29
+ addPlaceholder: '输入要忽略的字符',
30
+ add: '添加',
31
+ reset: '恢复默认',
32
+ loading: '加载中…',
33
+ note: '修改即时生效;「恢复默认」回到连字符与竖线。',
34
+ }
35
+ const en = {
36
+ nav: 'Dupguard',
37
+ title: 'Repetition Guard',
38
+ intro: 'Generation stops when the same string repeats 10 or more times in a row. Configure the whitelisted characters that are ignored during detection, e.g. Markdown table separators use many hyphens and pipes.',
39
+ list: 'Whitelist',
40
+ empty: 'Whitelist is empty: every character counts.',
41
+ addPlaceholder: 'Character to ignore',
42
+ add: 'Add',
43
+ reset: 'Reset to defaults',
44
+ loading: 'Loading…',
45
+ note: 'Changes take effect immediately; "Reset to defaults" restores hyphen and pipe.',
46
+ }
47
+
48
+ const css = [
49
+ '.dg-section{display:flex;flex-direction:column;gap:14px;padding:4px 2px 24px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
50
+ '.dg-title{font-size:15px;font-weight:600;line-height:24px;margin:0}',
51
+ '.dg-intro{color:var(--dsw-alias-label-secondary);margin:0}',
52
+ '.dg-chips{display:flex;flex-wrap:wrap;gap:8px}',
53
+ '.dg-chip{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 6px 0 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);font-size:12px;color:var(--dsw-alias-label-primary)}',
54
+ '.dg-chip-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:none;border-radius:5px;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:0}',
55
+ '.dg-chip-remove:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
56
+ '.dg-add{display:flex;gap:8px}',
57
+ '.dg-input{flex:1;max-width:240px;height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;outline:none;font-family:inherit}',
58
+ '.dg-input:focus{border-color:var(--dsw-alias-brand-primary)}',
59
+ '.dg-btn{height:32px;padding:0 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;cursor:pointer;font-family:inherit}',
60
+ '.dg-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
61
+ '.dg-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-on-primary,#fff)}',
62
+ '.dg-btn-primary:hover{background:var(--dsw-alias-brand-primary)}',
63
+ '.dg-note{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}',
64
+ '.dg-empty{color:var(--dsw-alias-label-tertiary);margin:0}',
65
+ ].join('\n')
66
+
67
+ if (typeof document !== 'undefined') {
68
+ const tag = document.createElement('style')
69
+ tag.dataset.plugin = 'dsh-dupguard'
70
+ tag.dataset.pluginCss = 'dsh-dupguard/client.css'
71
+ tag.textContent = css
72
+ document.head.appendChild(tag)
73
+ }
74
+
75
+ const inject = ['slots', 'locale', 'connection', 'settingsScope']
76
+
77
+ function DupguardSection(props) {
78
+ const controller = props.controller
79
+ const t = props.t
80
+ const snap = React.useSyncExternalStore(
81
+ (cb) => controller.subscribe(cb),
82
+ () => controller.getSnapshot(),
83
+ )
84
+ const [draft, setDraft] = React.useState('')
85
+
86
+ if (snap.status !== 'ready') {
87
+ return React.createElement('div', { className: 'dg-section' },
88
+ React.createElement('p', { className: 'dg-empty' }, t('loading')))
89
+ }
90
+ const list = Array.isArray(snap.value && snap.value.ignoredChars) ? snap.value.ignoredChars : []
91
+
92
+ const remove = (ch) => {
93
+ controller.set('ignoredChars', list.filter((item) => item !== ch))
94
+ }
95
+ const add = () => {
96
+ const ch = draft.trim()
97
+ if (ch.length === 0) return
98
+ if (list.indexOf(ch) !== -1) {
99
+ setDraft('')
100
+ return
101
+ }
102
+ controller.set('ignoredChars', [...list, ch])
103
+ setDraft('')
104
+ }
105
+ const reset = () => {
106
+ controller.unset('ignoredChars')
107
+ }
108
+ const onKey = (e) => {
109
+ if (e.key === 'Enter') add()
110
+ }
111
+
112
+ return React.createElement('div', { className: 'dg-section' },
113
+ React.createElement('h2', { className: 'dg-title' }, t('title')),
114
+ React.createElement('p', { className: 'dg-intro' }, t('intro')),
115
+ React.createElement('p', { className: 'dg-note' }, t('list')),
116
+ list.length === 0
117
+ ? React.createElement('p', { className: 'dg-empty' }, t('empty'))
118
+ : React.createElement('div', { className: 'dg-chips' },
119
+ list.map((ch) => React.createElement('span', { className: 'dg-chip', key: ch },
120
+ ch,
121
+ React.createElement('button', {
122
+ className: 'dg-chip-remove',
123
+ onClick: () => remove(ch),
124
+ 'aria-label': 'remove',
125
+ }, '\u00d7')))),
126
+ React.createElement('div', { className: 'dg-add' },
127
+ React.createElement('input', {
128
+ className: 'dg-input',
129
+ placeholder: t('addPlaceholder'),
130
+ value: draft,
131
+ onChange: (e) => setDraft(e.target.value),
132
+ onKeyDown: onKey,
133
+ }),
134
+ React.createElement('button', { className: 'dg-btn dg-btn-primary', onClick: add }, t('add')),
135
+ React.createElement('button', { className: 'dg-btn', onClick: reset }, t('reset')),
136
+ ),
137
+ React.createElement('p', { className: 'dg-note' }, t('note')),
138
+ )
139
+ }
140
+
141
+ function apply(ctx) {
142
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dupguard: dictionaries')
143
+ const t = ctx.locale.bind(NS)
144
+ const controller = ctx.settingsScope.bind({ namespace: SETTINGS_NS })
145
+ ctx.effect(() => () => {
146
+ controller.dispose()
147
+ }, 'dupguard: settings scope')
148
+ const injected = () => ({ controller, t })
149
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
150
+ name: 'settings.section',
151
+ id: 'dupguard',
152
+ order: 25,
153
+ label: () => t('nav'),
154
+ inject: injected,
155
+ }, DupguardSection))
156
+ }
157
+
158
+ exports.apply = apply
159
+ exports.inject = inject
160
+ return module.exports
161
+ },
162
+ })
package/lib/index.js CHANGED
@@ -10,10 +10,24 @@
10
10
  // { name, apply },供 DSH 组合(cordis.yml / preset)挂载;
11
11
  // 与 plugin/host.js(动态 cordis_define 形式)行为完全一致,
12
12
  // tests/detector.test.js 会对两者同时跑同一套用例,防止两份代码漂移。
13
+ //
14
+ // 设置集成(仅本常驻版):通过 DSH 的 settings 服务注册命名空间
15
+ // "dsh-dupguard",白名单(ignoredChars)可在 Web 设置页修改并持久化
16
+ // (见 lib/client.js),修改即时热生效。
13
17
  // ============================================================================
14
18
 
19
+ const z = require('@deepseek-ai/schemastery')
20
+
15
21
  const name = 'dupguard'
16
22
 
23
+ /** 设置命名空间:小写 kebab-case(dsh-settings 的命名规范)。 */
24
+ const SETTINGS_NS = 'dsh-dupguard'
25
+
26
+ /** 设置 schema:白名单字符数组(其余检测参数保持代码常量)。 */
27
+ const SETTINGS_SCHEMA = z.object({
28
+ ignoredChars: z.array(z.string()).default(['-', '|']),
29
+ })
30
+
17
31
  const CONFIG = {
18
32
  // 触发阈值:同一字符串连续重复次数达到该值时停止输出(用户需求:重复十次以上)。
19
33
  // 语义为「>= threshold」,即第 10 次重复出现时就触发。
@@ -32,9 +46,10 @@ const CONFIG = {
32
46
  // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
33
47
  // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
34
48
  ignoredChars: ['-', '|'],
35
- // 是否同时检测思考(reasoning)文本。默认关闭:思考内容不可见,且正常思考文本
36
- // 更可能出现连续重复片段,误伤风险高。
37
- monitorReasoning: false,
49
+ // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
50
+ // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
51
+ // 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
52
+ monitorReasoning: true,
38
53
  // 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
39
54
  monitorToolArguments: false,
40
55
  // DSH ≤ 0.1.1-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 仍未修复):preset 的 standing mount
@@ -102,8 +117,10 @@ function sanitizeDelta(delta, config) {
102
117
  /**
103
118
  * 为一次 llm/stream 调用创建守卫。
104
119
  * 每次模型调用都会新建一份状态,互不干扰。
120
+ * @param options - llm/stream 的 GenerateOptions。
121
+ * @param runtime - 运行时配置(ignoredChars 可被设置页热更新)。
105
122
  */
106
- function createStreamGuard(options) {
123
+ function createStreamGuard(options, runtime) {
107
124
  // index -> 块状态。用 Map 按块索引累积,避免多个文本块交替输出时互相打断检测。
108
125
  const blocks = new Map()
109
126
  let stopped = null
@@ -130,9 +147,9 @@ function createStreamGuard(options) {
130
147
  /** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
131
148
  function feedText(b, delta) {
132
149
  b.text += delta
133
- const piece = sanitizeDelta(delta, CONFIG)
134
- b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
135
- return findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
150
+ const piece = sanitizeDelta(delta, runtime)
151
+ b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
152
+ return findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength)
136
153
  }
137
154
 
138
155
  /** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
@@ -150,10 +167,12 @@ function createStreamGuard(options) {
150
167
  }
151
168
  case 'reasoning-delta': {
152
169
  const b = ensure(chunk.index, 'reasoning')
153
- b.text += chunk.text // 始终累积:停止时需要完整内容闭合块
154
- if (CONFIG.monitorReasoning) {
170
+ if (runtime.monitorReasoning) {
171
+ // feedText 内部负责累积完整文本与检测缓冲,这里不得重复 +=
155
172
  const hit = feedText(b, chunk.text)
156
173
  if (hit !== null) stopped = hit
174
+ } else {
175
+ b.text += chunk.text // 不检测时仍须累积完整内容以便停止时闭合块
157
176
  }
158
177
  return
159
178
  }
@@ -162,10 +181,10 @@ function createStreamGuard(options) {
162
181
  if (chunk.id !== undefined) b.toolCallId = chunk.id
163
182
  if (chunk.name !== undefined) b.toolCallName = chunk.name
164
183
  b.toolCallArguments += chunk.argumentsDelta
165
- if (CONFIG.monitorToolArguments) {
166
- const piece = sanitizeDelta(chunk.argumentsDelta, CONFIG)
167
- b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
168
- const hit = findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
184
+ if (runtime.monitorToolArguments) {
185
+ const piece = sanitizeDelta(chunk.argumentsDelta, runtime)
186
+ b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
187
+ const hit = findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength)
169
188
  if (hit !== null) stopped = hit
170
189
  }
171
190
  return
@@ -268,12 +287,44 @@ function installStandingMountPatch(ctx) {
268
287
  console.log('[dupguard] cordisInspect.register 已幂等化(DSH standing-mount 多代并存兼容补丁)')
269
288
  }
270
289
 
290
+ /**
291
+ * 把 CONFIG 拷贝为运行时配置:ignoredChars 会被设置页热更新,
292
+ * 其余字段保持代码常量(设置页只暴露白名单)。
293
+ */
294
+ function createRuntime() {
295
+ return {
296
+ ...CONFIG,
297
+ ignoredChars: [...CONFIG.ignoredChars],
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 从 DSH 的 settings 服务注册白名单设置(仅常驻版)。
303
+ * 返回注销器;settings 服务缺失(无头环境)时不做任何事。
304
+ */
305
+ function installSettings(ctx, runtime) {
306
+ const settings = ctx.get('settings')
307
+ if (settings === undefined || typeof settings.register !== 'function') return
308
+ const scope = settings.register(SETTINGS_NS, SETTINGS_SCHEMA, {
309
+ base: { ignoredChars: CONFIG.ignoredChars },
310
+ })
311
+ const sync = () => {
312
+ runtime.ignoredChars = [...scope.get().ignoredChars]
313
+ }
314
+ sync()
315
+ const stop = scope.watch(sync)
316
+ ctx.effect(() => () => stop(), 'dupguard: settings watch')
317
+ console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '(白名单可在设置页修改)')
318
+ }
319
+
271
320
  function apply(ctx) {
272
321
  if (CONFIG.fixStandingMountConflict) installStandingMountPatch(ctx)
322
+ const runtime = createRuntime()
323
+ installSettings(ctx, runtime)
273
324
  // llm/stream:包裹每次流式模型调用的瀑布事件。
274
325
  // 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。
275
326
  // (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式)
276
- ctx.on('llm/stream', (options, next) => createStreamGuard(options)(next()))
327
+ ctx.on('llm/stream', (options, next) => createStreamGuard(options, runtime)(next()))
277
328
  }
278
329
 
279
330
  module.exports = { name, apply }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dupguard",
3
- "version": "1.0.2",
3
+ "version": "1.1.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",
@@ -11,13 +11,21 @@
11
11
  "anti-repetition",
12
12
  "output-guard",
13
13
  "llm",
14
+ "client-plugin",
14
15
  "防复读",
15
16
  "重复检测"
16
17
  ],
17
18
  "license": "MIT",
18
19
  "main": "lib/index.js",
19
20
  "exports": {
20
- ".": "./lib/index.js"
21
+ ".": "./lib/index.js",
22
+ "./client": "./lib/client.js",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "dsh": {
26
+ "client": {
27
+ "platform": "web"
28
+ }
21
29
  },
22
30
  "files": [
23
31
  "lib",
@@ -36,6 +44,9 @@
36
44
  "access": "public"
37
45
  },
38
46
  "sideEffects": false,
47
+ "dependencies": {
48
+ "@deepseek-ai/schemastery": "^3.18.1"
49
+ },
39
50
  "repository": {
40
51
  "type": "git",
41
52
  "url": "git+https://github.com/zqh260619/dsh-dupguard.git"
package/plugin/host.js CHANGED
@@ -9,6 +9,11 @@
9
9
  // 需要随 DSH 常驻时,请改用 npm/组合形式 lib/index.js(与本文件行为一致,
10
10
  // tests/detector.test.js 会对两个入口跑同一套用例防止漂移)。
11
11
  // 安装/运行步骤与配置说明见仓库根目录的 README.md。
12
+ //
13
+ // 说明:动态插件代码体不能 require 依赖,因此本版本不集成 settings 服务,
14
+ // 白名单固定取 CONFIG.ignoredChars。npm 常驻版(lib/index.js)会注册
15
+ // "dsh-dupguard" 设置命名空间并提供 Web 设置页(lib/client.js),
16
+ // 白名单可在设置中修改并持久化。
12
17
  // ============================================================================
13
18
 
14
19
  const CONFIG = {
@@ -29,9 +34,10 @@ const CONFIG = {
29
34
  // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
30
35
  // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
31
36
  ignoredChars: ['-', '|'],
32
- // 是否同时检测思考(reasoning)文本。默认关闭:思考内容不可见,且正常思考文本
33
- // 更可能出现连续重复片段,误伤风险高。
34
- monitorReasoning: false,
37
+ // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
38
+ // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
39
+ // 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
40
+ monitorReasoning: true,
35
41
  // 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
36
42
  monitorToolArguments: false,
37
43
  // DSH ≤ 0.1.1-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 仍未修复):preset 的 standing mount
@@ -147,10 +153,12 @@ function createStreamGuard(options) {
147
153
  }
148
154
  case 'reasoning-delta': {
149
155
  const b = ensure(chunk.index, 'reasoning')
150
- b.text += chunk.text // 始终累积:停止时需要完整内容闭合块
151
156
  if (CONFIG.monitorReasoning) {
157
+ // feedText 内部负责累积完整文本与检测缓冲,这里不得重复 +=
152
158
  const hit = feedText(b, chunk.text)
153
159
  if (hit !== null) stopped = hit
160
+ } else {
161
+ b.text += chunk.text // 不检测时仍须累积完整内容以便停止时闭合块
154
162
  }
155
163
  return
156
164
  }