@modusensus/dsh-mneme 0.6.11 → 0.7.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.
Files changed (145) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/CHANGELOG.md +89 -0
  3. package/CONTRIBUTING.md +245 -0
  4. package/README.md +152 -398
  5. package/SECURITY.md +544 -0
  6. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  7. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  12. package/dsh-mneme/CHANGELOG.md +295 -0
  13. package/dsh-mneme/LICENSE +21 -0
  14. package/dsh-mneme/README.md +484 -0
  15. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  16. package/dsh-mneme/docs/ENTITIES.md +245 -0
  17. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  18. package/dsh-mneme/docs/MIGRATION.md +127 -0
  19. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  20. package/dsh-mneme/docs/SLEEP.md +163 -0
  21. package/{src → dsh-mneme/lib}/api.js +10 -4
  22. package/{lib → dsh-mneme/lib}/client.js +10 -2
  23. package/{lib → dsh-mneme/lib}/config.js +17 -0
  24. package/{lib → dsh-mneme/lib}/dream/sleep.js +13 -1
  25. package/{lib → dsh-mneme/lib}/dream.js +7 -2
  26. package/dsh-mneme/lib/heat.js +136 -0
  27. package/{lib → dsh-mneme/lib}/index.js +14 -6
  28. package/{src → dsh-mneme/lib}/service.js +100 -11
  29. package/{lib → dsh-mneme/lib}/settings.js +15 -6
  30. package/{lib → dsh-mneme/lib}/store.js +22 -0
  31. package/dsh-mneme/package-lock.json +1936 -0
  32. package/dsh-mneme/package.json +80 -0
  33. package/{lib → dsh-mneme/src}/api.js +10 -4
  34. package/{src → dsh-mneme/src}/config.js +17 -0
  35. package/{src → dsh-mneme/src}/dream/sleep.js +13 -1
  36. package/{src → dsh-mneme/src}/dream.js +7 -2
  37. package/dsh-mneme/src/heat.js +136 -0
  38. package/{src → dsh-mneme/src}/index.js +14 -6
  39. package/{lib → dsh-mneme/src}/service.js +100 -11
  40. package/{src → dsh-mneme/src}/settings.js +15 -6
  41. package/{src → dsh-mneme/src}/store.js +22 -0
  42. package/{test → dsh-mneme/test}/api.test.js +10 -3
  43. package/{test → dsh-mneme/test}/graph-api.test.js +34 -0
  44. package/dsh-mneme/test/heat.test.js +148 -0
  45. package/{test → dsh-mneme/test}/recall-layer.test.js +26 -5
  46. package/dsh-mneme/test/recall-runs.test.js +93 -0
  47. package/{test → dsh-mneme/test}/settings.test.js +6 -2
  48. package/dsh-mneme/test/sleep-heat.test.js +112 -0
  49. package/{test → dsh-mneme/test}/sleep.test.js +12 -4
  50. package/{test → dsh-mneme/test}/tag.test.js +114 -0
  51. package/dsh-mneme/test/updated-at-semantics.test.js +113 -0
  52. package/package.json +18 -40
  53. package/v0.7-plan-2026-08-21.md +75 -0
  54. package//346/250/252/345/271/205.png +0 -0
  55. /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
  56. /package/{lib → dsh-mneme/lib}/commands.js +0 -0
  57. /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
  58. /package/{lib → dsh-mneme/lib}/dream/decisions.js +0 -0
  59. /package/{lib → dsh-mneme/lib}/dream/tag-extractor.js +0 -0
  60. /package/{lib → dsh-mneme/lib}/embedding.js +0 -0
  61. /package/{lib → dsh-mneme/lib}/entities/extractor.js +0 -0
  62. /package/{lib → dsh-mneme/lib}/hot-memory.js +0 -0
  63. /package/{lib → dsh-mneme/lib}/inject.js +0 -0
  64. /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
  65. /package/{lib → dsh-mneme/lib}/mirror.js +0 -0
  66. /package/{lib → dsh-mneme/lib}/parser/tag.js +0 -0
  67. /package/{lib → dsh-mneme/lib}/parser/wiki-link.js +0 -0
  68. /package/{lib → dsh-mneme/lib}/quality-filter.js +0 -0
  69. /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
  70. /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
  71. /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
  72. /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
  73. /package/{lib → dsh-mneme/lib}/summarize.js +0 -0
  74. /package/{lib → dsh-mneme/lib}/tools.js +0 -0
  75. /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
  76. /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
  77. /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
  78. /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
  79. /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
  80. /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
  81. /package/{scripts → dsh-mneme/scripts}/sync-lib.js +0 -0
  82. /package/{src → dsh-mneme/src}/commands.js +0 -0
  83. /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
  84. /package/{src → dsh-mneme/src}/dream/decisions.js +0 -0
  85. /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
  86. /package/{src → dsh-mneme/src}/embedding.js +0 -0
  87. /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
  88. /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
  89. /package/{src → dsh-mneme/src}/inject.js +0 -0
  90. /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
  91. /package/{src → dsh-mneme/src}/mirror.js +0 -0
  92. /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
  93. /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
  94. /package/{src → dsh-mneme/src}/quality-filter.js +0 -0
  95. /package/{src → dsh-mneme/src}/reranker.js +0 -0
  96. /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
  97. /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
  98. /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
  99. /package/{src → dsh-mneme/src}/summarize.js +0 -0
  100. /package/{src → dsh-mneme/src}/tools.js +0 -0
  101. /package/{src → dsh-mneme/src}/vector-index.js +0 -0
  102. /package/{test → dsh-mneme/test}/audit.test.js +0 -0
  103. /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
  104. /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
  105. /package/{test → dsh-mneme/test}/client.test.js +0 -0
  106. /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
  107. /package/{test → dsh-mneme/test}/commands.test.js +0 -0
  108. /package/{test → dsh-mneme/test}/config.test.js +0 -0
  109. /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
  110. /package/{test → dsh-mneme/test}/directory.test.js +0 -0
  111. /package/{test → dsh-mneme/test}/dream.test.js +0 -0
  112. /package/{test → dsh-mneme/test}/entities.test.js +0 -0
  113. /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
  114. /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
  115. /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
  116. /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
  117. /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
  118. /package/{test → dsh-mneme/test}/inject.test.js +0 -0
  119. /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
  120. /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
  121. /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
  122. /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
  123. /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
  124. /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
  125. /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
  126. /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
  127. /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
  128. /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
  129. /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
  130. /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
  131. /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
  132. /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
  133. /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
  134. /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
  135. /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
  136. /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
  137. /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
  138. /package/{test → dsh-mneme/test}/service.test.js +0 -0
  139. /package/{test → dsh-mneme/test}/store.test.js +0 -0
  140. /package/{test → dsh-mneme/test}/stress.test.js +0 -0
  141. /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
  142. /package/{test → dsh-mneme/test}/tag-boost.test.js +0 -0
  143. /package/{test → dsh-mneme/test}/tools.test.js +0 -0
  144. /package/{test → dsh-mneme/test}/vector-index.test.js +0 -0
  145. /package/{test → dsh-mneme/test}/wiki-link.test.js +0 -0
