dsh-repeat-guard 0.1.1 → 0.1.2

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
@@ -4,11 +4,10 @@
4
4
 
5
5
  | 项 | 说明 |
6
6
  |---|---|
7
- | 类型 | dsh host 侧插件(函数式) |
7
+ | 类型 | dsh bundle,含宿主侧插件与客户端设置页 |
8
8
  | 监听 | `llm/stream`、`agent/turn-stopping` |
9
- | 运行时依赖 | **无**,不产生任何运行时 import |
10
- | 构建期依赖 | 仅 TypeScript |
11
- | 入口 | `lib/index.js`(由 `src/index.ts` 编译得到) |
9
+ | 入口 | `lib/index.js`(`src/index.ts`)、`lib/client.js`(`src/client.tsx`) |
10
+ | 可调项 | 拦截短句表、连续命中阈值(设置 → 复读打断) |
12
11
  | 分发 | npm 包 `dsh-repeat-guard` |
13
12
 
14
13
  ---
@@ -50,10 +49,32 @@
50
49
  | `好?` `好!` `好` `OK` | 不拦(表外) |
51
50
  | `我需要检查实现。\n然后重新编译。` | 不拦(各行都在表外) |
52
51
 
53
- **表在 `src/detect.ts` 的 `FRAGMENTS` 数组里**,增删词条改它即可。表项**连标点一起写**(`'好的。'` 对应思考里的 `好的。`),英文条目一律写小写。
52
+ 表项**连标点一起写**(`'好的。'` 对应思考里的 `好的。`),英文条目一律写小写。分割单位是单个换行,空行会把连续计数打断。
53
+
54
+ **表可以在设置里改**(见下面「可调项」);`src/config.ts` 的 `DEFAULT_FRAGMENTS` 是出厂默认值。
54
55
 
55
56
  已知边界:判据只看"有没有这样一行",不看上下文。所以"用户想让我改代码。\n好的。"这种**前面有实质内容、结尾又跟一句空话**的思考也会被拦。这是"按行判定"的直接结果。
56
57
 
58
+ ## 连续命中阈值
59
+
60
+ 阈值 `threshold` 是"连着几行命中才拦":
61
+
62
+ | 值 | 行为 |
63
+ |---|---|
64
+ | 1(默认) | 任意一行命中即拦 |
65
+ | n | 连着 n 行都是表项(**句子可以各不相同**)才拦;中间夹一行不命中的就重新计数 |
66
+
67
+ 阈值只在**同一次生成内**计数,不跨轮次。
68
+
69
+ ## 命中在思考段末尾时不拦
70
+
71
+ 命中之后,插件再往下探一格:
72
+
73
+ - 后面还是 `reasoning-delta` → 思考确实在继续复读,**拦**;
74
+ - 后面换成正文、工具调用,或直接收尾(`block-end` / `usage` / `finish` / 流结束)→ 说明命中行本来就是这段思考的最后一句,**不拦**,原样透传。
75
+
76
+ 判据是**思考段是否还在往下写**,不是整个响应是否结束——思考以一句"好。"收尾、接着写正文,属正常收尾。
77
+
57
78
  ## 工作原理
58
79
 
59
80
  1. 包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察 `reasoning-delta`,累积本次响应已产出的思考文本。
@@ -63,10 +84,12 @@
63
84
 
64
85
  辅助调用(上下文压缩、会话标题等带 `purpose` 的调用)不参与检测。
65
86
 
66
- 每次拦截都会把**命中的那一行原文**写进 journal,便于事后核对误杀:
87
+ 三种结局各有日志,便于事后核对误杀:
67
88
 
68
89
  ```
69
- [repeat-guard] 检出思考段复读,已掐断本次生成 | 命中行="好的。"
90
+ [repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=… | 命中行="好的。"
91
+ [repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=… | 命中行="好的。"
92
+ [repeat-guard] turn-stopping:会话 … 续跑一步
70
93
  ```
71
94
 
72
95
  ## 掐断之后怎么让本轮继续
