@max-null/dsh-allostasis 0.2.0 → 0.3.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/README.md CHANGED
@@ -7,7 +7,9 @@ This plugin belongs to the **`@max-null/*` family** — a set of plugins that to
7
7
  Allostasis for the DeepSeek Harness — session self-regulation rather than monitoring.
8
8
  Before each step it reads the most recent reasoning block and appends one near-end message
9
9
  when that thinking has drifted into English (**Chinese anchoring**) or collapsed into
10
- repetition (**degeneration reminder**).
10
+ repetition (**degeneration reminder**). When a turn ends with no visible output at all
11
+ (**silent turn**), it records the verdict in the session log and the browser half shows a
12
+ notice beneath that turn.
11
13
 
12
14
  ## 它做什么
13
15
 
@@ -17,23 +19,27 @@ repetition (**degeneration reminder**).
17
19
  |---|---|---|
18
20
  | **中文锚定** | **已实现** | 上一步思考的英文功能词密度越线 |
19
21
  | **推理退化提醒** | **已实现** | 上一步思考的重复率越线,且连续 N 步成立 |
22
+ | **空回合提示** | **已实现** | 回合收尾时末条助手消息没有非空文本(只在 `completed` 回合上判) |
20
23
  | 上下文占用感知 | 计划中 | 需先定「什么情况下才出现」(持续在场会退化成背景音) |
21
24
  | 压缩预约落盘 | 计划中 | 待通路验证:退化样本散布在整段退化区间,「只压一小段」能否打断循环尚无证据(二期方案 §八.1) |
22
25
 
23
- 两类提醒都以 `notice` 形式注入——在**轨迹页**是折叠态就显示一行摘要的注入行,不弹窗、不打断;
26
+ 前两类提醒以 `notice` 形式注入——在**轨迹页**是折叠态就显示一行摘要的注入行,不弹窗、不打断;
24
27
  **对话页不显示**(2026-09-29 在 SSiD dev / DSH 0.2.0-rc.1 上实测,口径见二期方案 §九)。
25
- 退化触发时还会 append 一条 `allostasis/degeneration` 事件,记下当时的重复率、阈值与连续步数,
26
- 因此「插件当时判了什么、用的什么阈值」可事后重建。
27
28
 
28
- **它全程静默,所以「确认它在工作」只能靠日志。** 本插件没有任何界面元素,命中时也只在轨迹页留一行;不命中时一个字都不说——「装了没有」「阈值生效没有」「这一步为什么没提醒」三个问题原本都无从回答,只能靠改配置去试。因此它在 `src/index.ts` 的 `apply` 里留两行痕:
29
+ **空回合提示是三者里唯一有界面的一个**,就挂在那一段空白下面(见「截图」段)。它不发消息、不改模型输入——
30
+ 浏览器半边从 `turn/start` / `assistant/message` / `turn/end` 自己折叠出判定,投影成 Turn 尾部的一行提示。
31
+
32
+ **判定结果不写会话日志。** 内核的事件词汇表在构建期生成,下游插件的自定义类型不在其中,而 `Session.append()` 没有 `ignorable` 标记通道;无标记的自定义事件会让**整份**日志在下次加载时被拒读。判定输入本来就可重放,留痕由 `console.debug` 诊断行与界面上那行提示承担。理由与取证见 `docs/设计/2026-09-30-空回合检测与可见化.md` §十一。
33
+
34
+ **除空回合外全程静默,所以「确认它在工作」只能靠日志。** 前两类命中时只在轨迹页留一行;不命中时一个字都不说——「装了没有」「阈值生效没有」「这一步为什么没提醒」三个问题原本都无从回答,只能靠改配置去试。因此它在 `src/index.ts` 的 `apply` 里留两行痕:
29
35
 
30
36
  ```
31
- [dsh-allostasis] loaded · driftThreshold=0.15 repetitionThreshold=0.5 consecutiveSteps=2
37
+ [dsh-allostasis] loaded · driftThreshold=0.15 repetitionThreshold=0.5 loopWindow=2/5
32
38
  [dsh-allostasis] turn 8 step 11 · drift=chinese funcDensity=0.6% chars=1764
33
39
  · repetition=normal units=29 ratio=0%
34
40
  ```
35
41
 
36
- 第一行 `info`、每个进程一次,报的是**生效阈值**(`Config` 的解析结果,不是代码里的缺省常量)。第二行 `debug`、**每一步判定一行**:两类判据的三态结论加度量。默认静默,排查时打开即可——不必为了看一眼判定结果去动阈值。其中 `units` 是切分后的单元数,**低于 12 判 `insufficient`**(样本不足不下结论),此时 `ratio` 仍会给出,只是不参与判定。
42
+ 第一行 `info`、每个进程一次,报的是**生效阈值**(`Config` 的解析结果,不是代码里的缺省常量)。第二行 `debug`、**每一步判定一行**:两类判据的三态结论加度量。默认静默,排查时打开即可——不必为了看一眼判定结果去动阈值。其中 `units` 是切分后**计入统计的实义单元数**(滤掉代码围栏与纯符号,见「判据」段),**低于 12 判 `insufficient`**(样本不足不下结论),此时 `ratio` 仍会给出,只是不参与判定。
37
43
 
