@max-null/dsh-allostasis 0.2.0 → 0.2.1

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,15 +19,19 @@ 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
37
  [dsh-allostasis] loaded · driftThreshold=0.15 repetitionThreshold=0.5 consecutiveSteps=2
@@ -66,14 +72,17 @@ repetition (**degeneration reminder**).
66
72
 
67
73
  **退化与上下文占用脱钩**:同一会话里 26.1% 占用时重复率 84%、67.9% 时 83–92%,压缩到 26.1% 并不降低重复率。所以「调低阈值」治不了它;压缩能否治它取决于力度。完整数据见 `docs/排查/2026-09-28-推理退化与上下文占用脱钩.md`。
68
74
 
69
- 退化触发时除提醒消息外还会 append 一条 `allostasis/degeneration` 事件,记下当时的重复率、阈值与连续步数——判定依据因此可事后重建,而不是只能按现在的脚本重算。
75
+ 退化触发的留痕是 `console.debug` 诊断行(`repetition=` 那一段给出 `units` 与 `ratio`)加上轨迹页那行提醒。**判定结果不落会话日志**——理由与空回合那条同源,见前文。
70
76
 
71
77
  ## 截图
72
78
 
73
- 本插件是**会话行为调节类**:不新增任何按钮、面板或设置项。它每个 step 前读取最近一条思考,判定为语言漂移或推理退化时向请求末尾追加一条提醒,效果体现在模型行为上。
79
+ 空回合提示:浏览器半边从会话事件流自行折叠出「这一轮没有产出内容」,对话页在该回合尾部显示一行说明。它贴着那一段空白,回看历史时也还在原处。
74
80
 
75
- > 按《SSiD 开发手册》§9 截图规范:截图须回答「装完会多出/变成什么」的**入口与面板**。
76
- > 本插件无界面元素(no UI surface),故**不适用**该项要求,改以上述行为效果说明代替。
81
+ | 空回合提示 |
82
+ |---|
83
+ | ![空回合提示](docs/shots/silent-turn-1.png) |
84
+
85
+ 其余两个能力(中文锚定、推理退化提醒)是**提示注入类**:不新增按钮、面板或设置项,每个 step 前读取最近一条思考,判定越线时向请求末尾追加一条提醒,效果体现在模型行为上——那两项按《SSiD 开发手册》§9 走无 UI 插件豁免,只有空回合提示有界面元素。
77
86
 
78
87
  ## Compose
79
88
 
@@ -88,13 +97,16 @@ Installs as a bundle: `dsh plugin --profile <name> add @max-null/dsh-allostasis`
88
97
 
89
98
  ## Config
90
99
 
91
- 三个判定阈值可在 `config` 段覆盖;省略即用括号内的默认值。
100
+ 三个判定阈值加一个空回合档位可在 `config` 段覆盖;省略即用括号内的默认值。
92
101
 
93
102
  | 字段 | 默认 | 含义 |
94
103
  |---|---|---|
95
104
  | `driftThreshold` | `0.15` | 英文功能词密度达到此值即判为漂移 |
96
105
  | `repetitionThreshold` | `0.5` | 推理重复率阈值,取值 0–1 |
97
106
  | `consecutiveSteps` | `2` | 连续多少步越线才触发退化提醒 |
107
+ | `silentTurn` | `observe` | 空回合档位:`off` 宿主不判定也不干预、`observe` 判定并打一行诊断、`steer` 额外补一次生成 |
108
+
109
+ **档位管不到对话页那行提示**:提示由浏览器半边折叠会话事件流得出,与宿主判据同源但独立于档位——浏览器半边的 `apply` 拿不到插件配置(2026-10-01 实测:在 `cordis.patch.yml` 里配 `silentTurn: off`,宿主读到 `off`,浏览器半边收到空对象)。要完全静默请禁用插件。
98
110
 
