dsh-whale-loli 0.5.0 → 0.5.2
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 +32 -1
- package/package.json +2 -2
- package/src/history-window.js +44 -5
- package/src/memory.js +43 -5
package/README.md
CHANGED
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
- **每轮都记**:不是挑着记。短对话直接记;长对话**先精简概括**再记(压成一两句说清聊了什么、结论是什么)。
|
|
26
26
|
- **80 条上限只算「滚动摘要」**:`# 固定记忆` 不占这个名额。
|
|
27
27
|
- **想长期留的就往上挪**:某条滚动摘要以后还用得上,就总结一句写进 `# 固定记忆`;否则到点会滚出去(并归档)。
|
|
28
|
-
- 上限有两道保险:注入时只取最近 80 条(文件里堆再多也只进 80
|
|
28
|
+
- 上限有两道保险:注入时只取最近 80 条(文件里堆再多也只进 80 条);**每轮结束(agent 转 idle)时自动**把文件本身裁到 80 条,超出的写进 `archive/<预设名>/`。
|
|
29
|
+
- 为什么放在 idle 而不是组装时:组装是热路径、每次都要跑,在那里写盘既慢又容易和 agent 正在编辑的文件打架。
|
|
30
|
+
- 不足 80 条时**完全不动文件**(不产生无谓写盘)。
|
|
31
|
+
- **记忆文件还是空的时候也会注入一段带绝对路径的提示**:否则新预设开局拿到一片空白,agent 连该往哪个文件写都不知道。
|
|
29
32
|
- 总长顶到 `maxChars`(16000)时,**先丢最旧的滚动摘要,再丢详细区**,固定记忆最后才动。
|
|
30
33
|
|
|
31
34
|
### 每轮先看什么
|
|
@@ -67,6 +70,32 @@ session.append('developer/message', 提示条, {
|
|
|
67
70
|
|
|
68
71
|
挂载点为 `agent/pre-step`:循环在**派生历史之前**运行它,是唯一既在请求前、又拿得到本 agent 的时机。
|
|
69
72
|
|
|
73
|
+
#### ⚠️ `agent/pre-step` 是 waterfall,必须 `return next()`
|
|
74
|
+
|
|
75
|
+
Cordis 的实现是这样的:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
waterfall (...args) {
|
|
79
|
+
const cbs = this.dispatch('waterfall', args)
|
|
80
|
+
const inner = args.pop()
|
|
81
|
+
const next = () => (cbs.shift() ?? inner)(...args)
|
|
82
|
+
args.push(next)
|
|
83
|
+
return next()
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**不调用 `next()` 的监听器会否决整条链**,并把自己的返回值当成最终结果。
|
|
88
|
+
本插件曾经写成 `async (payload) => { …裁剪… }`(不接 `next`、返回 `undefined`),于是
|
|
89
|
+
`agent/pre-step` 的 decision 变成 `undefined`,agent-loop 里 `decision.kind` 直接炸:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Cannot read properties of undefined (reading 'kind')
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
表现是**整个回合在第一个 step 之前就失败**,会话日志里只有 `turn/start` → `turn/end(error)`,
|
|
96
|
+
连 `step/start` 都没有,极难定位。所以无论裁剪成功、失败还是跳过,都必须 `return next()`。
|
|
97
|
+
`verify-history-window.mjs` 第 9 组用例复刻了这个 waterfall 语义,并保留了一个「老写法必崩」的反例。
|
|
98
|
+
|
|
70
99
|
### 为什么用「提示变量」而不是 section(重要)
|
|
71
100
|
|
|
72
101
|
`@deepseek-ai/dsh-system-prompt` 的 `assemble()` 末尾有这样一段:
|
|
@@ -187,6 +216,7 @@ dsh-whale-loli/
|
|
|
187
216
|
├── verify-memory-injection.mjs # 证明变量通道有效、section 通道被丢弃
|
|
188
217
|
├── verify-memory-isolation.mjs # 实测三个记忆文件不串
|
|
189
218
|
├── verify-memory-rolling.mjs # 实测四区结构与 80 条滚动 + 归档
|
|
219
|
+
├── verify-memory-archive.mjs # 超 80 条时真的归档并瘦身(idle 触发,三预设互不覆盖)
|
|
190
220
|
├── verify-peers.mjs # 校验 peer 范围覆盖 harness 各发布档
|
|
191
221
|
├── verify-listing.mjs # 复刻插件目录的收录门槛自查
|
|
192
222
|
└── prove-collision.mjs # 复现并证明重复注册冲突
|
|
@@ -209,6 +239,7 @@ node verify-history-window.mjs # 真的裁到最近 N 轮(不切
|
|
|
209
239
|
node verify-memory-injection.mjs # 变量通道有效 / section 通道被丢弃
|
|
210
240
|
node verify-memory-isolation.mjs # 记忆文件端到端隔离(三个预设)
|
|
211
241
|
node verify-memory-rolling.mjs # 固定记忆永久 + 滚动摘要 80 条 + 归档
|
|
242
|
+
node verify-memory-archive.mjs # 超 80 条自动归档瘦身(idle 触发、三预设隔离)
|
|
212
243
|
node smoke-memory.mjs # 记忆变量冒烟
|
|
213
244
|
node verify-peers.mjs # peer 范围无预发布坑
|
|
214
245
|
node verify-listing.mjs --topics=dsh-plugin # 收录门槛自查
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-whale-loli",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆 + 真正生效的 20 轮历史窗口。记忆分「固定记忆」(永久保留)/「滚动摘要」(每轮都记,最多 80 条 FIFO
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
7
7
|
"keywords": [
|
package/src/history-window.js
CHANGED
|
@@ -124,6 +124,19 @@ export function planPrune(session, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 收集被替换范围覆盖的**当前表层节点**。
|
|
129
|
+
*
|
|
130
|
+
* 宿主的 `assertSourceEventReferences` 硬性要求:`sourceEventSeqs` 必须列全每一个
|
|
131
|
+
* 被 shadow 的表层节点,否则抛
|
|
132
|
+
* `surface replace: sourceEventSeqs must include every shadowed surface node; missing …`
|
|
133
|
+
* 漏了这个参数 = 每次裁剪都失败(错误又被 try/catch 吞掉,表现为「裁剪从来没生效」)。
|
|
134
|
+
*/
|
|
135
|
+
export function shadowedNodes(session, startSeq, endSeq) {
|
|
136
|
+
const nodes = session?.surface?.nodes ?? [];
|
|
137
|
+
return nodes.filter((seq) => seq >= startSeq && seq <= endSeq);
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
/** 执行裁剪。返回计划;无需裁剪返回 null。 */
|
|
128
141
|
export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
129
142
|
const session = agent?.session;
|
|
@@ -131,6 +144,9 @@ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
|
131
144
|
const plan = planPrune(session, keepTurns);
|
|
132
145
|
if (plan === null) return null;
|
|
133
146
|
|
|
147
|
+
const shadowed = shadowedNodes(session, plan.startSeq, plan.endSeq);
|
|
148
|
+
if (shadowed.length === 0) return null;
|
|
149
|
+
|
|
134
150
|
session.append(
|
|
135
151
|
'developer/message',
|
|
136
152
|
{
|
|
@@ -141,7 +157,10 @@ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
|
141
157
|
content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
|
|
142
158
|
},
|
|
143
159
|
},
|
|
144
|
-
{
|
|
160
|
+
{
|
|
161
|
+
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
162
|
+
sourceEventSeqs: shadowed,
|
|
163
|
+
},
|
|
145
164
|
);
|
|
146
165
|
return plan;
|
|
147
166
|
}
|
|
@@ -149,6 +168,25 @@ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
|
149
168
|
/**
|
|
150
169
|
* 挂载:每个步骤派生历史之前检查并裁剪。
|
|
151
170
|
*
|
|
171
|
+
* ⚠️ `agent/pre-step` 是 Cordis 的 **waterfall**。宿主实现:
|
|
172
|
+
*
|
|
173
|
+
* waterfall(...args) {
|
|
174
|
+
* const cbs = this.dispatch("waterfall", args)
|
|
175
|
+
* const inner = args.pop()
|
|
176
|
+
* const next = () => (cbs.shift() ?? inner)(...args)
|
|
177
|
+
* args.push(next)
|
|
178
|
+
* return next()
|
|
179
|
+
* }
|
|
180
|
+
*
|
|
181
|
+
* 也就是说:**不调用 `next()` 的监听器会否决整条链**,并把它自己的返回值当作最终结果。
|
|
182
|
+
* 曾经这里写成 `async (payload) => { …裁剪… }`(不接 next、返回 undefined),结果
|
|
183
|
+
* `agent/pre-step` 的 decision 变成 undefined,agent-loop 里 `decision.kind` 直接抛
|
|
184
|
+
* `Cannot read properties of undefined (reading 'kind')`
|
|
185
|
+
* —— 整个回合在 step 之前就崩掉,而且没有任何 step/start 事件,极难定位。
|
|
186
|
+
*
|
|
187
|
+
* 所以:无论裁剪成功、失败还是被跳过,**必须 `return next()`**,把下游的行为(默认的
|
|
188
|
+
* `{kind:'enter'}` 以及 compaction 等其它监听器)原样放行。
|
|
189
|
+
*
|
|
152
190
|
* config:
|
|
153
191
|
* - `enable` 置 true 才真的裁(默认 false —— 改写历史是强动作)
|
|
154
192
|
* - `keepTurns` 保留最近多少轮(默认 20)
|
|
@@ -161,14 +199,15 @@ export function apply(ctx, config = {}) {
|
|
|
161
199
|
// agent/pre-step 在派生历史之前运行,且拿得到本 agent。
|
|
162
200
|
ctx.effect(
|
|
163
201
|
() =>
|
|
164
|
-
ctx.on('agent/pre-step', async (payload) => {
|
|
165
|
-
const agent = payload?.agent ?? payload?.handle?.agent ?? ctx.get('agents')?.currentInitiator();
|
|
166
|
-
if (agent?.session === undefined) return;
|
|
202
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
167
203
|
try {
|
|
168
|
-
|
|
204
|
+
const agent = payload?.agent ?? payload?.handle?.agent;
|
|
205
|
+
if (agent?.session !== undefined) pruneHistory(agent, keepTurns);
|
|
169
206
|
} catch {
|
|
170
207
|
// 裁剪失败绝不影响正常对话
|
|
171
208
|
}
|
|
209
|
+
// 必须放行 waterfall:绝不吞掉 decision,否则下游读 decision.kind 会崩。
|
|
210
|
+
return next();
|
|
172
211
|
}),
|
|
173
212
|
'whale-history-window.pre-step',
|
|
174
213
|
);
|
package/src/memory.js
CHANGED
|
@@ -547,21 +547,59 @@ export function apply(ctx, config = {}) {
|
|
|
547
547
|
|
|
548
548
|
/**
|
|
549
549
|
* 每次都重新读盘:改完文件下一个模型步就生效,不用重载宿主。
|
|
550
|
-
*
|
|
550
|
+
*
|
|
551
|
+
* 注意两点:
|
|
552
|
+
* 1. 必须返回**字符串** —— 变量值为空串时 interpolate 只是替换成空,不会报错。
|
|
553
|
+
* 2. **文件空(或不存在)时也要给出绝对路径**。否则新预设开局记忆是空骨架,
|
|
554
|
+
* agent 拿到的是一段空白,连该往哪个文件写都不知道(只能靠猜 ~/.dsh/…)。
|
|
555
|
+
* 所以空文件回一段带路径的提示,注入标记也照样出现,便于审计。
|
|
551
556
|
*/
|
|
552
557
|
const read = () => {
|
|
558
|
+
const path = memoryPath(file);
|
|
553
559
|
try {
|
|
554
560
|
const { text } = composeMemory(file, { maxChars, turnLimit, detailLimit });
|
|
555
|
-
if (!text)
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
561
|
+
if (!text) {
|
|
562
|
+
return [
|
|
563
|
+
`【鲸鱼记忆 · ${file}】`,
|
|
564
|
+
`文件:${path}`,
|
|
565
|
+
'(现在还是空的 —— 想到什么就直接写进这个文件;只写这一个,别动别的预设的记忆。)',
|
|
566
|
+
].join('\n');
|
|
567
|
+
}
|
|
568
|
+
return `【鲸鱼记忆 · ${file}】\n文件:${path}\n\n${text}`;
|
|
569
|
+
} catch (err) {
|
|
570
|
+
// 读失败也要让 agent 知道路径,而不是默默变成空串
|
|
571
|
+
return [`【鲸鱼记忆 · ${file}】`, `文件:${path}`, `(这次没读出来:${String(err?.message ?? err)})`].join('\n');
|
|
559
572
|
}
|
|
560
573
|
};
|
|
561
574
|
|
|
562
575
|
// 主通道:变量。人设 prefix 里写 {{variableName}},随 complete section 一起进提示。
|
|
563
576
|
ctx.effect(() => ctx.systemPrompt.variable(variableName, read), `whale-memory.variable(${variableName})`);
|
|
564
577
|
|
|
578
|
+
/**
|
|
579
|
+
* 每轮结束(agent 转为 idle)时,把超出 turnLimit 的滚动条目**归档并瘦身文件**。
|
|
580
|
+
*
|
|
581
|
+
* 为什么放这里而不是 `read()`:`read()` 是组装热路径(每次组装都会跑),
|
|
582
|
+
* 在那里写盘既慢又容易和 agent 自己的编辑打架 —— `pruneMemoryFile` 的注释里
|
|
583
|
+
* 也写明了「组装热路径不会调用」。而 idle 一轮只发生一次,时机正好。
|
|
584
|
+
*
|
|
585
|
+
* 不这么做的话:注入会按 turnLimit 截断(只喂最新 80 条),但文件本身无限增长,
|
|
586
|
+
* `archive/` 目录永远空着 —— 与「超过 80 条就不断覆盖」的约定不符。
|
|
587
|
+
*/
|
|
588
|
+
ctx.effect(
|
|
589
|
+
() =>
|
|
590
|
+
typeof ctx.on === 'function' &&
|
|
591
|
+
ctx.on('agent/status', ({ status }) => {
|
|
592
|
+
if (status !== 'idle') return;
|
|
593
|
+
try {
|
|
594
|
+
const { totalRoll } = composeMemory(file, { maxChars, turnLimit, detailLimit });
|
|
595
|
+
if (totalRoll > turnLimit) pruneMemoryFile(file, turnLimit, { detailLimit });
|
|
596
|
+
} catch {
|
|
597
|
+
// 整理失败不影响对话
|
|
598
|
+
}
|
|
599
|
+
}),
|
|
600
|
+
`whale-memory.trim(${file})`,
|
|
601
|
+
);
|
|
602
|
+
|
|
565
603
|
// 备用通道:仅在明确要求时注册独立 section(非 complete 预设才用得上)。
|
|
566
604
|
if (config.emitSection === true) {
|
|
567
605
|
const sectionName =
|