@modusensus/dsh-mneme 0.7.17 → 0.7.20

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/test/api.test.js CHANGED
@@ -527,10 +527,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
527
527
  assert.equal(res.statusCode, 200);
528
528
  const data = JSON.parse(res.body);
529
529
  assert.deepEqual(data.overrides, {});
530
- // effective 覆盖全部 30 个白名单键,未覆盖时取 bundle 配置的解析默认值;
531
- // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
532
- // 不编造给前端30 - 2 = 28
533
- assert.equal(Object.keys(data.effective).length, 28);
530
+ // effective 覆盖全部 31 个白名单键(含 v0.7.20 新增的 heatEnabled),未覆盖时
531
+ // 取 bundle 配置的解析默认值;dreamProvider/dreamModel 无 schema 默认值
532
+ // (Config({}) 解析为 undefined),不编造给前端 31 - 2 = 29
533
+ assert.equal(Object.keys(data.effective).length, 29);
534
534
  assert.equal(data.effective.autoInject, true);
535
535
  assert.equal(data.effective.codingRetrospect, false);
536
536
  assert.equal(data.effective.distillMaxChars, 24000);
@@ -903,7 +903,9 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as
903
903
  assert.equal(data.runs.length, 2);
904
904
  assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
905
905
  assert.deepEqual(data.lastRun, data.runs[0]);
906
- assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "error", "model", "provider", "status"]);
906
+ assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "status"]);
907
+ assert.equal(data.lastRun.run_type, "auto", "default run_type is auto");
908
+ assert.equal(data.lastRun.demotion, null, "no demotion info for non-sleep runs");
907
909
  assert.equal(data.runs[0].error, "boom");
908
910
  assert.equal(data.runs[1].provider, "ollama");
909
911
  assert.equal(data.pendingConflicts, 1);
@@ -994,3 +996,84 @@ test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default li
994
996
  await route.handler(req("/api/dsh-mneme/list"), back);
995
997
  assert.equal(JSON.parse(back.body).total, 2);
996
998
  });
999
+
1000
+ test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (receipts ∪ source=dream)", async () => {
1001
+ const { routes, service } = setup();
1002
+ const plain = service.saveWithDedupe({ type: "preference", title: "无关", content: "plain" });
1003
+ const merged = service.saveWithDedupe({ type: "project", title: "被巩固", content: "merged" });
1004
+ const updated = service.saveWithDedupe({ type: "decision", title: "被更新", content: "updated" });
1005
+ service.saveWithDedupe({ type: "summary", title: "总览", content: "overview", source: "dream" });
1006
+ const ids = [plain, merged, updated].map((r) => r.memory.id);
1007
+
1008
+ // 巩固账本:merge 落在 keepSource、update 落在目标;conflict 只仲裁不落
1009
+ // 内容,不算沉淀。verdict='live' 才有效。
1010
+ service.saveReceipt({
1011
+ receipt_id: "r-merge", run_id: "run-1", record_id: merged.memory.id, kind: "merge",
1012
+ input_digest: "d1", keep_source: merged.memory.id, sources: [merged.memory.id, ids[0]],
1013
+ verdict: "live", count_before: 2, count_after: 1, policy_epoch: 0,
1014
+ created_at: new Date().toISOString()
1015
+ });
1016
+ service.saveReceipt({
1017
+ receipt_id: "r-update", run_id: "run-1", record_id: updated.memory.id, kind: "update",
1018
+ input_digest: "d2", verdict: "live", count_before: 1, count_after: 1,
1019
+ policy_epoch: 0, created_at: new Date().toISOString()
1020
+ });
1021
+ service.saveReceipt({
1022
+ receipt_id: "r-conflict", run_id: "run-1", record_id: "winner-x", kind: "conflict",
1023
+ input_digest: "d3", winner_id: "winner-x", loser_id: "loser-y", verdict: "live",
1024
+ count_before: 2, count_after: 2, policy_epoch: 0, created_at: new Date().toISOString()
1025
+ });
1026
+
1027
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
1028
+ const res = new FakeRes();
1029
+ await route.handler(req("/api/dsh-mneme/list?deposited=only"), res);
1030
+ const data = JSON.parse(res.body);
1031
+ assert.deepEqual(
1032
+ data.items.map((m) => m.title).sort(),
1033
+ ["总览", "被巩固", "被更新"],
1034
+ "deposited view = receipt merge/update records ∪ source=dream writes"
1035
+ );
1036
+ assert.equal(data.total, 3, "total honors the deposited filter");
1037
+
1038
+ // 默认列表不受 deposited 过滤影响
1039
+ const def = new FakeRes();
1040
+ await route.handler(req("/api/dsh-mneme/list"), def);
1041
+ assert.equal(JSON.parse(def.body).total, 4);
1042
+
1043
+ // 与 archived=only 可叠加:归档的沉淀记忆才出现在交集视图里
1044
+ const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
1045
+ const arch = new FakeRes();
1046
+ await upd.handler(req("/api/dsh-mneme/update", "POST", { id: merged.memory.id, archived: true }), arch);
1047
+ assert.equal(arch.statusCode, 200);
1048
+ const both = new FakeRes();
1049
+ await route.handler(req("/api/dsh-mneme/list?deposited=only&archived=only"), both);
1050
+ const bothData = JSON.parse(both.body);
1051
+ assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
1052
+ assert.equal(bothData.total, 1);
1053
+ });
1054
+
1055
+ test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=true", async () => {
1056
+ // 默认(heatEnabled=false):heat 字段整体缺省——前端徽章据此自动隐藏
1057
+ const off = setup();
1058
+ off.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
1059
+ const r0 = new FakeRes();
1060
+ await off.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r0);
1061
+ const d0 = JSON.parse(r0.body);
1062
+ assert.equal(d0.total, 1);
1063
+ assert.equal("heat" in d0.items[0], false, "heat must be absent from the wire DTO when the flag is off");
1064
+
1065
+ // heatEnabled=true:逐条投影。λ=0 免疫类型(preference)恒 1.0;其余落在
1066
+ // [0,1] 区间(新建记忆 Δt≈0 接近满格,衰减数学由 heat.test.js 看门)。
1067
+ const on = setup(null, "", { heatEnabled: true });
1068
+ on.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
1069
+ on.service.saveWithDedupe({ type: "history", title: "会话历史", content: "recent" });
1070
+ const r1 = new FakeRes();
1071
+ await on.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r1);
1072
+ const d1 = JSON.parse(r1.body);
1073
+ assert.equal(d1.total, 2);
1074
+ const byTitle = Object.fromEntries(d1.items.map((m) => [m.title, m.heat]));
1075
+ assert.equal(byTitle["免疫型"], 1, "λ=0 immune types stay at full heat");
1076
+ for (const v of Object.values(byTitle)) {
1077
+ assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
1078
+ }
1079
+ });
@@ -219,6 +219,54 @@ test("importance renders as star glyphs, not raw text stars", () => {
219
219
  );
220
220
  });
