@modusensus/dsh-mneme 0.7.18 → 0.7.21

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.
@@ -0,0 +1,148 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ computeHeat,
6
+ buildHeatSignals,
7
+ TYPE_DECAY_DEFAULTS,
8
+ } from '../src/heat.js';
9
+
10
+ const HOUR = 3600000;
11
+
12
+ describe('heat.js', () => {
13
+ test('未知类型使用默认 λ=0.002;72h 后 heat < 1 且 > 0.8,并随 Δt 单调递减', () => {
14
+ const now = Date.now();
15
+ const base = { type: 'episodic', last_accessed_at: now - 72 * HOUR };
16
+
17
+ const heat72 = computeHeat(base, now, {});
18
+ const heat144 = computeHeat(
19
+ { ...base, last_accessed_at: now - 144 * HOUR },
20
+ now,
21
+ {}
22
+ );
23
+
24
+ assert(heat72 < 1.0, '72h 后热度应小于 1');
25
+ assert(heat72 > 0.8, '72h 后热度应仍大于 0.8');
26
+ assert(heat144 < heat72, 'Δt 越大,热度应越低');
27
+ });
28
+
29
+ test('λ=0 的免疫类型任意 Δt 返回 1.0', () => {
30
+ const now = Date.now();
31
+ const config = { heatTypeDecay: { preference: 0 } };
32
+
33
+ assert.strictEqual(
34
+ computeHeat(
35
+ { type: 'preference', last_accessed_at: now - 999 * 24 * HOUR },
36
+ now,
37
+ config
38
+ ),
39
+ 1.0
40
+ );
41
+
42
+ assert.strictEqual(
43
+ computeHeat(
44
+ { type: 'pattern', last_accessed_at: now - 365 * 24 * HOUR },
45
+ now,
46
+ { heatTypeDecay: TYPE_DECAY_DEFAULTS }
47
+ ),
48
+ 1.0
49
+ );
50
+ });
51
+
52
+ test('ref 优先使用 last_accessed_at,缺失退 created_at,皆无返回 1.0', () => {
53
+ const now = Date.now();
54
+ const config = {
55
+ heatTypeDecay: { decision: 0.002 },
56
+ heatGlobalAlpha: 1.0,
57
+ };
58
+
59
+ const withLast = computeHeat(
60
+ {
61
+ type: 'decision',
62
+ last_accessed_at: now - 24 * HOUR,
63
+ created_at: now - 100 * HOUR,
64
+ },
65
+ now,
66
+ config
67
+ );
68
+
69
+ const withCreated = computeHeat(
70
+ { type: 'decision', created_at: now - 24 * HOUR },
71
+ now,
72
+ config
73
+ );
74
+
75
+ assert(withLast < 1.0);
76
+ assert.strictEqual(withLast, withCreated);
77
+
78
+ const noRef = computeHeat({ type: 'decision' }, now, config);
79
+ assert.strictEqual(noRef, 1.0);
80
+ });
81
+
82
+ test('非法 ref 或 now < ref 时返回 1.0', () => {
83
+ const now = Date.now();
84
+
85
+ assert.strictEqual(
86
+ computeHeat(
87
+ { type: 'decision', last_accessed_at: 'not-a-date' },
88
+ now,
89
+ {}
90
+ ),
91
+ 1.0
92
+ );
93
+
94
+ assert.strictEqual(
95
+ computeHeat(
96
+ { type: 'decision', last_accessed_at: now + 1000 },
97
+ now,
98
+ {}
99
+ ),
100
+ 1.0
101
+ );
102
+ });
103
+
104
+ test('α 越大衰减越快(同一 Δt 下 α=2 的热度低于 α=1)', () => {
105
+ const now = Date.now();
106
+ const base = {
107
+ type: 'decision',
108
+ last_accessed_at: now - 7 * 24 * HOUR,
109
+ };
110
+
111
+ const h1 = computeHeat(base, now, {
112
+ heatTypeDecay: { decision: 0.002 },
113
+ heatGlobalAlpha: 1.0,
114
+ });
115
+
116
+ const h2 = computeHeat(base, now, {
117
+ heatTypeDecay: { decision: 0.002 },
118
+ heatGlobalAlpha: 2.0,
119
+ });
120
+
121
+ assert(h2 < h1, 'α 更大时,同一 Δt 热度应更低');
122
+ });
123
+
124
+ test('buildHeatSignals 返回字段齐全且 deltaHours 正确', () => {
125
+ const now = 1000000000000; // 固定毫秒时间戳
126
+ const ref = now - 12 * HOUR;
127
+
128
+ const signals = buildHeatSignals(
129
+ { type: 'project', last_accessed_at: ref },
130
+ { heatGlobalAlpha: 1.5 },
131
+ now
132
+ );
133
+
134
+ assert.deepStrictEqual(Object.keys(signals).sort(), [
135
+ 'alpha',
136
+ 'deltaHours',
137
+ 'lambda',
138
+ 'ref',
139
+ 'type',
140
+ ]);
141
+
142
+ assert.strictEqual(signals.type, 'project');
143
+ assert.strictEqual(signals.lambda, 0.0008);
144
+ assert.strictEqual(signals.alpha, 1.5);
145
+ assert.strictEqual(signals.ref, ref);
146
+ assert.strictEqual(signals.deltaHours, 12);
147
+ });
148
+ });
@@ -234,3 +234,117 @@ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async ()
234
234
  }
