dsh-repeat-guard 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  | 类型 | dsh bundle,含宿主侧插件与客户端设置页 |
8
8
  | 监听 | `llm/stream`、`agent/turn-stopping` |
9
9
  | 入口 | `lib/index.js`(`src/index.ts`)、`lib/client.js`(`src/client.tsx`) |
10
- | 可调项 | 拦截短句表、连续命中阈值(设置 → 复读打断) |
10
+ | 可调项 | 拦截短句表、连续命中阈值、行内重复检测、续跑指令与折叠摘要(设置 → 复读打断) |
11
11
  | 分发 | npm 包 `dsh-repeat-guard` |
12
12
 
13
13
  ---
@@ -51,6 +51,14 @@
51
51
 
52
52
  表项**连标点一起写**(`'好的。'` 对应思考里的 `好的。`),英文条目一律写小写。分割单位是单个换行,空行会把连续计数打断。
53
53
 
54
+ ### 行内重复检测(默认关)
55
+
56
+ 上面那条规则只认"独占一行的整句",所以 `好。好。好。` 这种挤在一行里的重复看不见——对 DeepSeek 够用,但有些模型是单行循环的,所以留了这个开关。
57
+
58
+ 打开之后多一条判据:**整行由一个或多个表项首尾相接拼成**,也算命中(`好。好。好。`、`好。ok.好。`)。它仍然不会误杀——`我觉得可以。好。` 的前面那截不是表项、整行拼不出来,放行;`好的,好的` 也放行(表项是带句号的 `好的。`,这里是逗号)。
59
+
60
+ 计数单位同时从"行"变成"表项":整行全等算一次,行内每一段各算一次。所以阈值 2 配上它时,`好。好。` 这种一行两段的也会被拦。
61
+
54
62
  **表可以在设置里改**(见下面「可调项」);`src/config.ts` 的 `DEFAULT_FRAGMENTS` 是出厂默认值。
55
63
 
56
64
  已知边界:判据只看"有没有这样一行",不看上下文。所以"用户想让我改代码。\n好的。"这种**前面有实质内容、结尾又跟一句空话**的思考也会被拦。这是"按行判定"的直接结果。
@@ -116,20 +124,28 @@ if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读收件箱
116
124
 
117
125
  | 项 | 含义 |
118
126
  |---|---|
127
+ | 累计拦截 | 自本次启动或上次清零以来掐断过多少次(只读,旁边有清零按钮) |
119
128
  | 拦截短句 | 判为复读的空话短句表,一行一句,连标点一起写 |
120
- | 连续命中次数 | 连着几行命中才拦;默认 1 = 发现即拦 |
129
+ | 连续命中次数 | 连着几次命中才拦;默认 1 = 发现即拦 |
130
+ | 行内重复检测 | 打开后"好。好。好。"这种挤在一行里的重复也算命中;默认关 |
131
+ | 续跑指令 | 截断后推给模型的指令正文,整段照发 |
132
+ | 折叠行摘要 | 那条注入消息在会话里的折叠行摘要 |
133
+
134
+ 配置存在 dsh 的 settings 服务里(命名空间 `repeat-guard`),落盘在 `~/.dsh/settings.yaml` 的 `repeat-guard:` 节下。
121
135
 
122
- 配置存在 dsh 的 settings 服务里(命名空间 `repeat-guard`),落盘位置由 dsh 决定。
136
+ **累计计数也寄在这个命名空间里**——宿主侧每掐断一次写一次,客户端读同一个值,清零就是把它写成 0。这样不用另开 host↔client 通道,代价是它跟着配置一起落盘;插件加载时会先把它归零,所以口径是"自本次启动或上次清零以来"。计数只在**真的掐断**时 +1,命中却落在思考段末尾(未拦截)的那种不计。
123
137
 
124
138
  ## 续跑时推给模型的输入
125
139
 
140
+ 默认正文(可在设置里改):
141
+
126
142
  ```
127
143
  [复读拦截]
128
144
  你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。
129
145
  请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。
130
146
  ```