221
221
 
222
+ // better-sidebar ecosystem integration is an optional capability (official
223
+ // external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
224
+ // runtime gates ctx property access on the inject declaration — probing
225
+ // without declaring fails the whole loader entry, verified in the field) and
226
+ // better-sidebar 软集成(issue #88 修正):模块级 inject 声明 betterSidebar
227
+ // 是硬等待——未安装 bs 的环境整个 entry pending("1 entry did not activate",
228
+ // Failed to load plugins)。正确模式(dsh-server-deck 同款):外层入口零
229
+ // inject 立即激活(独立模式保底),tab 注册挂在内层动态子插件
230
+ // ctx.plugin({ inject: ['betterSidebar'] }) 由 cordis 原生等待服务——bs 未装
231
+ // 时该内层 fiber 永远 INACTIVE,静默无害。
232
+ test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact", () => {
233
+ assert.ok(
234
+ /const reg = bsCtx\.betterSidebar;[\s\S]{0,60}typeof reg\.registerTab !== "function"/.test(clientSource),
235
+ "the inner apply must still guard the service shape before registering"
236
+ );
237
+ assert.ok(
238
+ /id: "dsh-mneme:memory"/.test(clientSource),
239
+ "the registered tab id must be package-prefixed"
240
+ );
241
+ assert.ok(
242
+ /title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
243
+ "the tab title must reuse the localized 记忆库 label"
244
+ );
245
+ assert.ok(
246
+ /component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
247
+ "the tab must reuse the MemoryExplorer views"
248
+ );
249
+ assert.ok(
250
+ clientSource.includes('"dsh-mneme: better-sidebar tab"'),
251
+ "the registration effect must carry a named label for scope cleanup"
252
+ );
253
+ // issue #88:模块级 inject 声明 betterSidebar 是硬等待——未安装 bs 的环境
254
+ // 整个 entry pending("1 entry did not activate")。tab 注册必须挂在内层
255
+ // 动态子插件(dsh-server-deck 同款模式),外层入口零 inject 立即激活。
256
+ assert.ok(
257
+ /const inject = \["slots", "locale"\]/.test(clientSource),
258
+ "the module inject must not declare betterSidebar (hard-wait regression)"
259
+ );
260
+ assert.ok(
261
+ /ctx\.plugin\?\.\(\{[\s\S]*?inject: \["betterSidebar"\][\s\S]*?apply: \(bsCtx\) =>/.test(clientSource),
262
+ "the tab registration must live in an inner dynamic sub-plugin waiting on cordis"
263
+ );
264
+ assert.ok(
265
+ /if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource) === false,
266
+ "the old 10×1s probe must go — cordis waits for the inner inject natively"
267
+ );
268
+ });
269
+
222
270
  // The graph toggle must not read as "share": the primitives share icon is