38
44
  判据来源、实测数据与完整设计见 `docs/设计/2026-09-20-应变-设计方案.md`、
39
45
  `docs/设计/2026-09-28-应变二期-退化检测与自动干预.md`。
@@ -60,20 +66,25 @@ repetition (**degeneration reminder**).
60
66
 
61
67
  ### 推理退化
62
68
 
63
- **重复率** = 单条推理按换行与中英句读切分后,**出现 ≥3 次的单元占全部单元的比例**;**单元数 ≥ 12 才判定**,**连续 N 步(默认 2)越线才触发**。
69
+ **重复率** = 单条推理按换行与中英句读切分、滤掉非实义单元后,**出现 ≥3 次的单元占全部单元的比例**;**单元数 ≥ 12 才判定**,**最近 5 步内累计 ≥2 步越线才触发**(窗口跨 turn,不要求连续)。
70
+
71
+ **非实义单元 = 以代码围栏开头的整段,以及不含「至少一个汉字或两个连续拉丁字母」的单元。** 排除它们是因为纯标记在写代码时天然高频:跨会话实测里三个会话的越线全部来自 ` ``` `、`}`、`*`,排除后归零;而真退化会话的峰值反而更高(`64e08c94` 51%→94%、`a8ac8e89` 53%→90%)——噪声单元出局后真实重复的占比更突出。
64
72
 
65
- 实测数据(一份 19 轮 / 416 条助手消息的真实会话):正常期 **0%–35%**、退化期 **48%–92%**,阈值 **0.5** 落在隔离带中段。连续步数不可省——正常期也有单次抖动(实测峰值 35%);代价是延迟,按同一份数据回放会晚约 3 步触发。
73
+ 实测数据(一份 19 轮 / 416 条助手消息的真实会话):正常期 **0%–35%**、退化期 **48%–92%**,阈值 **0.5** 落在隔离带中段。触发条件改用窗口累计后,抖动仍由「≥2 步」挡住,而散布型退化不再漏判——跨会话复核(本机 37 个 ≥3MB 会话)见 `docs/设计/2026-10-01-提醒判据修正与实测方案.md`。
66
74
 
67
75
  **退化与上下文占用脱钩**:同一会话里 26.1% 占用时重复率 84%、67.9% 时 83–92%,压缩到 26.1% 并不降低重复率。所以「调低阈值」治不了它;压缩能否治它取决于力度。完整数据见 `docs/排查/2026-09-28-推理退化与上下文占用脱钩.md`。
68
76
 
69
- 退化触发时除提醒消息外还会 append 一条 `allostasis/degeneration` 事件,记下当时的重复率、阈值与连续步数——判定依据因此可事后重建,而不是只能按现在的脚本重算。
77
+ 退化触发的留痕是 `console.debug` 诊断行(`repetition=` 那一段给出 `units` 与 `ratio`)加上轨迹页那行提醒。**判定结果不落会话日志**——理由与空回合那条同源,见前文。
70
78
 
71
79
  ## 截图
72
80
 
73
- 本插件是**会话行为调节类**:不新增任何按钮、面板或设置项。它每个 step 前读取最近一条思考,判定为语言漂移或推理退化时向请求末尾追加一条提醒,效果体现在模型行为上。
81
+ 空回合提示:浏览器半边从会话事件流自行折叠出「这一轮没有产出内容」,对话页在该回合尾部显示一行说明。它贴着那一段空白,回看历史时也还在原处。
74
82
 
75
- > 按《SSiD 开发手册》§9 截图规范:截图须回答「装完会多出/变成什么」的**入口与面板**。
76
- > 本插件无界面元素(no UI surface),故**不适用**该项要求,改以上述行为效果说明代替。
83
+ | 空回合提示 |
84
+ |---|
85
+ | ![空回合提示](docs/shots/silent-turn-1.png) |
86
+
87
+ 其余两个能力(中文锚定、推理退化提醒)是**提示注入类**:不新增按钮、面板或设置项,每个 step 前读取最近一条思考,判定越线时向请求末尾追加一条提醒,效果体现在模型行为上——那两项按《SSiD 开发手册》§9 走无 UI 插件豁免,只有空回合提示有界面元素。
77
88
 
78
89
  ## Compose
79
90
 
@@ -88,13 +99,17 @@ Installs as a bundle: `dsh plugin --profile <name> add @max-null/dsh-allostasis`
88
99
 
89
100
  ## Config
90
101
 
91
- 三个判定阈值可在 `config` 段覆盖;省略即用括号内的默认值。
102
+ 四个判定阈值加一个空回合档位可在 `config` 段覆盖;省略即用括号内的默认值。
92
103
 
93
104
  | 字段 | 默认 | 含义 |
94
105
  |---|---|---|
95
106
  | `driftThreshold` | `0.15` | 英文功能词密度达到此值即判为漂移 |
96
107
  | `repetitionThreshold` | `0.5` | 推理重复率阈值,取值 0–1 |
97
- | `consecutiveSteps` | `2` | 连续多少步越线才触发退化提醒 |
108
+ | `loopWindowSteps` | `5` | 观察窗口的步数;窗口**跨 turn** 累计 |
109
+ | `loopWindowHits` | `2` | 窗口内需要累计的越线步数。取 3 会漏掉「少而猛」型退化;超过 `loopWindowSteps` 会报错中止——那是一个永不成立的触发条件 |
110
+ | `silentTurn` | `observe` | 空回合档位:`off` 宿主不判定也不干预、`observe` 判定并打一行诊断、`steer` 额外补一次生成 |
111
+
112
+ **档位管不到对话页那行提示**:提示由浏览器半边折叠会话事件流得出,与宿主判据同源但独立于档位——浏览器半边的 `apply` 拿不到插件配置(2026-10-01 实测:在 `cordis.patch.yml` 里配 `silentTurn: off`,宿主读到 `off`,浏览器半边收到空对象)。要完全静默请禁用插件。
98
113
 
99
114
  ```yaml
