@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.
- package/.github/workflows/test.yml +32 -0
- package/CHANGELOG.md +89 -0
- package/CONTRIBUTING.md +245 -0
- package/README.md +152 -398
- package/SECURITY.md +544 -0
- package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
- package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
- package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
- package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
- package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
- package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
- package/dsh-mneme/CHANGELOG.md +295 -0
- package/dsh-mneme/LICENSE +21 -0
- package/dsh-mneme/README.md +484 -0
- package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
- package/dsh-mneme/docs/ENTITIES.md +245 -0
- package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
- package/dsh-mneme/docs/MIGRATION.md +127 -0
- package/dsh-mneme/docs/SEMANTIC.md +256 -0
- package/dsh-mneme/docs/SLEEP.md +163 -0
- package/{src → dsh-mneme/lib}/api.js +10 -4
- package/{lib → dsh-mneme/lib}/client.js +10 -2
- package/{lib → dsh-mneme/lib}/config.js +17 -0
- package/{lib → dsh-mneme/lib}/dream/sleep.js +13 -1
- package/{lib → dsh-mneme/lib}/dream.js +7 -2
- package/dsh-mneme/lib/heat.js +136 -0
- package/{lib → dsh-mneme/lib}/index.js +14 -6
- package/{src → dsh-mneme/lib}/service.js +100 -11
- package/{lib → dsh-mneme/lib}/settings.js +15 -6
- package/{lib → dsh-mneme/lib}/store.js +22 -0
- package/dsh-mneme/package-lock.json +1936 -0
- package/dsh-mneme/package.json +80 -0
- package/{lib → dsh-mneme/src}/api.js +10 -4
- package/{src → dsh-mneme/src}/config.js +17 -0
- package/{src → dsh-mneme/src}/dream/sleep.js +13 -1
- package/{src → dsh-mneme/src}/dream.js +7 -2
- package/dsh-mneme/src/heat.js +136 -0
- package/{src → dsh-mneme/src}/index.js +14 -6
- package/{lib → dsh-mneme/src}/service.js +100 -11
- package/{src → dsh-mneme/src}/settings.js +15 -6
- package/{src → dsh-mneme/src}/store.js +22 -0
- package/{test → dsh-mneme/test}/api.test.js +10 -3
- package/{test → dsh-mneme/test}/graph-api.test.js +34 -0
- package/dsh-mneme/test/heat.test.js +148 -0
- package/{test → dsh-mneme/test}/recall-layer.test.js +26 -5
- package/dsh-mneme/test/recall-runs.test.js +93 -0
- package/{test → dsh-mneme/test}/settings.test.js +6 -2
- package/dsh-mneme/test/sleep-heat.test.js +112 -0
- package/{test → dsh-mneme/test}/sleep.test.js +12 -4
- package/{test → dsh-mneme/test}/tag.test.js +114 -0
- package/dsh-mneme/test/updated-at-semantics.test.js +113 -0
- package/package.json +18 -40
- package/v0.7-plan-2026-08-21.md +75 -0
- package//346/250/252/345/271/205.png +0 -0
- /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
- /package/{lib → dsh-mneme/lib}/commands.js +0 -0
- /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
- /package/{lib → dsh-mneme/lib}/dream/decisions.js +0 -0
- /package/{lib → dsh-mneme/lib}/dream/tag-extractor.js +0 -0
- /package/{lib → dsh-mneme/lib}/embedding.js +0 -0
- /package/{lib → dsh-mneme/lib}/entities/extractor.js +0 -0
- /package/{lib → dsh-mneme/lib}/hot-memory.js +0 -0
- /package/{lib → dsh-mneme/lib}/inject.js +0 -0
- /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
- /package/{lib → dsh-mneme/lib}/mirror.js +0 -0
- /package/{lib → dsh-mneme/lib}/parser/tag.js +0 -0
- /package/{lib → dsh-mneme/lib}/parser/wiki-link.js +0 -0
- /package/{lib → dsh-mneme/lib}/quality-filter.js +0 -0
- /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
- /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
- /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
- /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
- /package/{lib → dsh-mneme/lib}/summarize.js +0 -0
- /package/{lib → dsh-mneme/lib}/tools.js +0 -0
- /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
- /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
- /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
- /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
- /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
- /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
- /package/{scripts → dsh-mneme/scripts}/sync-lib.js +0 -0
- /package/{src → dsh-mneme/src}/commands.js +0 -0
- /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
- /package/{src → dsh-mneme/src}/dream/decisions.js +0 -0
- /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
- /package/{src → dsh-mneme/src}/embedding.js +0 -0
- /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
- /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
- /package/{src → dsh-mneme/src}/inject.js +0 -0
- /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
- /package/{src → dsh-mneme/src}/mirror.js +0 -0
- /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
- /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
- /package/{src → dsh-mneme/src}/quality-filter.js +0 -0
- /package/{src → dsh-mneme/src}/reranker.js +0 -0
- /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
- /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
- /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
- /package/{src → dsh-mneme/src}/summarize.js +0 -0
- /package/{src → dsh-mneme/src}/tools.js +0 -0
- /package/{src → dsh-mneme/src}/vector-index.js +0 -0
- /package/{test → dsh-mneme/test}/audit.test.js +0 -0
- /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
- /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
- /package/{test → dsh-mneme/test}/client.test.js +0 -0
- /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
- /package/{test → dsh-mneme/test}/commands.test.js +0 -0
- /package/{test → dsh-mneme/test}/config.test.js +0 -0
- /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
- /package/{test → dsh-mneme/test}/directory.test.js +0 -0
- /package/{test → dsh-mneme/test}/dream.test.js +0 -0
- /package/{test → dsh-mneme/test}/entities.test.js +0 -0
- /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
- /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
- /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
- /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
- /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
- /package/{test → dsh-mneme/test}/inject.test.js +0 -0
- /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
- /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
- /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
- /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
- /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
- /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
- /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
- /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
- /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
- /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
- /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
- /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
- /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
- /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
- /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
- /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
- /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
- /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
- /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
- /package/{test → dsh-mneme/test}/service.test.js +0 -0
- /package/{test → dsh-mneme/test}/store.test.js +0 -0
- /package/{test → dsh-mneme/test}/stress.test.js +0 -0
- /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
- /package/{test → dsh-mneme/test}/tag-boost.test.js +0 -0
- /package/{test → dsh-mneme/test}/tools.test.js +0 -0
- /package/{test → dsh-mneme/test}/vector-index.test.js +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
314
|
-
|
|
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
|
|
103
|
+
test("autoTag config defaults to null (unset) and round-trips only explicit keys", () => {
|
|
104
104
|
const { store, settings } = setup();
|
|
105
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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");
|