223
271
  // banned and a custom node-graph glyph takes its place.
224
272
  test("graph toggle uses a node-graph glyph, not the share icon", () => {
@@ -233,6 +281,44 @@ test("graph toggle uses a node-graph glyph, not the share icon", () => {
233
281
  );
234
282
  });
235
283
 
284
+ // 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
285
+ // 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
286
+ // 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
287
+ test("status dashboard links into deposited/archived library views", () => {
288
+ assert.ok(
289
+ clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
290
+ "the workbench deposited preview must read the server-side deposited view"
291
+ );
292
+ assert.ok(
293
+ clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
294
+ "the workbench archived preview must cap at 3 rows backed by a server total"
295
+ );
296
+ assert.ok(
297
+ clientSource.includes("browseWithFilter"),
298
+ "the status page must jump into the library with preset filters"
299
+ );
300
+ assert.ok(
301
+ /view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
302
+ "the status panel must receive the browse-jump callback"
303
+ );
304
+ assert.ok(
305
+ clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
306
+ "the library filterQS must carry the deposited chip"
307
+ );
308
+ assert.ok(
309
+ clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
310
+ "the library filterQS must carry the archived chip"
311
+ );
312
+ assert.ok(
313
+ clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
314
+ "the drawer must offer restore for archived memories"
315
+ );
316
+ assert.ok(
317
+ clientSource.includes('"memory.status.viewAll"'),
318
+ "the view-all entries must come from the dictionary"
319
+ );
320
+ });
321
+
236
322
  // Every memory feature lives in the main-area library now: the explorer
237
323
  // hosts three sub-views (browse / entities / settings) switched by tabs whose
238
324
  // labels come from dedicated dictionary keys.
@@ -332,3 +418,51 @@ test("explorer chrome aligns with the host design system", () => {
332
418
  "pill chips belong to the drawer era and must stay gone"
333
419
  );
334
420
  });
421
+
422
+ // heat 阶段二:/list 仅在 heatEnabled=true 时下发逐条 heat,前端徽章三档
423
+ // 配色且自门控(字段缺省自动隐藏)——卡片页脚、抽屉 meta、状态分布卡共用
424
+ // 同一数据源,前端不感知开关状态。
425
+ test("heat badges render from the /list projection and self-hide when off", () => {
426
+ assert.ok(
427
+ /const HeatBadge = \(\{ value, size = 12 \}\) =>/.test(clientSource),
428
+ "the heat badge component must exist"
429
+ );
430
+ assert.ok(
431
+ clientSource.includes("flame:"),
432
+ "the Lucide flame glyph must back the badge"
433
+ );
434
+ assert.ok(
435
+ /h\(HeatBadge, \{ value: m\.heat \}\)/.test(clientSource),
436
+ "the card foot must render the heat badge"
437
+ );
438
+ assert.ok(
439
+ clientSource.includes('t("memory.explorer.heat")'),
440
+ "the drawer must show a localized heat meta row"
441
+ );
442
+ assert.ok(
443
+ clientSource.includes("function HeatStatusCard"),
444
+ "the status grid must include the heat distribution card"
445
+ );
446
+ assert.ok(
447
+ clientSource.includes('typeof items[0].heat !== "number"'),
448
+ "the distribution card must self-hide when /list omits heat"
449
+ );
450
+ assert.ok(
451
+ /\.mneme-heat--hot\{/.test(clientSource),
452
+ "the three-tier heat colors must be styled"
453
+ );
454
+ // order=heat 的前端补口:热度是运行时投影无存储序,SQL 排不了——页内
455
+ // 对已加载条目降序,时间树保持 chrono;chip 自门控(heat 缺省不出现)。
456
+ assert.ok(
457
+ /const heatAvailable = visible\.some\(\(m\) => typeof m\.heat === "number"\);/.test(clientSource),
458
+ "the heat-sort chip must self-gate on the /list heat field"
459
+ );
460
+ assert.ok(
461
+ /const gridItems = heatSort[\s\S]{0,80}\(b\.heat \?\? 0\) - \(a\.heat \?\? 0\)/.test(clientSource),
462
+ "the cards grid must sort loaded items by heat in-page"
463
+ );
464
+ assert.ok(
465
+ clientSource.includes("if (!heatSort) switchViewMode(\"cards\")"),
466
+ "toggling heat sort must land on the cards view (sort does not apply to the month tree)"
467
+ );
468
+ });
@@ -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
+ });
@@ -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");