235
235
  store.close();
236
236
  });
237
+
238
+ // ------------------------------------------------------------------ stream-level rejection
239
+ // dsh-llm rc.1 converts adapter-stage failures (including the provider's
240
+ // UNSUPPORTED_REASONING_EFFORT throw from resolveCallWithInfo) into a terminal
241
+ // error finish chunk inside adapterStream — the rejection NEVER reaches our
242
+ // catch. The v0.7.16 throw-based fallback was therefore dead code for the
243
+ // stream path; these tests pin the finish-chunk-based fallback.
244
+
245
+ test("rc.1 stream-level effort rejection (error finish chunk) also triggers the no-effort retry", async () => {
246
+ const store = createStore(":memory:");
247
+ const service = createService({ store, mirror: null, config: {} });
248
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
249
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
250
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
251
+ const calls = [];
252
+ const warnings = [];
253
+ const ctx = {
254
+ logger: { warn: (m) => warnings.push(String(m)) },
255
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
256
+ llm: {
257
+ async *stream(options) {
258
+ calls.push(options);
259
+ if (options.reasoningEffort) {
260
+ yield {
261
+ type: "finish",
262
+ reason: {
263
+ kind: "error",
264
+ failure: {
265
+ code: "UNSUPPORTED_REASONING_EFFORT",
266
+ message: 'provider "mock" model "mock-model" does not support reasoning effort "low"'
267
+ }
268
+ }
269
+ };
270
+ return;
271
+ }
272
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
273
+ if (userText.startsWith("id=")) {
274
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
275
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
276
+ ]) };
277
+ } else {
278
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
279
+ }
280
+ yield { type: "finish", reason: { kind: "stop" } };
281
+ }
282
+ }
283
+ };
284
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
285
+ assert.equal(result.ok, true, "run survives the stream-level effort rejection");
286
+ assert.ok(result.applied > 0, "consolidation still lands changes");
287
+ assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
288
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
289
+ assert.ok(warnings.some((w) => w.includes("rejected via stream")), "the stream-level rejection is logged");
290
+ store.close();
291
+ });
292
+
293
+ test("non-effort stream failures are not retried and the finish-chunk cause reaches the audit row", async () => {
294
+ const store = createStore(":memory:");
295
+ const service = createService({ store, mirror: null, config: {} });
296
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
297
+ const calls = [];
298
+ const ctx = {
299
+ logger: { warn: () => {} },
300
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
301
+ llm: {
302
+ async *stream(options) {
303
+ calls.push(options);
304
+ yield { type: "finish", reason: { kind: "error", failure: { code: "PROVIDER_GONE", message: "provider mock is not registered" } } };
305
+ }
306
+ }
307
+ };
308
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
309
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
310
+ assert.equal(result.ok, false);
311
+ assert.equal(result.error, "llm failed", "the run error stays the stable short string");
312
+ assert.equal(calls.length, 1, "no blind retry when the stream failure is not an effort rejection");
313
+ const row = service.listLlmAudits().find((r) => r.operation_type === "dream_consolidate");
314
+ assert.ok(row && row.status === "error", "failed consolidation still audited");
315
+ assert.ok(
316
+ String(row.error_message).includes("PROVIDER_GONE") && String(row.error_message).includes("provider mock is not registered"),
317
+ "audit error_message carries the finish-chunk cause"
318
+ );
319
+ store.close();
320
+ });
321
+
322
+ test("sleep passes the stream failure accessor so a stream-level effort rejection retries", async () => {
323
+ const { store, service, vectorIndex } = sleepSetup();
324
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
325
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
326
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
327
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
328
+ const captured = [];
329
+ const ctx = sleepCtx(null, { provider: "mock", model: "sleep-model" }, captured);
330
+ ctx.llm.stream = async function* (options) {
331
+ captured.push(options);
332
+ if (options.reasoningEffort) {
333
+ yield {
334
+ type: "finish",
335
+ reason: { kind: "error", failure: { code: "UNSUPPORTED_REASONING_EFFORT", message: 'provider "mock" model "sleep-model" does not support reasoning effort "low"' } }
336
+ };
337
+ return;
338
+ }
339
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
340
+ yield { type: "text-delta", index: 0, text: userText.startsWith("候选冲突")
341
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
342
+ : "[]" };
343
+ yield { type: "finish", reason: { kind: "stop" } };
344
+ };
345
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
346
+ assert.equal(result.status, "ok", "sleep survives the stream-level effort rejection");
347
+ assert.equal(captured[0].reasoningEffort, "low", "first conflict attempt forwards the effort");
348
+ assert.equal("reasoningEffort" in captured[1], false, "conflict retry omits the rejected effort field");
349
+ store.close();
350
+ });
@@ -77,14 +77,24 @@ test("recorded candidates carry id/title/content/score/source and match the retu
77
77
  assert.ok(cands.some((c) => c.id === b.id), "second memory recorded");
78
78
  });