100
115
  - id: allostasis
package/cordis.patch.yml CHANGED
@@ -2,7 +2,8 @@
2
2
  # `system-prompt` / `agents` / `token-meter` already live in the host (dsh-base);
3
3
  # this adds only the allostasis plugin, which registers its own reminders.
4
4
  #
5
- # `config` 可覆盖三个判定阈值:driftThreshold / repetitionThreshold / consecutiveSteps。
5
+ # `config` 可覆盖四个判定阈值:driftThreshold / repetitionThreshold /
6
+ # loopWindowSteps / loopWindowHits,以及空回合档位 silentTurn。
6
7
  # 字段都可省略,省略即用插件内的默认值——这里刻意不写具体值,免得默认值有两个来源。
7
8
  # 各字段的含义、取值范围与默认值见 README 的「配置」段。
8
9
  - insert:
package/dist/client.js ADDED
@@ -0,0 +1,238 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@max-null/dsh-allostasis",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react_jsx_runtime = require("react/jsx-runtime");
8
+ //#region src/tail.ts
9
+ /**
10
+ * 统计一条助手消息的块构成。
11
+ *
12
+ * `text` 按去掉首尾空白后的长度计入:只含空白的文本块在界面上不产生任何可见内容,
13
+ * 把它算作「说了话」会让判据漏掉真实的空回合。
14
+ * @param turn - 该消息所属的 turn。
15
+ * @param step - 该消息所属的 step。
16
+ * @param content - 助手消息的内容块,按出现顺序。
17
+ * @returns 该消息的产出形态。
18
+ */
19
+ function shapeOf(turn, step, content) {
20
+ let textChars = 0;
21
+ let reasoningChars = 0;
22
+ const blocks = {
23
+ reasoning: 0,
24
+ text: 0,
25
+ toolCalls: 0
26
+ };
27
+ for (const block of content) if (block.type === "reasoning") {
28
+ blocks.reasoning += 1;
29
+ reasoningChars += block.text?.length ?? 0;
30
+ } else if (block.type === "text") {
31
+ blocks.text += 1;
32
+ textChars += block.text?.trim().length ?? 0;
33
+ } else if (block.type === "tool-call") blocks.toolCalls += 1;
34
+ return {
35
+ turn,
36
+ step,
37
+ textChars,
38
+ reasoningChars,
39
+ blocks
40
+ };
41
+ }
42
+ /**
43
+ * 对一次测量下判定。
44
+ *
45
+ * 只有「有助手消息且它没有非空文本」才算空回合。工具调用**不**改变判定:一个以
46
+ * `tool-call` 结尾的完成态回合同样是异常(工具执行完还会再走一步,除非回合已经结束)。
47
+ * @param shape - 末条助手消息的形态;没有助手消息时传 `undefined`。
48
+ * @returns 三态判定。
49
+ */
50
+ function tailVerdict(shape) {
51
+ if (shape === void 0) return "unknown";
52
+ return shape.textChars > 0 ? "spoke" : "silent";
53
+ }
54
+ //#endregion
55
+ //#region src/client/silent-turn.ts
56
+ /** Turn 数据键;同时用作 Definition 的 `kind`。 */
57
+ const SILENT_TURN_KEY = "allostasis-silent";
58
+ /**
59
+ * 对一个已收尾的 Turn 下判定。
60
+ * @param turn - 收尾的回合号。
61
+ * @param reason - `turn/end` 记录的收尾原因。
62
+ * @param tail - 本 Turn 末条助手消息的形态;没有助手消息时为 null。
63
+ * @returns 命中时渲染端要读到的事实,否则 null。
64
+ */
65
+ function silentData(turn, reason, tail) {
66
+ if (reason.kind !== "completed" || tail === null) return null;
67
+ if (tailVerdict(tail) !== "silent") return null;
68
+ return {
69
+ turn,
70
+ textChars: tail.textChars,
71
+ reasoningChars: tail.reasoningChars
72
+ };
73
+ }
74
+ /**
75
+ * 累积空回合判定。
76
+ *
77
+ * `match` 只读当前事件:`turn/start` 开一个 Turn,之后两条更新各自折进 State。历史窗口从
78
+ * 中间加载时 `turn/start` 可能不在窗口里,那种情况下本 Definition 不启动——没有开头就
79
+ * 无从知道这一轮是怎么开始的。
80
+ */
81
+ const silentTurnDefinition = {
82
+ kind: SILENT_TURN_KEY,
83
+ match: (event) => {
84
+ if (event.type === "turn/start") return {
85
+ id: String(event.data.turn),
86
+ role: "start"
87
+ };
88
+ if (event.type === "assistant/message") return {
89
+ id: String(event.data.turn),
90
+ role: "update"
91
+ };
92
+ if (event.type === "turn/end") return {
93
+ id: String(event.data.turn),
94
+ role: "update"
95
+ };
96
+ return null;
97
+ },
98
+ start: (_context, match) => {
99
+ if (match.event.type !== "turn/start") throw new Error("allostasis-silent start requires turn/start");
100
+ return {
101
+ turn: match.event.data.turn,
102
+ tail: null,
103
+ found: null
104
+ };
105
+ },
106
+ update: (context, match) => {
107
+ const { event } = match;
108
+ if (event.type === "assistant/message") return {
109
+ ...context.state,
110
+ turn: event.data.turn,
111
+ tail: shapeOf(event.data.turn, event.data.step, event.data.message.content)
112
+ };
113
+ if (event.type === "turn/end") return {
114
+ ...context.state,
115
+ turn: event.data.turn,
116
+ found: silentData(event.data.turn, event.data.reason, context.state.tail)
117
+ };
118
+ return context.state;
119
+ },
120
+ buildLocationData: (context, scope, previous) => {
121
+ const { state } = context;
122
+ if (scope !== "turn" || state === void 0 || state.found === null) return null;
123
+ if (previous?.kind === "turn" && previous.turn === state.turn && previous.key === "allostasis-silent" && previous.value === state.found) return previous;
124
+ return {
125
+ kind: "turn",
126
+ turn: state.turn,
127
+ key: SILENT_TURN_KEY,
128
+ value: state.found
129
+ };
130
+ }
131
+ };
132
+ //#endregion
133
+ //#region src/client/SilentTurnNotice.tsx
134
+ /**
135
+ * 渲染本 Turn 的空回合提示;该 Turn 没有命中判定时什么都不渲染。
136
+ *
137
+ * 每个 list 条目都会对每一轮渲染一次,所以判定在这里收窄到自己的那两个键上。
138
+ * @param props - 回合尾部 owner 货币与本地化座位。
139
+ * @returns 提示条,或 `null`。
140
+ */
141
+ function SilentTurnNotice({ turn, t }) {
142
+ const data = turn.data.get(SILENT_TURN_KEY);
143
+ if (data === void 0) return null;
144
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
145
+ style: wrap,
146
+ role: "status",
147
+ "data-allostasis-silent-turn": data.turn,
148
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
149
+ style: headline,
150
+ children: t("silent.headline")
151
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
152
+ style: hint,
153
+ children: data.reasoningChars > 0 ? t("silent.hintReasoned") : t("silent.hintEmpty")
154
+ })]
155
+ });
156
+ }
157
+ const wrap = {
158
+ display: "flex",
159
+ flexDirection: "column",
160
+ gap: "2px",
161
+ margin: "6px 0 2px",
162
+ padding: "8px 12px",
163
+ borderLeft: "2px solid var(--dsw-alias-label-caption)",
164
+ borderRadius: "var(--dsw-radius-md)",
165
+ background: "var(--dsw-alias-bg-overlay)",
166
+ color: "var(--dsw-alias-label-secondary)",
167
+ fontFamily: "var(--dsw-font-family)",
168
+ fontSize: "12px",
169
+ lineHeight: "18px"
170
+ };
171
+ const headline = {
172
+ color: "var(--dsw-alias-label-primary)",
173
+ fontWeight: 600
174
+ };
175
+ const hint = { opacity: .85 };
176
+ //#endregion
177
+ //#region src/client/locales.ts
178
+ /**
179
+ * 浏览器半边的文案字典。
180
+ *
181
+ * 产品可见字符串一律走这里,组件不内联文案(DSH 的 `verify-client-ui-i18n` 按来源
182
+ * 归属校验)。`LocaleNamespaceMap` 里登记的是 `keyof typeof en`,两套字典的键必须一致。
183
+ *
184
+ * 措辞对着**用户此刻的困惑**写:他看到的是空白,所以先说明发生了什么,再给一条出路。
185
+ * 不说「模型坏了」——那是归因,而这里只知道「这一轮没有产出」。
186
+ * @module @max-null/dsh-allostasis/client/locales
187
+ */
188
+ /** 本插件在浏览器半边占用的 locale 命名空间。 */
189
+ const NS = "allostasis";
190
+ /** 中文文案。 */
191
+ const zh = {
192
+ /** 一行叙述:这条提醒说的是什么。 */
193
+ "silent.headline": "这一轮结束时模型没有产出内容",
194
+ /** 末步只生成了推理时的说明:给出可执行的下一步。 */
195
+ "silent.hintReasoned": "最后一步只生成了推理就停下了。可以再问一句让它接着说完,或直接看上面的操作记录。",
196
+ /** 末步什么块都没有时的说明。 */
197
+ "silent.hintEmpty": "最后一步没有产出任何内容。可以重问一次,或换一种说法。"
198
+ };
199
+ /** 英文文案;键与 {@link zh} 一一对应。 */
200
+ const en = {
201
+ "silent.headline": "This turn ended with no visible output",
202
+ "silent.hintReasoned": "The last step stopped after reasoning. Ask again to let it finish the thought, or read the operation log above.",
203
+ "silent.hintEmpty": "The last step produced nothing at all. Ask again, or rephrase the request."
204
+ };
205
+ //#endregion
206
+ //#region src/client/index.tsx
207
+ /** 本插件在浏览器半边的包名,用作诊断行的前缀。 */
208
+ const PLUGIN_ID = "@max-null/dsh-allostasis";
209
+ /** 需要槽位注册表、文案服务与 Conversation 的事件注册表。 */
210
+ const inject = [
211
+ "slots",
212
+ "locale",
213
+ "uiConversation"
214
+ ];
215
+ /**
216
+ * 登记文案与回合尾部座位。
217
+ * @param ctx - 浏览器半边上下文。
218
+ */
219
+ function apply(ctx) {
220
+ console.info(`[${PLUGIN_ID}] client applied`);
221
+ ctx.effect(() => ctx.locale.register(NS, {
222
+ zh,
223
+ en
224
+ }), "dsh-allostasis: 文案字典");
225
+ ctx.effect(() => ctx.uiConversation.events.register(silentTurnDefinition), "dsh-allostasis: 空回合投影");
226
+ ctx.slots.inject("conversation.chat.turnTail", () => ctx.slots.register({
227
+ name: "conversation.chat.turnTail",
228
+ id: SILENT_TURN_KEY,
229
+ order: 10,
230
+ locale: NS
231
+ }, SilentTurnNotice));
232
+ }
233
+ //#endregion
234
+ exports.apply = apply;
235
+ exports.inject = inject;
236
+ return module.exports;
237
+ }
238
+ });
package/dist/config.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * 插件的可配置面。
3
3
  *