131
147
 
132
- 这条消息带 `source.kind = 'plugin'`,客户端会渲染成折叠的 context 行,不是用户气泡。
148
+ 这条消息带 `source.kind = 'plugin'`,客户端按 `source.summary`(同样可配置)渲染成折叠的 context 行,不是用户气泡。
133
149
 
134
150
  ## 构建
135
151
 
package/lib/client.js CHANGED
@@ -36,6 +36,7 @@ var __dshClientBundle = (() => {
36
36
  var SETTINGS_NS = "repeat-guard";
37
37
  var THRESHOLD_MIN = 1;
38
38
  var THRESHOLD_MAX = 9;
39
+ var FIELD = `height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px`;
39
40
  var CSS = `
40
41
  .rg-root{display:flex;flex-direction:column;width:100%}
41
42
  .rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
@@ -57,6 +58,11 @@ var __dshClientBundle = (() => {
57
58
  .rg-arrow{display:inline-flex;justify-content:center;align-items:center;width:17px;height:12px;padding:0;border:none;border-radius:3px;cursor:pointer;color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent)}
58
59
  .rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
59
60
  .rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
61
+ .rg-switch{position:relative;flex:none;width:40px;height:24px;padding:0;border:none;border-radius:12px;cursor:pointer;background:var(--dsw-alias-bg-module-platform);transition:background .15s ease}
62
+ .rg-switch::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .15s ease}
63
+ .rg-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}
64
+ .rg-switch[aria-checked="true"]::after{transform:translateX(16px)}
65
+ .rg-count{display:inline-flex;align-items:center;justify-content:center;min-width:72px;height:36px;padding:0 14px;box-sizing:border-box;border-radius:18px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-variant-numeric:tabular-nums}
60
66
  .rg-list{display:flex;flex-direction:column;gap:2px;width:100%;max-height:300px;overflow-y:auto;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);box-sizing:border-box}
61
67
  .rg-item{display:flex;align-items:center;gap:8px;padding:5px 6px 5px 12px;border-radius:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}
62
68
  .rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
@@ -65,8 +71,10 @@ var __dshClientBundle = (() => {
65
71
  .rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
66
72
  .rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
67
73
  .rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
68
- .rg-input{flex:1;min-width:0;height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
69
- .rg-input:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
74
+ .rg-input{flex:1;min-width:0;${FIELD}}
75
+ .rg-summaryInput{flex:none;width:260px;${FIELD}}
76
+ .rg-textarea{width:100%;min-height:84px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px;resize:vertical}
77
+ .rg-input:focus,.rg-summaryInput:focus,.rg-textarea:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
70
78
  .rg-button{cursor:pointer;flex:none;height:32px;padding:0 14px;border:none;border-radius:16px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
71
79
  .rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
72
80
  `;
@@ -137,6 +145,21 @@ var __dshClientBundle = (() => {
137
145
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-stepperUnit", children: unit })
138
146
  ] });
139
147
  }
148
+ function Switch({ checked, label, onChange }) {
149
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
150
+ "button",
151
+ {
152
+ type: "button",
153
+ role: "switch",
154
+ "aria-checked": checked,
155
+ "aria-label": label,
156
+ className: "rg-switch",
157
+ onClick: () => {
158
+ onChange(!checked);
159
+ }
160
+ }
161
+ );
162
+ }
140
163
  function FragmentList({ items, draft, onDraft, onAdd, onRemove }) {
141
164
  function handleKey(event) {
142
165
  if (event.key === "Enter") {
@@ -189,9 +212,12 @@ var __dshClientBundle = (() => {
189
212
  ] })
190
213
  ] });
191
214
  }
