@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/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.3.0",
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
- }