4
- * 只暴露**判定阈值**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务类型
5
- * 与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
- * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)
7
- * 不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
4
+ * 只暴露**判定阈值与档位**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务
5
+ * 类型与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
+ * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)与
7
+ * 实义单元规则不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
8
8
  *
9
9
  * **schema 只管形式,缺省与范围在 `resolveConfig`**:两处各管一半是有意的。schema 用
10
10
  * `.default()` 会让输出类型变成 `number | Volatile<number>`(默认值允许是动态函数),
@@ -15,6 +15,7 @@
15
15
  * @module @max-null/dsh-allostasis/config
16
16
  */
17
17
  import Schema from '@deepseek-ai/schemastery';
18
+ import { type SilentTurnMode } from './tail.ts';
18
19
  /**
19
20
  * 插件配置,与同名 schemastery schema 一起由 Loader 校验。
20
21
  *
@@ -25,8 +26,30 @@ export interface Config {
25
26
  driftThreshold?: number;
26
27
  /** 重复率阈值,0–1(默认 0.5)。取 1 等于事实上关闭退化提醒。 */
27
28
  repetitionThreshold?: number;
28
- /** 连续多少步越线才触发退化提醒(默认 2)。取 1 会跟着正常期的单次抖动误报。 */
29
- consecutiveSteps?: number;
29
+ /** 观察窗口的大小,以步为单位(默认 5)。窗口内累计越线达标即触发。 */
30
+ loopWindowSteps?: number;
31
+ /**
32
+ * 窗口内需要累计的越线步数(默认 2)。
33
+ *
34
+ * 取 3 会漏掉「少而猛」型退化——单条推理重复率很高但只发生两次的那种(实测样本
35
+ * `8fa3b15e`,峰值 76%、只有 2 次语义越线)。取 1 会跟着正常期的单次抖动误报。
36
+ */
37
+ loopWindowHits?: number;
38
+ /**
39
+ * 空回合兜底的档位(默认 `observe`)。
40
+ *
41
+ * `off` 宿主不判定也不干预;`observe` 判定并打一行诊断;`steer` 在判定之外追加一次
42
+ * 补生成请求。补生成受两条边界约束:同一回合至多一次(`throttle.ts`),取消的回合与
43
+ * 子代理会话不触发(见 `index.ts` 的 `installSilentTurn`)。
44
+ *
45
+ * **档位管不到对话页那行提示**:提示由浏览器半边折叠会话事件流得出,与宿主判据同源
46
+ * 但独立于档位——浏览器半边的 `apply` 拿不到插件配置(2026-10-01 实测)。要完全静默
47
+ * 就禁用插件。
48
+ *
49
+ * **默认取 `observe` 而不是 `steer`**:补生成能不能改变采样轨迹尚未验证
50
+ * (设计文档 §六.1),先让它只观测、由使用者显式打开。
51
+ */
52
+ silentTurn?: SilentTurnMode;
30
53
  }