192
- function Form({ initial, onSave }) {
193
- const [items, setItems] = (0, import_react.useState)(() => initial.fragments ?? []);
194
- const [count, setCount] = (0, import_react.useState)(() => initial.threshold ?? 1);
215
+ function Form({ initial, count, onReset, onSave }) {
216
+ const [items, setItems] = (0, import_react.useState)(initial.fragments);
217
+ const [limit, setLimit] = (0, import_react.useState)(initial.threshold);
218
+ const [inlineRepeat, setInlineRepeat] = (0, import_react.useState)(initial.inlineRepeat);
219
+ const [resumeText, setResumeText] = (0, import_react.useState)(initial.resumeText);
220
+ const [resumeSummary, setResumeSummary] = (0, import_react.useState)(initial.resumeSummary);
195
221
  const [draft, setDraft] = (0, import_react.useState)("");
196
222
  const [note, setNote] = (0, import_react.useState)("");
197
223
  function add() {
@@ -209,33 +235,77 @@ var __dshClientBundle = (() => {
209
235
  }
210
236
  function save() {
211
237
  setNote("\u4FDD\u5B58\u4E2D\u2026");
212
- onSave(items, count).then(() => {
238
+ onSave({
239
+ fragments: [...items],
240
+ threshold: limit,
241
+ inlineRepeat,
242
+ resumeText,
243
+ resumeSummary,
244
+ count
245
+ }).then(() => {
213
246
  setNote("\u5DF2\u4FDD\u5B58");
214
247
  }).catch((error) => {
215
248
  setNote(`\u4FDD\u5B58\u5931\u8D25\uFF1A${String(error)}`);
216
249
  });
217
250
  }
251
+ function reset() {
252
+ onReset().then(() => {
253
+ setNote("\u8BA1\u6570\u5DF2\u6E05\u96F6");
254
+ }).catch((error) => {
255
+ setNote(`\u6E05\u96F6\u5931\u8D25\uFF1A${String(error)}`);
256
+ });
257
+ }
218
258
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-root", children: [
219
259
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-head", children: [
220
260
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-headTitle", children: "\u590D\u8BFB\u6253\u65AD" }),
221
261
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-headNote", children: note }),
222
262
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-save", onClick: save, children: "\u4FDD\u5B58" })
223
263
  ] }),
264
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
265
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
266
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u7D2F\u8BA1\u62E6\u622A" }),
267
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u91CD\u590D\u6253\u65AD\u8BA1\u6570" })
268
+ ] }),
269
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-control", children: [
270
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-count", children: [
271
+ count,
272
+ " \u6B21"
273
+ ] }),
274
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-button", onClick: reset, children: "\u6E05\u96F6" })
275
+ ] })
276
+ ] }),
224
277
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
225
278
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
226
279
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570" }),
227
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u8FDE\u7740\u51E0\u4E2A\u77ED\u53E5\u884C\u547D\u4E2D\u624D\u62E6\u622A\uFF1B\u586B 1 \u8868\u793A\u53D1\u73B0\u5373\u62E6" })
280
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u5FAA\u73AF\u6B21\u6570\u4E0A\u9650\uFF1B\u586B 1 \u8868\u793A\u53D1\u73B0\u5373\u62E6" })
228
281
  ] }),
229
282
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
230
283
  Stepper,
231
284
  {
232
- value: count,
285
+ value: limit,
233
286
  min: THRESHOLD_MIN,
234
287
  max: THRESHOLD_MAX,
235
288
  unit: "\u6B21",
236
289
  label: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570",
237
290
  onChange: (next) => {
238
- setCount(next);
291
+ setLimit(next);
292
+ setNote("");
293
+ }
294
+ }
295
+ )
296
+ ] }),
297
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
298
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
299
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u884C\u5185\u91CD\u590D\u68C0\u6D4B" }),
300
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: '\u62E6\u622A\u7C7B\u4F3C"\u597D\u3002\u597D\u3002\u597D\u3002"\u7684\u884C\u5185\u5FAA\u73AF\uFF1B\u5173\u95ED\u65F6\u53EA\u68C0\u6D4B\u72EC\u7ACB\u6BB5\u843D\u3002' })
301
+ ] }),
302
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
303
+ Switch,
304
+ {
305
+ checked: inlineRepeat,
306
+ label: "\u884C\u5185\u91CD\u590D\u68C0\u6D4B",
307
+ onChange: (next) => {
308
+ setInlineRepeat(next);
239
309
  setNote("");
240
310
  }
241
311
  }
