dsh-repeat-guard 0.2.1 → 0.2.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/LICENSE +21 -21
- package/README.md +121 -115
- package/cordis.patch.yml +7 -7
- package/package.json +64 -64
- package/src/client.tsx +459 -459
- package/src/config.ts +171 -171
- package/src/detect.ts +86 -86
- package/src/index.ts +48 -48
- package/src/resume.ts +62 -62
- package/src/stream-guard.ts +118 -118
- package/src/turn-stopping-guard.ts +23 -23
- package/src/types.ts +13 -13
package/src/config.ts
CHANGED
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
|
|
3
|
-
* 外加一个运行时计数。
|
|
4
|
-
*
|
|
5
|
-
* dsh 0.1.7 起,配置就是插件自己的 entry Config——标记 `.volatile()` 的字段由宿主
|
|
6
|
-
* 的设置表单读写,写回的是 profile 的 cordis patch。宿主侧不再注册命名空间,
|
|
7
|
-
* 而是持有这些稳定引用,需要时 `.get()` 现取,所以改完立即生效,不用重启。
|
|
8
|
-
*
|
|
9
|
-
* 计数也寄在同一份配置里:宿主侧每次掐断写一次,客户端读同一个值、清零就是把它
|
|
10
|
-
* 写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { Context } from '@deepseek-ai/cordis';
|
|
14
|
-
// 空导入:只为加载它的 declaration merging(往 cordis 的 Context 上补 settings 服务)。
|
|
15
|
-
import type {} from '@deepseek-ai/dsh-settings';
|
|
16
|
-
import z from '@deepseek-ai/schemastery';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* profile 里本插件的条目 id。设置表单按它定位配置,宿主写回也要用它,
|
|
20
|
-
* 所以必须与 cordis.patch.yml 里的 `id` 逐字一致。
|
|
21
|
-
*/
|
|
22
|
-
export const ENTRY_ID = 'dsh-repeat-guard';
|
|
23
|
-
|
|
24
|
-
/** 默认的拦截短句表,标点照原样写。 */
|
|
25
|
-
export const DEFAULT_FRAGMENTS: readonly string[] = [
|
|
26
|
-
'好。',
|
|
27
|
-
'好的。',
|
|
28
|
-
'好嘞。',
|
|
29
|
-
'对。',
|
|
30
|
-
'对的。',
|
|
31
|
-
'是。',
|
|
32
|
-
'是的。',
|
|
33
|
-
'行。',
|
|
34
|
-
'嗯。',
|
|
35
|
-
'可以。',
|
|
36
|
-
'明白。',
|
|
37
|
-
'收到。',
|
|
38
|
-
'了解。',
|
|
39
|
-
'执行。',
|
|
40
|
-
'继续。',
|
|
41
|
-
'确认。',
|
|
42
|
-
'完成。',
|
|
43
|
-
'搞定。',
|
|
44
|
-
'写。',
|
|
45
|
-
'查。',
|
|
46
|
-
'看。',
|
|
47
|
-
'ok.',
|
|
48
|
-
'okay.',
|
|
49
|
-
'sure.',
|
|
50
|
-
'alright.',
|
|
51
|
-
'right.',
|
|
52
|
-
'yes.',
|
|
53
|
-
'done.',
|
|
54
|
-
'got it.',
|
|
55
|
-
'let me go.',
|
|
56
|
-
'let me do it.',
|
|
57
|
-
'check it.',
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
/** 默认阈值:1 表示发现即拦。 */
|
|
61
|
-
export const DEFAULT_THRESHOLD = 1;
|
|
62
|
-
|
|
63
|
-
/** 默认关闭行内重复检测:只认独占一行的整句。 */
|
|
64
|
-
export const DEFAULT_INLINE_REPEAT = false;
|
|
65
|
-
|
|
66
|
-
/** 默认的续跑指令正文,截断后推给模型。 */
|
|
67
|
-
export const DEFAULT_RESUME_TEXT = [
|
|
68
|
-
'[复读拦截]',
|
|
69
|
-
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
70
|
-
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
71
|
-
].join('\n');
|
|
72
|
-
|
|
73
|
-
/** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
|
|
74
|
-
export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 插件 Config schema。只有标了 `.volatile()` 的字段会进设置表单并支持编辑,
|
|
78
|
-
* 其余字段仍是普通配置(只由 cordis 配置文件决定)。
|
|
79
|
-
*/
|
|
80
|
-
export const Config = z.object({
|
|
81
|
-
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]).volatile(),
|
|
82
|
-
threshold: z.number().default(DEFAULT_THRESHOLD).volatile(),
|
|
83
|
-
inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT).volatile(),
|
|
84
|
-
resumeText: z.string().default(DEFAULT_RESUME_TEXT).volatile(),
|
|
85
|
-
resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY).volatile(),
|
|
86
|
-
count: z.number().default(0).volatile(),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
/** loader 解析后交给插件的配置:volatile 字段是稳定引用,取值要 `.get()`。 */
|
|
90
|
-
export type RepeatGuardConfigSchema = Schemastery.TypeT<typeof Config>;
|
|
91
|
-
|
|
92
|
-
/** 判定与续跑用得上的一份配置。 */
|
|
93
|
-
export interface RepeatGuardConfig {
|
|
94
|
-
/** 短句表,已全部小写化。 */
|
|
95
|
-
readonly fragments: ReadonlySet<string>;
|
|
96
|
-
/** 连续命中多少次才拦。 */
|
|
97
|
-
readonly threshold: number;
|
|
98
|
-
/** 是否把"整行由多个表项拼成"也算命中。 */
|
|
99
|
-
readonly inlineRepeat: boolean;
|
|
100
|
-
/** 截断后推给模型的指令正文。 */
|
|
101
|
-
readonly resumeText: string;
|
|
102
|
-
/** 注入消息的折叠行摘要。 */
|
|
103
|
-
readonly resumeSummary: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** 取当前配置。 */
|
|
107
|
-
export type ConfigSource = () => RepeatGuardConfig;
|
|
108
|
-
|
|
109
|
-
/** 宿主侧要用到的两件事:取配置、记一次拦截。 */
|
|
110
|
-
export interface GuardRuntime {
|
|
111
|
-
/** 取当前配置。 */
|
|
112
|
-
readonly read: ConfigSource;
|
|
113
|
-
/** 记一次拦截,把计数写回配置。 */
|
|
114
|
-
readonly countHit: () => void;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function compile(value: {
|
|
118
|
-
fragments: readonly string[];
|
|
119
|
-
threshold: number;
|
|
120
|
-
inlineRepeat: boolean;
|
|
121
|
-
resumeText: string;
|
|
122
|
-
resumeSummary: string;
|
|
123
|
-
}): RepeatGuardConfig {
|
|
124
|
-
return {
|
|
125
|
-
fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
|
|
126
|
-
threshold: value.threshold,
|
|
127
|
-
inlineRepeat: value.inlineRepeat,
|
|
128
|
-
resumeText: value.resumeText,
|
|
129
|
-
resumeSummary: value.resumeSummary,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 把 volatile 引用折叠成一份快照,返回宿主侧要用的运行时句柄。
|
|
135
|
-
* @param ctx - 宿主 cordis 上下文。
|
|
136
|
-
* @param config - loader 解析后的本插件配置。
|
|
137
|
-
* @returns 取配置与记数两个入口。
|
|
138
|
-
*/
|
|
139
|
-
export function createRuntime(ctx: Context, config: RepeatGuardConfigSchema): GuardRuntime {
|
|
140
|
-
const read = (): RepeatGuardConfig =>
|
|
141
|
-
compile({
|
|
142
|
-
fragments: config.fragments.get(),
|
|
143
|
-
threshold: config.threshold.get(),
|
|
144
|
-
inlineRepeat: config.inlineRepeat.get(),
|
|
145
|
-
resumeText: config.resumeText.get(),
|
|
146
|
-
resumeSummary: config.resumeSummary.get(),
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
let write: ((count: number) => void) | undefined;
|
|
150
|
-
ctx.inject(['settings'], (settingsCtx) => {
|
|
151
|
-
const settings = settingsCtx.settings;
|
|
152
|
-
// 本插件自带设置页,关掉宿主按 schema 自动生成的页面。
|
|
153
|
-
settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber));
|
|
154
|
-
write = (count: number): void => {
|
|
155
|
-
// 现取 revision:表单刚写过的话,旧 revision 会被 SETTINGS_CONFLICT 拒掉。
|
|
156
|
-
const revision = settings.describe().find((entry) => entry.ns === ENTRY_ID)?.revision;
|
|
157
|
-
settings.update(ENTRY_ID, { count }, revision).catch((error: unknown) => {
|
|
158
|
-
console.log(`[repeat-guard] 写设置失败:${String(error)}`);
|
|
159
|
-
});
|
|
160
|
-
};
|
|
161
|
-
// 计数口径是"自本次启动或上次清零以来"。
|
|
162
|
-
write(0);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
return {
|
|
166
|
-
read,
|
|
167
|
-
countHit: () => {
|
|
168
|
-
write?.(config.count.get() + 1);
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
|
|
3
|
+
* 外加一个运行时计数。
|
|
4
|
+
*
|
|
5
|
+
* dsh 0.1.7 起,配置就是插件自己的 entry Config——标记 `.volatile()` 的字段由宿主
|
|
6
|
+
* 的设置表单读写,写回的是 profile 的 cordis patch。宿主侧不再注册命名空间,
|
|
7
|
+
* 而是持有这些稳定引用,需要时 `.get()` 现取,所以改完立即生效,不用重启。
|
|
8
|
+
*
|
|
9
|
+
* 计数也寄在同一份配置里:宿主侧每次掐断写一次,客户端读同一个值、清零就是把它
|
|
10
|
+
* 写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
14
|
+
// 空导入:只为加载它的 declaration merging(往 cordis 的 Context 上补 settings 服务)。
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-settings';
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* profile 里本插件的条目 id。设置表单按它定位配置,宿主写回也要用它,
|
|
20
|
+
* 所以必须与 cordis.patch.yml 里的 `id` 逐字一致。
|
|
21
|
+
*/
|
|
22
|
+
export const ENTRY_ID = 'dsh-repeat-guard';
|
|
23
|
+
|
|
24
|
+
/** 默认的拦截短句表,标点照原样写。 */
|
|
25
|
+
export const DEFAULT_FRAGMENTS: readonly string[] = [
|
|
26
|
+
'好。',
|
|
27
|
+
'好的。',
|
|
28
|
+
'好嘞。',
|
|
29
|
+
'对。',
|
|
30
|
+
'对的。',
|
|
31
|
+
'是。',
|
|
32
|
+
'是的。',
|
|
33
|
+
'行。',
|
|
34
|
+
'嗯。',
|
|
35
|
+
'可以。',
|
|
36
|
+
'明白。',
|
|
37
|
+
'收到。',
|
|
38
|
+
'了解。',
|
|
39
|
+
'执行。',
|
|
40
|
+
'继续。',
|
|
41
|
+
'确认。',
|
|
42
|
+
'完成。',
|
|
43
|
+
'搞定。',
|
|
44
|
+
'写。',
|
|
45
|
+
'查。',
|
|
46
|
+
'看。',
|
|
47
|
+
'ok.',
|
|
48
|
+
'okay.',
|
|
49
|
+
'sure.',
|
|
50
|
+
'alright.',
|
|
51
|
+
'right.',
|
|
52
|
+
'yes.',
|
|
53
|
+
'done.',
|
|
54
|
+
'got it.',
|
|
55
|
+
'let me go.',
|
|
56
|
+
'let me do it.',
|
|
57
|
+
'check it.',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** 默认阈值:1 表示发现即拦。 */
|
|
61
|
+
export const DEFAULT_THRESHOLD = 1;
|
|
62
|
+
|
|
63
|
+
/** 默认关闭行内重复检测:只认独占一行的整句。 */
|
|
64
|
+
export const DEFAULT_INLINE_REPEAT = false;
|
|
65
|
+
|
|
66
|
+
/** 默认的续跑指令正文,截断后推给模型。 */
|
|
67
|
+
export const DEFAULT_RESUME_TEXT = [
|
|
68
|
+
'[复读拦截]',
|
|
69
|
+
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
70
|
+
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
71
|
+
].join('\n');
|
|
72
|
+
|
|
73
|
+
/** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
|
|
74
|
+
export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 插件 Config schema。只有标了 `.volatile()` 的字段会进设置表单并支持编辑,
|
|
78
|
+
* 其余字段仍是普通配置(只由 cordis 配置文件决定)。
|
|
79
|
+
*/
|
|
80
|
+
export const Config = z.object({
|
|
81
|
+
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]).volatile(),
|
|
82
|
+
threshold: z.number().default(DEFAULT_THRESHOLD).volatile(),
|
|
83
|
+
inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT).volatile(),
|
|
84
|
+
resumeText: z.string().default(DEFAULT_RESUME_TEXT).volatile(),
|
|
85
|
+
resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY).volatile(),
|
|
86
|
+
count: z.number().default(0).volatile(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** loader 解析后交给插件的配置:volatile 字段是稳定引用,取值要 `.get()`。 */
|
|
90
|
+
export type RepeatGuardConfigSchema = Schemastery.TypeT<typeof Config>;
|
|
91
|
+
|
|
92
|
+
/** 判定与续跑用得上的一份配置。 */
|
|
93
|
+
export interface RepeatGuardConfig {
|
|
94
|
+
/** 短句表,已全部小写化。 */
|
|
95
|
+
readonly fragments: ReadonlySet<string>;
|
|
96
|
+
/** 连续命中多少次才拦。 */
|
|
97
|
+
readonly threshold: number;
|
|
98
|
+
/** 是否把"整行由多个表项拼成"也算命中。 */
|
|
99
|
+
readonly inlineRepeat: boolean;
|
|
100
|
+
/** 截断后推给模型的指令正文。 */
|
|
101
|
+
readonly resumeText: string;
|
|
102
|
+
/** 注入消息的折叠行摘要。 */
|
|
103
|
+
readonly resumeSummary: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 取当前配置。 */
|
|
107
|
+
export type ConfigSource = () => RepeatGuardConfig;
|
|
108
|
+
|
|
109
|
+
/** 宿主侧要用到的两件事:取配置、记一次拦截。 */
|
|
110
|
+
export interface GuardRuntime {
|
|
111
|
+
/** 取当前配置。 */
|
|
112
|
+
readonly read: ConfigSource;
|
|
113
|
+
/** 记一次拦截,把计数写回配置。 */
|
|
114
|
+
readonly countHit: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function compile(value: {
|
|
118
|
+
fragments: readonly string[];
|
|
119
|
+
threshold: number;
|
|
120
|
+
inlineRepeat: boolean;
|
|
121
|
+
resumeText: string;
|
|
122
|
+
resumeSummary: string;
|
|
123
|
+
}): RepeatGuardConfig {
|
|
124
|
+
return {
|
|
125
|
+
fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
|
|
126
|
+
threshold: value.threshold,
|
|
127
|
+
inlineRepeat: value.inlineRepeat,
|
|
128
|
+
resumeText: value.resumeText,
|
|
129
|
+
resumeSummary: value.resumeSummary,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 把 volatile 引用折叠成一份快照,返回宿主侧要用的运行时句柄。
|
|
135
|
+
* @param ctx - 宿主 cordis 上下文。
|
|
136
|
+
* @param config - loader 解析后的本插件配置。
|
|
137
|
+
* @returns 取配置与记数两个入口。
|
|
138
|
+
*/
|
|
139
|
+
export function createRuntime(ctx: Context, config: RepeatGuardConfigSchema): GuardRuntime {
|
|
140
|
+
const read = (): RepeatGuardConfig =>
|
|
141
|
+
compile({
|
|
142
|
+
fragments: config.fragments.get(),
|
|
143
|
+
threshold: config.threshold.get(),
|
|
144
|
+
inlineRepeat: config.inlineRepeat.get(),
|
|
145
|
+
resumeText: config.resumeText.get(),
|
|
146
|
+
resumeSummary: config.resumeSummary.get(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let write: ((count: number) => void) | undefined;
|
|
150
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
151
|
+
const settings = settingsCtx.settings;
|
|
152
|
+
// 本插件自带设置页,关掉宿主按 schema 自动生成的页面。
|
|
153
|
+
settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber));
|
|
154
|
+
write = (count: number): void => {
|
|
155
|
+
// 现取 revision:表单刚写过的话,旧 revision 会被 SETTINGS_CONFLICT 拒掉。
|
|
156
|
+
const revision = settings.describe().find((entry) => entry.ns === ENTRY_ID)?.revision;
|
|
157
|
+
settings.update(ENTRY_ID, { count }, revision).catch((error: unknown) => {
|
|
158
|
+
console.log(`[repeat-guard] 写设置失败:${String(error)}`);
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
// 计数口径是"自本次启动或上次清零以来"。
|
|
162
|
+
write(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
read,
|
|
167
|
+
countHit: () => {
|
|
168
|
+
write?.(config.count.get() + 1);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/detect.ts
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 复读退化判定:查表。
|
|
3
|
-
*
|
|
4
|
-
* 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
|
|
5
|
-
* 基本判据是"整行与表项全等",不拆解行的内部。
|
|
6
|
-
*
|
|
7
|
-
* 行内重复检测(可开关,默认关)打开时,"整行由多个表项首尾相接拼成"也算命中,
|
|
8
|
-
* 用来兜住把短句连成一串写在同一行里的模型(如"好。好。好。")。
|
|
9
|
-
*
|
|
10
|
-
* 阈值是"连续命中多少次才拦":默认 1 表示发现即拦。计数单位是表项——整行全等算一次,
|
|
11
|
-
* 开了行内检测后行内每一段各算一次;中间夹一行不命中的就重新计数。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* 在 line 的 at 处能匹配到的最长表项长度。
|
|
16
|
-
* @param line - 已小写化的行。
|
|
17
|
-
* @param at - 起始下标。
|
|
18
|
-
* @param fragments - 小写化的短句表。
|
|
19
|
-
* @returns 匹配到的长度,0 表示这个位置没有表项。
|
|
20
|
-
*/
|
|
21
|
-
function longestFragmentAt(line: string, at: number, fragments: ReadonlySet<string>): number {
|
|
22
|
-
let size = 0;
|
|
23
|
-
for (const fragment of fragments) {
|
|
24
|
-
if (fragment.length > size && line.startsWith(fragment, at)) {
|
|
25
|
-
size = fragment.length;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return size;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 这一行算几次命中。
|
|
33
|
-
* @param line - 已 trim 并小写化的行。
|
|
34
|
-
* @param fragments - 小写化的短句表。
|
|
35
|
-
* @param inlineRepeat - 是否把"整行由多个表项拼成"也算命中。
|
|
36
|
-
* @returns 0 表示不是命中行,其余为这一行贡献的次数。
|
|
37
|
-
*/
|
|
38
|
-
function countHits(line: string, fragments: ReadonlySet<string>, inlineRepeat: boolean): number {
|
|
39
|
-
if (fragments.has(line)) {
|
|
40
|
-
return 1;
|
|
41
|
-
}
|
|
42
|
-
if (!inlineRepeat) {
|
|
43
|
-
return 0;
|
|
44
|
-
}
|
|
45
|
-
let at = 0;
|
|
46
|
-
let parts = 0;
|
|
47
|
-
while (at < line.length) {
|
|
48
|
-
const size = longestFragmentAt(line, at, fragments);
|
|
49
|
-
if (size === 0) {
|
|
50
|
-
return 0;
|
|
51
|
-
}
|
|
52
|
-
at += size;
|
|
53
|
-
parts += 1;
|
|
54
|
-
}
|
|
55
|
-
return parts > 1 ? parts : 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* 找出退化的碎片行。
|
|
60
|
-
* @param text - 已生成的思考文本。
|
|
61
|
-
* @param fragments - 小写化的短句表。
|
|
62
|
-
* @param threshold - 连续命中多少次才判定为复读。
|
|
63
|
-
* @param inlineRepeat - 是否启用行内重复检测。
|
|
64
|
-
* @returns 计数成立时返回那一行的原文,否则返回 null。
|
|
65
|
-
*/
|
|
66
|
-
export function findDegenerateLine(
|
|
67
|
-
text: string,
|
|
68
|
-
fragments: ReadonlySet<string>,
|
|
69
|
-
threshold: number,
|
|
70
|
-
inlineRepeat: boolean,
|
|
71
|
-
): string | null {
|
|
72
|
-
let run = 0;
|
|
73
|
-
for (const raw of text.split('\n')) {
|
|
74
|
-
const line = raw.trim();
|
|
75
|
-
const hits = line === '' ? 0 : countHits(line.toLowerCase(), fragments, inlineRepeat);
|
|
76
|
-
if (hits === 0) {
|
|
77
|
-
run = 0;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
run += hits;
|
|
81
|
-
if (run >= threshold) {
|
|
82
|
-
return line;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 复读退化判定:查表。
|
|
3
|
+
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
|
|
5
|
+
* 基本判据是"整行与表项全等",不拆解行的内部。
|
|
6
|
+
*
|
|
7
|
+
* 行内重复检测(可开关,默认关)打开时,"整行由多个表项首尾相接拼成"也算命中,
|
|
8
|
+
* 用来兜住把短句连成一串写在同一行里的模型(如"好。好。好。")。
|
|
9
|
+
*
|
|
10
|
+
* 阈值是"连续命中多少次才拦":默认 1 表示发现即拦。计数单位是表项——整行全等算一次,
|
|
11
|
+
* 开了行内检测后行内每一段各算一次;中间夹一行不命中的就重新计数。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 在 line 的 at 处能匹配到的最长表项长度。
|
|
16
|
+
* @param line - 已小写化的行。
|
|
17
|
+
* @param at - 起始下标。
|
|
18
|
+
* @param fragments - 小写化的短句表。
|
|
19
|
+
* @returns 匹配到的长度,0 表示这个位置没有表项。
|
|
20
|
+
*/
|
|
21
|
+
function longestFragmentAt(line: string, at: number, fragments: ReadonlySet<string>): number {
|
|
22
|
+
let size = 0;
|
|
23
|
+
for (const fragment of fragments) {
|
|
24
|
+
if (fragment.length > size && line.startsWith(fragment, at)) {
|
|
25
|
+
size = fragment.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 这一行算几次命中。
|
|
33
|
+
* @param line - 已 trim 并小写化的行。
|
|
34
|
+
* @param fragments - 小写化的短句表。
|
|
35
|
+
* @param inlineRepeat - 是否把"整行由多个表项拼成"也算命中。
|
|
36
|
+
* @returns 0 表示不是命中行,其余为这一行贡献的次数。
|
|
37
|
+
*/
|
|
38
|
+
function countHits(line: string, fragments: ReadonlySet<string>, inlineRepeat: boolean): number {
|
|
39
|
+
if (fragments.has(line)) {
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (!inlineRepeat) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
let at = 0;
|
|
46
|
+
let parts = 0;
|
|
47
|
+
while (at < line.length) {
|
|
48
|
+
const size = longestFragmentAt(line, at, fragments);
|
|
49
|
+
if (size === 0) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
at += size;
|
|
53
|
+
parts += 1;
|
|
54
|
+
}
|
|
55
|
+
return parts > 1 ? parts : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 找出退化的碎片行。
|
|
60
|
+
* @param text - 已生成的思考文本。
|
|
61
|
+
* @param fragments - 小写化的短句表。
|
|
62
|
+
* @param threshold - 连续命中多少次才判定为复读。
|
|
63
|
+
* @param inlineRepeat - 是否启用行内重复检测。
|
|
64
|
+
* @returns 计数成立时返回那一行的原文,否则返回 null。
|
|
65
|
+
*/
|
|
66
|
+
export function findDegenerateLine(
|
|
67
|
+
text: string,
|
|
68
|
+
fragments: ReadonlySet<string>,
|
|
69
|
+
threshold: number,
|
|
70
|
+
inlineRepeat: boolean,
|
|
71
|
+
): string | null {
|
|
72
|
+
let run = 0;
|
|
73
|
+
for (const raw of text.split('\n')) {
|
|
74
|
+
const line = raw.trim();
|
|
75
|
+
const hits = line === '' ? 0 : countHits(line.toLowerCase(), fragments, inlineRepeat);
|
|
76
|
+
if (hits === 0) {
|
|
77
|
+
run = 0;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
run += hits;
|
|
81
|
+
if (run >= threshold) {
|
|
82
|
+
return line;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
// dsh-repeat-guard — host 侧插件入口:拦截"复读"型退化输出。
|
|
2
|
-
//
|
|
3
|
-
// 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
|
|
4
|
-
// (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
|
|
5
|
-
// 闭合思考块,再补一个 finish(stop),然后直接结束流。对 agent-loop 而言这就是一次正常
|
|
6
|
-
// 完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
7
|
-
//
|
|
8
|
-
// 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
|
|
9
|
-
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
10
|
-
//
|
|
11
|
-
// 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
|
|
12
|
-
// 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
|
|
13
|
-
// 具体、可增删、可审计的。
|
|
14
|
-
//
|
|
15
|
-
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
16
|
-
// 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
|
|
17
|
-
//
|
|
18
|
-
// 依赖:dsh 把 bundle 的 dependencies / peerDependencies 从安装目录软链进 profile
|
|
19
|
-
// (dsh-app-boot 的 healProfileModuleFallback),所以本插件可以正常 import
|
|
20
|
-
// `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
|
|
21
|
-
//
|
|
22
|
-
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
|
-
|
|
24
|
-
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
-
import { Config, createRuntime, type RepeatGuardConfigSchema } from './config.js';
|
|
26
|
-
import { createStreamGuard } from './stream-guard.js';
|
|
27
|
-
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
28
|
-
import type { GuardState } from './types.js';
|
|
29
|
-
|
|
30
|
-
// 插件模块的导出形态:loader 的 unwrapExports 优先取 `default`,而 Config 必须挂在
|
|
31
|
-
// 拿到的那一个对象上。所以这里不写 default 导出,而是与官方插件一样导出
|
|
32
|
-
// `Config` + `apply` 两个具名,让 loader 直接拿到带 schema 的插件对象。
|
|
33
|
-
export { Config };
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 函数式插件入口。ctx 为 cordis 上下文,config 是 loader 解析后的配置,
|
|
37
|
-
* 注册的监听随插件卸载自动释放。
|
|
38
|
-
* @param ctx - 宿主 cordis 上下文。
|
|
39
|
-
* @param config - 本插件的配置;volatile 字段是稳定引用,取值要 `.get()`。
|
|
40
|
-
*/
|
|
41
|
-
export function apply(ctx: Context, config: RepeatGuardConfigSchema): void {
|
|
42
|
-
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
43
|
-
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
44
|
-
const state: GuardState = { pending: new Set() };
|
|
45
|
-
const runtime = createRuntime(ctx, config);
|
|
46
|
-
ctx.on('llm/stream', createStreamGuard(state, runtime.read, runtime.countHit), { global: true });
|
|
47
|
-
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state, runtime.read));
|
|
48
|
-
}
|
|
1
|
+
// dsh-repeat-guard — host 侧插件入口:拦截"复读"型退化输出。
|
|
2
|
+
//
|
|
3
|
+
// 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
|
|
4
|
+
// (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
|
|
5
|
+
// 闭合思考块,再补一个 finish(stop),然后直接结束流。对 agent-loop 而言这就是一次正常
|
|
6
|
+
// 完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
7
|
+
//
|
|
8
|
+
// 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
|
|
9
|
+
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
10
|
+
//
|
|
11
|
+
// 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
|
|
12
|
+
// 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
|
|
13
|
+
// 具体、可增删、可审计的。
|
|
14
|
+
//
|
|
15
|
+
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
16
|
+
// 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
|
|
17
|
+
//
|
|
18
|
+
// 依赖:dsh 把 bundle 的 dependencies / peerDependencies 从安装目录软链进 profile
|
|
19
|
+
// (dsh-app-boot 的 healProfileModuleFallback),所以本插件可以正常 import
|
|
20
|
+
// `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
|
|
21
|
+
//
|
|
22
|
+
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
|
+
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
+
import { Config, createRuntime, type RepeatGuardConfigSchema } from './config.js';
|
|
26
|
+
import { createStreamGuard } from './stream-guard.js';
|
|
27
|
+
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
28
|
+
import type { GuardState } from './types.js';
|
|
29
|
+
|
|
30
|
+
// 插件模块的导出形态:loader 的 unwrapExports 优先取 `default`,而 Config 必须挂在
|
|
31
|
+
// 拿到的那一个对象上。所以这里不写 default 导出,而是与官方插件一样导出
|
|
32
|
+
// `Config` + `apply` 两个具名,让 loader 直接拿到带 schema 的插件对象。
|
|
33
|
+
export { Config };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 函数式插件入口。ctx 为 cordis 上下文,config 是 loader 解析后的配置,
|
|
37
|
+
* 注册的监听随插件卸载自动释放。
|
|
38
|
+
* @param ctx - 宿主 cordis 上下文。
|
|
39
|
+
* @param config - 本插件的配置;volatile 字段是稳定引用,取值要 `.get()`。
|
|
40
|
+
*/
|
|
41
|
+
export function apply(ctx: Context, config: RepeatGuardConfigSchema): void {
|
|
42
|
+
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
43
|
+
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
44
|
+
const state: GuardState = { pending: new Set() };
|
|
45
|
+
const runtime = createRuntime(ctx, config);
|
|
46
|
+
ctx.on('llm/stream', createStreamGuard(state, runtime.read, runtime.countHit), { global: true });
|
|
47
|
+
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state, runtime.read));
|
|
48
|
+
}
|