31
54
  /** {@link Config} 经校验后的形态:字段齐全,可直接参与判定。 */
32
55
  export interface ResolvedConfig {
@@ -34,8 +57,12 @@ export interface ResolvedConfig {
34
57
  readonly driftThreshold: number;
35
58
  /** 见 {@link Config.repetitionThreshold}。 */
36
59
  readonly repetitionThreshold: number;
37
- /** 见 {@link Config.consecutiveSteps}。 */
38
- readonly consecutiveSteps: number;
60
+ /** 见 {@link Config.loopWindowSteps}。 */
61
+ readonly loopWindowSteps: number;
62
+ /** 见 {@link Config.loopWindowHits}。 */
63
+ readonly loopWindowHits: number;
64
+ /** 见 {@link Config.silentTurn}。 */
65
+ readonly silentTurn: SilentTurnMode;
39
66
  }
40
67
  /** Loader 用于校验 `cordis.patch.yml` 里 `config` 段的 schema。 */
41
68
  export declare const Config: Schema<Config>;
@@ -44,6 +71,9 @@ export declare const Config: Schema<Config>;
44
71
  *
45
72
  * 范围约束不交给 schema 的 `.min()/.max()`:那样报错只会说「number」,而说出该字段的
46
73
  * 合理区间与收到的实际值,正是 fail-loud 的全部价值。
74
+ *
75
+ * `loopWindowHits` 超过 `loopWindowSteps` 时报错而不是静默夹取:那是一个**永不成立**的
76
+ * 触发条件(窗口装不下所需命中数),静默接受等于关掉退化提醒而不说。
47
77
  * @param config - Loader 传入的配置;未配置时传 `undefined` 或 `{}`。
48
78
  * @returns 字段齐全且已校验的配置。
49
79
  */
package/dist/config.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * 插件的可配置面。
3
3
  *
4
- * 只暴露**判定阈值**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务类型
5
- * 与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
- * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)
7
- * 不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
4
+ * 只暴露**判定阈值与档位**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务
5
+ * 类型与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
+ * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)与
7
+ * 实义单元规则不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
8
8
  *