79
79
 
80
- test("recordRecall defaults to offrecorder is not called", async () => {
80
+ test("recordRecall defaults to on (recallRecordDefault) explicit false opts out", async () => {
81
81
  const { service } = setup();
82
82
  let calls = 0;
83
83
  service.setRecallRecorder(() => calls++);
84
84
  saveMemory(service, null, { title: "量子计算", content: "入门" });
85
85
  await service.searchMemories("量子", { mode: "keyword" });
86
+ assert.equal(calls, 1, "unset recordRecall records by default (recallRecordDefault)");
86
87
  await service.searchMemories("量子", { mode: "keyword", recordRecall: false });
87
- assert.equal(calls, 0, "no recorder call when recordRecall is unset or false");
88
+ assert.equal(calls, 1, "explicit false opts out of recording");
89
+ });
90
+
91
+ test("recallRecordDefault=false config turns unset recordRecall off", async () => {
92
+ const { service } = setup({ recallRecordDefault: false });
93
+ let calls = 0;
94
+ service.setRecallRecorder(() => calls++);
95
+ saveMemory(service, null, { title: "量子计算", content: "入门" });
96
+ await service.searchMemories("量子", { mode: "keyword" });
97
+ assert.equal(calls, 0, "config recallRecordDefault:false → unset recordRecall is off");
88
98
  });
89
99
 
90
100
  test("recordRecall=true with no recorder installed is safe and returns normally", async () => {
@@ -301,7 +311,7 @@ test("e2e: index.js wiring — recordRecall search lands a row listRecallRuns ca
301
311
  assert.equal(store.getRecallRun(run.id).query, "量子", "row readable right away");
302
312
  });
303
313
 
304
- test("e2e: without recordRecall the recall_runs table gains no rows", async () => {
314
+ test("e2e: unset recordRecall records by default; explicit false adds no row", async () => {
305
315
  const store = createStore(":memory:");
306
316
  const service = createService({ store, mirror: null, config: {} });
307
317
  service.setRecallRecorder((recall) => store.saveRecallRun({
@@ -310,6 +320,7 @@ test("e2e: without recordRecall the recall_runs table gains no rows", async () =
310
320
  }));
311
321
  service.saveWithDedupe({ type: "preference", title: "量子计算入门", content: "叠加态" });
312
322
  await service.searchMemories("量子", { mode: "keyword" });
323
+ assert.equal(store.listRecallRuns().length, 1, "unset recordRecall records a run by default");
313
324
  await service.searchMemories("量子", { mode: "keyword", recordRecall: false });
314
- assert.deepEqual(store.listRecallRuns(), [], "no rows without recordRecall=true");
325
+ assert.equal(store.listRecallRuns().length, 1, "explicit false adds no row");
315
326
  });
@@ -0,0 +1,125 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { runSleep } from "../src/dream/sleep.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+
7
+ // v0.7.0 待办③ sleep 降级热联合判定:
8
+ // 降级需同时满足 时间窗 + heat<sleepHeatThreshold + importance<5 三条件;
9
+ // λ=0 的免疫类型 heat 恒 1.0 天然豁免;importance≥5 紧要记忆无论多冷都保留。
10
+ // 默认 λ 下:history(0.006) 约 77 天后热值跌破 0.05,project(0.0008) 约 581 天,
11
+ // decision(0.002) 约 232 天 —— 整体取向保守,突出"冷但重要"与"热但低值"都不降级。
12
+
13
+ const DAY = 86400000;
14
+
15
+ function setup(config = {}) {
16
+ const store = createStore(":memory:");
17
+ const service = createService({ store, mirror: null, config });
18
+ return { store, service };
19
+ }
20
+
21
+ function saveMemory(service, title, content, type = "project", importance = 3) {
22
+ return service.saveWithDedupe({ type, title, content, importance }).memory;
23
+ }
24
+
25
+ function sleepConfig(overrides = {}) {
26
+ return {
27
+ sleepModeEnabled: true,
28
+ // 本组测试全部验证 heat 保护语义 → 必须显式开启(v0.7.20 起默认关)。
29
+ heatEnabled: true,
30
+ sleepConflictStrictness: "normal",
31
+ sleepArchiveDays: 30, // 30-90 天窗口 → 压缩为摘要
32
+ sleepCompressDays: 90, // >=90 天 → 直接归档
33
+ sleepPatternMinMemories: 100, // 记忆数不足 → pattern 阶段跳过
34
+ sleepMaxPatternPerRun: 3,
35
+ ...overrides
36
+ };
37
+ }
38
+
39
+ function mockCtx() {
40
+ return {
41
+ logger: { warn: () => {}, info: () => {} },
42
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
43
+ llm: {
44
+ async *stream() {
45
+ yield { type: "text-delta", index: 0, text: "[]" };
46
+ yield { type: "finish", reason: { kind: "stop" } };
47
+ }
48
+ }
49
+ };
50
+ }
51
+
52
+ async function run(memories, service, config) {
53
+ const now = Date.now();
54
+ for (const [mem, daysAgo] of memories) {
55
+ service.touchLastAccess(mem.id, new Date(now - daysAgo * DAY).toISOString());
56
+ }
57
+ const result = await runSleep(mockCtx(), service, config, { warn: () => {}, info: () => {} }, null, null);
58
+ return result.phases?.["demotion"];
59
+ }
60
+
61
+ test("cold low-importance history past the compress tier archives (heat gate open)", async () => {
62
+ const { store, service } = setup();
63
+ const mem = saveMemory(service, "久远的会话记录", "早期讨论内容", "history", 2);
64
+ const demotion = await run([[mem, 120]], service, sleepConfig());
65
+ assert.ok(demotion, "demotion phase ran");
66
+ assert.ok(demotion.archived.includes(mem.id), "120 天 ref + 热值≈0.03<0.05 + importance 2 → 归档");
67
+ assert.equal(store.getById(mem.id).archived, true);
68
+ store.close();
69
+ });
70
+
71
+ test("importance 5 protects a memory even when the heat is icy", async () => {
72
+ const { store, service } = setup();
73
+ const mem = saveMemory(service, "关键决策", "不可丢失的重要结论", "decision", 5);
74
+ const demotion = await run([[mem, 400]], service, sleepConfig());
75
+ assert.ok(demotion, "demotion phase ran");
76
+ // decision λ=0.002, 400 天热值≈0.03<0.05,但 importance=5 → 保护,绝不降级。
77
+ assert.ok(!demotion.archived.includes(mem.id), "紧要记忆不被归档");
78
+ assert.ok(!demotion.demoted.includes(mem.id), "紧要记忆不被压缩");
79
+ assert.equal(store.getById(mem.id).archived, false);
80
+ store.close();
81
+ });
82
+
83
+ test("immune preference (λ=0, heat=1.0) is never demoted even when ancient", async () => {
84
+ const { store, service } = setup();
85
+ const mem = saveMemory(service, "用户偏好", "喜欢简洁的总结", "preference", 3);
86
+ const demotion = await run([[mem, 400]], service, sleepConfig());
87
+ assert.ok(!demotion.archived.includes(mem.id), "免疫类型不归档");
88
+ assert.ok(!demotion.demoted.includes(mem.id), "免疫类型不压缩");
89
+ assert.equal(store.getById(mem.id).archived, false);
90
+ store.close();
91
+ });
92
+
93
+ test("slow-decay project stays heat-protected at the compress tier (λ=0.0008)", async () => {
94
+ const { store, service } = setup();
95
+ const mem = saveMemory(service, "项目A", "慢衰减的进行中项目", "project", 2);
96
+ const demotion = await run([[mem, 100]], service, sleepConfig());
97
+ // 100 天已越过 90 天归档窗,但 project 热值≈0.28>0.05 → heat 闸拦下。
98
+ assert.ok(!demotion.archived.includes(mem.id), "慢衰减类型热值仍高 → 不归档");
99
+ assert.equal(store.getById(mem.id).archived, false);
100
+ store.close();
101
+ });
102
+
103
+ test("heatEnabled=false (v0.7.20 默认) 退回纯时间分层:importance 5 冷记忆也被归档", async () => {
104
+ const { store, service } = setup();
105
+ const mem = saveMemory(service, "紧要决策", "v0.7.12 无 heat 保护语义", "decision", 5);
106
+ // 显式关 heat(默认值)→ phaseDemotion 不做热联合判定,纯时间分层。
107
+ const config = sleepConfig({ heatEnabled: false });
108
+ const demotion = await run([[mem, 400]], service, config);
109
+ assert.ok(demotion.archived.includes(mem.id), "heat 关 → 400 天冷记忆直接归档(无 importance 保护)");
110
+ assert.equal(store.getById(mem.id).archived, true);
111
+ store.close();
112
+ });
113
+
114
+ test("demote tier (30-90d) fires when the type's λ is cold enough", async () => {
115
+ const { store, service } = setup();
116
+ const mem = saveMemory(service, "中期会话摘要", "可压缩的历史碎片", "history", 2);
117
+ // 调高 history 的 λ 到 0.05 → 40 天热值≈0.009<0.05,解锁 30-90 天压缩窗口。
118
+ const config = sleepConfig({ heatTypeDecay: { history: 0.05 } });
119
+ const demotion = await run([[mem, 40]], service, config);
120
+ assert.ok(!demotion.archived.includes(mem.id), "40 天未到归档线");
121
+ assert.ok(demotion.demoted.includes(mem.id), "热值足够冷 + importance 2 → 压缩为摘要");
122
+ const after = store.getById(mem.id);
123
+ assert.ok(after._full_content, "全文停放在 _full_content");
124
+ store.close();
125
+ });
@@ -45,6 +45,14 @@ function baseConfig(overrides = {}) {
45
45
  };
46
46
  }
47
47
 
48
+ // v0.7.0 heat 双保护默认保守:默认 heatTypeDecay 下 project 记忆 40 天热值
49
+ // ≈0.55、100 天 ≈0.28,始终高于 sleepHeatThreshold(0.05),demotion 不触发。
50
+ // 降级语义测试把 project 的 λ 调快到 0.02(40 天热值≈0.03),复现"时间窗冷态
51
+ // 即降级"的旧路径;默认保守语义由 sleep-heat.test.js 单独覆盖。
52
+ function demotionConfig(overrides = {}) {
53
+ return baseConfig({ heatTypeDecay: { project: 0.02 }, ...overrides });
54
+ }
55
+
48
56
  function setup() {
49
57
  const store = createStore(":memory:");
50
58
  const service = createService({ store, mirror: null, config: {} });
@@ -173,7 +181,7 @@ test("sleep: demotion shrinks cold memory to summary, keeps _full_content", asyn
173
181
  const m = makeMemory(service, "cold", "原内容".repeat(60), "project");
174
182
  service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
175
183
  const ctx = mockCtx(() => "[]");
176
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
184
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
177
185
  const after = service.getById(m.id);
178
186
  assert.equal(result.status, "ok");
179
187
  assert.ok(after._full_content && after._full_content.length > 0, "full body preserved");
@@ -188,7 +196,7 @@ test("sleep: demotion fully archives memory past sleepCompressDays", async () =>
188
196
  const m = makeMemory(service, "ancient", "很老的记忆", "project");
189
197
  service.touchLastAccess(m.id, new Date(now - 100 * 86400000).toISOString());
190
198
  const ctx = mockCtx(() => "[]");
191
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
199
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
192
200
  const after = service.getById(m.id);
193
201
  assert.equal(result.status, "ok");
194
202
  assert.equal(after.archived, true, "past compress days → archived");
@@ -275,7 +283,7 @@ test("sleep: a failing phase does not block the others (fail-safe)", async () =>
275
283
  const m = makeMemory(service, "cold", "内容".repeat(60), "project");
276
284
  service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
277
285
  const ctx = mockCtx(() => "[]");
278
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
286
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
279
287
  assert.equal(result.phases.conflicts.status, "skipped", "conflicts phase degraded gracefully (no usable vectors)");
280
288
  assert.equal(result.phases.demotion.status, "ok", "demotion still ran");
281
289
  assert.equal(result.status, "ok", "overall run still ok despite conflicts degrading");
@@ -289,7 +297,7 @@ test("sleep: no LLM route skips LLM phases but demotion still runs", async () =>
289
297
  const m = makeMemory(service, "cold", "内容".repeat(60), "project");
290
298
  service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
291
299
  const ctx = mockCtx(() => "[]", null); // currentSelection() → null, no route
292
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
300
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
293
301
  assert.equal(result.phases.conflicts.status, "skipped", "no llm route → conflicts skipped");
294
302
  assert.equal(result.phases.patterns.status, "skipped", "no llm route → patterns skipped");
295
303
  assert.equal(result.phases.demotion.status, "ok", "demotion is LLM-free and runs");
@@ -0,0 +1,113 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { runSleep } from "../src/dream/sleep.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+
7
+ // v0.7.0 语义修正(待办④ updated_at 不算访问):
8
+ // - merge/update 刷 updated_at 绝不当访问,last_accessed_at 与 updated_at 正交;
9
+ // - sleep 降级 ref 只用 last_accessed_at ?? created_at,杜绝 autoDream 合并
10
+ // 动作把"刚更新过内容"伪装成"刚被召回";
11
+ // - 触达数据采集(touchRecalled)由 heatEnabled 控制,不再依赖 sleepModeEnabled。
12
+
13
+ const DAY = 86400000;
14
+
15
+ function setup(config = {}) {
16
+ const store = createStore(":memory:");
17
+ const service = createService({ store, mirror: null, config });
18
+ return { store, service };
19
+ }
20
+
21
+ function saveMemory(service, title, content, type = "project") {
22
+ return service.saveWithDedupe({ type, title, content, importance: 3 }).memory;
23
+ }
24
+
25
+ test("store.update bumps updated_at but never last_accessed_at", () => {
26
+ const { store, service } = setup();
27
+ const mem = saveMemory(service, "项目A", "状态:进行中");
28
+ assert.equal(mem.last_accessed_at, undefined, "saved memory has no last_accessed_at");
29
+
30
+ const touched = service.touchLastAccess(mem.id, new Date(Date.now() - 5 * DAY).toISOString());
31
+ assert.equal(touched, true, "explicit touch succeeds");
32
+ const afterTouch = store.getById(mem.id);
33
+ assert.ok(afterTouch.last_accessed_at, "last_accessed_at set after touch");
34
+
35
+ const updatedAtBefore = afterTouch.updated_at;
36
+ store.update(mem.id, { content: "状态:已交付" });
37
+ const afterUpdate = store.getById(mem.id);
38
+ assert.notEqual(afterUpdate.updated_at, updatedAtBefore, "updated_at changed by update");
39
+ assert.equal(
40
+ afterUpdate.last_accessed_at,
41
+ afterTouch.last_accessed_at,
42
+ "last_accessed_at untouched by update (content change ≠ access)"
43
+ );
44
+ store.close();
45
+ });
46
+
47
+ test("search bumps last_accessed_at with heatEnabled on, and is gated off by heatEnabled=false", async () => {
48
+ // 显式开启 heat(v0.7.20 起默认关):last_accessed_at 被采集(与 sleep 开关解耦)。
49
+ const a = setup({ heatEnabled: true });
50
+ saveMemory(a.service, "量子计算", "入门");
51
+ await a.service.searchMemories("量子", { mode: "keyword" });
52
+ const rowA = a.store.getById(a.service.all()[0].id);
53
+ assert.ok(rowA.last_accessed_at, "touch runs when heatEnabled is true even when sleepModeEnabled off");
54
+ a.store.close();
55
+
56
+ // heatEnabled=false(默认值)→ 热路径零写入。
57
+ const b = setup({ heatEnabled: false });
58
+ saveMemory(b.service, "量子计算", "入门");
59
+ await b.service.searchMemories("量子", { mode: "keyword" });
60
+ const rowB = b.store.getById(b.service.all()[0].id);
61
+ assert.equal(rowB.last_accessed_at, undefined, "no touch when heatEnabled=false");
62
+ b.store.close();
63
+ });
64
+
65
+ test("sleep demotion anchors on last_accessed_at — a merge-bumped updated_at does not reset the fresh clock", async () => {
66
+ const { store, service } = setup();
67
+ const now = Date.now();
68
+ const idle = saveMemory(service, "冷查记忆A", "很久没被召回");
69
+ // 模拟一次 autoDream 合并:updated_at 刷成"现在",但真实访问在 100 天前。
70
+ store.update(idle.id, { content: "合并带来的内容更新" });
71
+ const wait = saveMemory(service, "冷查记忆B", "40 天未访问");
72
+
73
+ service.touchLastAccess(idle.id, new Date(now - 100 * DAY).toISOString());
74
+ service.touchLastAccess(wait.id, new Date(now - 40 * DAY).toISOString());
75
+
76
+ // runSleep 的最低配置:demotion 阶段不需要 LLM/语义,其余阶段会 skip。
77
+ const ctx = {
78
+ logger: { warn: () => {}, info: () => {} },
79
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
80
+ llm: {
81
+ async *stream() {
82
+ yield { type: "text-delta", index: 0, text: "[]" };
83
+ yield { type: "finish", reason: { kind: "stop" } };
84
+ }
85
+ }
86
+ };
87
+ const config = {
88
+ sleepModeEnabled: true,
89
+ sleepConflictStrictness: "normal",
90
+ sleepArchiveDays: 30,
91
+ sleepCompressDays: 90,
92
+ sleepPatternMinMemories: 100,
93
+ sleepMaxPatternPerRun: 3,
94
+ // v0.7.0 热联合判定:project λ=0.0008 默认太慢衰减,40/100 天热值都高于
95
+ // 阈值、被热闸保护——本测试只验证"updated_at 不算访问"的 ref 语义,故把
96
+ // λ 调快到 0.02 打开降级路径(默认保守语义由 sleep-heat.test.js 覆盖)。
97
+ heatTypeDecay: { project: 0.02 }
98
+ };
99
+
100
+ const result = await runSleep(ctx, service, config, { warn: () => {}, info: () => {} }, null, null);
101
+
102
+ const demotion = result.phases?.["demotion"];
103
+ assert.ok(demotion, "demotion phase ran");
104
+ // A:last_accessed 100 天前(即使 updated_at 是"现在")→ 越过 90 天归档线。
105
+ assert.ok(demotion.archived.includes(idle.id), "A archived by last_accessed_at (updated_at ignored)");
106
+ const afterA = store.getById(idle.id);
107
+ assert.equal(afterA.archived, true, "A is archived in store");
108
+ // B:last_accessed 40 天前 → 进入 30 天压缩窗口,被降级为摘要。
109
+ assert.ok(demotion.demoted.includes(wait.id), "B demoted in 30d compress window");
110
+ const afterB = store.getById(wait.id);
111
+ assert.ok(afterB._full_content, "B full body parked in _full_content");
112
+ store.close();
113
+ });