@@ -80,19 +103,23 @@ if (turnEnds && this.inbox.nextStep.length === 0) {
80
103
  if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读收件箱
81
104
  ```
82
105
 
83
- `agent/turn-stopping` 在 `break` 之前被 `await`,之后收件箱会被**重新读一次**。所以只要监听器往 `inbox.nextStep` 里推入东西,本轮就不 break,而是再跑一步。官方对该事件的说明也写明了这个用法,先例见 `dsh-hooks-claude-code/lib/index.js:292`。
106
+ `agent/turn-stopping` 在 `break` 之前被 `await`,之后收件箱会被**重新读一次**。所以只要监听器往 `inbox.nextStep` 里推入东西,本轮就不 break,而是再跑一步。官方对该事件的说明也写明了这个用法,先例见 `dsh-hooks-claude-code/lib/index.js:300`。
84
107
 
85
108
  注意:
86
109
 
87
110
  - **做不到"这一步当作没发生"。** 被截断的 assistant 消息在 `step()` 里已经 `session.append` 落盘,dsh 没有回滚一步的机制;`step()` 唯一返回 `null`(循环继续)的出口要求本步真的产生了 tool-call,插件够不着。所以这里的语义是"接着再跑一步",会话里会留下被截断的思考记录。
111
+ - **续跑没有次数配额。** 有标记就推,同一轮里反复退化就反复续跑。
88
112
 
89
113
  ## 可调项
90
114
 
91
- 判定本身没有阈值,只有 `src/detect.ts` 里的 `FRAGMENTS` 表。改完需要重新构建。
115
+ 在 **设置 → 复读打断** 里改,保存后宿主侧立即按新配置判定,不用重启。
116
+
117
+ | 项 | 含义 |
118
+ |---|---|
119
+ | 拦截短句 | 判为复读的空话短句表,一行一句,连标点一起写 |
120
+ | 连续命中次数 | 连着几行命中才拦;默认 1 = 发现即拦 |
92
121
 
93
- | 项 | 位置 | 含义 |
94
- |---|---|---|
95
- | `FRAGMENTS` | `src/detect.ts` | 会被判为复读的空话短句表,增删词条改它 |
122
+ 配置存在 dsh 的 settings 服务里(命名空间 `repeat-guard`),落盘位置由 dsh 决定。
96
123
 
97
124
  ## 续跑时推给模型的输入
98
125
 
@@ -108,9 +135,20 @@ if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读收件箱
108
135
 
109
136
  ```bash
110
137
  npm install
111
- npm run build # 等价于 tsc,产出 lib/
138
+ npm run build # tsc 产出 lib/*.js,再用 esbuild 打 lib/client.js
112
139
  ```
113
140
 
141
+ 两步各有各的产物:
142
+
143
+ - `tsc` 编译 `src/*.ts` → `lib/*.js`,宿主侧入口就是 `lib/index.js`(真正被 dsh import 的部分)。
144
+ - `scripts/build-client.mjs` 用 esbuild 把 `src/client.tsx` 打成**单文件** `lib/client.js`,外面套一层 dsh 客户端模块系统要求的包装:
145
+
146
+ ```js
147
+ window.__ModuleLoader__.load({ id: "dsh-repeat-guard", factory: function (require) { … } });
148
+ ```
149
+
150
+ `id` 必须是包名,`factory` 的返回值就是该包的客户端模块导出。`react` 等平台基座模块由模块系统提供,构建时设为 external,运行时从 `factory` 的 `require` 参数取。
151
+
114
152
  `lib/` **不进版本库**:`npm publish` 前由 `prepack` 钩子现构建,随包发布(`files` 字段已含 `lib`)。这样"改了 `src/` 忘了 build"不会让旧产物静默跟着提交——代价是构建失败时发不出去,这是有意的。
115
153
 
116
154
  ## 发布
@@ -122,7 +160,7 @@ npm publish --registry=https://registry.npmjs.org
122
160
 
123
161
  **两条命令都别省 `--registry`。** npm 的凭据是**按源绑定**的:`npm login` 登的是哪个源,`~/.npmrc` 里就只在那个源下记一条 `//registry.npmjs.org/:_authToken`。所以 `npm config get registry` 一旦被切到 npmmirror 这类只读镜像(它本身也发不上去),不带参数的 `npm publish` 会直接报 `need auth`。
124
162
 
125
- 发布前不必手动构建,`prepack` 会跑一次 `npm run build`;tsc 报错则发布中止。
163
+ 发布前不必手动构建,`prepack` 会跑一次 `npm run build`;tsc 或 esbuild 报错则发布中止。
126
164
 
127
165
  ## 安装到 dsh
128
166
 
@@ -130,7 +168,7 @@ npm publish --registry=https://registry.npmjs.org
130
168
  dsh plugin --profile web add dsh-repeat-guard
131
169
  ```
132
170
 
133
- 本插件是一个 **dsh bundle**——包根带 `cordis.patch.yml`,由 `package.json` 的 `dsh.bundle.patch` 声明。装进 profile 时它会自动进入 `dsh.profile.bundles` 分层栈,**不需要改 profile 自己的 `cordis.patch.yml`**。
171
+ 本插件是一个 **dsh bundle**——包根带 `cordis.patch.yml`,由 `package.json` 的 `dsh.bundle.patch` 声明。装进 profile 时它会自动进入 `dsh.profile.bundles` 分层栈,**不需要改 profile 自己的 `cordis.patch.yml`**。客户端半靠 `package.json` 的 `dsh.client` 声明被自动发现,同样不用改 profile。
134
172
 
135
173
  装完**必须重启 dsh 进程**。`patchReload: live` 只重载已有的层,不会加载新增的层——实测编辑后运行中的进程既不加载也不报错。
136
174
 
@@ -151,12 +189,15 @@ journalctl --user -u dsh-web --no-pager | grep -a repeat-guard
151
189
 
152
190
  dsh 会把 bundle 声明的 `dependencies` 与 `peerDependencies` 从安装目录软链进 profile(`dsh-app-boot` 的 `healProfileModuleFallback`,依赖名取自 `profileDependencyNames(manifest)`,注释原文是 "dependency names that may be imported by a loader-visible plugin")。软链落在 `~/.dsh/profiles/node_modules/`,Node 按常规向上查找即可解析到。
153
191
 
192
+ dsh 的 profile 同时带一份 `.npmrc`,写着 `auto-install-peers=false`(注释:core packages come from the CLI dependency tree; a profile must never resolve its own copy)——所以声明 `peerDependencies` 不会让 profile 自己再装一份宿主包。
193
+
154
194
  所以:
155
195
 
156
- - **宿主提供的包写 `peerDependencies`**:`@deepseek-ai/cordis`、`@deepseek-ai/dsh-llm`、`@deepseek-ai/dsh-agent`。它们不在 profile 里重复安装,用 dsh 自带的那份。
157
- - **类型一律从官方引**,不在本地重抄。`Context`、`StreamChunk`、`GenerateOptions`、`Agent`、`UserMessage` 都是 dsh 导出的;本地抄一份只会在 dsh 升级后于运行时暴露字段对不上,官方声明则会在 `tsc` 阶段直接报错。
196
+ - **宿主提供的包写 `peerDependencies`**:`@deepseek-ai/cordis`、`dsh-llm`、`dsh-agent`、`dsh-settings`、`schemastery`、`dsh-client-ui-renderer`、`dsh-client-ui-settings`,以及客户端侧由平台基座提供的 `react`。它们不在 profile 里重复安装,用 dsh 自带的那份。
197
+ - **类型一律从官方引**,不在本地重抄。`Context`、`StreamChunk`、`GenerateOptions`、`Agent`、`SettingsScope` 都是 dsh 导出的;本地抄一份只会在 dsh 升级后于运行时暴露字段对不上,官方声明则会在 `tsc` 阶段直接报错。
158
198
  - `src/types.ts` 只放本插件自有的类型(当前是 `GuardState`)。
159
- - 仍然只用 TypeScript 写源码,由 `tsc` 产出 `lib/` 供 dsh 加载。dsh 的 loader 是原生 ESM import,**没有转译层**,运行时读到的永远是编译产物——改完 `src/` 必须重新构建。`lib/` 只随 npm 包发布,不进版本库。
199
+ - 部分 dsh 包的类型只通过 module augmentation 生效(如 `Context.settings`、`Context.slots`、`Context.settingsScope`),要显式 `import type {} from '…'` 触发加载,否则 `tsc` 会报"属性不存在"。
200
+ - dsh 的 loader 是原生 ESM import,**没有转译层**,运行时读到的永远是编译产物——改完 `src/` 必须重新构建。`lib/` 只随 npm 包发布,不进版本库。
160
201
 
161
202
  ## 代码约定
162
203
 
@@ -165,7 +206,7 @@ dsh 会把 bundle 声明的 `dependencies` 与 `peerDependencies` 从安装目
165
206
  | 缩进 / 引号 / 分号 | 2 空格、单引号、语句末分号 |
166
207
  | 行宽 | 目标 100 字符,上限 120(中英混排按字符数计) |
167
208
  | 控制语句 | **一律带花括号**,单行 `if`、`continue`、`break` 也不例外,不写 `if (x) return;` |
168
- | 文件 | 一个文件只干一件事:类型声明 / 判定 / 续跑 / 单个监听器 / 装配,各占一个文件 |
209
+ | 文件 | 一个文件只干一件事:类型声明 / 配置 / 判定 / 续跑 / 单个监听器 / 装配,各占一个文件 |
169
210
  | 函数 | 不超过 50 行;超了就先看能不能按职责拆开 |
170
211
  | 注释 | 一律中文;导出函数与判定函数配 `@param` / `@returns`,文件内小工具函数用单行注释即可 |
171
212
 
@@ -176,18 +217,22 @@ dsh 会把 bundle 声明的 `dependencies` 与 `peerDependencies` 从安装目
176
217
  ```
177
218
  .
178
219
  ├── src/
179
- │ ├── index.ts 插件入口:装配状态,注册两个监听器(只做装配)
220
+ │ ├── index.ts 宿主侧入口:装配状态,注册两个监听器(只做装配)
180
221
  │ ├── types.ts 本插件自有的类型(dsh 的接口一律从 @deepseek-ai/* 引)
181
- │ ├── detect.ts 复读判定:FRAGMENTS 表 + 查表纯函数,不碰会话状态
222
+ │ ├── config.ts 配置:settings 命名空间、默认值、schema、取当前值
223
+ │ ├── detect.ts 复读判定:查表 + 连续计数,不碰会话状态
182
224
  │ ├── resume.ts 续跑:注入文案、消息构造、推送
183
225
  │ ├── stream-guard.ts llm/stream 监听器:思考段检测与掐断
184
- │ └── turn-stopping-guard.ts agent/turn-stopping 监听器:让本轮继续
185
- ├── lib/ tsc 产物(已 gitignore),dsh 实际加载 lib/index.js
226
+ │ ├── turn-stopping-guard.ts agent/turn-stopping 监听器:让本轮继续
227
+ │ └── client.tsx 客户端设置页:注册 settings.section
228
+ ├── scripts/
229
+ │ └── build-client.mjs esbuild 打包客户端半 → lib/client.js
230
+ ├── lib/ 构建产物(已 gitignore),dsh 加载 lib/index.js 与 lib/client.js
231
+ ├── cordis.patch.yml bundle 层声明:把本插件挂进 profile 树
186
232
  ├── package.json
187
233
  └── tsconfig.json
188
234
  ```
189
235
 
190
-
191
236
  ## 许可
192
237
 
193
238
  MIT,见 `LICENSE`。
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 客户端设置页:在设置窗口左侧加一项"复读打断"。
3
+ *
4
+ * 面板里编辑两样东西——拦截短句表(一行一句)与连续命中阈值,两者都写宿主侧
5
+ * 注册的 settings 命名空间,保存后宿主侧立即按新配置判定。
6
+ *
7
+ * 本文件由 scripts/build-client.mjs 用 esbuild 单独打包成单文件 bundle
8
+ * (`lib/client.js`),不走 tsc 的 lib 输出。
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ /** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
12
+ export declare const inject: string[];
13
+ /**
14
+ * 客户端插件入口。
15
+ * @param ctx - 浏览器侧 cordis 上下文。
16
+ */
17
+ export declare function apply(ctx: Context): void;
package/lib/client.js ADDED
@@ -0,0 +1,113 @@
1
+ window.__ModuleLoader__.load({ id: "dsh-repeat-guard", factory: function (require) {
2
+ "use strict";
3
+ var __dshClientBundle = (() => {
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
+ }) : x)(function(x) {
11
+ if (typeof require !== "undefined") return require.apply(this, arguments);
12
+ throw Error('Dynamic require of "' + x + '" is not supported');
13
+ });
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+
28
+ // src/client.tsx
29
+ var client_exports = {};
30
+ __export(client_exports, {
31
+ apply: () => apply,
32
+ inject: () => inject
33
+ });
34
+ var import_react = __require("react");
35
+ var import_jsx_runtime = __require("react/jsx-runtime");
36
+ var SETTINGS_NS = "repeat-guard";
37
+ var inject = ["slots", "settingsScope"];
38
+ function apply(ctx) {
39
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
40
+ function Form({ initial }) {
41
+ const [fragments, setFragments] = (0, import_react.useState)(() => (initial.fragments ?? []).join("\n"));
42
+ const [threshold, setThreshold] = (0, import_react.useState)(() => String(initial.threshold ?? 1));
43
+ const [message, setMessage] = (0, import_react.useState)("");
44
+ function save() {
45
+ const list = fragments.split("\n").map((line) => line.trim()).filter((line) => line !== "");
46
+ const count = Number(threshold);
47
+ setMessage("\u4FDD\u5B58\u4E2D\u2026");
48
+ scope.set("fragments", list).then(() => scope.set("threshold", count)).then(() => {
49
+ setMessage("\u5DF2\u4FDD\u5B58");
50
+ }).catch((error) => {
51
+ setMessage(`\u4FDD\u5B58\u5931\u8D25\uFF1A${String(error)}`);
52
+ });
53
+ }
54
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", flexDirection: "column", gap: "12px" }, children: [
55
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
56
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570" }),
57
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
58
+ "input",
59
+ {
60
+ type: "number",
61
+ value: threshold,
62
+ onChange: (event) => {
63
+ setThreshold(event.target.value);
64
+ }
65
+ }
66
+ ),
67
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u586B 1 \u8868\u793A\u547D\u4E2D\u5373\u62E6\uFF1B\u586B n \u8868\u793A\u8FDE\u7740 n \u884C\u90FD\u547D\u4E2D\u624D\u62E6\u3002" })
68
+ ] }),
69
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
70
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u62E6\u622A\u77ED\u53E5\uFF08\u4E00\u884C\u4E00\u53E5\uFF0C\u8FDE\u6807\u70B9\u4E00\u8D77\u5199\uFF09" }),
71
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
72
+ "textarea",
73
+ {
74
+ rows: 16,
75
+ style: { width: "100%", fontFamily: "monospace" },
76
+ value: fragments,
77
+ onChange: (event) => {
78
+ setFragments(event.target.value);
79
+ }
80
+ }
81
+ )
82
+ ] }),
83
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
84
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: save, children: "\u4FDD\u5B58" }),
85
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: message })
86
+ ] })
87
+ ] });
88
+ }
89
+ function Panel() {
90
+ const [snapshot, setSnapshot] = (0, import_react.useState)(() => scope.getSnapshot());
91
+ (0, import_react.useEffect)(
92
+ () => scope.subscribe(() => {
93
+ setSnapshot(scope.getSnapshot());
94
+ }),
95
+ []
96
+ );
97
+ const value = snapshot.value;
98
+ if (snapshot.status !== "ready" || value === void 0) {
99
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: `\u914D\u7F6E\u5C1A\u672A\u5C31\u7EEA\uFF08${snapshot.status}\uFF09` });
100
+ }
101
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Form, { initial: value });
102
+ }
103
+ ctx.slots.inject(
104
+ "settings.section",
105
+ () => ctx.slots.register(
106
+ { name: "settings.section", id: "repeat-guard", order: 100, label: "\u590D\u8BFB\u6253\u65AD" },
107
+ Panel
108
+ )
109
+ );
110
+ }
111
+ return __toCommonJS(client_exports);
112
+ })();
113
+ return __dshClientBundle; } });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 插件配置:拦截短句表与连续命中阈值。
3
+ *
4
+ * 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
5
+ * schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ /** settings 命名空间。客户端设置页必须写同一个值。 */
9
+ export declare const SETTINGS_NS = "repeat-guard";
10
+ /** 默认的拦截短句表,标点照原样写。 */
11
+ export declare const DEFAULT_FRAGMENTS: readonly string[];
12
+ /** 默认阈值:1 表示发现即拦。 */
13
+ export declare const DEFAULT_THRESHOLD = 1;
14
+ /** 一份生效中的配置,已编译成判定时直接可用的形式。 */
15
+ export interface RepeatGuardConfig {
16
+ /** 短句表,已全部小写化。 */
17
+ readonly fragments: ReadonlySet<string>;
18
+ /** 连续命中多少行才拦。 */
19
+ readonly threshold: number;
20
+ }
21
+ /** 取当前配置。 */
22
+ export type ConfigSource = () => RepeatGuardConfig;
23
+ /**
24
+ * 注册配置命名空间,并返回取当前配置的函数。
25
+ * @param ctx - 宿主 cordis 上下文。
26
+ * @returns 每次调用都返回最新配置。
27
+ */
28
+ export declare function createConfigSource(ctx: Context): ConfigSource;
package/lib/config.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 插件配置:拦截短句表与连续命中阈值。
3
+ *
4
+ * 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
5
+ * schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
6
+ */
7
+ import z from '@deepseek-ai/schemastery';
8
+ /** settings 命名空间。客户端设置页必须写同一个值。 */
9
+ export const SETTINGS_NS = 'repeat-guard';
10
+ /** 默认的拦截短句表,标点照原样写。 */
11
+ export const DEFAULT_FRAGMENTS = [
12
+ '好。',
13
+ '好的。',
14
+ '好嘞。',
15
+ '对。',
16
+ '对的。',
17
+ '是。',
18
+ '是的。',
19
+ '行。',
20
+ '嗯。',
21
+ '可以。',
22
+ '明白。',
23
+ '收到。',
24
+ '了解。',
25
+ '执行。',
26
+ '继续。',
27
+ '确认。',
28
+ '完成。',
29
+ '搞定。',
30
+ '写。',
31
+ '查。',
32
+ '看。',
33
+ 'ok.',
34
+ 'okay.',
35
+ 'sure.',
36
+ 'alright.',
37
+ 'right.',
38
+ 'yes.',
39
+ 'done.',
40
+ 'got it.',
41
+ 'let me go.',
42
+ 'let me do it.',
43
+ 'check it.',
44
+ ];
45
+ /** 默认阈值:1 表示发现即拦。 */
46
+ export const DEFAULT_THRESHOLD = 1;
47
+ const SCHEMA = z.object({
48
+ fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
49
+ threshold: z.number().default(DEFAULT_THRESHOLD),
50
+ });
51
+ function compile(value) {
52
+ return {
53
+ fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
54
+ threshold: value.threshold,
55
+ };
56
+ }
57
+ /**
58
+ * 注册配置命名空间,并返回取当前配置的函数。
59
+ * @param ctx - 宿主 cordis 上下文。
60
+ * @returns 每次调用都返回最新配置。
61
+ */
62
+ export function createConfigSource(ctx) {
63
+ let current = compile({ fragments: DEFAULT_FRAGMENTS, threshold: DEFAULT_THRESHOLD });
64
+ ctx.inject(['settings'], (settingsCtx) => {
65
+ const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
66
+ const sync = () => {
67
+ current = compile(scope.get());
68
+ };
69
+ sync();
70
+ scope.watch(sync);
71
+ });
72
+ return () => current;
73
+ }
package/lib/detect.d.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * 复读退化判定:查表。
3
3
  *
