dsh-whale-loli 0.5.3 → 0.5.4

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
@@ -96,17 +96,26 @@ Cannot read properties of undefined (reading 'kind')
96
96
  连 `step/start` 都没有,极难定位。所以无论裁剪成功、失败还是跳过,都必须 `return next()`。
97
97
  `verify-history-window.mjs` 第 9 组用例复刻了这个 waterfall 语义,并保留了一个「老写法必崩」的反例。
98
98
 
99
- #### ⚠️ 裁剪写的事件必须带合法 `turn`
99
+ #### ⚠️ 裁剪写的事件必须是宿主认得的形状(这条踩了三次)
100
100
 
101
- 宿主的会话格式校验(`dsh-session-format-v3-to-v4`)要求 `developer/message` 的 `turn` 是**正整数**。
102
- 写死 `turn: 0` 会抛:
101
+ 宿主的会话格式校验(`dsh-session-format-v3-to-v4`)对消息事件有硬性要求,**缺一项就抛错、整个回合失败**:
103
102
 
104
- ```
105
- developer/message turn must be positive
103
+ | 事件类型 | 要求 | 踩过的错 |
104
+ | --- | --- | --- |
105
+ | `developer/message` | `turn`/`step` 正整数 + `message.id` + `message.role='developer'` + `message.content` 数组 + `message.source`(producer-owned,且 ≠ `'plugin'`) | `developer/message turn must be positive`<br>`format v4 developer message requires id, role, content, and a producer-owned source` |
106
+ | `user/message` | `role !== 'developer'`、`content` 是数组、`source.kind` 非空且 ≠ `'plugin'` | `message requires a producer-owned source kind` |
107
+
108
+ 所以本插件**改用 `user/message`**(形状照 compaction 的 checkpoint 抄 —— 那是宿主自己在跑、
109
+ 必然通过校验的路径),并带上:
110
+
111
+ ```js
112
+ { id: randomUUID(), role: 'user', content: [...], source: { kind: 'history-window' } }
106
113
  ```
107
114
 
108
- 所以 `pruneHistory()` 从 `agent/pre-step` 的 payload 里取真实的 `turn` / `step` 透传;
109
- **拿不到正数 turn 就跳过本次裁剪**(宁可不裁,也不写非法事件)。第 10 组用例覆盖这个分支。
115
+ `source.kind = 'history-window'` 有两个用处:满足宿主的 producer-owned 要求,以及让
116
+ `isUserNode()` 能把自己的提示条和**真实用户开口**区分开。后者很重要 —— 宿主的运行时上下文
117
+ (`source.kind = 'runtime-context'`)同样是 `user/message`,此前会把轮数算多、导致过早裁剪。
118
+ 第 10 组用例同时验证「提示条合法」「提示条不计轮」「运行时上下文不计轮」「重复裁剪不会越积越多」。
110
119
 
111
120
  ### 为什么用「提示变量」而不是 section(重要)
112
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-loli",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆 + 真正生效的 20 轮历史窗口。记忆分「固定记忆」(永久保留)/「滚动摘要」(每轮都记,最多 80 条 FIFO,超限每轮结束自动归档到 archive/<预设名>/)/「最近详细区」/「归档索引」四区,经提示变量注入(persona 为 complete:true,独立 section 会被宿主丢弃);history-window 用宿主表层替换真的裁掉 20 轮以前的历史,不调 LLM、不切断工具配对,并正确放行 agent/pre-step 的 waterfall。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  * `agent/pre-step`:循环在每个步骤**派生历史之前**运行它,是唯一既在请求前、
31
31
  * 又能拿到本 agent 的时机。(`turn/start` 是 session 事件而非 ctx 事件,挂不上。)
32
32
  */
33
+ import { randomUUID } from 'node:crypto';
33
34
  import { homedir } from 'node:os';
34
35
  import { join } from 'node:path';
35
36
 
@@ -56,9 +57,27 @@ function dshHome() {
56
57
  return process.env.DSH_HOME || join(homedir(), '.dsh');
57
58
  }
58
59
 
59
- /** 一个「轮」的边界 = 一条 user 消息。表层里只有消息节点,turn 标记不进表层。 */
60
+ /**
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 消息。
72
+ *
73
+ * 不能只看 `type === 'user/message'`:宿主的运行时上下文(`source.kind = 'runtime-context'`)
74
+ * 和本插件自己的提示条也是 user/message,会把轮数算多、导致过早裁剪。
75
+ * 来源未知(老日志没有 source)时保守地当成用户开口。
76
+ */
60
77
  function isUserNode(event) {
61
- return event?.type === 'user/message';
78
+ if (event?.type !== 'user/message') return false;
79
+ const kind = event.data?.source?.kind;
80
+ return typeof kind !== 'string' || !NON_TURN_SOURCE_KINDS.has(kind);
62
81
  }
63
82
 
64
83
  /**
@@ -138,22 +157,24 @@ export function shadowedNodes(session, startSeq, endSeq) {
138
157
  }
139
158
 
140
159
  /**
141
- * 执行裁剪。返回计划;无需裁剪(或拿不到合法 position)返回 null。
142
- *
143
- * `position` = `{ turn, step }`,直接来自 `agent/pre-step` 的 payload。
144
- * **必须传真实 turn**:宿主 `dsh-session-format-v3-to-v4` 的校验器要求
145
- * `developer/message` 的 `turn` 为正整数,写死 0 会抛
146
- * `developer/message turn must be positive`
147
- * 拿不到正数 turn 就跳过这一轮(宁可不裁,也不写非法事件)。
160
+ * 执行裁剪。返回计划;无需裁剪返回 null。
161
+ *
162
+ * 替换消息用 **`user/message`**,形状照 compaction 的 checkpoint 抄 —— 这是宿主
163
+ * 已经在跑、必然通过全部校验的那条路径。相比之下 `developer/message` 的规则苛刻得多
164
+ * (`assertDeveloperMessage` 要求 `id` / `role` / `content` / **producer-owned `source`**,
165
+ * 且 `turn`、`step` 必须是正整数),少一项就抛:
166
+ * format v4 developer message requires id, role, content, and a producer-owned source
167
+ *
168
+ * `user/message` 这边只有两条硬要求:
169
+ * 1. `assertOrdinaryMessage`:`role !== 'developer'`、`content` 是数组
170
+ * 2. `assertV4MessageSources`:`source.kind` 是非空字符串且不等于 `'plugin'`
171
+ * 所以带上 `id` 和 `source: { kind: 'history-window' }` 即可,且能被 {@link isUserNode}
172
+ * 识别出来、不计入轮数。
148
173
  */
149
- export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS, position = {}) {
174
+ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
150
175
  const session = agent?.session;
151
176
  if (session === undefined) return null;
152
177
 
153
- const turn = Number.isInteger(position?.turn) && position.turn > 0 ? position.turn : null;
154
- if (turn === null) return null;
155
- const step = Number.isInteger(position?.step) && position.step > 0 ? position.step : 1;
156
-
157
178
  const plan = planPrune(session, keepTurns);
158
179
  if (plan === null) return null;
159
180
 
@@ -161,14 +182,12 @@ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS, position = {
161
182
  if (shadowed.length === 0) return null;
162
183
 
163
184
  session.append(
164
- 'developer/message',
185
+ 'user/message',
165
186
  {
166
- turn,
167
- step,
168
- message: {
169
- role: 'developer',
170
- content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
171
- },
187
+ id: randomUUID(),
188
+ role: 'user',
189
+ content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
190
+ source: { kind: NOTICE_SOURCE_KIND },
172
191
  },
173
192
  {
174
193
  surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
@@ -209,16 +228,13 @@ export function apply(ctx, config = {}) {
209
228
 
210
229
  const keepTurns = typeof config.keepTurns === 'number' ? config.keepTurns : DEFAULT_KEEP_TURNS;
211
230
 
212
- // agent/pre-step 在派生历史之前运行,且拿得到本 agent 与当前 turn/step。
231
+ // agent/pre-step 在派生历史之前运行,且拿得到本 agent。
213
232
  ctx.effect(
214
233
  () =>
215
234
  ctx.on('agent/pre-step', async (payload, next) => {
216
235
  try {
217
236
  const agent = payload?.agent ?? payload?.handle?.agent;
218
- if (agent?.session !== undefined) {
219
- // turn/step 必须原样透传给 pruneHistory:宿主校验器要求 turn 为正整数。
220
- pruneHistory(agent, keepTurns, { turn: payload?.turn, step: payload?.step });
221
- }
237
+ if (agent?.session !== undefined) pruneHistory(agent, keepTurns);
222
238
  } catch {
223
239
  // 裁剪失败绝不影响正常对话
224
240
  }