dsh-whale-loli 0.5.3 → 0.5.5
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 +16 -7
- package/package.json +1 -1
- package/src/history-window.js +77 -27
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
|
-
#### ⚠️
|
|
99
|
+
#### ⚠️ 裁剪写的事件必须是宿主认得的形状(这条踩了三次)
|
|
100
100
|
|
|
101
|
-
宿主的会话格式校验(`dsh-session-format-v3-to-v4
|
|
102
|
-
写死 `turn: 0` 会抛:
|
|
101
|
+
宿主的会话格式校验(`dsh-session-format-v3-to-v4`)对消息事件有硬性要求,**缺一项就抛错、整个回合失败**:
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
+
"version": "0.5.5",
|
|
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",
|
package/src/history-window.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -137,39 +156,73 @@ export function shadowedNodes(session, startSeq, endSeq) {
|
|
|
137
156
|
return nodes.filter((seq) => seq >= startSeq && seq <= endSeq);
|
|
138
157
|
}
|
|
139
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
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
140
169
|
/**
|
|
141
|
-
*
|
|
170
|
+
* 本地预检:把宿主 `dsh-session-format-v3-to-v4` 的规则在**写之前**自己先跑一遍。
|
|
142
171
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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` 非空(真实消息都有,便于溯源)
|
|
148
180
|
*/
|
|
149
|
-
export function
|
|
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
|
|
202
|
+
*
|
|
203
|
+
* `user/message` 这边只有两条硬要求:
|
|
204
|
+
* 1. `assertOrdinaryMessage`:`role !== 'developer'`、`content` 是数组
|
|
205
|
+
* 2. `assertV4MessageSources`:`source.kind` 是非空字符串且不等于 `'plugin'`
|
|
206
|
+
* 所以带上 `id` 和 `source: { kind: 'history-window' }` 即可,且能被 {@link isUserNode}
|
|
207
|
+
* 识别出来、不计入轮数。
|
|
208
|
+
*/
|
|
209
|
+
export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
150
210
|
const session = agent?.session;
|
|
151
211
|
if (session === undefined) return null;
|
|
152
212
|
|
|
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
213
|
const plan = planPrune(session, keepTurns);
|
|
158
214
|
if (plan === null) return null;
|
|
159
215
|
|
|
160
216
|
const shadowed = shadowedNodes(session, plan.startSeq, plan.endSeq);
|
|
161
217
|
if (shadowed.length === 0) return null;
|
|
162
218
|
|
|
219
|
+
const message = noticePayload(plan);
|
|
220
|
+
// 预检不过就跳过:宁可这次不裁,也不写一个会被宿主拒收、拖垮整回合的事件。
|
|
221
|
+
if (!noticeIsValid(message)) return null;
|
|
222
|
+
|
|
163
223
|
session.append(
|
|
164
|
-
'
|
|
165
|
-
|
|
166
|
-
turn,
|
|
167
|
-
step,
|
|
168
|
-
message: {
|
|
169
|
-
role: 'developer',
|
|
170
|
-
content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
|
|
171
|
-
},
|
|
172
|
-
},
|
|
224
|
+
'user/message',
|
|
225
|
+
message,
|
|
173
226
|
{
|
|
174
227
|
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
175
228
|
sourceEventSeqs: shadowed,
|
|
@@ -209,16 +262,13 @@ export function apply(ctx, config = {}) {
|
|
|
209
262
|
|
|
210
263
|
const keepTurns = typeof config.keepTurns === 'number' ? config.keepTurns : DEFAULT_KEEP_TURNS;
|
|
211
264
|
|
|
212
|
-
// agent/pre-step 在派生历史之前运行,且拿得到本 agent
|
|
265
|
+
// agent/pre-step 在派生历史之前运行,且拿得到本 agent。
|
|
213
266
|
ctx.effect(
|
|
214
267
|
() =>
|
|
215
268
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
216
269
|
try {
|
|
217
270
|
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
|
-
}
|
|
271
|
+
if (agent?.session !== undefined) pruneHistory(agent, keepTurns);
|
|
222
272
|
} catch {
|
|
223
273
|
// 裁剪失败绝不影响正常对话
|
|
224
274
|
}
|