99
111
  ```yaml
100
112
  - id: allostasis
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,8 +1,8 @@
1
1
  /**
2
2
  * 插件的可配置面。
3
3
  *
4
- * 只暴露**判定阈值**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务类型
5
- * 与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
4
+ * 只暴露**判定阈值与档位**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务
5
+ * 类型与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
6
  * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)
7
7
  * 不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
8
8
  *
@@ -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
  *
@@ -27,6 +28,21 @@ export interface Config {
27
28
  repetitionThreshold?: number;
28
29
  /** 连续多少步越线才触发退化提醒(默认 2)。取 1 会跟着正常期的单次抖动误报。 */
29
30
  consecutiveSteps?: number;
31
+ /**
32
+ * 空回合兜底的档位(默认 `observe`)。
33
+ *
34
+ * `off` 宿主不判定也不干预;`observe` 判定并打一行诊断;`steer` 在判定之外追加一次
35
+ * 补生成请求。补生成受两条边界约束:同一回合至多一次(`throttle.ts`),取消的回合与
36
+ * 子代理会话不触发(见 `index.ts` 的 `installSilentTurn`)。
37
+ *
38
+ * **档位管不到对话页那行提示**:提示由浏览器半边折叠会话事件流得出,与宿主判据同源
39
+ * 但独立于档位——浏览器半边的 `apply` 拿不到插件配置(2026-10-01 实测)。要完全静默
40
+ * 就禁用插件。
41
+ *
42
+ * **默认取 `observe` 而不是 `steer`**:补生成能不能改变采样轨迹尚未验证
43
+ * (设计文档 §六.1),先让它只观测、由使用者显式打开。
44
+ */
45
+ silentTurn?: SilentTurnMode;
30
46
  }
31
47
  /** {@link Config} 经校验后的形态:字段齐全,可直接参与判定。 */
32
48
  export interface ResolvedConfig {
@@ -36,6 +52,8 @@ export interface ResolvedConfig {
36
52
  readonly repetitionThreshold: number;
37
53
  /** 见 {@link Config.consecutiveSteps}。 */
38
54
  readonly consecutiveSteps: number;
55
+ /** 见 {@link Config.silentTurn}。 */
56
+ readonly silentTurn: SilentTurnMode;
39
57
  }
40
58
  /** Loader 用于校验 `cordis.patch.yml` 里 `config` 段的 schema。 */
41
59
  export declare const Config: Schema<Config>;
package/dist/config.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 插件的可配置面。
3
3
  *
4
- * 只暴露**判定阈值**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务类型
5
- * 与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
4
+ * 只暴露**判定阈值与档位**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务
5
+ * 类型与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
6
  * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)
7
7
  * 不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
8
8
  *
@@ -17,11 +17,13 @@
17
17
  import Schema from '@deepseek-ai/schemastery';
18
18
  import { DRIFT_THRESHOLD } from "./drift.js";
19
19
  import { CONSECUTIVE_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
25
  consecutiveSteps: Schema.number(),
26
+ silentTurn: Schema.union(SILENT_TURN_MODES),
25
27
  });
26
28
  /**
27
29
  * 校验一个 0–1 的阈值字段。
@@ -35,6 +37,19 @@ function ratio(field, value) {
35
37
  }
36
38
  return value;
37
39
  }
40
+ /**
41
+ * 校验一个枚举字段。
42
+ * @param field - 字段名,用于报错文案。
43
+ * @param value - 字段值。
44
+ * @param allowed - 允许的取值。
45
+ * @returns 校验通过的原值。
46
+ */
47
+ function oneOf(field, value, allowed) {
48
+ if (!allowed.includes(value)) {
49
+ throw new Error(`dsh-allostasis: \`${field}\` must be one of ${allowed.join(' | ')}, got ${String(value)}`);
50
+ }
51
+ return value;
52
+ }
38
53
  /**
39
54
  * 校验配置并补齐缺省值。
40
55
  *
@@ -50,5 +65,6 @@ export function resolveConfig(config = {}) {
50
65
  if (!Number.isInteger(consecutiveSteps) || consecutiveSteps < 1) {
51
66
  throw new Error(`dsh-allostasis: \`consecutiveSteps\` must be an integer >= 1, got ${consecutiveSteps}`);
52
67
  }
53
- return { driftThreshold, repetitionThreshold, consecutiveSteps };
68
+ const silentTurn = oneOf('silentTurn', config.silentTurn ?? SILENT_TURN_MODE, SILENT_TURN_MODES);
69
+ return { driftThreshold, repetitionThreshold, consecutiveSteps, silentTurn };
54
70
  }
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` 生命周期销毁。
package/dist/index.js CHANGED
@@ -1,11 +1,29 @@
1
1
  /**
2
2
  * 应变(allostasis):会话状态的自我调节。
3
3
  *
4
- * 两期能力都落在同一个 `agent/pre-step` 上,因为它们读的是同一份输入——最近一条思考:
4
+ * 三个能力落在两个挂载点上:
5
5
  *
6
- * · **一期 · 中文锚定**:判定为语言漂移(英文功能词密度越线)就追加一条中文锚定消息。
7
- * · **二期 · 退化提醒**:判定为推理退化(重复率越线且连续若干步成立)就追加减速提醒,
8
- * 并向会话日志 append 一条 `allostasis/degeneration` 记录当时的判定依据。
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,30 +31,32 @@
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 { anchorText } from "./anchor.js";
30
50
  import { Config, resolveConfig } from "./config.js";
31
51
  import { measureThinking, verdict } from "./drift.js";
32
- import { degenerationEvent } from "./events.js";
33
- import { degenerationText, pluginNotice } from "./messages.js";
52
+ import { degenerationText, pluginNotice, silentTurnText } from "./messages.js";
34
53
  import { name } from "./name.js";
35
54
  import { measureRepetition, repetitionVerdict, trackLoop } from "./repetition.js";
55
+ import { measureTail, tailSamples, tailVerdict } from "./tail.js";
36
56
  import { latestThinking } from "./thinking.js";
37
57
  import { admitPerTurn } from "./throttle.js";
38
58
  export { anchorText, Config, name };
39
- /** 需要 `agents` 服务来接收 `agent/pre-step` 事件。 */
59
+ /** 需要 `agents` 服务来接收 `agent/pre-step` 与 `agent/turn-stopping` 事件。 */
40
60
  export const inject = ['agents'];
