@modusensus/dsh-mneme 0.4.3 → 0.4.4

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
@@ -162,6 +162,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
162
162
  | **v0.4.0** | ✅ 完成 | 系统级睡眠 Sleep Mode | 空闲触发的四阶段深度维护(冲突消解 / 归档降级 / 模式发现 / 关系补全)、分层压缩、可中断串行 fail-safe;471 测试全绿 |
163
163
  | **v0.4.2** | ✅ 完成 | autoSummarize 自定义模型 | `summarizeProvider`/`summarizeModel` 配置项支持,可独立指定轻量模型(如 qwen3.6-plus)用于会话摘要,节省主模型 token;473 测试全绿 |
164
164
  | **v0.4.3** | ✅ 完成 | autoDream 大记忆量修复 | issue#9 B+A:`dreamMaxTokens` 上限 32768→131072 + `dreamReasoningEffort`/`sleepReasoningEffort` 思考开关(none 默认,主对话不受影响);478 测试全绿 |
165
+ | **v0.4.4** | ✅ 完成 | autoDream 决策覆盖修复 | issue#9 方案C:滑动窗口 `dreamMaxSnapshotSize`(默认200,updated_at 倒序截断) + 隐式 keep `dreamImplicitKeep`(默认true) + 覆盖率下限 `dreamMinExplicitCoverage`(默认50%) + 固定决策 schema;487 测试全绿 |
165
166
  | **v0.5.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
166
167
 
167
168
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
package/lib/config.js CHANGED
@@ -29,6 +29,19 @@ export const Config = z.object({
29
29
  z.const("high"),
30
30
  z.const("none")
31
31
  ]).default("none"),
32
+ // 滑动窗口上限(v0.4.4):autoDream 每次只对最近 dreamMaxSnapshotSize 条
33
+ // 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
34
+ // 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
35
+ dreamMaxSnapshotSize: z.natural().min(1).max(1000).default(200),
36
+ // 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
37
+ // 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
38
+ // (未覆盖即拒绝整单)。
39
+ dreamImplicitKeep: z.boolean().default(true),
40
+ // 显式决策覆盖率下限(v0.4.4 fix):dreamImplicitKeep 开启时,LLM 输出被
41
+ // 截断只显式 claim 少量 snapshot 记忆(claimed.size / snapshot.size < 该阈值)
42
+ // → 整单拒绝,防止残缺输出被隐式 keep 洗白成 ok 后再被真实 apply。0-1,
43
+ // 默认 0.5(至少显式覆盖一半 snapshot)。
44
+ dreamMinExplicitCoverage: z.number().min(0).max(1).default(0.5),
32
45
  // Rule version for dream adjudication: when this bumps, older dream_runs
33
46
  // degrade to historical evidence (their receipts no longer drive live
34
47
  // decisions). Default 0 = no versioning in use yet.