9
9
  * **schema 只管形式,缺省与范围在 `resolveConfig`**:两处各管一半是有意的。schema 用
10
10
  * `.default()` 会让输出类型变成 `number | Volatile<number>`(默认值允许是动态函数),
@@ -16,12 +16,15 @@
16
16
  */
17
17
  import Schema from '@deepseek-ai/schemastery';
18
18
  import { DRIFT_THRESHOLD } from "./drift.js";
19
- import { CONSECUTIVE_STEPS, REPETITION_THRESHOLD } from "./repetition.js";
19
+ import { LOOP_WINDOW_HITS, LOOP_WINDOW_STEPS, REPETITION_THRESHOLD } from "./repetition.js";
20
+ import { SILENT_TURN_MODE, SILENT_TURN_MODES } from "./tail.js";
20
21
  /** Loader 用于校验 `cordis.patch.yml` 里 `config` 段的 schema。 */
21
22
  export const Config = Schema.object({
22
23
  driftThreshold: Schema.number(),
23
24
  repetitionThreshold: Schema.number(),
24
- consecutiveSteps: Schema.number(),
25
+ loopWindowSteps: Schema.number(),
26
+ loopWindowHits: Schema.number(),
27
+ silentTurn: Schema.union(SILENT_TURN_MODES),
25
28
  });