@@ -244,7 +314,7 @@ var __dshClientBundle = (() => {
244
314
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row rg-row--stack", children: [
245
315
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
246
316
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u77ED\u53E5" }),
247
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u4E00\u884C\u4E00\u53E5\uFF0C\u8FDE\u6807\u70B9\u4E00\u8D77\u5199\uFF1B\u53EA\u7B97\u72EC\u5360\u4E00\u884C\u7684\u6574\u884C\u5339\u914D" })
317
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u6807\u70B9\u4E0D\u53EF\u7701\u7565" })
248
318
  ] }),
249
319
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
250
320
  FragmentList,
@@ -256,6 +326,40 @@ var __dshClientBundle = (() => {
256
326
  onRemove: remove
257
327
  }
258
328
  )
329
+ ] }),
330
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row rg-row--stack", children: [
331
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
332
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u63D0\u793A\u8BCD" }),
333
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u622A\u65AD\u540E\u4E0A\u4E0B\u6587\u6CE8\u5165\u7684\u63D0\u793A\u8BCD" })
334
+ ] }),
335
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
336
+ "textarea",
337
+ {
338
+ className: "rg-textarea",
339
+ value: resumeText,
340
+ onChange: (event) => {
341
+ setResumeText(event.target.value);
342
+ setNote("");
343
+ }
344
+ }
345
+ )
346
+ ] }),
347
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
348
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
349
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u6458\u8981" }),
350
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u4E0A\u4E0B\u6587\u6CE8\u5165\u7684\u7B80\u4ECB\u4FE1\u606F\uFF0C\u4EC5\u7528\u6237\u53EF\u89C1" })
351
+ ] }),
352
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
353
+ "input",
354
+ {
355
+ className: "rg-summaryInput",
356
+ value: resumeSummary,
357
+ onChange: (event) => {
358
+ setResumeSummary(event.target.value);
359
+ setNote("");
360
+ }
361
+ }
362
+ )
259
363
  ] })
260
364
  ] });
261
365
  }