4
- * 表里是已知会被模型复读的空话短句,**连标点一起写**。判定就是"思考里有没有
5
- * 独占一行的表项",整行与表项全等,不拆解行的内部。
4
+ * 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
5
+ * 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
6
6
  *
7
- * 增删条目直接改下面的 FRAGMENTS 即可,改完重新构建。
7
+ * 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
8
+ * (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
8
9
  */
9
10
  /**
10
11
  * 找出退化的碎片行。
11
12
  * @param text - 已生成的思考文本。
12
- * @returns 命中返回该行的原文,未命中返回 null。
13
+ * @param fragments - 小写化的短句表。
14
+ * @param threshold - 连续命中多少行才判定为复读。
15
+ * @returns 计数成立时返回那一行的原文,否则返回 null。
13
16
  */
14
- export declare function findDegenerateLine(text: string): string | null;
17
+ export declare function findDegenerateLine(text: string, fragments: ReadonlySet<string>, threshold: number): string | null;
package/lib/detect.js CHANGED
@@ -1,64 +1,31 @@
1
1
  /**
2
2
  * 复读退化判定:查表。
3
3
  *
4
- * 表里是已知会被模型复读的空话短句,**连标点一起写**。判定就是"思考里有没有
5
- * 独占一行的表项",整行与表项全等,不拆解行的内部。
4
+ * 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
5
+ * 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
6
6
  *
7
- * 增删条目直接改下面的 FRAGMENTS 即可,改完重新构建。
7
+ * 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
8
+ * (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
8
9
  */
9
- /**
10
- * 会被复读的空话短句表,标点照原样写。
11
- *
12
- * 英文条目一律写小写,判定时会把整行转成小写再比。
13
- */
14
- const FRAGMENTS = [
15
- // 中文
16
- '好。',
17
- '好的。',
18
- '好嘞。',
19
- '对。',
20
- '对的。',
21
- '是。',
22
- '是的。',
23
- '行。',
24
- '嗯。',
25
- '可以。',
26
- '明白。',
27
- '收到。',
28
- '了解。',
29
- '执行。',
30
- '继续。',
31
- '确认。',
32
- '完成。',
33
- '搞定。',
34
- '写。',
35
- '查。',
36
- '看。',
37
- // 英文
38
- 'ok.',
39
- 'okay.',
40
- 'sure.',
41
- 'alright.',
42
- 'right.',
43
- 'yes.',
44
- 'done.',
45
- 'got it.',
46
- 'let me go.',
47
- 'let me do it.',
48
- 'check it.',
49
- ];
50
- /** 查表用的集合。 */
51
- const FRAGMENT_SET = new Set(FRAGMENTS);
52
10
  /**
53
11
  * 找出退化的碎片行。
54
12
  * @param text - 已生成的思考文本。
55
- * @returns 命中返回该行的原文,未命中返回 null。
13
+ * @param fragments - 小写化的短句表。
14
+ * @param threshold - 连续命中多少行才判定为复读。
15
+ * @returns 计数成立时返回那一行的原文,否则返回 null。
56
16
  */
57
- export function findDegenerateLine(text) {
17
+ export function findDegenerateLine(text, fragments, threshold) {
18
+ let run = 0;
58
19
  for (const raw of text.split('\n')) {
59
20
  const line = raw.trim();
60
- if (line !== '' && FRAGMENT_SET.has(line.toLowerCase())) {
61
- return line;
21
+ if (line !== '' && fragments.has(line.toLowerCase())) {
22
+ run += 1;
23
+ if (run >= threshold) {
24
+ return line;
25
+ }
26
+ }
27
+ else {
28
+ run = 0;
62
29
  }
63
30
  }
64
31
  return null;
package/lib/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
10
10
  //
11
11
  // 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
12
- // 表在 detect.ts 的 FRAGMENTS 里。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
12
+ // 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
13
13
  // 具体、可增删、可审计的。
14
14
  //
15
15
  // 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
@@ -20,6 +20,7 @@
20
20
  // `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
21
21
  //
22
22
  // 本文件只做装配,具体逻辑在各自的模块里。
23
+ import { createConfigSource } from './config.js';
23
24
  import { createStreamGuard } from './stream-guard.js';
24
25
  import { createTurnStoppingGuard } from './turn-stopping-guard.js';
25
26
  /**
@@ -30,6 +31,7 @@ export default function repeatGuard(ctx) {
30
31
  // 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
31
32
  console.log('[repeat-guard] 已加载,复读拦截生效');
32
33
  const state = { pending: new Set() };
33
- ctx.on('llm/stream', createStreamGuard(state), { global: true });
34
+ const readConfig = createConfigSource(ctx);
35
+ ctx.on('llm/stream', createStreamGuard(state, readConfig), { global: true });
34
36
  ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
35
37
  }
@@ -4,13 +4,15 @@
4
4
  * 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
5
5
  */
6
6
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
7
+ import type { ConfigSource } from './config.js';
7
8
  import type { GuardState } from './types.js';
8
9
  /** `llm/stream` 的监听器签名。 */
9
10
  type StreamListener = (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>;
10
11
  /**
11
12
  * 造一个 `llm/stream` 监听器。
12
13
  * @param state - 跨监听保留的拦截状态。
14
+ * @param readConfig - 取当前配置。
13
15
  * @returns 监听器;辅助调用直接透传,其余包一层复读检测。
14
16
  */
15
- export declare function createStreamGuard(state: GuardState): StreamListener;
17
+ export declare function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener;
16
18
  export {};
@@ -10,12 +10,17 @@ import { findDegenerateLine } from './detect.js';
10
10
  * 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
11
11
  * 所以累积文本用一个字符串即可,不需要按 index 分开存。
12
12
  *
13
+ * 命中之后先往下探一格,判"思考段是不是就到这儿了":再往下若还是 reasoning-delta,
14
+ * 说明思考在继续复读,该拦;若换成正文、工具调用,或直接收尾,说明命中行本来就是
15
+ * 这段思考的最后一句,属正常收尾,不拦。
16
+ *
13
17
  * @param downstream - 上游模型的流。
14
18
  * @param sessionId - 当前会话 id;有值才登记待续跑标记。
15
19
  * @param state - 跨监听保留的拦截状态。
20
+ * @param readConfig - 取当前配置;每个 chunk 现取,改设置立即生效。
16
21
  * @returns 包好的流。
17
22
  */
18
- async function* guardStream(downstream, sessionId, state) {
23
+ async function* guardStream(downstream, sessionId, state, readConfig) {
19
24
  const iterator = downstream[Symbol.asyncIterator]();
20
25
  let accumulated = '';
21
26
  try {
@@ -27,17 +32,27 @@ async function* guardStream(downstream, sessionId, state) {
27
32
  const chunk = step.value;
28
33
  if (chunk.type === 'reasoning-delta') {
29
34
  accumulated += chunk.text;
30
- const hit = findDegenerateLine(accumulated);
35
+ const config = readConfig();
36
+ const hit = findDegenerateLine(accumulated, config.fragments, config.threshold);
31
37
  if (hit !== null) {
32
- if (sessionId !== undefined) {
33
- state.pending.add(sessionId);
38
+ const probe = await iterator.next();
39
+ if (!probe.done && probe.value.type === 'reasoning-delta') {
40
+ if (sessionId !== undefined) {
41
+ state.pending.add(sessionId);
42
+ }
43
+ console.log(`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
44
+ // 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
45
+ yield chunk;
46
+ yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
47
+ yield { type: 'finish', reason: { kind: 'stop' } };
48
+ return;
34
49
  }
35
- console.log(`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
36
- // 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
50
+ console.log(`[repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
37
51
  yield chunk;
38
- yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
39
- yield { type: 'finish', reason: { kind: 'stop' } };
40
- return;
52
+ if (!probe.done) {
53
+ yield probe.value;
54
+ }
55
+ continue;
41
56
  }
42
57
  }
43
58
  yield chunk;
@@ -57,14 +72,15 @@ async function* guardStream(downstream, sessionId, state) {
57
72
  /**
58
73
  * 造一个 `llm/stream` 监听器。
59
74
  * @param state - 跨监听保留的拦截状态。
75
+ * @param readConfig - 取当前配置。
60
76
  * @returns 监听器;辅助调用直接透传,其余包一层复读检测。
61
77
  */
62
- export function createStreamGuard(state) {
78
+ export function createStreamGuard(state, readConfig) {
63
79
  return (options, next) => {
64
80
  // 辅助调用(上下文压缩、会话标题)不参与检测。
65
81
  if (options.purpose !== undefined) {
66
82
  return next();
67
83
  }
68
- return guardStream(next(), options.sessionId, state);
84
+ return guardStream(next(), options.sessionId, state, readConfig);
69
85
  };
70
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-repeat-guard",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "流式输出退化拦截:检测碎片复读,掐断本次生成并提醒模型直接执行工具调用",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,6 +10,9 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": {
14
+ "default": "./lib/client.js"
15
+ },
13
16
  "./package.json": "./package.json"
14
17
  },
15
18
  "files": [
@@ -20,10 +23,17 @@
20
23
  "dsh": {
21
24
  "bundle": {
22
25
  "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "platform": "web",
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-ui-renderer",
31
+ "@deepseek-ai/dsh-client-ui-settings"
32
+ ]
23
33
  }
24
34
  },
25
35
  "scripts": {
26
- "build": "tsc",
36
+ "build": "tsc && node scripts/build-client.mjs",
27
37
  "prepack": "npm run build"
28
38
  },
29
39
  "license": "MIT",
@@ -31,13 +41,24 @@
31
41
  "peerDependencies": {
32
42
  "@deepseek-ai/cordis": "^4.0.2",
33
43
  "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
34
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.2"
44
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
45
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
46
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
47
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
48
+ "@deepseek-ai/schemastery": "^3.18.2",
49
+ "react": "^18.2.0"
35
50
  },
36
51
  "devDependencies": {
37
52
  "@deepseek-ai/cordis": "4.0.2",
38
53
  "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
54
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.2",
55
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
39
56
  "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
57
+ "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
58
+ "@deepseek-ai/schemastery": "3.18.2",
40
59
  "@types/node": "^22.0.0",
60
+ "@types/react": "^19.3.0",
61
+ "esbuild": "^0.28.2",
41
62
  "typescript": "^7.0.2"
42
63
  }
43
64
  }
package/src/client.tsx ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 客户端设置页:在设置窗口左侧加一项"复读打断"。
3
+ *
4
+ * 面板里编辑两样东西——拦截短句表(一行一句)与连续命中阈值,两者都写宿主侧
5
+ * 注册的 settings 命名空间,保存后宿主侧立即按新配置判定。
6
+ *
7
+ * 本文件由 scripts/build-client.mjs 用 esbuild 单独打包成单文件 bundle
8
+ * (`lib/client.js`),不走 tsc 的 lib 输出。
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ // 空导入:只为加载这两处的 declaration merging(往 cordis 的 Context 上补
13
+ // slots 与 settingsScope 两个客户端服务)。
14
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
15
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
16
+ import { useEffect, useState, type ReactElement } from 'react';
17
+
18
+ /** settings 命名空间,必须与宿主侧 config.ts 的 SETTINGS_NS 逐字一致。 */
19
+ const SETTINGS_NS = 'repeat-guard';
20
+
21
+ /** 本插件配置节选。 */
22
+ interface RepeatGuardSettings {
23
+ /** 拦截短句表。 */
24
+ fragments?: string[];
25
+ /** 连续命中多少行才拦。 */
26
+ threshold?: number;
27
+ }
28
+
29
+ /** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
30
+ export const inject = ['slots', 'settingsScope'];
31
+
32
+ /**
33
+ * 客户端插件入口。
34
+ * @param ctx - 浏览器侧 cordis 上下文。
35
+ */
36
+ export function apply(ctx: Context): void {
37
+ const scope = ctx.settingsScope.bind<RepeatGuardSettings>({ namespace: SETTINGS_NS });
38
+
39
+ function Form({ initial }: { initial: RepeatGuardSettings }): ReactElement {
40
+ const [fragments, setFragments] = useState(() => (initial.fragments ?? []).join('\n'));
41
+ const [threshold, setThreshold] = useState(() => String(initial.threshold ?? 1));
42
+ const [message, setMessage] = useState('');
43
+
44
+ function save(): void {
45
+ const list = fragments
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .filter((line) => line !== '');
49
+ const count = Number(threshold);
50
+ setMessage('保存中…');
51
+ scope
52
+ .set('fragments', list)
53
+ .then(() => scope.set('threshold', count))
54
+ .then(() => {
55
+ setMessage('已保存');
56
+ })
57
+ .catch((error: unknown) => {
58
+ setMessage(`保存失败:${String(error)}`);
59
+ });
60
+ }
61
+
62
+ return (
63
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
64
+ <label>
65
+ <span>连续命中次数</span>
66
+ <input
67
+ type="number"
68
+ value={threshold}
69
+ onChange={(event) => {
70
+ setThreshold(event.target.value);
71
+ }}
72
+ />
73
+ <span>填 1 表示命中即拦;填 n 表示连着 n 行都命中才拦。</span>
74
+ </label>
75
+ <label>
76
+ <span>拦截短句(一行一句,连标点一起写)</span>
77
+ <textarea
78
+ rows={16}
79
+ style={{ width: '100%', fontFamily: 'monospace' }}
80
+ value={fragments}
81
+ onChange={(event) => {
82
+ setFragments(event.target.value);
83
+ }}
84
+ />
85
+ </label>
86
+ <div>
87
+ <button type="button" onClick={save}>
88
+ 保存
89
+ </button>
90
+ <span>{message}</span>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function Panel(): ReactElement {
97
+ const [snapshot, setSnapshot] = useState(() => scope.getSnapshot());
98
+ useEffect(
99
+ () =>
100
+ scope.subscribe(() => {
101
+ setSnapshot(scope.getSnapshot());
102
+ }),
103
+ [],
104
+ );
105
+ const value = snapshot.value;
106
+ if (snapshot.status !== 'ready' || value === undefined) {
107
+ return <p>{`配置尚未就绪(${snapshot.status})`}</p>;
108
+ }
109
+ return <Form initial={value} />;
110
+ }
111
+
112
+ ctx.slots.inject('settings.section', () =>
113
+ ctx.slots.register(
114
+ { name: 'settings.section', id: 'repeat-guard', order: 100, label: '复读打断' },
115
+ Panel,
116
+ ),
117
+ );
118
+ }
package/src/config.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 插件配置:拦截短句表与连续命中阈值。
3
+ *
4
+ * 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
5
+ * schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
6
+ */
7
+
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ // 空导入:只为加载它的 declaration merging(往 cordis 的 Context 上补 settings 服务)。
10
+ import type {} from '@deepseek-ai/dsh-settings';
11
+ import z from '@deepseek-ai/schemastery';
12
+
13
+ /** settings 命名空间。客户端设置页必须写同一个值。 */
14
+ export const SETTINGS_NS = 'repeat-guard';
15
+
16
+ /** 默认的拦截短句表,标点照原样写。 */
17
+ export const DEFAULT_FRAGMENTS: readonly string[] = [
18
+ '好。',
19
+ '好的。',
20
+ '好嘞。',
21
+ '对。',
22
+ '对的。',
23
+ '是。',
24
+ '是的。',
25
+ '行。',
26
+ '嗯。',
27
+ '可以。',
28
+ '明白。',
29
+ '收到。',
30
+ '了解。',
31
+ '执行。',
32
+ '继续。',
33
+ '确认。',
34
+ '完成。',
35
+ '搞定。',
36
+ '写。',
37
+ '查。',
38
+ '看。',
39
+ 'ok.',
40
+ 'okay.',
41
+ 'sure.',
42
+ 'alright.',
43
+ 'right.',
44
+ 'yes.',
45
+ 'done.',
46
+ 'got it.',
47
+ 'let me go.',
48
+ 'let me do it.',
49
+ 'check it.',
50
+ ];
51
+
52
+ /** 默认阈值:1 表示发现即拦。 */
53
+ export const DEFAULT_THRESHOLD = 1;
54
+
55
+ const SCHEMA = z.object({
56
+ fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
57
+ threshold: z.number().default(DEFAULT_THRESHOLD),
58
+ });
59
+
60
+ /** 一份生效中的配置,已编译成判定时直接可用的形式。 */
61
+ export interface RepeatGuardConfig {
62
+ /** 短句表,已全部小写化。 */
63
+ readonly fragments: ReadonlySet<string>;
64
+ /** 连续命中多少行才拦。 */
65
+ readonly threshold: number;
66
+ }
67
+
68
+ /** 取当前配置。 */
69
+ export type ConfigSource = () => RepeatGuardConfig;
70
+
71
+ function compile(value: { fragments: readonly string[]; threshold: number }): RepeatGuardConfig {
72
+ return {
73
+ fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
74
+ threshold: value.threshold,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * 注册配置命名空间,并返回取当前配置的函数。
80
+ * @param ctx - 宿主 cordis 上下文。
81
+ * @returns 每次调用都返回最新配置。
82
+ */
83
+ export function createConfigSource(ctx: Context): ConfigSource {
84
+ let current = compile({ fragments: DEFAULT_FRAGMENTS, threshold: DEFAULT_THRESHOLD });
85
+ ctx.inject(['settings'], (settingsCtx) => {
86
+ const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
87
+ const sync = (): void => {
88
+ current = compile(scope.get());
89
+ };
90
+ sync();
91
+ scope.watch(sync);
92
+ });
93
+ return () => current;
94
+ }
package/src/detect.ts CHANGED
@@ -1,67 +1,35 @@
1
1
  /**
2
2
  * 复读退化判定:查表。
3
3
  *
4
- * 表里是已知会被模型复读的空话短句,**连标点一起写**。判定就是"思考里有没有
5
- * 独占一行的表项",整行与表项全等,不拆解行的内部。
4
+ * 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
5
+ * 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
6
6
  *
7
- * 增删条目直接改下面的 FRAGMENTS 即可,改完重新构建。
7
+ * 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
8
+ * (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
8
9
  */
9
10
 
10
- /**
11
- * 会被复读的空话短句表,标点照原样写。
12
- *
13
- * 英文条目一律写小写,判定时会把整行转成小写再比。
14
- */
15
- const FRAGMENTS: readonly string[] = [
16
- // 中文
17
- '好。',
18
- '好的。',
19
- '好嘞。',
20
- '对。',
21
- '对的。',
22
- '是。',
23
- '是的。',
24
- '行。',
25
- '嗯。',
26
- '可以。',
27
- '明白。',
28
- '收到。',
29
- '了解。',
30
- '执行。',
31
- '继续。',
32
- '确认。',
33
- '完成。',
34
- '搞定。',
35
- '写。',
36
- '查。',
37
- '看。',
38
- // 英文
39
- 'ok.',
40
- 'okay.',
41
- 'sure.',
42
- 'alright.',
43
- 'right.',
44
- 'yes.',
45
- 'done.',
46
- 'got it.',
47
- 'let me go.',
48
- 'let me do it.',
49
- 'check it.',
50
- ];
51
-
52
- /** 查表用的集合。 */
53
- const FRAGMENT_SET = new Set(FRAGMENTS);
54
-
55
11
  /**
56
12
  * 找出退化的碎片行。
57
13
  * @param text - 已生成的思考文本。
58
- * @returns 命中返回该行的原文,未命中返回 null。
14
+ * @param fragments - 小写化的短句表。
15
+ * @param threshold - 连续命中多少行才判定为复读。
16
+ * @returns 计数成立时返回那一行的原文,否则返回 null。
59
17
  */
60
- export function findDegenerateLine(text: string): string | null {
18
+ export function findDegenerateLine(
19
+ text: string,
20
+ fragments: ReadonlySet<string>,
21
+ threshold: number,
22
+ ): string | null {
23
+ let run = 0;
61
24
  for (const raw of text.split('\n')) {
62
25
  const line = raw.trim();
63
- if (line !== '' && FRAGMENT_SET.has(line.toLowerCase())) {
64
- return line;
26
+ if (line !== '' && fragments.has(line.toLowerCase())) {
27
+ run += 1;
28
+ if (run >= threshold) {
29
+ return line;
30
+ }
31
+ } else {
32
+ run = 0;
65
33
  }
66
34
  }
67
35
  return null;
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
10
10
  //
11
11
  // 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
12
- // 表在 detect.ts 的 FRAGMENTS 里。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
12
+ // 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
13
13
  // 具体、可增删、可审计的。
14
14
  //
15
15
  // 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
@@ -22,6 +22,7 @@
22
22
  // 本文件只做装配,具体逻辑在各自的模块里。
23
23
 
24
24
  import type { Context } from '@deepseek-ai/cordis';
25
+ import { createConfigSource } from './config.js';
25
26
  import { createStreamGuard } from './stream-guard.js';
26
27
  import { createTurnStoppingGuard } from './turn-stopping-guard.js';
27
28
  import type { GuardState } from './types.js';
@@ -34,6 +35,7 @@ export default function repeatGuard(ctx: Context): void {
34
35
  // 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
35
36
  console.log('[repeat-guard] 已加载,复读拦截生效');
36
37
  const state: GuardState = { pending: new Set() };
37
- ctx.on('llm/stream', createStreamGuard(state), { global: true });
38
+ const readConfig = createConfigSource(ctx);
39
+ ctx.on('llm/stream', createStreamGuard(state, readConfig), { global: true });
38
40
  ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
39
41
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
8
+ import type { ConfigSource } from './config.js';
8
9
  import { findDegenerateLine } from './detect.js';
9
10
  import type { GuardState } from './types.js';
10
11
 
@@ -20,15 +21,21 @@ type StreamListener = (
20
21
  * 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
21
22
  * 所以累积文本用一个字符串即可,不需要按 index 分开存。
22
23
  *
24
+ * 命中之后先往下探一格,判"思考段是不是就到这儿了":再往下若还是 reasoning-delta,
25
+ * 说明思考在继续复读,该拦;若换成正文、工具调用,或直接收尾,说明命中行本来就是
26
+ * 这段思考的最后一句,属正常收尾,不拦。
27
+ *
23
28
  * @param downstream - 上游模型的流。
24
29
  * @param sessionId - 当前会话 id;有值才登记待续跑标记。
25
30
  * @param state - 跨监听保留的拦截状态。
31
+ * @param readConfig - 取当前配置;每个 chunk 现取,改设置立即生效。
26
32
  * @returns 包好的流。
27
33
  */
28
34
  async function* guardStream(
29
35
  downstream: AsyncIterable<StreamChunk>,
30
36
  sessionId: GenerateOptions['sessionId'],
31
37
  state: GuardState,
38
+ readConfig: ConfigSource,
32
39
  ): AsyncGenerator<StreamChunk> {
33
40
  const iterator = downstream[Symbol.asyncIterator]();
34
41
  let accumulated = '';
@@ -41,19 +48,31 @@ async function* guardStream(
41
48
  const chunk = step.value;
42
49
  if (chunk.type === 'reasoning-delta') {
43
50
  accumulated += chunk.text;
44
- const hit = findDegenerateLine(accumulated);
51
+ const config = readConfig();
52
+ const hit = findDegenerateLine(accumulated, config.fragments, config.threshold);
45
53
  if (hit !== null) {
46
- if (sessionId !== undefined) {
47
- state.pending.add(sessionId);
54
+ const probe = await iterator.next();
55
+ if (!probe.done && probe.value.type === 'reasoning-delta') {
56
+ if (sessionId !== undefined) {
57
+ state.pending.add(sessionId);
58
+ }
59
+ console.log(
60
+ `[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
61
+ );
62
+ // 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
63
+ yield chunk;
64
+ yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
65
+ yield { type: 'finish', reason: { kind: 'stop' } };
66
+ return;
48
67
  }
49
68
  console.log(
50
- `[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
69
+ `[repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
51
70
  );
52
- // 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
53
71
  yield chunk;
54
- yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
55
- yield { type: 'finish', reason: { kind: 'stop' } };
56
- return;
72
+ if (!probe.done) {
73
+ yield probe.value;
74
+ }
75
+ continue;
57
76
  }
58
77
  }
59
78
  yield chunk;
@@ -72,14 +91,15 @@ async function* guardStream(
72
91
  /**
73
92
  * 造一个 `llm/stream` 监听器。
74
93
  * @param state - 跨监听保留的拦截状态。
94
+ * @param readConfig - 取当前配置。
75
95
  * @returns 监听器;辅助调用直接透传,其余包一层复读检测。
76
96
  */
77
- export function createStreamGuard(state: GuardState): StreamListener {
97
+ export function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener {
78
98
  return (options, next) => {
79
99
  // 辅助调用(上下文压缩、会话标题)不参与检测。
80
100
  if (options.purpose !== undefined) {
81
101
  return next();
82
102
  }
83
- return guardStream(next(), options.sessionId, state);
103
+ return guardStream(next(), options.sessionId, state, readConfig);
84
104
  };
85
105
  }