41
61
  /**
42
62
  * 组装一期的语言漂移提醒。
@@ -57,7 +77,7 @@ function driftMessage(session, sample, resolved, throttles) {
57
77
  return pluginNotice(anchorText(sample.turn, sample.step, metrics, advanced.count), `语言漂移 · turn ${sample.turn} · 第 ${advanced.count} 次`);
58
78
  }
59
79
  /**
60
- * 组装二期的推理退化提醒,并落下判定依据。
80
+ * 组装二期的推理退化提醒。
61
81
  *
62
82
  * 追踪状态**每步都推进**,包括被节流挡住的那几步:计数描述的是退化本身持续了多久,
63
83
  * 与「这一步有没有说出口」无关。
@@ -79,16 +99,64 @@ function loopMessage(session, sample, resolved, trackers, throttles) {
79
99
  if (advanced === undefined)
80
100
  return undefined;
81
101
  throttles.set(session, advanced);
82
- session.append('allostasis/degeneration', degenerationEvent({
83
- turn: sample.turn,
84
- step: sample.step,
85
- metrics,
86
- consecutive: tracked.state.consecutive,
87
- threshold: resolved.repetitionThreshold,
88
- required: resolved.consecutiveSteps,
89
- }));
90
102
  return pluginNotice(degenerationText(sample.turn, sample.step, metrics, advanced.count), `推理退化 · turn ${sample.turn} · 重复率 ${(metrics.ratio * 100).toFixed(0)}%`);
91
103
  }
104
+ /**
105
+ * 注册回合收尾监听:检出空回合;`steer` 档位下追加一次补生成。
106
+ *
107
+ * **为什么整段包 try/catch**:内核的契约测试写明该事件里抛出的异常会让 turn 以 error
108
+ * 结束(`packages/core/agent-loop/tests/contract-regressions.spec.ts:357`,loop 本身
109
+ * 存活)。判据失败不得升级成回合失败——与二期对 `compactRegion` 的要求同源。这里记
110
+ * `warn` 而不是 `debug`:它意味着判据本身坏了,与「这一步判成了什么」不是一类。
111
+ *
112
+ * **为什么要排除取消与子代理**:`aborted` / `interrupted` 回合的沉默是用户中止的预期
113
+ * 结果(实测 22 轮里 12 轮),子代理的回合属于父回复的中间产物——两者都不该计入空回合,
114
+ * 前者由 `signal.aborted` 判、后者由会话头的 `parentSession` 判,两条先例见
115
+ * `@changfenhuang/dsh-genui` 的 `fenceFeedback`。
116
+ *
117
+ * **补生成的记账发生在发送之前**:`turn-stopping` 是可能重入的边界,先记账才能保证同一次
118
+ * 补生成不会被投两遍——与 `fenceFeedback` 同一条约束。
119
+ *
120
+ * **补生成单独包一层 catch**:判定已经成立,补生成失败不该升级成回合失败。
121
+ * @param ctx - 插件上下文。
122
+ * @param resolved - 已校验的配置。
123
+ * @param steers - 补生成的节流状态表,按会话各一份。
124
+ */
125
+ function installSilentTurn(ctx, resolved, steers) {
126
+ ctx.on('agent/turn-stopping', ({ agent, turn, signal }) => {
127
+ try {
128
+ if (resolved.silentTurn === 'off')
129
+ return;
130
+ if (signal.aborted)
131
+ return;
132
+ if (agent.session.header.parentSession !== undefined)
133
+ return;
134
+ const { session } = agent;
135
+ const shape = measureTail(tailSamples(session.snapshotEvents()), turn);
136
+ if (shape === undefined || tailVerdict(shape) !== 'silent')
137
+ return;
138
+ const plan = resolved.silentTurn === 'steer' ? admitPerTurn(steers.get(session), turn) : undefined;
139
+ if (plan !== undefined)
140
+ steers.set(session, plan);
141
+ console.debug(`[${name}] 空回合 · turn ${turn} step ${shape.step}`
142
+ + ` · reasoning ${shape.reasoningChars} 字`
143
+ + ` blocks=reasoning×${shape.blocks.reasoning} text×${shape.blocks.text}`
144
+ + ` tool×${shape.blocks.toolCalls}`
145
+ + ` steered=${plan !== undefined}`);
146
+ if (plan === undefined)
147
+ return;
148
+ try {
149
+ agent.steer(pluginNotice(silentTurnText(turn, shape), `空回合 · turn ${turn} · 补一次生成`));
150
+ }
151
+ catch (error) {
152
+ ctx.logger?.warn?.(`${name}: silent-turn steer failed (${error instanceof Error ? error.message : String(error)})`);
153
+ }
154
+ }
155
+ catch (error) {
156
+ ctx.logger?.warn?.(`${name}: silent-turn check failed (${error instanceof Error ? error.message : String(error)})`);
157
+ }
158
+ });
159
+ }
92
160
  /**
93
161
  * 注册 pre-step 监听器;监听器随 `ctx` 生命周期销毁。
94
162
  *
@@ -104,11 +172,13 @@ export function apply(ctx, config = {}) {
104
172
  // 判定行走 debug,默认静默、排查时打开即可,不必为了看一眼判定去改阈值试。
105
173
  console.info(`[${name}] loaded · driftThreshold=${resolved.driftThreshold}`
106
174
  + ` repetitionThreshold=${resolved.repetitionThreshold}`
107
- + ` consecutiveSteps=${resolved.consecutiveSteps}`);
175
+ + ` consecutiveSteps=${resolved.consecutiveSteps}`
176
+ + ` silentTurn=${resolved.silentTurn}`);
108
177
  /** 每个会话各一份状态;用 WeakMap 以免会话销毁后残留。 */