@@ -279,9 +383,16 @@ var __dshClientBundle = (() => {
279
383
  Form,
280
384
  {
281
385
  initial: value,
282
- onSave: async (fragments, threshold) => {
283
- await scope.set("fragments", [...fragments]);
284
- await scope.set("threshold", threshold);
386
+ count: value.count,
387
+ onReset: async () => {
388
+ await scope.set("count", 0);
389
+ },
390
+ onSave: async (next) => {
391
+ await scope.set("fragments", next.fragments);
392
+ await scope.set("threshold", next.threshold);
393
+ await scope.set("inlineRepeat", next.inlineRepeat);
394
+ await scope.set("resumeText", next.resumeText);
395
+ await scope.set("resumeSummary", next.resumeSummary);
285
396
  }
286
397
  }
287
398
  );
package/lib/config.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * 插件配置:拦截短句表与连续命中阈值。
2
+ * 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
3
+ * 外加一个运行时计数。
3
4
  *
4
5
  * 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
5
- * schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
6
+ * schema,客户端设置页写同一个命名空间。判定与续跑时现取,改完立即生效,不用重启。
7
+ *
8
+ * 计数也寄在同一个命名空间里:宿主侧每次掐断写一次,客户端读同一个值、清零就是
9
+ * 把它写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
6
10
  */
7
11
  import type { Context } from '@deepseek-ai/cordis';
8
12
  /** settings 命名空间。客户端设置页必须写同一个值。 */
@@ -11,18 +15,37 @@ export declare const SETTINGS_NS = "repeat-guard";
11
15
  export declare const DEFAULT_FRAGMENTS: readonly string[];
12
16
  /** 默认阈值:1 表示发现即拦。 */
13
17
  export declare const DEFAULT_THRESHOLD = 1;
14
- /** 一份生效中的配置,已编译成判定时直接可用的形式。 */
18
+ /** 默认关闭行内重复检测:只认独占一行的整句。 */
19
+ export declare const DEFAULT_INLINE_REPEAT = false;
20
+ /** 默认的续跑指令正文,截断后推给模型。 */
21
+ export declare const DEFAULT_RESUME_TEXT: string;
22
+ /** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
23
+ export declare const DEFAULT_RESUME_SUMMARY = "\u590D\u8BFB\u5DF2\u622A\u65AD\uFF1A\u8BF7\u7EE7\u7EED\u6267\u884C";
24
+ /** 判定与续跑用得上的一份配置。 */
15
25
  export interface RepeatGuardConfig {
16
26
  /** 短句表,已全部小写化。 */
17
27
  readonly fragments: ReadonlySet<string>;
18
- /** 连续命中多少行才拦。 */
28
+ /** 连续命中多少次才拦。 */
19
29
  readonly threshold: number;
30
+ /** 是否把"整行由多个表项拼成"也算命中。 */
31
+ readonly inlineRepeat: boolean;
32
+ /** 截断后推给模型的指令正文。 */
33
+ readonly resumeText: string;
34
+ /** 注入消息的折叠行摘要。 */
35
+ readonly resumeSummary: string;
20
36
  }
21
37
  /** 取当前配置。 */
22
38
  export type ConfigSource = () => RepeatGuardConfig;
39
+ /** 宿主侧要用到的两件事:取配置、记一次拦截。 */
40
+ export interface GuardRuntime {
41
+ /** 取当前配置。 */
42
+ readonly read: ConfigSource;
43
+ /** 记一次拦截,把计数写回 settings。 */
44
+ readonly countHit: () => void;
45
+ }
23
46
  /**
24
- * 注册配置命名空间,并返回取当前配置的函数。
47
+ * 注册配置命名空间,返回宿主侧要用的运行时句柄。
25
48
  * @param ctx - 宿主 cordis 上下文。
26
- * @returns 每次调用都返回最新配置。
49
+ * @returns 取配置与记数两个入口。
27
50
  */
28
- export declare function createConfigSource(ctx: Context): ConfigSource;
51
+ export declare function createRuntime(ctx: Context): GuardRuntime;
package/lib/config.js CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * 插件配置:拦截短句表与连续命中阈值。
2
+ * 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
3
+ * 外加一个运行时计数。
3
4
  *
4
5
  * 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
5
- * schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
6
+ * schema,客户端设置页写同一个命名空间。判定与续跑时现取,改完立即生效,不用重启。
7
+ *
8
+ * 计数也寄在同一个命名空间里:宿主侧每次掐断写一次,客户端读同一个值、清零就是
9
+ * 把它写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
6
10
  */
7
11
  import z from '@deepseek-ai/schemastery';
8
12
  /** settings 命名空间。客户端设置页必须写同一个值。 */
@@ -44,30 +48,69 @@ export const DEFAULT_FRAGMENTS = [
44
48
  ];
45
49
  /** 默认阈值:1 表示发现即拦。 */
46
50
  export const DEFAULT_THRESHOLD = 1;
51
+ /** 默认关闭行内重复检测:只认独占一行的整句。 */
52
+ export const DEFAULT_INLINE_REPEAT = false;
53
+ /** 默认的续跑指令正文,截断后推给模型。 */
54
+ export const DEFAULT_RESUME_TEXT = [
55
+ '[复读拦截]',
56
+ '你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
57
+ '请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
58
+ ].join('\n');
59
+ /** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
60
+ export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
47
61
  const SCHEMA = z.object({
48
62
  fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
49
63
  threshold: z.number().default(DEFAULT_THRESHOLD),
64
+ inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT),
65
+ resumeText: z.string().default(DEFAULT_RESUME_TEXT),
66
+ resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY),
67
+ count: z.number().default(0),
50
68
  });
51
69
  function compile(value) {
52
70
  return {
53
71
  fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
54
72
  threshold: value.threshold,
73
+ inlineRepeat: value.inlineRepeat,
74
+ resumeText: value.resumeText,
75
+ resumeSummary: value.resumeSummary,
55
76
  };
56
77
  }
57
78
  /**
58
- * 注册配置命名空间,并返回取当前配置的函数。
79
+ * 注册配置命名空间,返回宿主侧要用的运行时句柄。
59
80
  * @param ctx - 宿主 cordis 上下文。
60
- * @returns 每次调用都返回最新配置。
81
+ * @returns 取配置与记数两个入口。
61
82
  */
62
- export function createConfigSource(ctx) {
63
- let current = compile({ fragments: DEFAULT_FRAGMENTS, threshold: DEFAULT_THRESHOLD });
83
+ export function createRuntime(ctx) {
84
+ let current = compile({
85
+ fragments: DEFAULT_FRAGMENTS,
86
+ threshold: DEFAULT_THRESHOLD,
87
+ inlineRepeat: DEFAULT_INLINE_REPEAT,
88
+ resumeText: DEFAULT_RESUME_TEXT,
89
+ resumeSummary: DEFAULT_RESUME_SUMMARY,
90
+ });
91
+ let bump;
64
92
  ctx.inject(['settings'], (settingsCtx) => {
65
93
  const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
66
94
  const sync = () => {
67
95
  current = compile(scope.get());
68
96
  };
97
+ const write = (patch) => {
98
+ scope.update(patch).catch((error) => {
99
+ console.log(`[repeat-guard] 写设置失败:${String(error)}`);
100
+ });
101
+ };
69
102
  sync();
70
103
  scope.watch(sync);
104
+ // 计数口径是"自本次启动或上次清零以来"。
105
+ write({ count: 0 });
106
+ bump = () => {
107
+ write({ count: scope.get().count + 1 });
108
+ };
71
109
  });
72
- return () => current;
110
+ return {
111
+ read: () => current,
112
+ countHit: () => {
113
+ bump?.();
114
+ },
115
+ };
73
116
  }
package/lib/detect.d.ts CHANGED
@@ -2,16 +2,20 @@
2
2
  * 复读退化判定:查表。
3
3
  *
4
4
  * 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
5
- * 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
5
+ * 基本判据是"整行与表项全等",不拆解行的内部。
6
6
  *
7
- * 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
8
- * (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
7
+ * 行内重复检测(可开关,默认关)打开时,"整行由多个表项首尾相接拼成"也算命中,
8
+ * 用来兜住把短句连成一串写在同一行里的模型(如"好。好。好。")。
9
+ *
10
+ * 阈值是"连续命中多少次才拦":默认 1 表示发现即拦。计数单位是表项——整行全等算一次,
11
+ * 开了行内检测后行内每一段各算一次;中间夹一行不命中的就重新计数。
9
12
  */
10
13
  /**
11
14
  * 找出退化的碎片行。
12
15
  * @param text - 已生成的思考文本。
13
16
  * @param fragments - 小写化的短句表。
14
- * @param threshold - 连续命中多少行才判定为复读。
17
+ * @param threshold - 连续命中多少次才判定为复读。
18
+ * @param inlineRepeat - 是否启用行内重复检测。
15
19
  * @returns 计数成立时返回那一行的原文,否则返回 null。
16
20
  */
17
- export declare function findDegenerateLine(text: string, fragments: ReadonlySet<string>, threshold: number): string | null;
21
+ export declare function findDegenerateLine(text: string, fragments: ReadonlySet<string>, threshold: number, inlineRepeat: boolean): string | null;
package/lib/detect.js CHANGED
@@ -2,30 +2,76 @@
2
2
  * 复读退化判定:查表。
3
3
  *
4
4
  * 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
5
- * 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
5
+ * 基本判据是"整行与表项全等",不拆解行的内部。
6
6
  *
7
- * 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
8
- * (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
7
+ * 行内重复检测(可开关,默认关)打开时,"整行由多个表项首尾相接拼成"也算命中,
8
+ * 用来兜住把短句连成一串写在同一行里的模型(如"好。好。好。")。
9
+ *
10
+ * 阈值是"连续命中多少次才拦":默认 1 表示发现即拦。计数单位是表项——整行全等算一次,
11
+ * 开了行内检测后行内每一段各算一次;中间夹一行不命中的就重新计数。
9
12
  */
13
+ /**
14
+ * 在 line 的 at 处能匹配到的最长表项长度。
15
+ * @param line - 已小写化的行。
16
+ * @param at - 起始下标。
17
+ * @param fragments - 小写化的短句表。
18
+ * @returns 匹配到的长度,0 表示这个位置没有表项。
19
+ */
20
+ function longestFragmentAt(line, at, fragments) {
21
+ let size = 0;
22
+ for (const fragment of fragments) {
23
+ if (fragment.length > size && line.startsWith(fragment, at)) {
24
+ size = fragment.length;
25
+ }
26
+ }
27
+ return size;
28
+ }
29
+ /**
30
+ * 这一行算几次命中。
31
+ * @param line - 已 trim 并小写化的行。
32
+ * @param fragments - 小写化的短句表。
33
+ * @param inlineRepeat - 是否把"整行由多个表项拼成"也算命中。
34
+ * @returns 0 表示不是命中行,其余为这一行贡献的次数。
35
+ */
36
+ function countHits(line, fragments, inlineRepeat) {
37
+ if (fragments.has(line)) {
38
+ return 1;
39
+ }
40
+ if (!inlineRepeat) {
41
+ return 0;
42
+ }
43
+ let at = 0;
44
+ let parts = 0;
45
+ while (at < line.length) {
46
+ const size = longestFragmentAt(line, at, fragments);
47
+ if (size === 0) {
48
+ return 0;
49
+ }
50
+ at += size;
51
+ parts += 1;
52
+ }
53
+ return parts > 1 ? parts : 0;
54
+ }
10
55
  /**
11
56
  * 找出退化的碎片行。
12
57
  * @param text - 已生成的思考文本。
13
58
  * @param fragments - 小写化的短句表。
14
- * @param threshold - 连续命中多少行才判定为复读。
59
+ * @param threshold - 连续命中多少次才判定为复读。
60
+ * @param inlineRepeat - 是否启用行内重复检测。
15
61
  * @returns 计数成立时返回那一行的原文,否则返回 null。
16
62
  */
17
- export function findDegenerateLine(text, fragments, threshold) {
63
+ export function findDegenerateLine(text, fragments, threshold, inlineRepeat) {
18
64
  let run = 0;
19
65
  for (const raw of text.split('\n')) {
20
66
  const line = raw.trim();
21
- if (line !== '' && fragments.has(line.toLowerCase())) {
22
- run += 1;
23
- if (run >= threshold) {
24
- return line;
25
- }
26
- }
27
- else {
67
+ const hits = line === '' ? 0 : countHits(line.toLowerCase(), fragments, inlineRepeat);
68
+ if (hits === 0) {
28
69
  run = 0;
70
+ continue;
71
+ }
72
+ run += hits;
73
+ if (run >= threshold) {
74
+ return line;
29
75
  }
30
76
  }
31
77
  return null;
package/lib/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  // `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
21
21
  //
22
22
  // 本文件只做装配,具体逻辑在各自的模块里。
23
- import { createConfigSource } from './config.js';
23
+ import { createRuntime } from './config.js';
24
24
  import { createStreamGuard } from './stream-guard.js';
25
25
  import { createTurnStoppingGuard } from './turn-stopping-guard.js';
26
26
  /**
@@ -31,7 +31,7 @@ export default function repeatGuard(ctx) {
31
31
  // 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
32
32
  console.log('[repeat-guard] 已加载,复读拦截生效');
33
33
  const state = { pending: new Set() };
34
- const readConfig = createConfigSource(ctx);
35
- ctx.on('llm/stream', createStreamGuard(state, readConfig), { global: true });
36
- ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
34
+ const runtime = createRuntime(ctx);
35
+ ctx.on('llm/stream', createStreamGuard(state, runtime.read, runtime.countHit), { global: true });
36
+ ctx.on('agent/turn-stopping', createTurnStoppingGuard(state, runtime.read));
37
37
  }
package/lib/resume.d.ts CHANGED
@@ -11,12 +11,16 @@
11
11
  *
12
12
  * 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
13
13
  * 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
14
+ *
15
+ * 推什么内容由配置决定(见 config.ts 的 resumeText / resumeSummary)。
14
16
  */
15
17
  import type { Agent } from '@deepseek-ai/dsh-agent';
18
+ import type { RepeatGuardConfig } from './config.js';
16
19
  import type { GuardState } from './types.js';
17
20
  /**
18
21
  * 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
19
22
  * @param state - 跨监听保留的拦截状态。
20
23
  * @param agent - 本轮所属的 agent 句柄。
24
+ * @param config - 当前配置,正文与摘要都从这里取。
21
25
  */
22
- export declare function reviveTurn(state: GuardState, agent: Agent): void;
26
+ export declare function reviveTurn(state: GuardState, agent: Agent, config: RepeatGuardConfig): void;
package/lib/resume.js CHANGED
@@ -11,31 +11,17 @@
11
11
  *
12
12
  * 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
13
13
  * 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
14
+ *
15
+ * 推什么内容由配置决定(见 config.ts 的 resumeText / resumeSummary)。
14
16
  */
15
17
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
16
- /** 续跑时推给模型的指令正文。 */
17
- const RESUME_TEXT = [
18
- '[复读拦截]',
19
- '你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
20
- '请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
21
- ].join('\n');
22
- /**
23
- * 这条注入消息的来源标记。
24
- * `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
25
- * 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
26
- */
27
- const PLUGIN_SOURCE = {
28
- kind: 'plugin',
29
- plugin: 'repeat-guard',
30
- form: 'notice',
31
- summary: '复读已截断:请继续执行',
32
- };
33
18
  /**
34
19
  * 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
35
20
  * @param state - 跨监听保留的拦截状态。
36
21
  * @param agent - 本轮所属的 agent 句柄。
22
+ * @param config - 当前配置,正文与摘要都从这里取。
37
23
  */
38
- export function reviveTurn(state, agent) {
24
+ export function reviveTurn(state, agent, config) {
39
25
  const sessionId = agent.session.id;
40
26
  if (!state.pending.has(sessionId)) {
41
27
  return;
@@ -43,7 +29,14 @@ export function reviveTurn(state, agent) {
43
29
  state.pending.delete(sessionId);
44
30
  console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
45
31
  agent.steer(createUserMessage({
46
- content: [{ type: 'text', text: RESUME_TEXT }],
47
- source: PLUGIN_SOURCE,
32
+ content: [{ type: 'text', text: config.resumeText }],
33
+ // `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
34
+ // 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
35
+ source: {
36
+ kind: 'plugin',
37
+ plugin: 'repeat-guard',
38
+ form: 'notice',
39
+ summary: config.resumeSummary,
40
+ },
48
41
  }));
49
42
  }
@@ -12,7 +12,8 @@ type StreamListener = (options: GenerateOptions, next: () => AsyncIterable<Strea
12
12
  * 造一个 `llm/stream` 监听器。
13
13
  * @param state - 跨监听保留的拦截状态。
14
14
  * @param readConfig - 取当前配置。
15
+ * @param countHit - 记一次拦截。
15
16
  * @returns 监听器;辅助调用直接透传,其余包一层复读检测。
16
17
  */
17
- export declare function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener;
18
+ export declare function createStreamGuard(state: GuardState, readConfig: ConfigSource, countHit: () => void): StreamListener;
18
19
  export {};