dsh-whale-loli 0.5.1 → 0.5.3

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
@@ -25,7 +25,10 @@
25
25
  - **每轮都记**:不是挑着记。短对话直接记;长对话**先精简概括**再记(压成一两句说清聊了什么、结论是什么)。
26
26
  - **80 条上限只算「滚动摘要」**:`# 固定记忆` 不占这个名额。
27
27
  - **想长期留的就往上挪**:某条滚动摘要以后还用得上,就总结一句写进 `# 固定记忆`;否则到点会滚出去(并归档)。
28
- - 上限有两道保险:注入时只取最近 80 条(文件里堆再多也只进 80 条);`appendRolling()` / `pruneMemoryFile()` 可把文件本身裁到 80 条并自动归档。
28
+ - 上限有两道保险:注入时只取最近 80 条(文件里堆再多也只进 80 条);**每轮结束(agent 转 idle)时自动**把文件本身裁到 80 条,超出的写进 `archive/<预设名>/`。
29
+ - 为什么放在 idle 而不是组装时:组装是热路径、每次都要跑,在那里写盘既慢又容易和 agent 正在编辑的文件打架。
30
+ - 不足 80 条时**完全不动文件**(不产生无谓写盘)。
31
+ - **记忆文件还是空的时候也会注入一段带绝对路径的提示**:否则新预设开局拿到一片空白,agent 连该往哪个文件写都不知道。
29
32
  - 总长顶到 `maxChars`(16000)时,**先丢最旧的滚动摘要,再丢详细区**,固定记忆最后才动。
30
33
 
31
34
  ### 每轮先看什么
@@ -93,6 +96,18 @@ Cannot read properties of undefined (reading 'kind')
93
96
  连 `step/start` 都没有,极难定位。所以无论裁剪成功、失败还是跳过,都必须 `return next()`。
94
97
  `verify-history-window.mjs` 第 9 组用例复刻了这个 waterfall 语义,并保留了一个「老写法必崩」的反例。
95
98
 
99
+ #### ⚠️ 裁剪写的事件必须带合法 `turn`
100
+
101
+ 宿主的会话格式校验(`dsh-session-format-v3-to-v4`)要求 `developer/message` 的 `turn` 是**正整数**。
102
+ 写死 `turn: 0` 会抛:
103
+
104
+ ```
105
+ developer/message turn must be positive
106
+ ```
107
+
108
+ 所以 `pruneHistory()` 从 `agent/pre-step` 的 payload 里取真实的 `turn` / `step` 透传;
109
+ **拿不到正数 turn 就跳过本次裁剪**(宁可不裁,也不写非法事件)。第 10 组用例覆盖这个分支。
110
+
96
111
  ### 为什么用「提示变量」而不是 section(重要)
97
112
 
98
113
  `@deepseek-ai/dsh-system-prompt` 的 `assemble()` 末尾有这样一段:
@@ -213,6 +228,7 @@ dsh-whale-loli/
213
228
  ├── verify-memory-injection.mjs # 证明变量通道有效、section 通道被丢弃
214
229
  ├── verify-memory-isolation.mjs # 实测三个记忆文件不串
215
230
  ├── verify-memory-rolling.mjs # 实测四区结构与 80 条滚动 + 归档
231
+ ├── verify-memory-archive.mjs # 超 80 条时真的归档并瘦身(idle 触发,三预设互不覆盖)
216
232
  ├── verify-peers.mjs # 校验 peer 范围覆盖 harness 各发布档
217
233
  ├── verify-listing.mjs # 复刻插件目录的收录门槛自查
218
234
  └── prove-collision.mjs # 复现并证明重复注册冲突
@@ -235,6 +251,7 @@ node verify-history-window.mjs # 真的裁到最近 N 轮(不切
235
251
  node verify-memory-injection.mjs # 变量通道有效 / section 通道被丢弃
236
252
  node verify-memory-isolation.mjs # 记忆文件端到端隔离(三个预设)
237
253
  node verify-memory-rolling.mjs # 固定记忆永久 + 滚动摘要 80 条 + 归档
254
+ node verify-memory-archive.mjs # 超 80 条自动归档瘦身(idle 触发、三预设隔离)
238
255
  node smoke-memory.mjs # 记忆变量冒烟
239
256
  node verify-peers.mjs # peer 范围无预发布坑
240
257
  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.1",
4
- "description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆 + 真正生效的 20 轮历史窗口。记忆分「固定记忆」(永久保留)/「滚动摘要」(每轮都记,最多 80 条 FIFO)/「最近详细区」/「归档索引」四区,经提示变量注入(persona 为 complete:true,独立 section 会被宿主丢弃);history-window 用宿主表层替换真的裁掉 20 轮以前的历史,不调 LLM、不切断工具配对,并正确放行 agent/pre-step 的 waterfall。",
3
+ "version": "0.5.3",
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": [
@@ -137,10 +137,23 @@ export function shadowedNodes(session, startSeq, endSeq) {
137
137
  return nodes.filter((seq) => seq >= startSeq && seq <= endSeq);
138
138
  }
139
139
 
140
- /** 执行裁剪。返回计划;无需裁剪返回 null。 */
141
- export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
140
+ /**
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 就跳过这一轮(宁可不裁,也不写非法事件)。
148
+ */
149
+ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS, position = {}) {
142
150
  const session = agent?.session;
143
151
  if (session === undefined) return null;
152
+
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
+
144
157
  const plan = planPrune(session, keepTurns);
145
158
  if (plan === null) return null;
146
159
 
@@ -150,8 +163,8 @@ export function pruneHistory(agent, keepTurns = DEFAULT_KEEP_TURNS) {
150
163
  session.append(
151
164
  'developer/message',
152
165
  {
153
- turn: 0,
154
- step: 0,
166
+ turn,
167
+ step,
155
168
  message: {
156
169
  role: 'developer',
157
170
  content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
@@ -196,13 +209,16 @@ export function apply(ctx, config = {}) {
196
209
 
197
210
  const keepTurns = typeof config.keepTurns === 'number' ? config.keepTurns : DEFAULT_KEEP_TURNS;
198
211
 
199
- // agent/pre-step 在派生历史之前运行,且拿得到本 agent。
212
+ // agent/pre-step 在派生历史之前运行,且拿得到本 agent 与当前 turn/step。
200
213
  ctx.effect(
201
214
  () =>
202
215
  ctx.on('agent/pre-step', async (payload, next) => {
203
216
  try {
204
217
  const agent = payload?.agent ?? payload?.handle?.agent;
205
- if (agent?.session !== undefined) pruneHistory(agent, keepTurns);
218
+ if (agent?.session !== undefined) {
219
+ // turn/step 必须原样透传给 pruneHistory:宿主校验器要求 turn 为正整数。
220
+ pruneHistory(agent, keepTurns, { turn: payload?.turn, step: payload?.step });
221
+ }
206
222
  } catch {
207
223
  // 裁剪失败绝不影响正常对话
208
224
  }
package/src/memory.js CHANGED
@@ -547,21 +547,59 @@ export function apply(ctx, config = {}) {
547
547
 
548
548
  /**
549
549
  * 每次都重新读盘:改完文件下一个模型步就生效,不用重载宿主。
550
- * 注意必须返回字符串 —— 变量为空串时 interpolate 会把它替换成空,不会报错。
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) return '';
556
- return `【鲸鱼记忆 · ${file}】\n文件:${memoryPath(file)}\n\n${text}`;
557
- } catch {
558
- return '';
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 =