109
178
  const anchorThrottles = new WeakMap();
110
179
  const loopThrottles = new WeakMap();
111
180
  const loopTrackers = new WeakMap();
181
+ const silentSteers = new WeakMap();
112
182
  ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
113
183
  const decision = await next();
114
184
  if (decision.kind === 'reject' || signal.aborted)
@@ -136,4 +206,5 @@ export function apply(ctx, config = {}) {
136
206
  return decision;
137
207
  return { ...decision, messages: [...decision.messages, ...appended] };
138
208
  }, { prepend: true });
209
+ installSilentTurn(ctx, resolved, silentSteers);
139
210
  }
@@ -20,6 +20,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm';
20
20
  import type { UserMessage } from '@deepseek-ai/dsh-session';
21
21
  import { SOURCE_KIND } from './name.ts';
22
22
  import type { RepetitionMetrics } from './repetition.ts';
23
+ import type { TailShape } from './tail.ts';
23
24
  declare module '@deepseek-ai/dsh-llm' {
24
25
  interface MessageSourceMap {
25
26
  /** 应变注入的即时提醒:语言漂移锚定与推理退化提醒共用这一个生产者身份。 */
@@ -48,3 +49,14 @@ export declare function pluginNotice(text: string, summary: string): UserMessage
48
49
  * @returns 一条退化提醒消息的正文。
49
50
  */
50
51
  export declare function degenerationText(turn: number, step: number, metrics: RepetitionMetrics, reminder?: number): string;
52
+ /**
53
+ * 组装空回合的补生成请求。
54
+ *
55
+ * 措辞对着**用户此刻的处境**写:他看到的是空白,所以先说清发生了什么,再给一条可执行的
56
+ * 出路。两条禁令是必要的——不点明「不要重做工具」,模型很可能把整轮动作再跑一遍,而这一轮
57
+ * 的产出已经不是用户缺的东西了。
58
+ * @param turn - 判定的回合号。
59
+ * @param shape - 该回合末条助手消息的产出形态。
60
+ * @returns 一条补生成请求的正文。
61
+ */
62
+ export declare function silentTurnText(turn: number, shape: TailShape): string;
package/dist/messages.js CHANGED
@@ -65,3 +65,25 @@ export function degenerationText(turn, step, metrics, reminder = 1) {
65
65
  + '现在检查手上已有的信息够不够完成任务——够就直接给结论,'
66
66
  + '不够就换一个与前面不同的动作去取,而不是把同一句话再写一遍。';
67
67
  }
68
+ /**
69
+ * 组装空回合的补生成请求。
70
+ *
71
+ * 措辞对着**用户此刻的处境**写:他看到的是空白,所以先说清发生了什么,再给一条可执行的
72
+ * 出路。两条禁令是必要的——不点明「不要重做工具」,模型很可能把整轮动作再跑一遍,而这一轮
73
+ * 的产出已经不是用户缺的东西了。
74
+ * @param turn - 判定的回合号。
75
+ * @param shape - 该回合末条助手消息的产出形态。
76
+ * @returns 一条补生成请求的正文。
77
+ */
78
+ export function silentTurnText(turn, shape) {
79
+ const produced = shape.reasoningChars > 0
80
+ ? `只生成了推理(${shape.reasoningChars} 字),没有文本`
81
+ : '没有产出任何内容';
82
+ const tools = shape.blocks.toolCalls > 0
83
+ ? `,另有 ${shape.blocks.toolCalls} 个工具调用`
84
+ : ',也没有工具调用';
85
+ return `⚠️ 空回合(应变):turn ${turn} 的最后一步${produced}${tools}。`
86
+ + '用户此刻看到的是一串折叠的操作条,然后什么都没有——他不知道这一轮发生了什么。'
87
+ + '用一两句话补上:这一轮得出的结论、以及下一步需要他做什么。'
88
+ + '不要重做已经执行过的工具调用,也不要复述推理里的过程。';
89
+ }
package/dist/tail.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 回合末步的产出形态判定(空回合检测的判据)。
3
+ *
4
+ * 落点:`assistant/message` 事件的 `message.content` —— 与 `thinking.ts` 读同一个事件的
5
+ * 另一个字段(那边读 `stream` 里的 `reasoning-chunks`,这里读 `content` 的块构成)。
6
+ *
7
+ * 判据 = **本 turn 最后一条助手消息里没有非空 text 块**。
8
+ *
9
+ * 实测依据(2026-09-30,14 天内 1202 个 `completed` 回合):完成态结尾沉默 34 轮
10
+ * (2.8%),其中 **32 轮的末步只有 `reasoning`** —— 判据纯度 94%,误报面小。
11
+ * `aborted` / `interrupted` 的沉默另有 15 轮,但那是用户中止的预期结果,**不在判据内**:
12
+ * 调用方按 `signal.aborted` 排除,见 `index.ts` 的 turn-stopping 监听。
13
+ *
14
+ * 为什么读 `content` 而不是 `stream`:`stream` 里的 `text-chunks` 回答「模型有没有生成过
15
+ * 文本」,`content` 回答「这一步最终产出了什么」。判据要的是后者,前者只在排查时用来
16
+ * 交叉验证(设计文档 §2.2)。
17
+ *
18
+ * 设计出处:`docs/设计/2026-09-30-空回合检测与可见化.md` §二
19
+ * @module @max-null/dsh-allostasis/tail
20
+ */
21
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
22
+ /** 空回合兜底的档位:`off` 不判定,`observe` 判定并留痕,`steer` 额外补一次生成。 */
23
+ export type SilentTurnMode = 'off' | 'observe' | 'steer';
24
+ /** 全部档位,供配置校验枚举。 */
25
+ export declare const SILENT_TURN_MODES: readonly SilentTurnMode[];
26
+ /**
27
+ * 空回合兜底的档位缺省值。
28
+ *
29
+ * `observe` 而不是 `off`:判定与留痕必须先跑起来,否则「这个现象有多频繁、判据准不准」
30
+ * 永远没有数据。补生成(`steer`)要等到有数据说明它值得开之后再加。
31
+ */
32
+ export declare const SILENT_TURN_MODE: SilentTurnMode;
33
+ /** 判定只需要块的类型与文本;用最小形状接收,判据模块因此不依赖会话包的内容类型。 */
34
+ export interface TailBlock {
35
+ /** 块类型:`reasoning` / `text` / `tool-call`。 */
36
+ readonly type: string;
37
+ /** 文本与推理块的正文;工具调用块没有这个字段。 */
38
+ readonly text?: string;
39
+ }
40
+ /** 一条助手消息的产出形态。 */
41
+ export interface TailShape {
42
+ /** 该消息所属的 turn。 */
43
+ readonly turn: number;
44
+ /** 该消息所属的 step。 */
45
+ readonly step: number;
46
+ /** 非空 `text` 块的总字符数。空回合恒为 0。 */
47
+ readonly textChars: number;
48
+ /** `reasoning` 块的总字符数——区分「想了很久没说」与「完全没输出」。 */
49
+ readonly reasoningChars: number;
50
+ /** 按类型的块计数。 */
51
+ readonly blocks: {
52
+ readonly reasoning: number;
53
+ readonly text: number;
54
+ readonly toolCalls: number;
55
+ };
56
+ }
57
+ /**
58
+ * 三态判定。`unknown` 用于「本回合没有助手消息」——比如用户消息刚落下、助手还没产出,
59
+ * 此时既不能说它说了话,也不能说它是空回合。
60
+ */
61
+ export type TailVerdict = 'spoke' | 'silent' | 'unknown';
62
+ /**
63
+ * 统计一条助手消息的块构成。
64
+ *
65
+ * `text` 按去掉首尾空白后的长度计入:只含空白的文本块在界面上不产生任何可见内容,
66
+ * 把它算作「说了话」会让判据漏掉真实的空回合。
67
+ * @param turn - 该消息所属的 turn。
68
+ * @param step - 该消息所属的 step。
69
+ * @param content - 助手消息的内容块,按出现顺序。
70
+ * @returns 该消息的产出形态。
71
+ */
72
+ export declare function shapeOf(turn: number, step: number, content: readonly TailBlock[]): TailShape;
73
+ /** 从会话事件里取出的助手消息样本:判定只读这三个字段。 */
74
+ export interface TailSample {
75
+ /** 该消息所属的 turn。 */
76
+ readonly turn: number;
77
+ /** 该消息所属的 step。 */
78
+ readonly step: number;
79
+ /** 内容块,按出现顺序。 */
80
+ readonly content: readonly TailBlock[];
81
+ }
82
+ /**
83
+ * 把会话事件收窄成判定样本。
84
+ *
85
+ * 与 {@link measureTail} 分成两步,是为了让判定完全落在纯函数上:`SessionEvent` 是
86
+ * 所有事件的联合,拿它当参数就得在测试里伪造整个事件(含 `usage` / `stream`),
87
+ * 或者用类型断言把缺口盖掉。收窄只在这里做一次。
88
+ * @param events - 会话事件,按 seq 升序。
89
+ * @returns 助手消息样本,保持原顺序;其它类型的事件被跳过。
90
+ */
91
+ export declare function tailSamples(events: readonly SessionEvent[]): TailSample[];
92
+ /**
93
+ * 取本 turn **最后一条**助手消息的产出形态。
94
+ *
95
+ * 倒扫而不是取「最后一条样本」:会话里可能夹着别的 turn 的助手消息(中断后的残留、
96
+ * 子代理的投递),而判定要问的是「这一轮结束时用户看到了什么」。
97
+ * @param samples - {@link tailSamples} 的产出。
98
+ * @param turn - 要检查的 turn。
99
+ * @returns 末条助手消息的形态;该 turn 没有助手消息时返回 `undefined`。
100
+ */
101
+ export declare function measureTail(samples: readonly TailSample[], turn: number): TailShape | undefined;
102
+ /**
103
+ * 对一次测量下判定。
104
+ *
105
+ * 只有「有助手消息且它没有非空文本」才算空回合。工具调用**不**改变判定:一个以
106
+ * `tool-call` 结尾的完成态回合同样是异常(工具执行完还会再走一步,除非回合已经结束)。
107
+ * @param shape - 末条助手消息的形态;没有助手消息时传 `undefined`。
108
+ * @returns 三态判定。
109
+ */
110
+ export declare function tailVerdict(shape: TailShape | undefined): TailVerdict;
package/dist/tail.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 回合末步的产出形态判定(空回合检测的判据)。
3
+ *
4
+ * 落点:`assistant/message` 事件的 `message.content` —— 与 `thinking.ts` 读同一个事件的
5
+ * 另一个字段(那边读 `stream` 里的 `reasoning-chunks`,这里读 `content` 的块构成)。
6
+ *
7
+ * 判据 = **本 turn 最后一条助手消息里没有非空 text 块**。
8
+ *
9
+ * 实测依据(2026-09-30,14 天内 1202 个 `completed` 回合):完成态结尾沉默 34 轮
10
+ * (2.8%),其中 **32 轮的末步只有 `reasoning`** —— 判据纯度 94%,误报面小。
11
+ * `aborted` / `interrupted` 的沉默另有 15 轮,但那是用户中止的预期结果,**不在判据内**:
12
+ * 调用方按 `signal.aborted` 排除,见 `index.ts` 的 turn-stopping 监听。
13
+ *
14
+ * 为什么读 `content` 而不是 `stream`:`stream` 里的 `text-chunks` 回答「模型有没有生成过
15
+ * 文本」,`content` 回答「这一步最终产出了什么」。判据要的是后者,前者只在排查时用来
16
+ * 交叉验证(设计文档 §2.2)。
17
+ *
18
+ * 设计出处:`docs/设计/2026-09-30-空回合检测与可见化.md` §二
19
+ * @module @max-null/dsh-allostasis/tail
20
+ */
21
+ /** 全部档位,供配置校验枚举。 */
22
+ export const SILENT_TURN_MODES = ['off', 'observe', 'steer'];
23
+ /**
24
+ * 空回合兜底的档位缺省值。
25
+ *
26
+ * `observe` 而不是 `off`:判定与留痕必须先跑起来,否则「这个现象有多频繁、判据准不准」
27
+ * 永远没有数据。补生成(`steer`)要等到有数据说明它值得开之后再加。
28
+ */
29
+ export const SILENT_TURN_MODE = 'observe';
30
+ /**
31
+ * 统计一条助手消息的块构成。
32
+ *
33
+ * `text` 按去掉首尾空白后的长度计入:只含空白的文本块在界面上不产生任何可见内容,
34
+ * 把它算作「说了话」会让判据漏掉真实的空回合。
35
+ * @param turn - 该消息所属的 turn。
36
+ * @param step - 该消息所属的 step。
37
+ * @param content - 助手消息的内容块,按出现顺序。
38
+ * @returns 该消息的产出形态。
39
+ */
40
+ export function shapeOf(turn, step, content) {
41
+ let textChars = 0;
42
+ let reasoningChars = 0;
43
+ const blocks = { reasoning: 0, text: 0, toolCalls: 0 };
44
+ for (const block of content) {
45
+ if (block.type === 'reasoning') {
46
+ blocks.reasoning += 1;
47
+ reasoningChars += block.text?.length ?? 0;
48
+ }
49
+ else if (block.type === 'text') {
50
+ blocks.text += 1;
51
+ textChars += block.text?.trim().length ?? 0;
52
+ }
53
+ else if (block.type === 'tool-call') {
54
+ blocks.toolCalls += 1;
55
+ }
56
+ }
57
+ return { turn, step, textChars, reasoningChars, blocks };
58
+ }
59
+ /**
60
+ * 把会话事件收窄成判定样本。
61
+ *
62
+ * 与 {@link measureTail} 分成两步,是为了让判定完全落在纯函数上:`SessionEvent` 是
63
+ * 所有事件的联合,拿它当参数就得在测试里伪造整个事件(含 `usage` / `stream`),
64
+ * 或者用类型断言把缺口盖掉。收窄只在这里做一次。
65
+ * @param events - 会话事件,按 seq 升序。
66
+ * @returns 助手消息样本,保持原顺序;其它类型的事件被跳过。
67
+ */
68
+ export function tailSamples(events) {
69
+ const samples = [];
70
+ for (const event of events) {
71
+ if (event.type !== 'assistant/message')
72
+ continue;
73
+ samples.push({
74
+ turn: event.data.turn,
75
+ step: event.data.step,
76
+ content: event.data.message.content,
77
+ });
78
+ }
79
+ return samples;
80
+ }
81
+ /**
82
+ * 取本 turn **最后一条**助手消息的产出形态。
83
+ *
84
+ * 倒扫而不是取「最后一条样本」:会话里可能夹着别的 turn 的助手消息(中断后的残留、
85
+ * 子代理的投递),而判定要问的是「这一轮结束时用户看到了什么」。
86
+ * @param samples - {@link tailSamples} 的产出。
87
+ * @param turn - 要检查的 turn。
88
+ * @returns 末条助手消息的形态;该 turn 没有助手消息时返回 `undefined`。
89
+ */
90
+ export function measureTail(samples, turn) {
91
+ for (let index = samples.length - 1; index >= 0; index -= 1) {
92
+ const sample = samples[index];
93
+ if (sample === undefined || sample.turn !== turn)
94
+ continue;
95
+ return shapeOf(sample.turn, sample.step, sample.content);
96
+ }
97
+ return undefined;
98
+ }
99
+ /**
100
+ * 对一次测量下判定。
101
+ *
102
+ * 只有「有助手消息且它没有非空文本」才算空回合。工具调用**不**改变判定:一个以
103
+ * `tool-call` 结尾的完成态回合同样是异常(工具执行完还会再走一步,除非回合已经结束)。
104
+ * @param shape - 末条助手消息的形态;没有助手消息时传 `undefined`。
105
+ * @returns 三态判定。
106
+ */
107
+ export function tailVerdict(shape) {
108
+ if (shape === undefined)
109
+ return 'unknown';
110
+ return shape.textChars > 0 ? 'spoke' : 'silent';
111
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-allostasis",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "应变(allostasis):会话状态的自我调节。第一期:中文思考的漂移检测与近因锚定",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -13,16 +13,26 @@
13
13
  ".": {
14
14
  "types": "./dist/index.d.ts",
15
15
  "default": "./dist/index.js"
16
- }
16
+ },
17
+ "./client": "./dist/client.js"
17
18
  },
18
19
  "files": [
19
20
  "dist",
20
21
  "cordis.patch.yml",
21
- "tools"
22
+ "tools",
23
+ "docs/shots"
22
24
  ],
23
25
  "dsh": {
24
26
  "bundle": {
25
27
  "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-locale",
33
+ "@deepseek-ai/dsh-client-ui-chat",
34
+ "@deepseek-ai/dsh-client-ui-conversation"
35
+ ]
26
36
  }
27
37
  },
