@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 +13 -0
- package/lib/dream/decisions.js +30 -4
- package/lib/dream/sleep.js +3 -5
- package/lib/dream.js +21 -4
- package/package.json +1 -1
- package/src/config.js +13 -0
- package/src/dream/decisions.js +30 -4
- package/src/dream/sleep.js +3 -5
- package/src/dream.js +21 -4
- package/test/config.test.js +19 -0
- package/test/dream.test.js +145 -2
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.
|
package/lib/dream/decisions.js
CHANGED
|
@@ -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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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. */
|
package/lib/dream/sleep.js
CHANGED
|
@@ -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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
package/src/dream/decisions.js
CHANGED
|
@@ -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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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. */
|
package/src/dream/sleep.js
CHANGED
|
@@ -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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
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.
|
|
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
|
-
|
|
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/test/config.test.js
CHANGED
|
@@ -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
|
package/test/dream.test.js
CHANGED
|
@@ -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("
|
|
91
|
+
test("uncovered snapshot memories are auto-filled with keep (implicit keep, v0.4.4)", () => {
|
|
91
92
|
const snap = snapshot(["a", "b"]);
|
|
92
|
-
const
|
|
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
|
+
});
|