@modusensus/dsh-mneme 0.4.3 → 0.4.4-beta.1

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/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
@@ -16,10 +16,10 @@ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记
16
16
  - 仅当内容确实需要修正时才使用,不要滥用
17
17
  - 每次整理最多输出 2 个 update
18
18
  - 24 小时内新建的记忆不可 update
19
- 5. 无问题的条目 → 输出 keep
19
+ 5. 无问题的条目无需输出(未提及的条目将自动保留 keep
20
20
 
21
21
  规则:
22
- - 每条记忆至少出现在一个决策中
22
+ - 未在决策中提及的记忆将自动保留(keep),无需为每条记忆输出 keep
23
23
  - merge 的 keepSource 必须是 ids 之一
24
24
  - 仅合并同类型条目(type 相同)
25
25
  - 不要编造 ids;只使用提供的 id
@@ -344,8 +344,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
344
344
 
345
345
  async function runDream(ctx, service, config) {
346
346
  const logger = ctx.logger;
347
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
347
+ let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
348
348
  if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
349
+ // v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
350
+ // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
351
+ // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
352
+ const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
353
+ memories = [...memories]
354
+ .sort((a, b) => {
355
+ const ta = String(a.updated_at ?? "");
356
+ const tb = String(b.updated_at ?? "");
357
+ if (ta < tb) return 1;
358
+ if (ta > tb) return -1;
359
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
360
+ })
361
+ .slice(0, Math.max(1, maxSize));
349
362
  const snapshot = new Map(memories.map((m) => [m.id, m]));
350
363
  const route = resolveRoute(ctx, config, logger);
351
364
  const runId = randomUUID();
@@ -489,7 +502,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
489
502
  }
490
503
  const { ok, errors } = validateDecisions(decisions, snapshot, {
491
504
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
492
- minAgeHours: config.reflectionUpdateMinAgeHours
505
+ minAgeHours: config.reflectionUpdateMinAgeHours,
506
+ // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
507
+ // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
508
+ dreamImplicitKeep: config.dreamImplicitKeep,
509
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
493
510
  });
494
511
  if (!ok) {
495
512
  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-beta.1",
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
@@ -16,10 +16,10 @@ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记
16
16
  - 仅当内容确实需要修正时才使用,不要滥用
17
17
  - 每次整理最多输出 2 个 update
18
18
  - 24 小时内新建的记忆不可 update
19
- 5. 无问题的条目 → 输出 keep
19
+ 5. 无问题的条目无需输出(未提及的条目将自动保留 keep
20
20
 
21
21
  规则:
22
- - 每条记忆至少出现在一个决策中
22
+ - 未在决策中提及的记忆将自动保留(keep),无需为每条记忆输出 keep
23
23
  - merge 的 keepSource 必须是 ids 之一
24
24
  - 仅合并同类型条目(type 相同)
25
25
  - 不要编造 ids;只使用提供的 id
@@ -344,8 +344,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
344
344
 
345
345
  async function runDream(ctx, service, config) {
346
346
  const logger = ctx.logger;
347
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
347
+ let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
348
348
  if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
349
+ // v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
350
+ // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
351
+ // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
352
+ const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
353
+ memories = [...memories]
354
+ .sort((a, b) => {
355
+ const ta = String(a.updated_at ?? "");
356
+ const tb = String(b.updated_at ?? "");
357
+ if (ta < tb) return 1;
358
+ if (ta > tb) return -1;
359
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
360
+ })
361
+ .slice(0, Math.max(1, maxSize));
349
362
  const snapshot = new Map(memories.map((m) => [m.id, m]));
350
363
  const route = resolveRoute(ctx, config, logger);
351
364
  const runId = randomUUID();
@@ -489,7 +502,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
489
502
  }
490
503
  const { ok, errors } = validateDecisions(decisions, snapshot, {
491
504
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
492
- minAgeHours: config.reflectionUpdateMinAgeHours
505
+ minAgeHours: config.reflectionUpdateMinAgeHours,
506
+ // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
507
+ // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
508
+ dreamImplicitKeep: config.dreamImplicitKeep,
509
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
493
510
  });
494
511
  if (!ok) {
495
512
  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,135 @@ 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
+ });