28
38
  "keywords": [
@@ -42,7 +52,7 @@
42
52
  "url": "git+https://github.com/Max-Null/dsh-allostasis.git"
43
53
  },
44
54
  "scripts": {
45
- "build": "tsc -p tsconfig.build.json",
55
+ "build": "tsc -p tsconfig.build.json && tsdown",
46
56
  "typecheck": "tsc --noEmit -p tsconfig.json",
47
57
  "test": "vitest run",
48
58
  "prepublishOnly": "npm run build"
@@ -57,13 +67,16 @@
57
67
  "@deepseek-ai/dsh-session": ">=0.1.7-rc.1 <0.3.0"
58
68
  },
59
69
  "devDependencies": {
60
- "@types/node": "^26.2.0",
61
- "tsx": "^4.0.0",
62
- "typescript": "^5.5.0",
63
- "vitest": "^2.1.0",
64
70
  "@deepseek-ai/cordis": "^4.0.1",
65
71
  "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
66
72
  "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
67
- "@deepseek-ai/dsh-session": "^0.1.7-rc.2"
73
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.2",
74
+ "@types/node": "^26.2.0",
75
+ "@types/react": "~18.3.31",
76
+ "react": "^18.3.1",
77
+ "tsdown": "^0.22.14",
78
+ "tsx": "^4.0.0",
79
+ "typescript": "^5.5.0",
80
+ "vitest": "^2.1.0"
68
81
  }