26
29
  /**
27
30
  * 校验一个 0–1 的阈值字段。
@@ -35,20 +38,51 @@ function ratio(field, value) {
35
38
  }
36
39
  return value;
37
40
  }
41
+ /**
42
+ * 校验一个正整数步数字段。
43
+ * @param field - 字段名,用于报错文案。
44
+ * @param value - 字段值。
45
+ * @returns 校验通过的原值。
46
+ */
47
+ function steps(field, value) {
48
+ if (!Number.isInteger(value) || value < 1) {
49
+ throw new Error(`dsh-allostasis: \`${field}\` must be an integer >= 1, got ${value}`);
50
+ }
51
+ return value;
52
+ }
53
+ /**
54
+ * 校验一个枚举字段。
55
+ * @param field - 字段名,用于报错文案。
56
+ * @param value - 字段值。
57
+ * @param allowed - 允许的取值。
58
+ * @returns 校验通过的原值。
59
+ */
60
+ function oneOf(field, value, allowed) {
61
+ if (!allowed.includes(value)) {
62
+ throw new Error(`dsh-allostasis: \`${field}\` must be one of ${allowed.join(' | ')}, got ${String(value)}`);
63
+ }
64
+ return value;
65
+ }
38
66
  /**
39
67
  * 校验配置并补齐缺省值。
40
68
  *
41
69
  * 范围约束不交给 schema 的 `.min()/.max()`:那样报错只会说「number」,而说出该字段的
42
70
  * 合理区间与收到的实际值,正是 fail-loud 的全部价值。
71
+ *
72
+ * `loopWindowHits` 超过 `loopWindowSteps` 时报错而不是静默夹取:那是一个**永不成立**的
73
+ * 触发条件(窗口装不下所需命中数),静默接受等于关掉退化提醒而不说。
43
74
  * @param config - Loader 传入的配置;未配置时传 `undefined` 或 `{}`。
44
75
  * @returns 字段齐全且已校验的配置。
45
76
  */
