@modusensus/dsh-mneme 0.7.12 → 0.7.13

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/src/summarize.js CHANGED
@@ -1,9 +1,22 @@
1
1
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
 
3
- const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
4
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
3
+ const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。
4
+ 原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
5
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
5
6
  不要输出任何其他文字。`;
6
7
 
8
+ // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
+ // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
10
+ // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
11
+ const CODING_SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手思考/回答、工具调用与结果),提炼值得跨会话记住的原子记忆。
12
+ 原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
13
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|rejected_solution|pitfall|constraint","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
14
+ 若对话涉及编码/调试,可额外提取编码类记忆:
15
+ - rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)
16
+ - pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)
17
+ - constraint:项目工程约束(content 含约束描述 + 来源)
18
+ 普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。`;
19
+
7
20
  /** Extract a JSON array from LLM output that may contain prose around it. */
8
21
  export function parseSummaryJson(raw) {
9
22
  const text = String(raw ?? "");
@@ -17,7 +30,7 @@ export function parseSummaryJson(raw) {
17
30
  return [];
18
31
  }
19
32
  if (!Array.isArray(arr)) return [];
20
- const VALID = new Set(["preference", "project", "decision", "history"]);
33
+ const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
21
34
  return arr.filter(
22
35
  (item) =>
23
36
  item &&
@@ -76,22 +89,108 @@ function toProtocolChunk(chunk) {
76
89
  // events must not leak into the memory store. Events without a data payload
77
90
  // (minimal test doubles) pass the kind check and are handled by the content
78
91
  // check below.
79
- // DSH ≥0.1.2-rc removed the Session.events property; events are only reachable
80
- // via snapshotEvents(). Older DSH builds still expose .events, so fall back.
81
- function getSessionEvents(session) {
82
- return session?.snapshotEvents?.() ?? session?.events ?? [];
92
+ //
93
+ // codingRetrospect: the distill context is the FULL turn transcript
94
+ // user prompts plus assistant thinking/replies, tool calls + results and code
95
+ // dispatch output so the summarizer can see tool errors and extract pitfall
96
+ // root causes, not just what the user typed. The same filtering stays: only
97
+ // source.kind === "user" prompts enter (plugin/machine content is excluded).
98
+ // The result is a single text transcript passed to the LLM as one user message
99
+ // (SUMMARY_PROMPT already says "根据下面的会话内容").
100
+ function collectMessages(session, maxChars = 8000) {
101
+ // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events
102
+ const events = session.snapshotEvents?.() ?? session.events ?? [];
103
+ const lines = [];
104
+ // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
105
+ // 直接传字符串数组)。text 之外按需抽 thinking/reasoning 块。
106
+ const textOf = (content) => {
107
+ if (typeof content === "string") return content;
108
+ if (!Array.isArray(content)) return "";
109
+ return content
110
+ .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
111
+ .filter((s) => s)
112
+ .join("\n");
113
+ };
114
+ const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
115
+ for (const event of events) {
116
+ const data = event?.data ?? {};
117
+ const kind = data?.source?.kind;
118
+ switch (event.type) {
119
+ case "user/message": {
120
+ if (kind !== undefined && kind !== "user") break;
121
+ const text = textOf(data?.content);
122
+ if (text.trim()) lines.push(`用户:${text}`);
123
+ break;
124
+ }
125
+ case "assistant/message": {
126
+ const msg = data?.message;
127
+ const blocks = Array.isArray(msg?.content) ? msg.content : [];
128
+ const text = textOf(blocks);
129
+ if (text.trim()) lines.push(`助手:${text}`);
130
+ const thinking = blocks
131
+ .map((b) => (typeof b === "string" ? "" : (b && b.type === "reasoning" && typeof b.text === "string" ? b.text : "")))
132
+ .filter((s) => s)
133
+ .join("\n");
134
+ if (thinking.trim()) lines.push(`助手思考:${thinking}`);
135
+ break;
136
+ }
137
+ case "tool/call": {
138
+ const args = typeof data.arguments === "string"
139
+ ? data.arguments
140
+ : data.arguments ? JSON.stringify(data.arguments) : "";
141
+ lines.push(`工具调用:${data.name ?? "?"}(${trim(args, 300)})`);
142
+ break;
143
+ }
144
+ case "tool/result": {
145
+ const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
146
+ const status = data.ok === false ? "失败" : "成功";
147
+ lines.push(`工具结果(${status}):${trim(out, 500)}`);
148
+ break;
149
+ }
150
+ case "tool/code-dispatch": {
151
+ const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
152
+ const status = data.ok === false ? "失败" : "成功";
153
+ lines.push(`代码执行(${status}):${trim(out, 500)}`);
154
+ break;
155
+ }
156
+ default:
157
+ break;
158
+ }
159
+ }
160
+ return lines.length ? [createUserMessage({ content: [{ type: "text", text: trim(lines.join("\n"), maxChars) }] })] : [];
83
161
  }
84
162
 
85
- function collectMessages(session) {
86
- const messages = [];
87
- for (const event of getSessionEvents(session)) {
88
- const kind = event.data?.source?.kind;
89
- if (event.type !== "user/message") continue;
90
- if (kind !== undefined && kind !== "user") continue;
91
- if (!event.data?.content?.length) continue; // nothing to summarize
92
- messages.push(createUserMessage({ content: event.data.content }));
93
- }
94
- return messages.slice(-20);
163
+ // ── 智能调速器(429 保护)─────────────────────────────────────────────
164
+ // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
165
+ // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
166
+ // distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
167
+ // 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
168
+ // 不把 429 错误码直接抛出去。
169
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
170
+
171
+ function isRateLimited(error) {
172
+ const status = error?.status ?? error?.statusCode ?? error?.response?.status;
173
+ if (status === 429) return true;
174
+ const msg = String(error?.message ?? error ?? "");
175
+ return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
176
+ }
177
+
178
+ // 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
179
+ // 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
180
+ // 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
181
+ let distillQueue = Promise.resolve();
182
+ let lastDistillEnd = 0;
183
+ function enqueueDistill(task, intervalMs = 0) {
184
+ const run = distillQueue.then(async () => {
185
+ if (intervalMs > 0) {
186
+ const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
187
+ if (wait > 0) await sleep(wait);
188
+ }
189
+ lastDistillEnd = Date.now();
190
+ return task();
191
+ });
192
+ distillQueue = run.catch(() => {});
193
+ return run;
95
194
  }
96
195
 
97
196
  export function createSummarizer(ctx, service, config) {
@@ -104,7 +203,7 @@ export function createSummarizer(ctx, service, config) {
104
203
  if (disposed || inFlight.has(session.id)) return;
105
204
  const controller = new AbortController();
106
205
  inFlight.set(session.id, controller);
107
- // Bug8: audit state for the compression call. null = no audit for this run
206
+ // audit state for the compression call. null = no audit for this run
108
207
  // (disabled, or no LLM call was actually made). The audit row is written in
109
208
  // the finally below — once, regardless of which exit path the call took —
110
209
  // so a failed/aborted stream still leaves a status='error' trail without
@@ -119,7 +218,9 @@ export function createSummarizer(ctx, service, config) {
119
218
  ? { provider: header.provider, model: header.model }
120
219
  : undefined;
121
220
  if (!route) return;
122
- const messages = collectMessages(session);
221
+ // 完整转录(Codex 式):蒸馏把整轮对话交给 LLM 提炼原子记忆,不再硬裁
222
+ // 8000 字截断语义;上限由 distillMaxChars 控制(默认 24000,可调大)。
223
+ const messages = collectMessages(session, config.distillMaxChars ?? 24000);
123
224
  if (!messages.length) return;
124
225
 
125
226
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
@@ -134,61 +235,98 @@ export function createSummarizer(ctx, service, config) {
134
235
  };
135
236
  }
136
237
 
137
- const assembler = new BlockAssembler();
138
- let text = "";
139
238
  const options = {
140
239
  provider: route.provider,
141
240
  model: route.model,
142
241
  purpose: "summarization",
143
242
  messages: [
144
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
243
+ { role: "system", content: [{ type: "text", text: config.codingRetrospect ? CODING_SUMMARY_PROMPT : SUMMARY_PROMPT }] },
145
244
  ...messages
146
245
  ],
147
246
  signal: controller.signal
148
247
  };
149
- try {
150
- for await (const chunk of ctx.llm.stream(options)) {
151
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
152
- if (chunk.type === "text-delta") {
153
- text += chunk.text ?? chunk.delta ?? "";
154
- }
155
- if (chunk.type === "usage" && audit) {
156
- const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
157
- const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
158
- if (Number.isFinite(i)) audit.inputTokens = i;
159
- if (Number.isFinite(o)) audit.outputTokens = o;
160
- }
161
- if (chunk.type === "finish") {
162
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
163
- if (reasonKind === "error" || reasonKind === "aborted") {
164
- if (audit) {
165
- audit.status = "error";
166
- audit.errorMessage = `llm stream ${reasonKind}`;
248
+ // 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
249
+ // 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
250
+ const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
251
+ const { text, assembledText, aborted } = await enqueueDistill(async () => {
252
+ const retries = config.distillRateLimitRetries ?? 3;
253
+ const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
254
+ for (let attempt = 0; ; attempt++) {
255
+ const assembler = new BlockAssembler();
256
+ let text = "";
257
+ let aborted = false;
258
+ try {
259
+ for await (const chunk of ctx.llm.stream(options)) {
260
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
261
+ if (chunk.type === "text-delta") {
262
+ text += chunk.text ?? chunk.delta ?? "";
263
+ }
264
+ if (chunk.type === "usage" && audit) {
265
+ const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
266
+ const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
267
+ if (Number.isFinite(i)) audit.inputTokens = i;
268
+ if (Number.isFinite(o)) audit.outputTokens = o;
167
269
  }
168
- return;
270
+ if (chunk.type === "finish") {
271
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
272
+ if (reasonKind === "error" || reasonKind === "aborted") {
273
+ // 429 也可能以 finish reason error 携带 rate-limit 信息,统一
274
+ // 转抛错走指数退避重试。
275
+ if (isRateLimited(chunk.reason ?? chunk)) {
276
+ throw Object.assign(new Error("rate limited"), { status: 429 });
277
+ }
278
+ if (audit) {
279
+ audit.status = "error";
280
+ audit.errorMessage = `llm stream ${reasonKind}`;
281
+ }
282
+ aborted = true;
283
+ break;
284
+ }
285
+ }
286
+ }
287
+ // Direct delta accumulation is the primary extraction path (it works
288
+ // for real protocol chunks {index,text} and looser {delta} shapes
289
+ // alike); the assembler blocks are a fallback for streams that only
290
+ // deliver text inside block-end. This dsh-llm exposes no public
291
+ // no-arg assemble() — blocks() is the message-level API.
292
+ const blocks = assembler.blocks();
293
+ const assembledText = blocks
294
+ .filter((b) => b.type === "text")
295
+ .map((b) => b.text ?? "")
296
+ .join("");
297
+ return { text, assembledText, aborted };
298
+ } catch (error) {
299
+ if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
300
+ if (isRateLimited(error) && attempt < retries) {
301
+ const delay = baseDelayMs * 2 ** attempt;
302
+ ctx.logger?.warn?.(
303
+ `dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
304
+ );
305
+ await sleep(delay);
306
+ continue;
307
+ }
308
+ // 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
309
+ if (audit) {
310
+ audit.status = "error";
311
+ audit.errorMessage = String(error?.message ?? error);
169
312
  }
313
+ throw error;
170
314
  }
171
315
  }
172
- } catch (error) {
173
- if (audit) {
174
- audit.status = "error";
175
- audit.errorMessage = String(error?.message ?? error);
176
- }
177
- throw error; // caller's catch handles the failure; audit already staged
178
- }
179
- // Direct delta accumulation is the primary extraction path (it works
180
- // for real protocol chunks {index,text} and looser {delta} shapes
181
- // alike); the assembler blocks are a fallback for streams that only
182
- // deliver text inside block-end. This dsh-llm exposes no public
183
- // no-arg assemble() — blocks() is the message-level API.
184
- const blocks = assembler.blocks();
185
- const assembledText = blocks
186
- .filter((b) => b.type === "text")
187
- .map((b) => b.text ?? "")
188
- .join("");
316
+ }, intervalMs);
317
+ if (aborted) return;
189
318
  const entries = parseSummaryJson(text || assembledText);
190
319
  for (const entry of entries) {
191
- service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
320
+ // Provenance: the summarizer runs on a real session (turn/end hook), so
321
+ // session.id is always available here — it rides the human-readable
322
+ // source label.
323
+ // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
324
+ // (rejected_solution/pitfall/constraint)区分即可,tag 体系
325
+ // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
326
+ service.saveWithDedupe({
327
+ ...entry,
328
+ source: `session:${session.id}`
329
+ });
192
330
  }
193
331
  } finally {
194
332
  if (audit) {
package/src/tools.js CHANGED
@@ -30,7 +30,7 @@ export function createTools(ctx, service, config, embedder) {
30
30
  "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
31
31
  "Merges into an existing entry of the same type when the title matches.",
32
32
  parameters: {
33
- type: { type: "string", required: true, enum: ["preference", "project", "decision", "history"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary" },
33
+ type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary; rejected_solution=rejected/abandoned implementation approach; pitfall=debugging lesson (symptom+root cause+fix); constraint=engineering constraint" },
34
34
  title: { type: "string", required: true, description: "Short unique title" },
35
35
  content: { type: "string", required: true, description: "Memory body" },
36
36
  tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
@@ -100,7 +100,7 @@ export function createTools(ctx, service, config, embedder) {
100
100
  name: "memory_list",
101
101
  description: "List memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored.",
102
102
  parameters: {
103
- type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
103
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "Filter by type; omit for all" },
104
104
  limit: { type: "integer", description: "Page size (default 50)" },
105
105
  offset: { type: "integer", description: "Page offset (default 0)" },
106
106
  include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
@@ -138,7 +138,7 @@ export function createTools(ctx, service, config, embedder) {
138
138
  id: { type: "string", required: true, description: "Memory id" },
139
139
  title: { type: "string" },
140
140
  content: { type: "string" },
141
- type: { type: "string", enum: ["preference", "project", "decision", "history"] },
141
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"] },
142
142
  tags: { type: "array", items: { type: "string" } },
143
143
  importance: { type: "integer", description: "1-5" },
144
144
  reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
@@ -0,0 +1,10 @@
1
+ // Multi-process concurrency worker for test/peer-blockers.test.js:
2
+ // opens the SQLite store passed via argv and increments the mirror
3
+ // generation N times, so several real OS processes contend on one DB file.
4
+ // Usage: node test/helpers/peer-worker.mjs <dbPath> <iterations>
5
+ import { createStore } from "../../src/store.js";
6
+
7
+ const [, , dbPath, iterations] = process.argv;
8
+ const store = createStore(dbPath);
9
+ for (let i = 0; i < Number(iterations); i++) store.incrementGeneration();
10
+ store.close();
@@ -11,7 +11,6 @@ import { createMirror } from "../src/mirror.js";
11
11
  import { createService } from "../src/service.js";
12
12
 
13
13
  const execFileP = promisify(execFile);
14
- const STORE_PATH = fileURLToPath(new URL("../src/store.js", import.meta.url));
15
14
 
16
15
  /**
17
16
  * v0.3.8 回归测试(audit peer 6 项运行时阻断 → INSTALLATION_NOT_APPROVED)。
@@ -98,16 +97,12 @@ test("peer-C: 多进程并发原子递增——8 进程×10 次 incrementGenerat
98
97
  const N = 8;
99
98
  const M = 10;
100
99
  try {
101
- // 子进程脚本:打开同一 DB 文件,原子递增 M 次
102
- const worker = `
103
- const { createStore } = require(process.argv[1]);
104
- const store = createStore(process.argv[2]);
105
- for (let i = 0; i < ${M}; i++) { store.incrementGeneration(); }
106
- store.close();
107
- `;
100
+ // 子进程:静态 worker 脚本(test/helpers/peer-worker.mjs,可审查、无动态
101
+ // 生成代码),execFile 参数数组、无 shell;DB 路径与迭代数经 argv 传入。
102
+ const workerPath = fileURLToPath(new URL("./helpers/peer-worker.mjs", import.meta.url));
108
103
  await Promise.all(
109
104
  Array.from({ length: N }, () =>
110
- execFileP(process.execPath, ["-e", worker, STORE_PATH, dbPath], { timeout: 30000 })
105
+ execFileP(process.execPath, [workerPath, dbPath, String(M)], { timeout: 30000 })
111
106
  )
112
107
  );
113
108
  const store = createStore(dbPath);
@@ -205,3 +205,119 @@ test("falls back to session header when summarize config is empty", async () =>
205
205
  assert.equal(calls[0].provider, "deepseek");
206
206
  assert.equal(calls[0].model, "deepseek-chat");
207
207
  });
208
+
209
+ // ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
210
+
211
+ test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
212
+ const timeline = [];
213
+ const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
214
+ stream() {
215
+ return (async function* () {
216
+ timeline.push(`start:${Date.now()}`);
217
+ await new Promise((r) => setTimeout(r, 8));
218
+ yield { type: "block-start", block: { type: "text" } };
219
+ yield { type: "text-delta", delta: "[]" };
220
+ yield { type: "finish", kind: "ok" };
221
+ timeline.push(`end:${Date.now()}`);
222
+ })();
223
+ }
224
+ });
225
+ const handler = events.find((e) => e.name === "session/event").fn;
226
+ const mkSession = (id) => ({
227
+ id,
228
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
229
+ events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
230
+ });
231
+ // 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
232
+ await Promise.all([
233
+ handler(mkSession("a"), { seq: 2, type: "turn/end" }),
234
+ handler(mkSession("b"), { seq: 2, type: "turn/end" })
235
+ ]);
236
+ assert.equal(timeline.length, 4); // 每个流 start + end
237
+ const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
238
+ const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
239
+ assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
240
+ });
241
+
242
+ test("retries with exponential backoff on 429 and still stores entries", async () => {
243
+ let attempts = 0;
244
+ const { events, store } = setup(
245
+ { distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
246
+ {
247
+ stream() {
248
+ return (async function* () {
249
+ attempts++;
250
+ if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
251
+ yield { type: "block-start", block: { type: "text" } };
252
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
253
+ yield { type: "finish", kind: "ok" };
254
+ })();
255
+ }
256
+ }
257
+ );
258
+ const handler = events.find((e) => e.name === "session/event").fn;
259
+ const session = {
260
+ id: "s9",
261
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
262
+ events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
263
+ };
264
+ const startedAt = Date.now();
265
+ await handler(session, { seq: 2, type: "turn/end" });
266
+ // 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
267
+ assert.equal(attempts, 3);
268
+ assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
269
+ assert.equal(store.count(), 1);
270
+ assert.equal(store.all()[0].title, "重试成功");
271
+ });
272
+
273
+ test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
274
+ const { events, calls } = setup();
275
+ const handler = events.find((e) => e.name === "session/event").fn;
276
+ const session = {
277
+ id: "s10",
278
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
279
+ events: [
280
+ userMessage("帮我修这个 bug"),
281
+ { seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
282
+ { seq: 3, type: "tool/result", data: { ok: false, output: "TypeError: x is not a function" } },
283
+ { seq: 4, type: "tool/code-dispatch", data: { ok: true, output: "fixed" } },
284
+ { seq: 5, type: "turn/end" }
285
+ ]
286
+ };
287
+ await handler(session, { seq: 5, type: "turn/end" });
288
+ const transcript = JSON.stringify(calls[0].messages);
289
+ assert.ok(transcript.includes("修这个 bug"));
290
+ assert.ok(transcript.includes("TypeError: x is not a function"));
291
+ assert.ok(transcript.includes("代码执行"));
292
+ // 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
293
+ assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
294
+ });
295
+
296
+ test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
297
+ const { events, store, calls } = setup({ codingRetrospect: true }, {
298
+ stream() {
299
+ return (async function* () {
300
+ yield { type: "block-start", block: { type: "text" } };
301
+ yield { type: "text-delta", delta: JSON.stringify([
302
+ { type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
303
+ ]) };
304
+ yield { type: "finish", kind: "ok" };
305
+ })();
306
+ }
307
+ });
308
+ const handler = events.find((e) => e.name === "session/event").fn;
309
+ const session = {
310
+ id: "s11",
311
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
312
+ events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
313
+ };
314
+ await handler(session, { seq: 2, type: "turn/end" });
315
+ assert.equal(store.count(), 1);
316
+ const m = store.all()[0];
317
+ assert.equal(m.type, "rejected_solution");
318
+ // 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
319
+ // sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
320
+ assert.deepEqual(m.tags, []);
321
+ // 编码模式用编码 prompt(含 rejected_solution 类型说明)。
322
+ assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
323
+ });