dsh-whale-loli 0.5.6 → 0.5.7

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
@@ -70,6 +70,22 @@ session.append('developer/message', 提示条, {
70
70
 
71
71
  挂载点为 `agent/pre-step`:循环在**派生历史之前**运行它,是唯一既在请求前、又拿得到本 agent 的时机。
72
72
 
73
+ #### 现在的做法:交给宿主压缩,不自己写事件
74
+
75
+ > **历史改动**:曾经用 `session.append(...)` + `surfaceOp: replace` 自己做纯删除,连崩四次
76
+ > (`turn must be positive` → `requires id, role, content, and a producer-owned source` …),
77
+ > 因为 `dsh-session-format-v3-to-v4` 对事件形状的要求极严,**少一项就是整个回合失败**。
78
+
79
+ 现在改为调用宿主自己的压缩服务:
80
+
81
+ ```js
82
+ ctx.compaction.compactRegion(start, end, agent) // 切点由我们算,摘要/写事件/校验由宿主做
83
+ ```
84
+
85
+ - 触发时机:`agent/status === 'idle'`(压缩服务拒绝 active 会话),且**不 await**,绝不拖住对话。
86
+ - 结果:`[start, end]` 这段旧节点被替换成**一条摘要**,原始旧消息不再计入输入。
87
+ - 天然限流:裁完轮数掉回 20,要再长到超过 20 才会触发,不会每轮都调摘要。
88
+ - 失败只跳过本次,`try/catch` + `.catch(() => {})` 双保险,绝不影响对话。
73
89
  #### ⚠️ `agent/pre-step` 是 waterfall,必须 `return next()`
74
90
 
75
91
  Cordis 的实现是这样的:
package/cordis.patch.yml CHANGED
@@ -107,12 +107,19 @@
107
107
  maxChars: 16000
108
108
  turnLimit: 80
109
109
  detailLimit: 5
110
- # ⚠️ 这里**故意不挂** whale-history-window(20 轮历史裁剪)。
111
- # 宿主 dsh-session-format-v3-to-v4 的校验会拒收我们写的替换事件
112
- # (developer/message 要求 id+role+content+producer-owned source;改用 user/message
113
- # 仍触发同类失败),一旦被拒就是整个回合失败,代价远大于收益。
114
- # 现在改成**纯提示层**:人设里要求小鱼只把最近 20 轮当"当前对话"、不主动翻更早的。
115
- # 模块保留在 src/history-window.js(附单测),等宿主给出正式 API 再接。
110
+ # 历史窗口:让「最近 N 轮」以外的内容**不计入输入**。
111
+ # 关键:**不自己写事件**。改用宿主自己的压缩服务
112
+ # ctx.compaction.compactRegion(start, end, agent)
113
+ # 由宿主做摘要、写事件、自己校验 —— 之前自己拼 surfaceOp:replace 连崩四次,
114
+ # 因为 dsh-session-format-v3-to-v4 对事件形状的要求(turn 正整数、id、role、
115
+ # content、producer-owned source…)少一项就是整回合失败。
116
+ # 代价:每次裁剪跑一次摘要 LLM 调用;只在 agent 空闲时做,且裁完轮数掉回 N,
117
+ # 要再长到超过 N 才会触发,天然限流。出错只跳过本次,绝不影响对话。
118
+ - id: whale-history-window
119
+ name: 'dsh-whale-loli/history-window'
120
+ config:
121
+ enable: true
122
+ keepTurns: 20
116
123
  - id: agent-instructions
117
124
  name: '@deepseek-ai/dsh-agent-instructions'
118
125
  config:
@@ -309,12 +316,19 @@
309
316
  maxChars: 16000
310
317
  turnLimit: 80
311
318
  detailLimit: 5
312
- # ⚠️ 这里**故意不挂** whale-history-window(20 轮历史裁剪)。
313
- # 宿主 dsh-session-format-v3-to-v4 的校验会拒收我们写的替换事件
314
- # (developer/message 要求 id+role+content+producer-owned source;改用 user/message
315
- # 仍触发同类失败),一旦被拒就是整个回合失败,代价远大于收益。
316
- # 现在改成**纯提示层**:人设里要求小鱼只把最近 20 轮当"当前对话"、不主动翻更早的。
317
- # 模块保留在 src/history-window.js(附单测),等宿主给出正式 API 再接。
319
+ # 历史窗口:让「最近 N 轮」以外的内容**不计入输入**。
320
+ # 关键:**不自己写事件**。改用宿主自己的压缩服务
321
+ # ctx.compaction.compactRegion(start, end, agent)
322
+ # 由宿主做摘要、写事件、自己校验 —— 之前自己拼 surfaceOp:replace 连崩四次,
323
+ # 因为 dsh-session-format-v3-to-v4 对事件形状的要求(turn 正整数、id、role、
324
+ # content、producer-owned source…)少一项就是整回合失败。
325
+ # 代价:每次裁剪跑一次摘要 LLM 调用;只在 agent 空闲时做,且裁完轮数掉回 N,
326
+ # 要再长到超过 N 才会触发,天然限流。出错只跳过本次,绝不影响对话。
327
+ - id: whale-history-window
328
+ name: 'dsh-whale-loli/history-window'
329
+ config:
330
+ enable: true
331
+ keepTurns: 20
318
332
  - id: agent-instructions
319
333
  name: '@deepseek-ai/dsh-agent-instructions'
320
334
  config:
@@ -456,12 +470,19 @@
456
470
  maxChars: 16000
457
471
  turnLimit: 80
458
472
  detailLimit: 5
459
- # ⚠️ 这里**故意不挂** whale-history-window(20 轮历史裁剪)。
460
- # 宿主 dsh-session-format-v3-to-v4 的校验会拒收我们写的替换事件
461
- # (developer/message 要求 id+role+content+producer-owned source;改用 user/message
462
- # 仍触发同类失败),一旦被拒就是整个回合失败,代价远大于收益。
463
- # 现在改成**纯提示层**:人设里要求小鱼只把最近 20 轮当"当前对话"、不主动翻更早的。
464
- # 模块保留在 src/history-window.js(附单测),等宿主给出正式 API 再接。
473
+ # 历史窗口:让「最近 N 轮」以外的内容**不计入输入**。
474
+ # 关键:**不自己写事件**。改用宿主自己的压缩服务
475
+ # ctx.compaction.compactRegion(start, end, agent)
476
+ # 由宿主做摘要、写事件、自己校验 —— 之前自己拼 surfaceOp:replace 连崩四次,
477
+ # 因为 dsh-session-format-v3-to-v4 对事件形状的要求(turn 正整数、id、role、
478
+ # content、producer-owned source…)少一项就是整回合失败。
479
+ # 代价:每次裁剪跑一次摘要 LLM 调用;只在 agent 空闲时做,且裁完轮数掉回 N,
480
+ # 要再长到超过 N 才会触发,天然限流。出错只跳过本次,绝不影响对话。
481
+ - id: whale-history-window
482
+ name: 'dsh-whale-loli/history-window'
483
+ config:
484
+ enable: true
485
+ keepTurns: 20
465
486
  - id: agent-instructions
466
487
  name: '@deepseek-ai/dsh-agent-instructions'
467
488
  config:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-whale-loli",
3
- "version": "0.5.6",
4
- "description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆(提示变量通道,per-preset 独立文件)+ 纯提示层的「最近 20 轮」约束:默认不把更早内容当依据,但允许主动翻自己的记忆文件或旧会话日志。不再改写会话历史(宿主格式校验会拒收,故去掉 history-window)。",
3
+ "version": "0.5.7",
4
+ "description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆(提示变量通道,per-preset 独立文件)+ 最近 20 轮历史窗口:调宿主 ctx.compaction.compactRegion() 把更早的内容压成一条摘要,让它们不再计入输入;自己不写任何会话事件(宿主摘要+写入+校验)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -1,80 +1,56 @@
1
1
  /**
2
- * history-window —— 把会话历史真的裁到「最近 N 轮」。
2
+ * history-window —— 让「最近 N 轮」以外的内容**不计入输入**。
3
3
  *
4
- * ── 为什么需要它 ──────────────────────────────────────────────────────
4
+ * ── 为什么是这个做法 ──────────────────────────────────────────────────
5
5
  *
6
- * 只在人设里写「每轮先看最近 20 轮」是**空话**:宿主不会因为那句话就不发旧消息。
7
- * 实测(本机最长会话)33 轮 / 85 条 user 消息 / 1565 条 tool 结果,全部发给模型;
8
- * 宿主自带的 compaction 只在 token 压力超阈值时才压,粒度是「保留一段 token 尾巴」,
9
- * 不是轮数。
6
+ * 之前试过自己往会话里写替换事件(`surfaceOp: replace`)来做纯删除,连崩四次:
7
+ * `dsh-session-format-v3-to-v4` 的校验器对消息事件极其苛刻 ——
8
+ * 先要求 `developer/message` 的 turn 为正整数,再要求 `id`/`role`/`content`/
9
+ * producer-owned `source`,改 `user/message` 也照样被拒;而一旦被拒就是**整个回合失败**。
10
+ * 换句话说:**事件形状不是插件该自己拼的东西**。
10
11
  *
11
- * 本模块用宿主自己的表层替换机制(compaction 用的是同一套)做**纯删除**:
12
+ * 现在改成调用宿主自己的压缩服务:
12
13
  *
13
- * session.append('user/message', 提示条, {
14
- * surfaceOp: { op: 'replace', startSeq, endSeq },
15
- * })
14
+ * ctx.compaction.compactRegion(start, end, agent, signal)
16
15
  *
17
- * 被 replace 覆盖的表层节点从 `deriveMessages()` 里消失 —— 模型真的收不到它们,
18
- * 但会话日志仍然留着(可回溯、不丢数据)。**不调 LLM、不花 token、不生成摘要。**
16
+ * 它把 `[start, end]` 这段表层节点**替换成一条摘要**(由宿主写事件、宿主校验),
17
+ * 于是原始旧消息真的不再计入输入,只有那条摘要计入。切点仍然由我算(保留最近 N 轮),
18
+ * 但合法性判断与写入全部交给宿主。
19
19
  *
20
- * ── 三条安全线 ────────────────────────────────────────────────────────
20
+ * ── 代价与边界 ────────────────────────────────────────────────────────
21
21
  *
22
- * 1. **工具配对不能切断**:切点必须落在「没有未回应的 tool-call」的位置,
23
- * 否则模型会看到孤立的 tool 结果而报错。这里复用与 compaction 同款的平衡判定
24
- * (遍历当前表层、累计 in-progress tool-call 数为 0 才允许切)。
25
- * 2. **system prompt 不能裁**:表层第 0 个节点是系统提示,永不进入裁剪范围。
26
- * 3. **默认关闭**:改写会话历史是强动作,必须显式 `enable: true`。
27
- *
28
- * ── 挂在哪里 ──────────────────────────────────────────────────────────
29
- *
30
- * `agent/pre-step`:循环在每个步骤**派生历史之前**运行它,是唯一既在请求前、
31
- * 又能拿到本 agent 的时机。(`turn/start` 是 session 事件而非 ctx 事件,挂不上。)
22
+ * · 每次裁剪会跑**一次摘要 LLM 调用**(宿主用当前路由模型)。
23
+ * · 只能在 agent 空闲时做(压缩服务拒绝 active 会话),所以挂在 `agent/status === 'idle'`。
24
+ * · 裁完轮数会掉到 N 上下,必须等到再次超过 N 才会触发 —— 天然限流,不会每轮都调。
25
+ * · 任何失败都被吞掉并跳过本次,**绝不影响正常对话**。
32
26
  */
33
- import { randomUUID } from 'node:crypto';
34
27
  import { homedir } from 'node:os';
35
28
  import { join } from 'node:path';
36
29
 
37
30
  /** Cordis 插件名。 */
38
31
  export const name = 'whale-history-window';
39
32
 
40
- /** 需要会话存储与 agent 注册表。 */
33
+ /** 需要会话表(用来拿 agent.session)。 */
41
34
  export const inject = ['sessions'];
42
35
 
43
36
  /** 默认保留轮数。 */
44
37
  export const DEFAULT_KEEP_TURNS = 20;
45
38
 
46
- /** 被裁掉时写进历史的那条提示(模型唯一能看到的痕迹)。 */
47
- export function noticeText(removedTurns, keptTurns) {
48
- return (
49
- `【已裁剪更早的对话】为了只保留最近 ${keptTurns} 轮,` +
50
- `${removedTurns} 轮更早的对话已从上下文中移除(仍保存在会话日志里,可回溯)。` +
51
- `结合小鱼自己的记忆文件继续就好。`
52
- );
53
- }
39
+ /** 不算「用户开口」的 source.kind:宿主注入的运行时上下文。 */
40
+ const NON_TURN_SOURCE_KINDS = new Set(['runtime-context']);
54
41
 
55
- /** dsh 家目录。 */
42
+ /** dsh 家目录(仅用于自检输出)。 */
56
43
  function dshHome() {
57
44
  return process.env.DSH_HOME || join(homedir(), '.dsh');
58
45
  }
59
46
 
60
47
  /**
61
- * 本插件写进会话的「已裁剪」提示条所用的 source.kind。
62
- * 宿主只要求 message.source.kind 是非空字符串且不是 `'plugin'`,
63
- * 但我们必须能把自己的提示条和「真实用户开口」区分开,否则每裁一次就多算一轮。
64
- */
65
- export const NOTICE_SOURCE_KIND = 'history-window';
66
-
67
- /** 不算「用户开口」的 source.kind:本插件的提示条 + 宿主注入的运行时上下文。 */
68
- const NON_TURN_SOURCE_KINDS = new Set([NOTICE_SOURCE_KIND, 'runtime-context']);
69
-
70
- /**
71
- * 一个「轮」的边界 = 一条**真实用户**发出的 user 消息。
48
+ * 一条事件算不算「用户开口」。
72
49
  *
73
50
  * 不能只看 `type === 'user/message'`:宿主的运行时上下文(`source.kind = 'runtime-context'`)
74
- * 和本插件自己的提示条也是 user/message,会把轮数算多、导致过早裁剪。
75
- * 来源未知(老日志没有 source)时保守地当成用户开口。
51
+ * 也是 user/message,会把轮数算多、导致过早压缩。来源未知时保守地算作一轮。
76
52
  */
77
- function isUserNode(event) {
53
+ export function isUserTurn(event) {
78
54
  if (event?.type !== 'user/message') return false;
79
55
  const kind = event.data?.source?.kind;
80
56
  return typeof kind !== 'string' || !NON_TURN_SOURCE_KINDS.has(kind);
@@ -82,7 +58,7 @@ function isUserNode(event) {
82
58
 
83
59
  /**
84
60
  * 切点之前是否工具配对平衡(没有未回应的 tool-call 跨过切点)。
85
- * 与 `@deepseek-ai/dsh-compaction` 的 tool-pairing 同款判定。
61
+ * 压缩服务要求两端都平衡,否则拒绝该范围。
86
62
  */
87
63
  export function balancedBefore(session, nodes, cutIndex) {
88
64
  let pending = 0;
@@ -100,188 +76,103 @@ export function balancedBefore(session, nodes, cutIndex) {
100
76
  }
101
77
 
102
78
  /**
103
- * 计算裁剪计划(纯函数,不写会话)。
79
+ * 计算压缩范围(纯函数,不动会话)。
104
80
  *
105
81
  * @param session - 需要 `surface.nodes` 与 `eventAt()`。
106
82
  * @param keepTurns - 保留最近多少轮。
107
- * @returns `{ startSeq, endSeq, removedTurns, keptTurns, cutIndex }`,无需裁剪返回 null。
83
+ * @returns `{ startSeq, endSeq, removedTurns, keptTurns }`;无需压缩返回 null。
108
84
  */
109
- export function planPrune(session, keepTurns = DEFAULT_KEEP_TURNS) {
85
+ export function planCompact(session, keepTurns = DEFAULT_KEEP_TURNS) {
110
86
  const nodes = session?.surface?.nodes ?? [];
111
87
  if (nodes.length === 0) return null;
112
88
 
113
- // 系统提示所在位置:表层里第一个 system/message 节点。它和它之前的节点永不裁剪。
114
- let firstUser = 0;
89
+ // 表层第一个 user 节点:系统提示(节点 0)永不进入范围
90
+ let firstTurn = -1;
115
91
  for (let i = 0; i < nodes.length; i++) {
116
92
  if (session.eventAt(nodes[i])?.type === 'user/message') {
117
- firstUser = i;
93
+ firstTurn = i;
118
94
  break;
119
95
  }
120
96
  }
97
+ if (firstTurn < 0) return null;
121
98
 
122
- const userIdx = [];
123
- for (let i = 0; i < nodes.length; i++) {
124
- if (isUserNode(session.eventAt(nodes[i]))) userIdx.push(i);
99
+ const turnIdx = [];
100
+ for (let i = firstTurn; i < nodes.length; i++) {
101
+ if (isUserTurn(session.eventAt(nodes[i]))) turnIdx.push(i);
125
102
  }
126
- if (userIdx.length <= keepTurns) return null;
103
+ if (turnIdx.length <= keepTurns) return null;
127
104
 
128
- // 保留最近 keepTurns 个 user 轮(提示条走 developer/message,不占 user 名额)
129
- if (userIdx.length <= keepTurns) return null;
105
+ // 保留最近 keepTurns 轮:切点 = 第 (总数 - keepTurns) 个用户轮
106
+ let cut = turnIdx[turnIdx.length - keepTurns];
107
+ // 往前挪到最近的工具配对平衡位置
108
+ while (cut > firstTurn && !balancedBefore(session, nodes, cut)) cut--;
109
+ if (cut <= firstTurn) return null;
130
110
 
131
- let cut = userIdx[userIdx.length - keepTurns];
132
- while (cut > firstUser && !balancedBefore(session, nodes, cut)) cut--;
133
- if (cut <= firstUser) return null;
134
-
135
- // 关键:startSeq 必须是第一个 user 节点,**绝不能是 nodes[0]**,
136
- // 否则表层第 0 个 system/message 会被一起 replace 掉。
137
111
  return {
138
- startSeq: nodes[firstUser],
112
+ startSeq: nodes[firstTurn],
139
113
  endSeq: nodes[cut - 1],
140
- removedTurns: userIdx.length - keepTurns,
114
+ removedTurns: turnIdx.length - keepTurns,
141
115
  keptTurns: keepTurns,
142
- cutIndex: cut,
143
- };
144
- }
145
-
146
- /**
147
- * 收集被替换范围覆盖的**当前表层节点**。
148
- *
149
- * 宿主的 `assertSourceEventReferences` 硬性要求:`sourceEventSeqs` 必须列全每一个
150
- * 被 shadow 的表层节点,否则抛
151
- * `surface replace: sourceEventSeqs must include every shadowed surface node; missing …`
152
- * 漏了这个参数 = 每次裁剪都失败(错误又被 try/catch 吞掉,表现为「裁剪从来没生效」)。
153
- */
154
- export function shadowedNodes(session, startSeq, endSeq) {
155
- const nodes = session?.surface?.nodes ?? [];
156
- return nodes.filter((seq) => seq >= startSeq && seq <= endSeq);
157
- }
158
-
159
- /** 提示条事件的唯一构造点。 */
160
- function noticePayload(plan) {
161
- return {
162
- id: randomUUID(),
163
- role: 'user',
164
- content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
165
- source: { kind: NOTICE_SOURCE_KIND },
166
116
  };
167
117
  }
168
118
 
169
119
  /**
170
- * 本地预检:把宿主 `dsh-session-format-v3-to-v4` 的规则在**写之前**自己先跑一遍。
171
- *
172
- * 为什么需要:连踩四次都是「写出去才被宿主拒收」,而拒收会直接打死整个回合
173
- * (`turn/end {kind:'error'}`),用户看到的就是一句看不懂的校验错误。预检之后,
174
- * 形状不对只会让我们**跳过这次裁剪**,对话继续。
175
- *
176
- * 校验项(对 `user/message`):
177
- * 1. `assertOrdinaryMessage` —— `role !== 'developer'`、`content` 必须是数组
178
- * 2. `assertV4MessageSources` —— `source` 是对象、`source.kind` 非空且 ≠ `'plugin'`
179
- * 3. 本插件额外要求 `id` 非空(真实消息都有,便于溯源)
180
- */
181
- export function noticeIsValid(message) {
182
- const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
183
- if (!isObject(message)) return false;
184
- if (typeof message.id !== 'string' || message.id.length === 0) return false;
185
- if (message.role === 'developer') return false;
186
- if (!Array.isArray(message.content)) return false;
187
- const source = message.source;
188
- if (!isObject(source)) return false;
189
- if (typeof source.kind !== 'string' || source.kind.length === 0) return false;
190
- if (source.kind === 'plugin') return false;
191
- return true;
192
- }
193
-
194
- /**
195
- * 执行裁剪。返回计划;无需裁剪返回 null。
196
- *
197
- * 替换消息用 **`user/message`**,形状照 compaction 的 checkpoint 抄 —— 这是宿主
198
- * 已经在跑、必然通过全部校验的那条路径。相比之下 `developer/message` 的规则苛刻得多
199
- * (`assertDeveloperMessage` 要求 `id` / `role` / `content` / **producer-owned `source`**,
200
- * 且 `turn`、`step` 必须是正整数),少一项就抛:
201
- * format v4 developer message requires id, role, content, and a producer-owned source
120
+ * 执行压缩。成功返回计划,跳过返回 null。
202
121
  *
203
- * `user/message` 这边只有两条硬要求:
204
- * 1. `assertOrdinaryMessage`:`role !== 'developer'`、`content` 是数组
205
- * 2. `assertV4MessageSources`:`source.kind` 是非空字符串且不等于 `'plugin'`
206
- * 所以带上 `id` 和 `source: { kind: 'history-window' }` 即可,且能被 {@link isUserNode}
207
- * 识别出来、不计入轮数。
122
+ * 真正的替换由 `ctx.compaction.compactRegion()` 完成:它负责摘要、写事件、写日志。
123
+ * 本函数只负责「算范围」和「不让异常外泄」。
208
124
  */
209
- export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
125
+ export async function compactHistory(ctx, agent, keepTurns = DEFAULT_KEEP_TURNS, signal) {
210
126
  const session = agent?.session;
211
127
  if (session === undefined) return null;
212
128
 
213
- const plan = planPrune(session, keepTurns);
214
- if (plan === null) return null;
215
-
216
- const shadowed = shadowedNodes(session, plan.startSeq, plan.endSeq);
217
- if (shadowed.length === 0) return null;
129
+ const compaction = ctx.get?.('compaction') ?? ctx.compaction;
130
+ if (compaction?.compactRegion === undefined) return null;
218
131
 
219
- const message = noticePayload(plan);
220
- // 预检不过就跳过:宁可这次不裁,也不写一个会被宿主拒收、拖垮整回合的事件。
221
- if (!noticeIsValid(message)) return null;
132
+ const plan = planCompact(session, keepTurns);
133
+ if (plan === null) return null;
222
134
 
223
- session.append(
224
- 'user/message',
225
- message,
226
- {
227
- surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
228
- sourceEventSeqs: shadowed,
229
- },
230
- );
135
+ await compaction.compactRegion(plan.startSeq, plan.endSeq, agent, signal);
231
136
  return plan;
232
137
  }
233
138
 
139
+ /** 每个 agent 同时只允许一次压缩(压缩服务本身也拒绝并发,这里提前避开)。 */
140
+ const inFlight = new WeakSet();
141
+
234
142
  /**
235
- * 挂载:每个步骤派生历史之前检查并裁剪。
236
- *
237
- * ⚠️ `agent/pre-step` 是 Cordis 的 **waterfall**。宿主实现:
238
- *
239
- * waterfall(...args) {
240
- * const cbs = this.dispatch("waterfall", args)
241
- * const inner = args.pop()
242
- * const next = () => (cbs.shift() ?? inner)(...args)
243
- * args.push(next)
244
- * return next()
245
- * }
246
- *
247
- * 也就是说:**不调用 `next()` 的监听器会否决整条链**,并把它自己的返回值当作最终结果。
248
- * 曾经这里写成 `async (payload) => { …裁剪… }`(不接 next、返回 undefined),结果
249
- * `agent/pre-step` 的 decision 变成 undefined,agent-loop 里 `decision.kind` 直接抛
250
- * `Cannot read properties of undefined (reading 'kind')`
251
- * —— 整个回合在 step 之前就崩掉,而且没有任何 step/start 事件,极难定位。
252
- *
253
- * 所以:无论裁剪成功、失败还是被跳过,**必须 `return next()`**,把下游的行为(默认的
254
- * `{kind:'enter'}` 以及 compaction 等其它监听器)原样放行。
143
+ * 挂载:agent 转为空闲时检查轮数,超过 keepTurns 就压缩掉更早的部分。
255
144
  *
256
145
  * config:
257
- * - `enable` 置 true 才真的裁(默认 false —— 改写历史是强动作)
146
+ * - `enable` 置 false 完全不挂(默认**开启**;出问题一行改回)
258
147
  * - `keepTurns` 保留最近多少轮(默认 20)
259
148
  */
260
149
  export function apply(ctx, config = {}) {
261
- if (config.enable !== true) return;
150
+ if (config.enable === false) return;
262
151
 
263
152
  const keepTurns = typeof config.keepTurns === 'number' ? config.keepTurns : DEFAULT_KEEP_TURNS;
153
+ if (typeof ctx.on !== 'function') return;
264
154
 
265
- // agent/pre-step 在派生历史之前运行,且拿得到本 agent。
266
155
  ctx.effect(
267
156
  () =>
268
- ctx.on('agent/pre-step', async (payload, next) => {
269
- try {
270
- const agent = payload?.agent ?? payload?.handle?.agent;
271
- if (agent?.session !== undefined) pruneHistory(agent, keepTurns);
272
- } catch {
273
- // 裁剪失败绝不影响正常对话
274
- }
275
- // 必须放行 waterfall:绝不吞掉 decision,否则下游读 decision.kind 会崩。
276
- return next();
157
+ ctx.on('agent/status', (payload) => {
158
+ if (payload?.status !== 'idle') return;
159
+ const agent = payload?.agent;
160
+ if (agent?.session === undefined) return;
161
+ if (inFlight.has(agent)) return;
162
+ inFlight.add(agent);
163
+ // 不 await:这是空闲期的后台整理,绝不能拖住/影响对话。
164
+ void Promise.resolve()
165
+ .then(() => compactHistory(ctx, agent, keepTurns))
166
+ .catch(() => {})
167
+ .finally(() => inFlight.delete(agent));
277
168
  }),
278
- 'whale-history-window.pre-step',
169
+ 'whale-history-window.idle-compact',
279
170
  );
280
171
  }
281
172
 
282
- /** 窗口配置(给文档/自检用)。 */
173
+ /** 给文档/自检用的配置快照。 */
283
174
  export function windowConfig() {
284
- return { defaultKeepTurns: DEFAULT_KEEP_TURNS, marker: join(dshHome(), 'whale-history-window.json') };
175
+ return { defaultKeepTurns: DEFAULT_KEEP_TURNS, home: dshHome() };
285
176
  }
286
177
 
287
178
  export default { name, inject, apply };