69
82
  }
package/dist/events.d.ts DELETED
@@ -1,67 +0,0 @@
1
- /**
2
- * 退化触发的会话事件——「插件当时判了什么」的落盘。
3
- *
4
- * 判定输入本来就可重放(推理原文在日志的 `reasoning-chunks.texts`,占用与压缩点在
5
- * `data.usage` / `compaction/start`),所以「如果当时阈值是 X 会怎样」能离线回答。
6
- * 缺的是**插件实际用过的阈值与判定结果**:只靠重算,复盘的是「按现在的脚本判会怎样」,
7
- * 不是「插件当时判了什么」,两个口径会随时间漂移。这个事件补的就是这道缝
8
- * (设计方案 §4.3 选项 A)。
9
- *
10
- * `SessionEventMap` 是 merge-extensible 的,内核的会话 invariant 对未知事件类型直接
11
- * 放行——「Merge-extensible event relations belong to their owning plugin」
12
- * (`packages/core/session/src/invariant.ts:165-167`),因此这里不需要在内核侧登记。
13
- *
14
- * @module @max-null/dsh-allostasis/events
15
- */
16
- import type { RepetitionMetrics } from './repetition.ts';
17
- declare module '@deepseek-ai/dsh-session/types' {
18
- interface SessionEventMap {
19
- /** 一次退化触发;只记录判定,不记录干预——压缩动作是否发生由压缩事件自己回答。 */
20
- 'allostasis/degeneration': DegenerationEvent;
21
- }
22
- }
23
- /** 落盘时每个重复单元的截断长度;够复盘看出重复的是什么,又不让日志被碎片撑大。 */
24
- export declare const UNIT_SAMPLE_MAX_CHARS = 80;
25
- /** {@link degenerationEvent} 的输入。 */
26
- export interface DegenerationTrigger {
27
- /** 产出该推理的 turn。 */
28
- readonly turn: number;
29
- /** 产出该推理的 step。 */
30
- readonly step: number;
31
- /** 该步的重复度量化结果。 */
32
- readonly metrics: RepetitionMetrics;
33
- /** 已连续越线的步数。 */
34
- readonly consecutive: number;
35
- /** 判定所用的重复率阈值。 */
36
- readonly threshold: number;
37
- /** 触发所需的连续步数。 */
38
- readonly required: number;
39
- }
40
- /** `allostasis/degeneration` 的 payload:一次触发的完整判定依据。 */
41
- export interface DegenerationEvent {
42
- /** 产出该推理的 turn。 */
43
- turn: number;
44
- /** 产出该推理的 step。 */
45
- step: number;
46
- /** 触发时的重复率。 */
47
- ratio: number;
48
- /** 单元总数,即重复率的样本量。 */
49
- units: number;
50
- /** 已连续越线的步数。 */
51
- consecutive: number;
52
- /** 判定所用的重复率阈值。 */
53
- threshold: number;
54
- /** 触发所需的连续步数。 */
55
- required: number;
56
- /** 最高频的重复单元,按出现次数降序,单元已截断。 */
57
- top: Array<{
58
- unit: string;
59
- count: number;
60
- }>;
61
- }
62
- /**
63
- * 把一次触发整理成事件 payload。
64
- * @param trigger - 触发时的判定依据。
65
- * @returns 可直接交给 `session.append` 的 payload。
66
- */
67
- export declare function degenerationEvent(trigger: DegenerationTrigger): DegenerationEvent;
package/dist/events.js DELETED
@@ -1,40 +0,0 @@
1
- /**
2
- * 退化触发的会话事件——「插件当时判了什么」的落盘。
3
- *
4
- * 判定输入本来就可重放(推理原文在日志的 `reasoning-chunks.texts`,占用与压缩点在
5
- * `data.usage` / `compaction/start`),所以「如果当时阈值是 X 会怎样」能离线回答。
6
- * 缺的是**插件实际用过的阈值与判定结果**:只靠重算,复盘的是「按现在的脚本判会怎样」,
7
- * 不是「插件当时判了什么」,两个口径会随时间漂移。这个事件补的就是这道缝
8
- * (设计方案 §4.3 选项 A)。
9
- *
10
- * `SessionEventMap` 是 merge-extensible 的,内核的会话 invariant 对未知事件类型直接
11
- * 放行——「Merge-extensible event relations belong to their owning plugin」
12
- * (`packages/core/session/src/invariant.ts:165-167`),因此这里不需要在内核侧登记。
13
- *
14
- * @module @max-null/dsh-allostasis/events
15
- */
16
- /** 落盘时每个重复单元的截断长度;够复盘看出重复的是什么,又不让日志被碎片撑大。 */
17
- export const UNIT_SAMPLE_MAX_CHARS = 80;
18
- /**
19
- * 把一次触发整理成事件 payload。
20
- * @param trigger - 触发时的判定依据。
21
- * @returns 可直接交给 `session.append` 的 payload。
22
- */
23
- export function degenerationEvent(trigger) {
24
- const { metrics } = trigger;
25
- return {
26
- turn: trigger.turn,
27
- step: trigger.step,
28
- ratio: metrics.ratio,
29
- units: metrics.units,
30
- consecutive: trigger.consecutive,
31
- threshold: trigger.threshold,
32
- required: trigger.required,
33
- top: metrics.top.map(entry => ({
34
- unit: entry.unit.length <= UNIT_SAMPLE_MAX_CHARS
35
- ? entry.unit
36
- : `${entry.unit.slice(0, UNIT_SAMPLE_MAX_CHARS)}…`,
37
- count: entry.count,
38
- })),
39
- };
40
- }