@@ -29,8 +29,10 @@ function req(path, method = "GET", body = null) {
29
29
 
30
30
  function setup(embedder, apiToken = "") {
31
31
  const store = createStore(":memory:");
32
- const service = createService({ store, mirror: null, config: {} });
33
32
  const settings = createSettings(store.db);
33
+ // settings is threaded into the service so the /config endpoint reports the
34
+ // effective settings-over-config tag toggles (issue #31).
35
+ const service = createService({ store, mirror: null, config: {}, settings });
34
36
  const commands = {
35
37
  add: (def) => settings.addCommand(def),
36
38
  remove: (id) => settings.removeCommand(id),
@@ -554,7 +556,9 @@ test("GET/PUT /api/dsh-mneme/config round-trips autoTag switches", async () => {
554
556
  const res1 = new FakeRes();
555
557
  await route.handler(req("/api/dsh-mneme/config"), res1);
556
558
  assert.equal(res1.statusCode, 200);
557
- assert.deepEqual(JSON.parse(res1.body).config, { autoTagEnabled: false, manualTagEnabled: false });
559
+ // unset → effective config falls back to plugin-config defaults:
560
+ // autoTagEnabled=false, manualTagEnabled=true (issue #31 default unification)
561
+ assert.deepEqual(JSON.parse(res1.body).config, { autoTagEnabled: false, manualTagEnabled: true });
558
562
  const res2 = new FakeRes();
559
563
  await route.handler(req("/api/dsh-mneme/config", "PUT", { autoTagEnabled: true, manualTagEnabled: false }), res2);
560
564
  assert.equal(res2.statusCode, 200);
@@ -590,5 +594,8 @@ test("PUT /api/dsh-mneme/config supports partial update", async () => {
590
594
  const res = new FakeRes();
591
595
  await route.handler(req("/api/dsh-mneme/config", "PUT", { autoTagEnabled: true }), res);
592
596
  assert.equal(res.statusCode, 200);
593
- assert.deepEqual(JSON.parse(res.body).config, { autoTagEnabled: true, manualTagEnabled: false });
597
+ // Partial PUT only stores autoTagEnabled; manualTagEnabled stays unset and
598
+ // falls back to the plugin-config default (true) — a lone autoTag toggle must
599
+ // never silently disable manual tagging (issue #31).
600
+ assert.deepEqual(JSON.parse(res.body).config, { autoTagEnabled: true, manualTagEnabled: true });
594
601
  });
@@ -173,3 +173,37 @@ test("ego 2-hop over 100+ nodes stays under 50ms (spec §7)", async () => {
173
173
  assert.ok(data.edges.length > 0);
174
174
  assert.ok(ms < 50, `2-hop query took ${ms.toFixed(1)}ms, spec budget is 50ms`);
175
175
  });
176
+
177
+ test("ego node heat projects from linked memory heat (v0.7.0)", async () => {
178
+ const { routes, service } = setup();
179
+ const mem = service.saveWithDedupe({ type: "history", title: "test", content: "test entity heat", importance: 3 });
180
+ const entity = service.createEntity({ name: "E", type: "project" });
181
+ service.saveRelation({ from_entity: entity.id, to_entity: entity.id, relation_type: "evidence", memory_id: mem.memory.id });
182
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/graph/ego");
183
+ assert.ok(route, "ego route exists");
184
+ const res = new FakeRes();
185
+ await route.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=E"), res);
186
+ const data = JSON.parse(res.body);
187
+ const node = data.nodes.find((n) => n.name === "E");
188
+ assert.ok(typeof node.heat === "number" && node.heat >= 0 && node.heat <= 1, "entity node has a heat value");
189
+ assert.ok(Math.abs(node.heat - 1) < 0.01, "fresh memory -> heat ~1.0 -> entity heat ~1.0");
190
+ });
191
+
192
+ test("ego node heat is null when heatEnabled=false (v0.7.0)", async () => {
193
+ const store = createStore(":memory:");
194
+ const service = createService({ store, mirror: null, config: { heatEnabled: false } });
195
+ const settings = createSettings(store.db);
196
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
197
+ const routes = [];
198
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
199
+ createApi(ctx, service, settings, commands, null, undefined, "");
200
+ const mem = service.saveWithDedupe({ type: "history", title: "关联记忆", content: "实体关联", importance: 3 });
201
+ const entity = service.createEntity({ name: "F", type: "project" });
202
+ service.saveRelation({ from_entity: entity.id, to_entity: entity.id, relation_type: "evidence", memory_id: mem.memory.id });
203
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/graph/ego");
204
+ const res = new FakeRes();
205
+ await route.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=F"), res);
206
+ const data = JSON.parse(res.body);
207
+ assert.equal(data.nodes.find((n) => n.name === "F").heat, null, "heat disabled → entity heat is null");
208
+ store.close();
209
+ });
@@ -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,23 @@ 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 off — recorder is not called", async () => {
80
+ test("recordRecall defaults on (v0.7.0) — recorder is called by default", 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, "recorder called once by default (recall_runs 记录默认开)");
87
+ });
88
+
89
+ test("recordRecall can be opted out via explicit false or config.recallRecordDefault=false", async () => {
90
+ const { service } = setup({ recallRecordDefault: false });
91
+ let calls = 0;
92
+ service.setRecallRecorder(() => calls++);
93
+ saveMemory(service, null, { title: "量子计算", content: "入门" });
94
+ await service.searchMemories("量子", { mode: "keyword" });
86
95
  await service.searchMemories("量子", { mode: "keyword", recordRecall: false });
87
- assert.equal(calls, 0, "no recorder call when recordRecall is unset or false");
96
+ assert.equal(calls, 0, "no recorder call when default disabled or explicit false");
88
97
  });
89
98
 
90
99
  test("recordRecall=true with no recorder installed is safe and returns normally", async () => {
@@ -301,7 +310,7 @@ test("e2e: index.js wiring — recordRecall search lands a row listRecallRuns ca
301
310
  assert.equal(store.getRecallRun(run.id).query, "量子", "row readable right away");
302
311
  });
303
312
 
304
- test("e2e: without recordRecall the recall_runs table gains no rows", async () => {
313
+ test("e2e: recordRecall defaults on (v0.7.0) but is disabled by config.recallRecordDefault=false", async () => {
305
314
  const store = createStore(":memory:");
306
315
  const service = createService({ store, mirror: null, config: {} });
307
316
  service.setRecallRecorder((recall) => store.saveRecallRun({
@@ -310,6 +319,18 @@ test("e2e: without recordRecall the recall_runs table gains no rows", async () =
310
319
  }));
311
320
  service.saveWithDedupe({ type: "preference", title: "量子计算入门", content: "叠加态" });
312
321
  await service.searchMemories("量子", { mode: "keyword" });
313
- await service.searchMemories("量子", { mode: "keyword", recordRecall: false });
314
- assert.deepEqual(store.listRecallRuns(), [], "no rows without recordRecall=true");
322
+ assert.equal(store.listRecallRuns().length, 1, "default config records a row (记录默认开)");
323
+
324
+ const store2 = createStore(":memory:");
325
+ const service2 = createService({ store: store2, mirror: null, config: { recallRecordDefault: false } });
326
+ service2.setRecallRecorder((recall) => store2.saveRecallRun({
327
+ query: recall.query, mode: recall.mode, topK: recall.topK, threshold: recall.threshold ?? null,
328
+ candidates: recall.candidates ?? [], created_at: recall.createdAt
329
+ }));
330
+ service2.saveWithDedupe({ type: "preference", title: "量子计算入门", content: "叠加态" });
331
+ await service2.searchMemories("量子", { mode: "keyword" });
332
+ await service2.searchMemories("量子", { mode: "keyword", recordRecall: false });
333
+ assert.deepEqual(store2.listRecallRuns(), [], "no rows when recallRecordDefault=false or explicit false");
334
+ store.close();
335
+ store2.close();
315
336
  });
@@ -0,0 +1,93 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+
6
+ // v0.7.0 recall-layer 强度标记 + 滚动清理:
7
+ // - searchMemories 记搜索帧:candidates 打 injected:false(被召回);
8
+ // - injectCandidates 记注入帧:mode="inject"、candidates 打 injected:true
9
+ // (被注入上下文)—— 两个消耗强度可分可查;
10
+ // - 两路都受 recallRecordDefault 门控,注入帧记账失败绝不阻断注入;
11
+ // - store.purgeRecallRunsOlderThan(days) 按 created_at 滚动清理防膨胀。
12
+
13
+ function setup(config = {}) {
14
+ const store = createStore(":memory:");
15
+ const service = createService({ store, mirror: null, config });
16
+ return { store, service };
17
+ }
18
+
19
+ function wireRecorder(service, seen) {
20
+ service.setRecallRecorder((r) => seen.push(r));
21
+ }
22
+
23
+ test("search scene candidates carry injected:false", async () => {
24
+ const { service } = setup();
25
+ const seen = [];
26
+ wireRecorder(service, seen);
27
+ service.saveWithDedupe({ type: "preference", title: "量子计算", content: "入门", importance: 5 });
28
+ const rows = await service.searchMemories("量子", { mode: "keyword", recordRecall: true });
29
+ assert.equal(rows.length, 1);
30
+ assert.equal(seen[0].mode, "keyword");
31
+ assert.equal(seen[0].candidates[0].injected, false, "搜索帧是被召回的帧,标记 false");
32
+ });
33
+
34
+ test("inject scene records mode=inject with injected:true on every candidate", async () => {
35
+ const { service } = setup();
36
+ const seen = [];
37
+ wireRecorder(service, seen);
38
+ const mem = service.saveWithDedupe({ type: "preference", title: "量子计算", content: "入门", importance: 5 }).memory;
39
+ const injected = service.injectCandidates({ query: "量子", maxItems: 3 });
40
+ assert.ok(injected.some((m) => m.id === mem.id), "注入选中的记忆在返回里");
41
+ assert.equal(seen.length, 1, "注入帧记了 1 条 recall_runs");
42
+ const rec = seen[0];
43
+ assert.equal(rec.mode, "inject", "注入帧 mode 为 inject");
44
+ assert.equal(rec.topK, injected.length);
45
+ assert.ok(rec.candidates.length >= 1 && rec.candidates.every((c) => c.injected === true), "注入候选全部 injected:true");
46
+ assert.ok(rec.candidates.every((c) => c.source === "inject"), "注入候选 source 标记 inject");
47
+ });
48
+
49
+ test("inject recording is gated off by recallRecordDefault=false, search stays explicit", async () => {
50
+ const { service } = setup({ recallRecordDefault: false });
51
+ const seen = [];
52
+ wireRecorder(service, seen);
53
+ service.saveWithDedupe({ type: "preference", title: "量子计算", content: "入门", importance: 5 });
54
+ service.injectCandidates({ query: "量子", maxItems: 3 });
55
+ assert.equal(seen.length, 0, "recallRecordDefault=false 时注入帧不上报");
56
+ await service.searchMemories("量子", { mode: "keyword", recordRecall: true });
57
+ assert.equal(seen.length, 1, "显式 recordRecall:true 的搜索帧照常上报");
58
+ });
59
+
60
+ test("injected flag survives the store round-trip", async () => {
61
+ const store = createStore(":memory:");
62
+ store.saveRecallRun({
63
+ query: "量子", mode: "inject", topK: 1, candidates: [{ id: "a", title: "量子", content: "入门", score: 0.8, source: "inject", injected: true }]
64
+ });
65
+ store.saveRecallRun({
66
+ query: "量子", mode: "keyword", topK: 1, candidates: [{ id: "b", title: "量子计算", content: "叠加态", score: 0.9, source: "keyword", injected: false }]
67
+ });
68
+ const runs = store.listRecallRuns();
69
+ assert.equal(runs.length, 2);
70
+ const inject = runs.find((r) => r.mode === "inject");
71
+ const search = runs.find((r) => r.mode === "keyword");
72
+ assert.equal(inject.candidates[0].injected, true, "注入帧 injected 存得回来");
73
+ assert.equal(search.candidates[0].injected, false, "搜索帧 injected 存得回来");
74
+ assert.equal(store.getRecallRun(search.id).candidates[0].injected, false);
75
+ });
76
+
77
+ test("purgeRecallRunsOlderThan drops only rows past the retention window", async () => {
78
+ const store = createStore(":memory:");
79
+ const old = store.saveRecallRun({
80
+ id: "old", query: "旧查询", mode: "keyword", topK: 5, candidates: [],
81
+ created_at: new Date(Date.now() - 200 * 86400000).toISOString()
82
+ });
83
+ const fresh = store.saveRecallRun({
84
+ id: "fresh", query: "新查询", mode: "inject", topK: 5, candidates: [],
85
+ created_at: new Date().toISOString()
86
+ });
87
+ const deleted = store.purgeRecallRunsOlderThan(90);
88
+ assert.equal(deleted, 1, "只有 200 天前的老行被清除");
89
+ const runs = store.listRecallRuns();
90
+ assert.deepEqual(runs.map((r) => r.id), ["fresh"], "新行保留");
91
+ assert.equal(store.getRecallRun(old.id), undefined, "旧行不可再读");
92
+ store.close();
93
+ });
@@ -100,11 +100,15 @@ test("vector config disabled value is stored as false", () => {
100
100
  store.close();
101
101
  });
102
102
 
103
- test("autoTag config defaults to disabled and round-trips", () => {
103
+ test("autoTag config defaults to null (unset) and round-trips only explicit keys", () => {
104
104
  const { store, settings } = setup();
105
- assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: false, manualTagEnabled: false });
105
+ // Issue #31: an unset key is null so runtime consumers fall back to the
106
+ // plugin config default — the settings layer must not invent `false`.
107
+ assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: null, manualTagEnabled: null });
106
108
  settings.setAutoTagConfig({ autoTagEnabled: true, manualTagEnabled: true });
107
109
  assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: true, manualTagEnabled: true });
110
+ // A partial update only touches the key it was given: manualTagEnabled was
111
+ // stored earlier so it keeps its value (true), it does not revert to null.
108
112
  settings.setAutoTagConfig({ autoTagEnabled: false });
109
113
  assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: false, manualTagEnabled: true });
110
114
  store.close();
@@ -0,0 +1,112 @@
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
+ sleepConflictStrictness: "normal",
29
+ sleepArchiveDays: 30, // 30-90 天窗口 → 压缩为摘要
30
+ sleepCompressDays: 90, // >=90 天 → 直接归档
31
+ sleepPatternMinMemories: 100, // 记忆数不足 → pattern 阶段跳过
32
+ sleepMaxPatternPerRun: 3,
33
+ ...overrides
34
+ };
35
+ }
36
+
37
+ function mockCtx() {
38
+ return {
39
+ logger: { warn: () => {}, info: () => {} },
40
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
41
+ llm: {
42
+ async *stream() {
43
+ yield { type: "text-delta", index: 0, text: "[]" };
44
+ yield { type: "finish", reason: { kind: "stop" } };
45
+ }
46
+ }
47
+ };
48
+ }
49
+
50
+ async function run(memories, service, config) {
51
+ const now = Date.now();
52
+ for (const [mem, daysAgo] of memories) {
53
+ service.touchLastAccess(mem.id, new Date(now - daysAgo * DAY).toISOString());
54
+ }
55
+ const result = await runSleep(mockCtx(), service, config, { warn: () => {}, info: () => {} }, null, null);
56
+ return result.phases?.["demotion"];
57
+ }
58
+
59
+ test("cold low-importance history past the compress tier archives (heat gate open)", async () => {
60
+ const { store, service } = setup();
61
+ const mem = saveMemory(service, "久远的会话记录", "早期讨论内容", "history", 2);
62
+ const demotion = await run([[mem, 120]], service, sleepConfig());
63
+ assert.ok(demotion, "demotion phase ran");
64
+ assert.ok(demotion.archived.includes(mem.id), "120 天 ref + 热值≈0.03<0.05 + importance 2 → 归档");
65
+ assert.equal(store.getById(mem.id).archived, true);
66
+ store.close();
67
+ });
68
+
69
+ test("importance 5 protects a memory even when the heat is icy", async () => {
70
+ const { store, service } = setup();
71
+ const mem = saveMemory(service, "关键决策", "不可丢失的重要结论", "decision", 5);
72
+ const demotion = await run([[mem, 400]], service, sleepConfig());
73
+ assert.ok(demotion, "demotion phase ran");
74
+ // decision λ=0.002, 400 天热值≈0.03<0.05,但 importance=5 → 保护,绝不降级。
75
+ assert.ok(!demotion.archived.includes(mem.id), "紧要记忆不被归档");
76
+ assert.ok(!demotion.demoted.includes(mem.id), "紧要记忆不被压缩");
77
+ assert.equal(store.getById(mem.id).archived, false);
78
+ store.close();
79
+ });
80
+
81
+ test("immune preference (λ=0, heat=1.0) is never demoted even when ancient", async () => {
82
+ const { store, service } = setup();
83
+ const mem = saveMemory(service, "用户偏好", "喜欢简洁的总结", "preference", 3);
84
+ const demotion = await run([[mem, 400]], service, sleepConfig());
85
+ assert.ok(!demotion.archived.includes(mem.id), "免疫类型不归档");
86
+ assert.ok(!demotion.demoted.includes(mem.id), "免疫类型不压缩");
87
+ assert.equal(store.getById(mem.id).archived, false);
88
+ store.close();
89
+ });
90
+
91
+ test("slow-decay project stays heat-protected at the compress tier (λ=0.0008)", async () => {
92
+ const { store, service } = setup();
93
+ const mem = saveMemory(service, "项目A", "慢衰减的进行中项目", "project", 2);
94
+ const demotion = await run([[mem, 100]], service, sleepConfig());
95
+ // 100 天已越过 90 天归档窗,但 project 热值≈0.28>0.05 → heat 闸拦下。
96
+ assert.ok(!demotion.archived.includes(mem.id), "慢衰减类型热值仍高 → 不归档");
97
+ assert.equal(store.getById(mem.id).archived, false);
98
+ store.close();
99
+ });
100
+
101
+ test("demote tier (30-90d) fires when the type's λ is cold enough", async () => {
102
+ const { store, service } = setup();
103
+ const mem = saveMemory(service, "中期会话摘要", "可压缩的历史碎片", "history", 2);
104
+ // 调高 history 的 λ 到 0.05 → 40 天热值≈0.009<0.05,解锁 30-90 天压缩窗口。
105
+ const config = sleepConfig({ heatTypeDecay: { history: 0.05 } });
106
+ const demotion = await run([[mem, 40]], service, config);
107
+ assert.ok(!demotion.archived.includes(mem.id), "40 天未到归档线");
108
+ assert.ok(demotion.demoted.includes(mem.id), "热值足够冷 + importance 2 → 压缩为摘要");
109
+ const after = store.getById(mem.id);
110
+ assert.ok(after._full_content, "全文停放在 _full_content");
111
+ store.close();
112
+ });
@@ -45,6 +45,14 @@ function baseConfig(overrides = {}) {
45
45
  };
46
46
  }
47
47
 
48
+ // v0.7.0 热联合判定:慢衰减类型(project λ=0.0008)默认受热闸保护——40 天热值
49
+ // ≈0.5、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");