@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/lib/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/lib/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" }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.7.12",
4
+ "version": "0.7.13",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -62,12 +62,14 @@
62
62
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
63
63
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
64
64
  "@deepseek-ai/schemastery": "^3.18.1",
65
+ "c8": "^12.0.0",
65
66
  "lucide": "^1.42.0"
66
67
  },
67
68
  "scripts": {
68
69
  "sync": "node scripts/sync-lib.js",
69
70
  "prepack": "npm run sync",
70
71
  "test": "node --test test/*.test.js",
72
+ "test:coverage": "c8 node --test test/*.test.js",
71
73
  "e2e": "node scripts/e2e-dsh.js",
72
74
  "stress": "node scripts/stress-dsh.js"
73
75
  },
package/src/config.js CHANGED
@@ -9,8 +9,39 @@ export const Config = z.object({
9
9
  // active provider/model (same as before).
10
10
  summarizeProvider: z.string().default(""),
11
11
  summarizeModel: z.string().default(""),
12
+ // 蒸馏转录上限(字符)。借鉴 Codex「保留原始、替代压缩摘要」的思路:
13
+ // 蒸馏把完整对话上下文交给 LLM 提炼,不硬裁到 8000 字就截断语义;默认
14
+ // 24000 字符(约覆盖一整轮中等对话),需要更完整可调大。
15
+ distillMaxChars: z.natural().min(1000).max(200000).default(24000),
16
+ // 智能调速器(429 保护,默认开):蒸馏 LLM 调用全局串行排队,相邻请求
17
+ // 间隔 distillRateLimitIntervalMs(默认 1s 一次);命中 429 限流时按
18
+ // distillRateLimitBaseDelayMs 指数退避(1s→2s→4s…)自动重试
19
+ // distillRateLimitRetries 次,全程对用户透明,不把 429 错误码抛给用户。
20
+ distillRateLimitIntervalMs: z.natural().min(0).max(60000).default(1000),
21
+ distillRateLimitRetries: z.natural().min(0).max(10).default(3),
22
+ distillRateLimitBaseDelayMs: z.natural().min(100).max(60000).default(1000),
12
23
  maxInjectedItems: z.natural().min(1).max(20).default(5),
13
24
  importanceThreshold: z.natural().min(1).max(5).default(3),
25
+ // 编码记忆蒸馏(codingRetrospect,opt-in,默认关)。开启时,turn/end 蒸馏
26
+ // 额外提取三类编码专属记忆:rejected_solution(被否决方案)/ pitfall(踩坑)/
27
+ // constraint(工程约束)。蒸馏上下文为整轮完整对话(用户输入 → 助手思考/回答
28
+ // → 工具调用与结果 → 代码执行),不再只看用户消息,便于提炼踩坑根因。
29
+ // 关闭时行为与之前完全一致。
30
+ codingRetrospect: z.boolean().default(false),
31
+ // 编码任务识别词表(读取侧门控用):命中即视为编码类任务,编码记忆才注入。
32
+ codingKeywords: z.array(z.string()).default([
33
+ "代码", "编码", "写一个", "写个", "实现", "函数", "方法", "类",
34
+ "接口", "bug", "调试", "报错", "错误", "异常", "堆栈", "脚本",
35
+ "python", "javascript", "typescript", "node", "js", "ts",
36
+ "sql", "sqlite", "数据库", "算法", "重构", "优化", "性能",
37
+ "测试", "单测", "修复", "补丁", "依赖", "npm", "pip",
38
+ "命令行", "shell", "配置", "配置文件", "yaml", "json",
39
+ "插件", "开发", "编译", "构建", "部署", "git", "commit",
40
+ "review", "前端", "后端", "页面", "组件", "dsh", "memos"
41
+ ]),
42
+ // 编码记忆注入加权系数:编码任务时对 rejected_solution/pitfall/constraint
43
+ // 记忆的 importance 乘以该系数排序,让编码记忆在编码场景更靠前。
44
+ codingBoostFactor: z.number().min(1).max(5).default(2),
14
45
  autoDream: z.boolean().default(true),
15
46
  dreamThresholdCount: z.natural().min(1).max(1000).default(10),
16
47
  dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
@@ -34,7 +34,10 @@ const TYPE_LABELS = {
34
34
  decision: ["decision", "决策", "决定"],
35
35
  history: ["history", "历史", "事件"],
36
36
  summary: ["summary", "总结", "摘要", "总览"],
37
- pattern: ["pattern", "模式", "规律"]
37
+ pattern: ["pattern", "模式", "规律"],
38
+ rejected_solution: ["rejected_solution", "被否决", "废弃方案"],
39
+ pitfall: ["pitfall", "踩坑"],
40
+ constraint: ["constraint", "约束"]
38
41
  };
39
42
 
40
43
  /** Normalized bigram-overlap similarity in [0,1]; 0 for tiny/empty inputs. */
package/src/service.js CHANGED
@@ -4,7 +4,21 @@ import { evaluateMemoryQuality } from "./quality-filter.js";
4
4
  import { createBM25Index } from "./search/bm25.js";
5
5
  import { adaptiveThreshold } from "./search/adaptive.js";
6
6
 
7
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
7
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint"]);
8
+
9
+ // 编码记忆类型(codingRetrospect):rejected_solution / pitfall / constraint
10
+ // 只在编码任务时注入(防噪声污染其他业务),且编码场景下按 codingBoostFactor
11
+ // 加权排序提前。
12
+ const CODING_MEMORY_TYPES = new Set(["rejected_solution", "pitfall", "constraint"]);
13
+
14
+ /**
15
+ * 判断一段文本是否编码类任务(关键词匹配,codingRetrospect 读取侧门控)。
16
+ * 纯函数,无副作用,便于单测。
17
+ */
18
+ export function isCodingTask(text, keywords = []) {
19
+ const t = String(text ?? "").toLowerCase();
20
+ return keywords.some((kw) => t.includes(String(kw).toLowerCase()));
21
+ }
8
22
 
9
23
  // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
10
24
  // each recall candidate's existing score is multiplied by the weight of its
@@ -863,17 +877,35 @@ export function createService({ store, mirror, config, onWrite, logger }) {
863
877
  */
864
878
  function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
865
879
  const q = String(query ?? "").trim();
880
+ // codingRetrospect 读取侧门控:编码记忆(rejected_solution / pitfall /
881
+ // constraint)只在编码任务时注入,防噪声污染其他业务;编码任务时按
882
+ // codingBoostFactor 加权,让编码记忆在编码场景更靠前。
883
+ const isCoding = isCodingTask(q, config.codingKeywords ?? []);
884
+ const codingGate = (m) => isCoding || !CODING_MEMORY_TYPES.has(m.type);
866
885
  // Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
867
886
  // (quality_score null) count as 100 (weight 1), so legacy stores keep their
868
887
  // exact summary>preference>importance ordering.
869
888
  const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
870
889
  const items = store.list({ limit: 200, includeForgotten: false })
871
890
  .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
891
+ codingGate(m) &&
872
892
  (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
873
893
  .sort((a, b) => {
874
- const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
875
- const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
876
- return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
894
+ // 编码记忆在编码任务时优先于普通 decision(与 preference 同级),
895
+ // importance codingBoostFactor 加权(封顶 5,保持 importance 语义)。
896
+ const priority = (m) => {
897
+ if (m.type === "summary") return 0;
898
+ if (m.type === "preference") return 1;
899
+ if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1;
900
+ return 2;
901
+ };
902
+ const effImportance = (m) =>
903
+ (isCoding && CODING_MEMORY_TYPES.has(m.type))
904
+ ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2))
905
+ : m.importance;
906
+ const pa = priority(a);
907
+ const pb = priority(b);
908
+ return pa - pb || (effImportance(b) * qualityWeight(b)) - (effImportance(a) * qualityWeight(a));
877
909
  });
878
910
  let candidates = items;
879
911
  if (config.hybridInject !== false && q) {
@@ -888,6 +920,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
888
920
  const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
889
921
  for (const m of hits) {
890
922
  if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
923
+ codingGate(m) &&
891
924
  (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
892
925
  semanticItems.push(m);
893
926
  }
@@ -896,7 +929,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
896
929
  }
897
930
  if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
898
931
  for (const m of lastSemanticRecall.items) {
899
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten) semanticItems.push(m);
932
+ if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten && codingGate(m)) semanticItems.push(m);
900
933
  }
901
934
  }
902
935
  if (semanticItems.length) {
package/src/store.js CHANGED
@@ -242,7 +242,7 @@ CREATE TABLE IF NOT EXISTS mirror_state (
242
242
  // Exported for API-layer type validation (standalone API POST /memories and
243
243
  // the /status byType breakdown); the set itself stays the single source of
244
244
  // truth for what store.save accepts.
245
- export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
245
+ export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "rejected_solution", "pitfall", "constraint"]);
246
246
 
247
247
  // Epistemic status: what kind of evidence a memory rests on. Defaults to
248
248
  // 'subjective' so legacy rows (and rows without any signal) stay compatible.