46
77
  export function resolveConfig(config = {}) {
47
78
  const driftThreshold = ratio('driftThreshold', config.driftThreshold ?? DRIFT_THRESHOLD);
48
79
  const repetitionThreshold = ratio('repetitionThreshold', config.repetitionThreshold ?? REPETITION_THRESHOLD);
49
- const consecutiveSteps = config.consecutiveSteps ?? CONSECUTIVE_STEPS;
50
- if (!Number.isInteger(consecutiveSteps) || consecutiveSteps < 1) {
51
- throw new Error(`dsh-allostasis: \`consecutiveSteps\` must be an integer >= 1, got ${consecutiveSteps}`);
80
+ const loopWindowSteps = steps('loopWindowSteps', config.loopWindowSteps ?? LOOP_WINDOW_STEPS);
81
+ const loopWindowHits = steps('loopWindowHits', config.loopWindowHits ?? LOOP_WINDOW_HITS);
82
+ if (loopWindowHits > loopWindowSteps) {
83
+ throw new Error(`dsh-allostasis: \`loopWindowHits\` (${loopWindowHits}) cannot exceed`
84
+ + ` \`loopWindowSteps\` (${loopWindowSteps}) — that trigger can never fire`);
52
85
  }
53
- return { driftThreshold, repetitionThreshold, consecutiveSteps };
86
+ const silentTurn = oneOf('silentTurn', config.silentTurn ?? SILENT_TURN_MODE, SILENT_TURN_MODES);
87
+ return { driftThreshold, repetitionThreshold, loopWindowSteps, loopWindowHits, silentTurn };
54
88
  }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,29 @@
1
1
  /**
2
2
  * 应变(allostasis):会话状态的自我调节。
3
3
  *
4
- * 两期能力都落在同一个 `agent/pre-step` 上,因为它们读的是同一份输入——最近一条思考:
5
- *
6
- * · **一期 · 中文锚定**:判定为语言漂移(英文功能词密度越线)就追加一条中文锚定消息。
7
- * · **二期 · 退化提醒**:判定为推理退化(重复率越线且连续若干步成立)就追加减速提醒,
8
- * 并向会话日志 append 一条 `allostasis/degeneration` 记录当时的判定依据。
4
+ * 三个能力落在两个挂载点上:
5
+ *
6
+ * · **一期 · 中文锚定**(`agent/pre-step`):判定为语言漂移(英文功能词密度越线)就追加
7
+ * 一条中文锚定消息。
8
+ * · **二期 · 退化提醒**(`agent/pre-step`):判定为推理退化(窗口内累计若干步重复率越线)
9
+ * 就追加减速提醒。
10
+ * · **空回合检测**(`agent/turn-stopping`):回合收尾时末条助手消息没有非空文本即判定成立,
11
+ * `steer` 档位下追加一次补生成。
12
+ *
13
+ * **判定结果不写会话日志**。会话日志的事件词汇表 `KNOWN_SESSION_EVENT_TYPES` 由内核在构建期
14
+ * 生成,下游插件的事件类型不在其中;`Session.append()` 的封装
15
+ * (`packages/core/session/src/index.ts:744-750`)没有 `ignorable` 通道,而
16
+ * `SessionEvent.ignorable` 的契约要求这类「丢失不影响重建」的记录必须自带该标记
17
+ * (`packages/core/session/src/types.ts:501-511`)。缺标记的自定义类型会让**整份**日志在下次
18
+ * 加载时被拒读(`packages/session/session-persistence/src/storage-contract.ts:75-80`)——
19
+ * 2026-10-01 实测:dev 与装版各有会话因此打不开。判定输入本来就可重放(推理原文在
20
+ * `reasoning-chunks.texts`、回合边界在 `turn/start` / `turn/end`),留痕因此改由本模块的
21
+ * `console.debug` 诊断行与浏览器半边的提示承担;浏览器半边自行从事件流折叠判定,
22
+ * 见 `client/silent-turn.ts`。
23
+ *
24
+ * 前两个共用 `agent/pre-step`,因为它们读同一份输入——最近一条思考。空回合读的是另一种
25
+ * 输入(这一轮最终产出了什么),而且**它之后没有下一步**,`pre-step` 不会被再次调用,
26
+ * 判定只能落在回合收尾。
9
27
  *
10
28
  * **平时不出现、异常时才出现**——按需出现是它作为信号的前提。但异常持续时也不每个 step
11
29
  * 都提醒:同一 turn 至多一次,见 `throttle.ts`。两类提醒各有独立的一份节流状态:判据无关,
@@ -13,17 +31,19 @@
13
31
  *
14
32
  * 为什么不挂到 system prompt 前缀上:那是 `dsh-chinese-thinking` 的位置,它作为基线
15
33
  * 永远在场。而基线在长会话里会失效(固定前缀离输出最远,语言模式受近因支配)。本插件
16
- * 补的是「**你正在漂移 / 正在打转**」这个纠偏信号,因此必须落在近因位置。
34
+ * 补的是「**你正在漂移 / 正在打转 / 这一轮什么也没说**」这类纠偏信号,因此必须落在近因
35
+ * 或收尾位置。
17
36
  *
18
37
  * 为什么用 `agent/pre-step` 而不是 `systemPrompt.context()`:前者直接给出 `turn` /
19
38
  * `step`,且追加的是一条独立消息,落点比快照里的一个段更靠后。用法先例见官方
20
39
  * `packages/context/time-context/src/index.ts:180-220`。
21
40
  *
22
- * 阈值全部走 `config.ts` 的配置面。**压缩动作按设计方案 §九 暂缓**:数据指向退化样本
23
- * 散布在整段退化区间,而 `compactRegion` 只压指定区间,能否打断循环尚未验证。
41
+ * 阈值与档位全部走 `config.ts` 的配置面。**压缩动作按设计方案 §九 暂缓**:数据指向退化
42
+ * 样本散布在整段退化区间,而 `compactRegion` 只压指定区间,能否打断循环尚未验证。
24
43
  *
25
44
  * 设计出处:`docs/设计/2026-09-20-应变-设计方案.md`、
26
- * `docs/设计/2026-09-28-应变二期-退化检测与自动干预.md`
45
+ * `docs/设计/2026-09-28-应变二期-退化检测与自动干预.md`、
46
+ * `docs/设计/2026-09-30-空回合检测与可见化.md`
27
47
  * @module @max-null/dsh-allostasis
28
48
  */
29
49
  import type { Context } from '@deepseek-ai/cordis';
@@ -31,7 +51,7 @@ import { anchorText } from './anchor.ts';
31
51
  import { Config } from './config.ts';
32
52
  import { name } from './name.ts';
33
53
  export { anchorText, Config, name };
34
- /** 需要 `agents` 服务来接收 `agent/pre-step` 事件。 */
54
+ /** 需要 `agents` 服务来接收 `agent/pre-step` 与 `agent/turn-stopping` 事件。 */
35
55
  export declare const inject: string[];
36
56
  /**
37
57
  * 注册 pre-step 监听器;监听器随 `ctx` 生命周期销毁。