@@ -120,11 +120,37 @@ export function validateDecisions(decisions, snapshot, options = {}) {
120
120
  if (createCount > maxCreatePerRun) {
121
121
  errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
122
122
  }
123
- // Every snapshot id must appear in at least one decision
124
- for (const id of snapshot.keys()) {
125
- if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
123
+ // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim
124
+ // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
125
+ // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
126
+ // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
127
+ // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
128
+ //
129
+ // v0.4.4 fix(残缺输出防洗白):先收集所有非覆盖类 errors,有错直接 ok:false
130
+ // 且绝不 push 任何补齐 keep——残缺决策必须被真实拒绝,不能被隐式 keep 洗白成
131
+ // ok 后再 apply。只有无错时才检查显式覆盖率:LLM 输出被截断只 claim 少量
132
+ // snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
133
+ // 而不是用 keep 把绝大部分 snapshot 全部"通过"。
134
+ if (errors.length > 0) {
135
+ return { ok: false, errors };
126
136
  }
127
- return { ok: errors.length === 0, errors };
137
+ const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
138
+ if (options.dreamImplicitKeep !== false) {
139
+ const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
140
+ if (coverage < minCoverage) {
141
+ errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
142
+ return { ok: false, errors };
143
+ }
144
+ for (const id of snapshot.keys()) {
145
+ if (!claimed.has(id)) decisions.push({ action: "keep", ids: [id] });
146
+ }
147
+ } else {
148
+ for (const id of snapshot.keys()) {
149
+ if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
150
+ }
151
+ if (errors.length > 0) return { ok: false, errors };
152
+ }
153
+ return { ok: true, errors };
128
154
  }
129
155
 
130
156
  /** Marker thrown when a decision target changed since the run snapshot. */
@@ -198,11 +198,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
198
198
  if (text === undefined) return { status: "failed", error: "llm failed" };
199
199
  const decisions = parseJsonArray(text);
200
200
  if (!decisions) return { status: "failed", error: "invalid decisions json" };
201
- // validateDecisions requires every snapshot id to be claimed exactly once.
202
- // An LLM that omits a pair would otherwise fail the whole phase, so any
203
- // snapshot id the output leaves uncovered is defaulted to `keep` — the
204
- // fail-safe reading is "no conflict decided" rather than "conflict phase
205
- // aborted". validateDecisions stays strict for the dream consolidation path.
201
+ // validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
202
+ // 就会为未覆盖的 id 自动补 keep(dreamImplicitKeep 默认开启),这里保留显式
203
+ // 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
206
204
  const covered = new Set();
207
205
  for (const d of decisions) {
208
206
  if (d?.action === "conflict") {
package/lib/dream.js CHANGED
@@ -6,25 +6,47 @@ export { validateDecisions, applyDecisions };
6
6
  const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
7
7
 
8
8
  const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
9
- 请执行记忆巩固(consolidation):
10
- 1. 识别主题相近的条目 → 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
11
- 2. 识别重复/过时信息 → 输出 archive
12
- 3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser)
13
- 4. 发现单条记忆中的信息已过时、错误或遗漏 输出 update(直接修正内容)
9
+ 请执行记忆巩固(consolidation),输出一个决策 JSON 数组。
10
+
11
+ 【决策格式(必须严格遵守)】
12
+ 每个决策必须是对象,字段固定:
13
+ - "action":必填。取值只能是 "keep" / "merge" / "archive" / "update" / "conflict" 之一(字段名必须是 action,严禁写成 type)
14
+ - "ids":必填,数组,本决策涉及的记忆 id 列表
15
+ - "reason":可选,字符串,决策理由
16
+ - "importance":可选,整数 1-5
17
+ - merge 额外字段:"keepSource"(单个 id 字符串,必须是 ids 之一)+ 合并后的 "title"、"content"
18
+ - conflict 额外字段:"winner" 与 "loser",都是【单个 id 字符串,不是数组】
19
+ - update 额外字段:修正后的 "title" 和/或 "content";"ids" 只能包含一个 id
20
+
21
+ 【决策 JSON 示例】
22
+ [
23
+ { "action": "merge", "ids": ["m1", "m2"], "keepSource": "m1", "title": "合并标题", "content": "合并后的摘要内容", "importance": 4, "reason": "主题相近" },
24
+ { "action": "conflict", "winner": "m3", "loser": "m4", "reason": "内容矛盾,保留更新的信息" },
25
+ { "action": "update", "ids": ["m5"], "content": "修正后的内容", "reason": "信息过时" },
26
+ { "action": "archive", "ids": ["m6"], "reason": "重复或过时" }
27
+ ]
28
+
29
+ 【任务】
30
+ 1. 识别主题相近的条目 → merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
31
+ 2. 识别重复/过时信息 → archive
32
+ 3. 识别内容矛盾的条目 → conflict(按时间新旧、来源完整性、信息具体程度判断 winner/loser)
33
+ 4. 发现单条记忆中的信息过时、错误或遗漏 → update(直接修正内容)
14
34
  - update 的 ids 只能包含一个 id
15
35
  - 必须提供修正后的 title 和/或 content
16
36
  - 仅当内容确实需要修正时才使用,不要滥用
17
37
  - 每次整理最多输出 2 个 update
18
38
  - 24 小时内新建的记忆不可 update
19
- 5. 无问题的条目 → 输出 keep
39
+ 5. 无问题的条目无需输出(未提及的条目将自动保留 keep
20
40
 
21
- 规则:
22
- - 每条记忆至少出现在一个决策中
41
+ 【硬性规则】
42
+ - 字段名必须精确为 "action",严禁写成 "type";字段名统一用双引号
43
+ - conflict 的 winner/loser、merge 的 keepSource 都是【单个 id 字符串,绝不是数组】
44
+ - 每条记忆最多被 claim 一次:同一个 id 不能出现在多个决策中(同一 id 不能被 merge 和 conflict/archive 等重复占用)
45
+ - 未在决策中提及的记忆将自动保留(keep),无需为每条记忆输出 keep
23
46
  - merge 的 keepSource 必须是 ids 之一
24
47
  - 仅合并同类型条目(type 相同)
25
48
  - 不要编造 ids;只使用提供的 id
26
49
  - 重要性 1-5,合并后取最高
27
- - update 只能改一条,且要有实际变化
28
50
  - 只输出 JSON 数组,不要其他文字`;
29
51
 
30
52
  function totalChars(memories) {
@@ -344,8 +366,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
344
366
 
345
367
  async function runDream(ctx, service, config) {
346
368
  const logger = ctx.logger;
347
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
369
+ let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
348
370
  if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
371
+ // v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
372
+ // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
373
+ // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
374
+ const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
375
+ memories = [...memories]
376
+ .sort((a, b) => {
377
+ const ta = String(a.updated_at ?? "");
378
+ const tb = String(b.updated_at ?? "");
379
+ if (ta < tb) return 1;
380
+ if (ta > tb) return -1;
381
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
382
+ })
383
+ .slice(0, Math.max(1, maxSize));
349
384
  const snapshot = new Map(memories.map((m) => [m.id, m]));
350
385
  const route = resolveRoute(ctx, config, logger);
351
386
  const runId = randomUUID();
@@ -489,7 +524,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
489
524
  }
490
525
  const { ok, errors } = validateDecisions(decisions, snapshot, {
491
526
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
492
- minAgeHours: config.reflectionUpdateMinAgeHours
527
+ minAgeHours: config.reflectionUpdateMinAgeHours,
528
+ // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
529
+ // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
530
+ dreamImplicitKeep: config.dreamImplicitKeep,
531
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
493
532
  });
494
533
  if (!ok) {
495
534
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
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.4.3",
4
+ "version": "0.4.4",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/config.js CHANGED
@@ -29,6 +29,19 @@ export const Config = z.object({
29
29
  z.const("high"),
30
30
  z.const("none")
31
31
  ]).default("none"),
32
+ // 滑动窗口上限(v0.4.4):autoDream 每次只对最近 dreamMaxSnapshotSize 条
33
+ // 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
34
+ // 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
35
+ dreamMaxSnapshotSize: z.natural().min(1).max(1000).default(200),
36
+ // 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
37
+ // 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
38
+ // (未覆盖即拒绝整单)。
39
+ dreamImplicitKeep: z.boolean().default(true),
40
+ // 显式决策覆盖率下限(v0.4.4 fix):dreamImplicitKeep 开启时,LLM 输出被
41
+ // 截断只显式 claim 少量 snapshot 记忆(claimed.size / snapshot.size < 该阈值)
42
+ // → 整单拒绝,防止残缺输出被隐式 keep 洗白成 ok 后再被真实 apply。0-1,
43
+ // 默认 0.5(至少显式覆盖一半 snapshot)。
44
+ dreamMinExplicitCoverage: z.number().min(0).max(1).default(0.5),
32
45
  // Rule version for dream adjudication: when this bumps, older dream_runs
33
46
  // degrade to historical evidence (their receipts no longer drive live
34
47
  // decisions). Default 0 = no versioning in use yet.
@@ -120,11 +120,37 @@ export function validateDecisions(decisions, snapshot, options = {}) {
120
120
  if (createCount > maxCreatePerRun) {
121
121
  errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
122
122
  }
123
- // Every snapshot id must appear in at least one decision
124
- for (const id of snapshot.keys()) {
125
- if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
123
+ // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim
124
+ // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
125
+ // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
126
+ // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
127
+ // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
128
+ //
129
+ // v0.4.4 fix(残缺输出防洗白):先收集所有非覆盖类 errors,有错直接 ok:false
130
+ // 且绝不 push 任何补齐 keep——残缺决策必须被真实拒绝,不能被隐式 keep 洗白成
131
+ // ok 后再 apply。只有无错时才检查显式覆盖率:LLM 输出被截断只 claim 少量
132
+ // snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
133
+ // 而不是用 keep 把绝大部分 snapshot 全部"通过"。
134
+ if (errors.length > 0) {
135
+ return { ok: false, errors };
126
136
  }
127
- return { ok: errors.length === 0, errors };
137
+ const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
138
+ if (options.dreamImplicitKeep !== false) {
139
+ const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
140
+ if (coverage < minCoverage) {
141
+ errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
142
+ return { ok: false, errors };
143
+ }
144
+ for (const id of snapshot.keys()) {
145
+ if (!claimed.has(id)) decisions.push({ action: "keep", ids: [id] });
146
+ }
147
+ } else {
148
+ for (const id of snapshot.keys()) {
149
+ if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
150
+ }
151
+ if (errors.length > 0) return { ok: false, errors };
152
+ }
153
+ return { ok: true, errors };
128
154
  }
129
155
 
130
156
  /** Marker thrown when a decision target changed since the run snapshot. */
@@ -198,11 +198,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
198
198
  if (text === undefined) return { status: "failed", error: "llm failed" };
199
199
  const decisions = parseJsonArray(text);
200
200
  if (!decisions) return { status: "failed", error: "invalid decisions json" };
201
- // validateDecisions requires every snapshot id to be claimed exactly once.
202
- // An LLM that omits a pair would otherwise fail the whole phase, so any
203
- // snapshot id the output leaves uncovered is defaulted to `keep` — the
204
- // fail-safe reading is "no conflict decided" rather than "conflict phase
205
- // aborted". validateDecisions stays strict for the dream consolidation path.
201
+ // validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
202
+ // 就会为未覆盖的 id 自动补 keep(dreamImplicitKeep 默认开启),这里保留显式
203
+ // 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
206
204
  const covered = new Set();
207
205
  for (const d of decisions) {
208
206
  if (d?.action === "conflict") {
package/src/dream.js CHANGED
@@ -6,25 +6,47 @@ export { validateDecisions, applyDecisions };
6
6
  const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
7
7
 
8
8
  const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
9
- 请执行记忆巩固(consolidation):
10
- 1. 识别主题相近的条目 → 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
11
- 2. 识别重复/过时信息 → 输出 archive
12
- 3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser)
13
- 4. 发现单条记忆中的信息已过时、错误或遗漏 输出 update(直接修正内容)
9
+ 请执行记忆巩固(consolidation),输出一个决策 JSON 数组。
10
+
11
+ 【决策格式(必须严格遵守)】
12
+ 每个决策必须是对象,字段固定:
13
+ - "action":必填。取值只能是 "keep" / "merge" / "archive" / "update" / "conflict" 之一(字段名必须是 action,严禁写成 type)
14
+ - "ids":必填,数组,本决策涉及的记忆 id 列表
15
+ - "reason":可选,字符串,决策理由
16
+ - "importance":可选,整数 1-5
17
+ - merge 额外字段:"keepSource"(单个 id 字符串,必须是 ids 之一)+ 合并后的 "title"、"content"
18
+ - conflict 额外字段:"winner" 与 "loser",都是【单个 id 字符串,不是数组】
19
+ - update 额外字段:修正后的 "title" 和/或 "content";"ids" 只能包含一个 id
20
+
21
+ 【决策 JSON 示例】
22
+ [
23
+ { "action": "merge", "ids": ["m1", "m2"], "keepSource": "m1", "title": "合并标题", "content": "合并后的摘要内容", "importance": 4, "reason": "主题相近" },
24
+ { "action": "conflict", "winner": "m3", "loser": "m4", "reason": "内容矛盾,保留更新的信息" },
25
+ { "action": "update", "ids": ["m5"], "content": "修正后的内容", "reason": "信息过时" },
26
+ { "action": "archive", "ids": ["m6"], "reason": "重复或过时" }
27
+ ]
28
+
29
+ 【任务】
30
+ 1. 识别主题相近的条目 → merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
31
+ 2. 识别重复/过时信息 → archive
32
+ 3. 识别内容矛盾的条目 → conflict(按时间新旧、来源完整性、信息具体程度判断 winner/loser)
33
+ 4. 发现单条记忆中的信息过时、错误或遗漏 → update(直接修正内容)
14
34
  - update 的 ids 只能包含一个 id
15
35
  - 必须提供修正后的 title 和/或 content
16
36
  - 仅当内容确实需要修正时才使用,不要滥用
17
37
  - 每次整理最多输出 2 个 update
18
38
  - 24 小时内新建的记忆不可 update
19
- 5. 无问题的条目 → 输出 keep
39
+ 5. 无问题的条目无需输出(未提及的条目将自动保留 keep
20
40
 
21
- 规则:
22
- - 每条记忆至少出现在一个决策中
41
+ 【硬性规则】
42
+ - 字段名必须精确为 "action",严禁写成 "type";字段名统一用双引号
43
+ - conflict 的 winner/loser、merge 的 keepSource 都是【单个 id 字符串,绝不是数组】
44
+ - 每条记忆最多被 claim 一次:同一个 id 不能出现在多个决策中(同一 id 不能被 merge 和 conflict/archive 等重复占用)
45
+ - 未在决策中提及的记忆将自动保留(keep),无需为每条记忆输出 keep
23
46
  - merge 的 keepSource 必须是 ids 之一
24
47
  - 仅合并同类型条目(type 相同)
25
48
  - 不要编造 ids;只使用提供的 id
26
49
  - 重要性 1-5,合并后取最高
27
- - update 只能改一条,且要有实际变化
28
50
  - 只输出 JSON 数组,不要其他文字`;
29
51
 
30
52
  function totalChars(memories) {
@@ -344,8 +366,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
344
366
 
345
367
  async function runDream(ctx, service, config) {
346
368
  const logger = ctx.logger;
347
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
369
+ let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
348
370
  if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
371
+ // v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
372
+ // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
373
+ // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
374
+ const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
375
+ memories = [...memories]
376
+ .sort((a, b) => {
377
+ const ta = String(a.updated_at ?? "");
378
+ const tb = String(b.updated_at ?? "");
379
+ if (ta < tb) return 1;
380
+ if (ta > tb) return -1;
381
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
382
+ })
383
+ .slice(0, Math.max(1, maxSize));
349
384
  const snapshot = new Map(memories.map((m) => [m.id, m]));
350
385
  const route = resolveRoute(ctx, config, logger);
351
386
  const runId = randomUUID();
@@ -489,7 +524,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
489
524
  }
490
525
  const { ok, errors } = validateDecisions(decisions, snapshot, {
491
526
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
492
- minAgeHours: config.reflectionUpdateMinAgeHours
527
+ minAgeHours: config.reflectionUpdateMinAgeHours,
528
+ // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
529
+ // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
530
+ dreamImplicitKeep: config.dreamImplicitKeep,
531
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
493
532
  });
494
533
  if (!ok) {
495
534
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
@@ -17,6 +17,25 @@ test("rerank is opt-in: default config does not enable the local reranker", () =
17
17
  assert.equal(enabled.rerankEnabled && enabled.rerankProvider === "local", true, "explicit opt-in opens the gate");
18
18
  });
19
19
 
20
+ test("dream sliding window + implicit keep config defaults and bounds (v0.4.4)", () => {
21
+ const cfg = Config({});
22
+ assert.equal(cfg.dreamMaxSnapshotSize, 200, "window defaults to 200");
23
+ assert.equal(cfg.dreamImplicitKeep, true, "implicit keep defaults to true");
24
+ const capped = Config({ dreamMaxSnapshotSize: 1000 });
25
+ assert.equal(capped.dreamMaxSnapshotSize, 1000, "upper bound accepted");
26
+ const off = Config({ dreamImplicitKeep: false });
27
+ assert.equal(off.dreamImplicitKeep, false, "implicit keep can be disabled");
28
+ });
29
+
30
+ test("dream explicit coverage threshold config defaults and bounds (v0.4.4 fix)", () => {
31
+ const cfg = Config({});
32
+ assert.equal(cfg.dreamMinExplicitCoverage, 0.5, "coverage threshold defaults to 0.5");
33
+ const low = Config({ dreamMinExplicitCoverage: 0.1 });
34
+ assert.equal(low.dreamMinExplicitCoverage, 0.1, "lower bound accepted");
35
+ const high = Config({ dreamMinExplicitCoverage: 1 });
36
+ assert.equal(high.dreamMinExplicitCoverage, 1, "upper bound accepted");
37
+ });
38
+
20
39
  test("startup probe: the reranker module never statically imports transformers/onnxruntime", () => {
21
40
  // LocalReranker is imported eagerly by index.js, so a bare install must not
22
41
  // pull onnxruntime in at module load. The heavy load is a lazy dynamic import
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { validateDecisions, applyDecisions, createDreamScheduler } from "../src/dream.js";
4
4
  import { createStore } from "../src/store.js";
5
5
  import { createService } from "../src/service.js";
6
+ import { mockCtx } from "./helpers/dream-mock.js";
6
7
 
7
8
  function snapshot(ids, type = "project") {
8
9
  return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
@@ -87,9 +88,19 @@ test("summary entries cannot be decision targets", () => {
87
88
  assert.equal(ok, false);
88
89
  });
89
90
 
90
- test("every snapshot memory must be covered by a decision", () => {
91
+ test("uncovered snapshot memories are auto-filled with keep (implicit keep, v0.4.4)", () => {
91
92
  const snap = snapshot(["a", "b"]);
92
- const { ok, errors } = validateDecisions([{ action: "keep", ids: ["a"] }], snap);
93
+ const decisions = [{ action: "keep", ids: ["a"] }];
94
+ const { ok, errors } = validateDecisions(decisions, snap);
95
+ assert.equal(ok, true, errors.join("; "));
96
+ assert.equal(decisions.length, 2, "keep appended for the uncovered snapshot id");
97
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("b")), "b auto-kept");
98
+ });
99
+
100
+ test("dreamImplicitKeep=false keeps the strict full-coverage validation", () => {
101
+ const snap = snapshot(["a", "b"]);
102
+ const decisions = [{ action: "keep", ids: ["a"] }];
103
+ const { ok, errors } = validateDecisions(decisions, snap, { dreamImplicitKeep: false });
93
104
  assert.equal(ok, false);
94
105
  assert.ok(errors.some((e) => e.includes("missing from decisions")));
95
106
  });
@@ -671,3 +682,168 @@ test("runDream freeze store failure never blocks the run (fail-safe)", async ()
671
682
  assert.equal(store.getById(w.id).content, "8月20日", "winner untouched");
672
683
  store.close();
673
684
  });
685
+
686
+ // --- v0.4.4: 大记忆量 autoDream(滑动窗口 + 隐式 keep)回归 -----------------
687
+
688
+ test("autoDream with 650 memories: sliding window truncates snapshot + implicit keep fills uncovered ids", async () => {
689
+ const { store, service } = dreamSetup();
690
+ // seed 650 memories — the size that used to produce 677 "missing from
691
+ // decisions" errors and applied=0 under the strict full-coverage check
692
+ for (let i = 0; i < 650; i++) {
693
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
694
+ }
695
+ // mock LLM claims only a small subset (20 archives) and never emits keeps
696
+ // for the rest — implicit keep must auto-fill the uncovered snapshot ids
697
+ const ctx = mockCtx({
698
+ onConsolidation: (listText) => {
699
+ const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
700
+ .map((m) => m[1]);
701
+ return JSON.stringify(ids.slice(0, 20).map((id) => ({ action: "archive", ids: [id], reason: "stale" })));
702
+ }
703
+ });
704
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
705
+ const result = await dream.runDream(ctx, service, {
706
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 200,
707
+ // mock only claims 20/200 = 10%; the implicit-keep coverage floor must be
708
+ // lowered so this deliberate "tiny explicit claim" scenario still passes
709
+ // (it exercises the window + keep-fill, not the coverage guard)
710
+ dreamMinExplicitCoverage: 0.1
711
+ });
712
+ assert.equal(result.ok, true, "run succeeds instead of 677-error rejection");
713
+ assert.ok(result.applied > 0, "archive decisions applied");
714
+ // snapshot capped at the sliding window
715
+ const run = store.listDreamRuns()[0];
716
+ assert.equal(run.input_count, 200, "snapshot truncated to dreamMaxSnapshotSize");
717
+ // decisions cover exactly the snapshot window, with implicit keeps
718
+ assert.equal(result.decisions.length, 200, "decisions count = snapshot count");
719
+ assert.equal(result.decisions.filter((d) => d.action === "archive").length, 20, "claimed subset present");
720
+ assert.equal(result.decisions.filter((d) => d.action === "keep").length, 180, "uncovered ids auto-kept");
721
+ assert.equal(store.getById(result.decisions.find((d) => d.action === "archive").ids[0]).archived, true, "an archive landed");
722
+ store.close();
723
+ });
724
+
725
+ // --- v0.4.4 fix: 残缺输出防洗白(显式覆盖率下限) + 严格模式透传 --------------
726
+
727
+ test("validateDecisions rejects a truncated output whose explicit coverage is below the floor", () => {
728
+ const snap = snapshot(["a", "b", "c", "d"]);
729
+ const decisions = [{ action: "keep", ids: ["a"] }]; // claims 1/4 = 25%
730
+ const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
731
+ assert.equal(ok, false, "low explicit coverage rejects the whole list");
732
+ assert.ok(
733
+ errors.some((e) => e.includes("explicit decision coverage 25% < minimum 50%")),
734
+ `coverage error present, got: ${errors.join("; ")}`
735
+ );
736
+ assert.equal(decisions.length, 1, "no keep-fill pushed on rejection (nothing washed white)");
737
+ });
738
+
739
+ test("validateDecisions covers the whole snapshot when explicit coverage meets the floor", () => {
740
+ const snap = snapshot(["a", "b", "c", "d"]);
741
+ const decisions = [{ action: "archive", ids: ["a", "b"], reason: "stale" }]; // claims 2/4 = 50%
742
+ const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
743
+ assert.equal(ok, true, errors.join("; "));
744
+ assert.equal(decisions.length, 3, "archive + 2 implicit keeps for c/d");
745
+ assert.equal(decisions.filter((d) => d.action === "keep").length, 2);
746
+ });
747
+
748
+ test("runDream with dreamImplicitKeep=false rejects a partial mock output (missing from decisions)", async () => {
749
+ const { store, service } = dreamSetup();
750
+ for (let i = 0; i < 3; i++) {
751
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
752
+ }
753
+ // mock only claims the first snapshot id, misses the rest — strict mode must
754
+ // reject the whole run instead of auto-keeping the uncovered ids
755
+ const warnings = [];
756
+ const ctx = {
757
+ warnings,
758
+ logger: { warn: (m) => warnings.push(m) },
759
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "stress-model" }) },
760
+ llm: {
761
+ async *stream(options) {
762
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
763
+ if (userText.startsWith("id=")) {
764
+ const ids = [...userText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)].map((m) => m[1]);
765
+ yield { type: "text-delta", index: 0, text: JSON.stringify(ids.slice(0, 1).map((id) => ({ action: "archive", ids: [id], reason: "stale" }))) };
766
+ } else {
767
+ yield { type: "text-delta", index: 0, text: "记忆库总览摘要" };
768
+ }
769
+ yield { type: "finish", reason: { kind: "stop" } };
770
+ }
771
+ }
772
+ };
773
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
774
+ const result = await dream.runDream(ctx, service, {
775
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat",
776
+ dreamImplicitKeep: false
777
+ });
778
+ assert.equal(result.ok, false, "strict mode rejects the partial output");
779
+ assert.match(result.error, /invalid decisions: \d+ errors/);
780
+ assert.ok(warnings.some((m) => m.includes("missing from decisions")), "warned which ids are missing");
781
+ assert.equal(store.listDreamRuns()[0].status, "failed", "run audited as failed");
782
+ store.close();
783
+ });
784
+
785
+ test("runDream sliding window keeps the newest N memories and excludes the oldest", async () => {
786
+ const { store, service } = dreamSetup();
787
+ // seed 5 memories, backdate updated_at so i=0 is oldest (5h ago), i=4 newest (1h ago)
788
+ const ids = [];
789
+ for (let i = 0; i < 5; i++) {
790
+ const { memory } = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
791
+ ids.push(memory.id);
792
+ }
793
+ for (let i = 0; i < 5; i++) {
794
+ const old = new Date(Date.now() - (5 - i) * 3600000).toISOString();
795
+ store.db.prepare("UPDATE memories SET updated_at = ?, created_at = ? WHERE id = ?").run(old, old, ids[i]);
796
+ }
797
+ const ctx = mockCtx({
798
+ onConsolidation: (listText) => {
799
+ const inWindow = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)]
800
+ .map((m) => m[1]);
801
+ return JSON.stringify(inWindow.map((id) => ({ action: "keep", ids: [id] })));
802
+ }
803
+ });
804
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
805
+ const result = await dream.runDream(ctx, service, {
806
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 3
807
+ });
808
+ const run = store.listDreamRuns()[0];
809
+ assert.equal(run.input_count, 3, "window capped at dreamMaxSnapshotSize");
810
+ const windowIds = run.input.map((m) => m.id).sort();
811
+ const expected = [ids[2], ids[3], ids[4]].sort(); // newest 3 by updated_at
812
+ assert.deepEqual(windowIds, expected, "window contains the newest 3 memories");
813
+ assert.ok(!windowIds.includes(ids[0]), "oldest memory excluded from the window");
814
+ assert.equal(result.decisions.length, 3, "decisions cover exactly the window");
815
+ store.close();
816
+ });
817
+
818
+ // --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
819
+
820
+ test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
821
+ const { store, service } = dreamSetup();
822
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
823
+ let systemText = "";
824
+ const ctx = {
825
+ logger: { warn: () => {} },
826
+ llm: {
827
+ async *stream(options) {
828
+ systemText = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
829
+ yield { type: "text-delta", text: "[]" };
830
+ yield { type: "finish", reason: { kind: "ok" } };
831
+ }
832
+ }
833
+ };
834
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
835
+ await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
836
+ // 字段名必须写死为 action(kimi 曾输出 "type" 导致整单拒绝)
837
+ assert.match(systemText, /"action"/, "prompt names the action field");
838
+ assert.match(systemText, /严禁写成\s*type/, "prompt forbids the type field name");
839
+ // conflict winner/loser 是单个 id 字符串而非数组
840
+ assert.match(systemText, /"winner"/, "prompt names the winner field");
841
+ assert.match(systemText, /"loser"/, "prompt names the loser field");
842
+ assert.match(systemText, /单个 id 字符串/, "winner/loser must be a single id string");
843
+ assert.match(systemText, /不是数组|绝不是数组/, "winner/loser must not be an array");
844
+ // 同一 id 不可被多个决策重复 claim
845
+ assert.match(systemText, /最多被 claim 一次/, "each memory claimed at most once");
846
+ // 决策 JSON 示例块
847
+ assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
848
+ store.close();
849
+ });