@modusensus/dsh-mneme 0.9.5 → 0.10.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 (85) hide show
  1. package/README.md +10 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +76 -6
  9. package/lib/client.js +259 -11
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/graph/anchoring.js +7 -2
  17. package/lib/index.js +15 -6
  18. package/lib/inject.js +9 -4
  19. package/lib/lang.js +17 -12
  20. package/lib/pipeline-nudge-auto.js +3 -0
  21. package/lib/pipeline-nudge.js +41 -8
  22. package/lib/pipeline-runner.js +15 -0
  23. package/lib/pre-inject-gate.js +14 -0
  24. package/lib/profile-extract.js +4 -1
  25. package/lib/provenance-http.js +162 -37
  26. package/lib/service.js +99 -8
  27. package/lib/session-gate.js +91 -0
  28. package/lib/settings.js +13 -1
  29. package/lib/store.js +681 -18
  30. package/lib/summarize.js +152 -15
  31. package/lib/tools.js +26 -6
  32. package/package.json +4 -2
  33. package/src/api-standalone.js +11 -3
  34. package/src/api.js +76 -6
  35. package/src/config.js +33 -1
  36. package/src/continuity.js +11 -3
  37. package/src/dream/decisions.js +88 -27
  38. package/src/dream/sleep.js +467 -81
  39. package/src/dream.js +110 -13
  40. package/src/fence.js +24 -0
  41. package/src/graph/anchoring.js +7 -2
  42. package/src/index.js +15 -6
  43. package/src/inject.js +9 -4
  44. package/src/lang.js +17 -12
  45. package/src/pipeline-nudge-auto.js +3 -0
  46. package/src/pipeline-nudge.js +41 -8
  47. package/src/pipeline-runner.js +15 -0
  48. package/src/pre-inject-gate.js +14 -0
  49. package/src/profile-extract.js +4 -1
  50. package/src/provenance-http.js +162 -37
  51. package/src/service.js +99 -8
  52. package/src/session-gate.js +91 -0
  53. package/src/settings.js +13 -1
  54. package/src/store.js +681 -18
  55. package/src/summarize.js +152 -15
  56. package/src/tools.js +26 -6
  57. package/test/api.test.js +153 -4
  58. package/test/client.test.js +380 -0
  59. package/test/config.test.js +8 -0
  60. package/test/conflict-reviewed.test.js +79 -0
  61. package/test/continuity.test.js +29 -1
  62. package/test/dependency-overrides.test.js +78 -0
  63. package/test/differentiate-feedback.test.js +248 -0
  64. package/test/dream-decision-evidence.test.js +314 -0
  65. package/test/dream-fence.test.js +43 -0
  66. package/test/graph-boosted-relations.test.js +189 -0
  67. package/test/graph-multipath.test.js +269 -0
  68. package/test/inject-pools.test.js +2 -1
  69. package/test/mint-source.test.js +83 -0
  70. package/test/pipeline-nudge-auto.test.js +26 -2
  71. package/test/pipeline-nudge.test.js +87 -0
  72. package/test/pipeline-runner.test.js +26 -0
  73. package/test/prompt-stance.test.js +128 -0
  74. package/test/provenance-http.test.js +200 -17
  75. package/test/release-docs.test.js +7 -1
  76. package/test/security-doc.test.js +43 -4
  77. package/test/session-gate.test.js +264 -0
  78. package/test/settings.test.js +4 -0
  79. package/test/sleep-action-set.test.js +16 -11
  80. package/test/sleep-catchup.test.js +315 -0
  81. package/test/sleep-failure-backoff.test.js +156 -0
  82. package/test/sleep.test.js +293 -0
  83. package/test/standalone-api.test.js +50 -0
  84. package/test/store.test.js +220 -0
  85. package/test/summarize.test.js +172 -0
@@ -0,0 +1,189 @@
1
+ // issue #24 块4(被动确认)的复核出口:
2
+ // GET /api/dsh-mneme/semantic/graph/boosted-relations
3
+ // 复核的对象是「权重被抬过的边」——抬升是唯一会改变检索形状的动作,所以它自己
4
+ // 就是证据,不必再开一张记账表。这里同时钉住 ego 边的 weight/source 形状:
5
+ // 少了那两个字段,面板就分不清「这条边被用过」和「这条边从没被用过」。
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { EventEmitter } from "node:events";
9
+ import { createStore } from "../src/store.js";
10
+ import { createService } from "../src/service.js";
11
+ import { createApi } from "../src/api.js";
12
+ import { createSettings } from "../src/settings.js";
13
+
14
+ class FakeRes extends EventEmitter {
15
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
16
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
17
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
18
+ }
19
+
20
+ function req(path) {
21
+ const r = new EventEmitter();
22
+ r.url = path;
23
+ r.method = "GET";
24
+ r.headers = {};
25
+ return r;
26
+ }
27
+
28
+ function setup({ config = {}, apiToken = "" } = {}) {
29
+ const store = createStore(":memory:");
30
+ const service = createService({ store, mirror: null, config });
31
+ const settings = createSettings(store.db);
32
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
33
+ const routes = [];
34
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
35
+ createApi(ctx, service, settings, commands, null, undefined, apiToken, config);
36
+ return { store, service, routes };
37
+ }
38
+
39
+ const BOOSTED = "/api/dsh-mneme/semantic/graph/boosted-relations";
40
+
41
+ test("boosted-relations route is registered and open without apiToken", async () => {
42
+ const { routes } = setup({ apiToken: "secret-token" });
43
+ const route = routes.find((r) => r.path === BOOSTED);
44
+ assert.ok(route, "route must be registered");
45
+ const res = new FakeRes();
46
+ await route.handler(req(BOOSTED), res);
47
+ assert.equal(res.statusCode, 200, "read-only route must not demand a token");
48
+ });
49
+
50
+ test("boosted-relations lists only edges whose weight rose above its source baseline", async () => {
51
+ const { store, service, routes } = setup();
52
+ const a = service.createEntity({ name: "A", type: "concept" });
53
+ const b = service.createEntity({ name: "B", type: "concept" });
54
+ const c = service.createEntity({ name: "C", type: "concept" });
55
+ // tag 边初值 0.3,抬两次 → 0.5,gain 0.2,必进表。
56
+ const boosted = service.saveRelation({
57
+ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "tag"
58
+ });
59
+ store.bumpRelationWeight(boosted.id, 0.1);
60
+ store.bumpRelationWeight(boosted.id, 0.1);
61
+ // manual 边留在初值 1.0:gain 为 0,不得进表。
62
+ service.saveRelation({ from_entity: b.id, to_entity: c.id, relation_type: "uses", source: "manual" });
63
+
64
+ const res = new FakeRes();
65
+ await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
66
+ assert.equal(res.statusCode, 200);
67
+ const data = JSON.parse(res.body);
68
+ assert.equal(data.count, 1, "only the raised edge qualifies");
69
+ const item = data.items[0];
70
+ assert.equal(item.id, boosted.id);
71
+ assert.equal(item.from, a.id);
72
+ assert.equal(item.to, b.id);
73
+ assert.equal(item.from_name, "A", "both endpoint names ride along so the panel need not re-query");
74
+ assert.equal(item.to_name, "B");
75
+ assert.equal(item.source, "tag");
76
+ assert.ok(Math.abs(item.base_weight - 0.3) < 1e-9, "baseline comes from RELATION_SOURCE_DEFAULTS");
77
+ assert.ok(Math.abs(item.weight - 0.5) < 1e-9, `weight rose to 0.5, got ${item.weight}`);
78
+ assert.ok(Math.abs(item.gain - 0.2) < 1e-9, `gain = weight − base, got ${item.gain}`);
79
+ });
80
+
81
+ test("boosted-relations reports an empty table honestly, with the gate that explains why", async () => {
82
+ const { service, routes } = setup();
83
+ const a = service.createEntity({ name: "A", type: "concept" });
84
+ const b = service.createEntity({ name: "B", type: "concept" });
85
+ service.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
86
+
87
+ const res = new FakeRes();
88
+ await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
89
+ const data = JSON.parse(res.body);
90
+ assert.equal(data.count, 0, "an unraised edge is not a finding");
91
+ assert.equal(data.passiveConfirmEnabled, false, "the gate is off by default — the panel can say so");
92
+ });
93
+
94
+ test("boosted-relations surfaces passiveConfirmEnabled=true when the gate is on", async () => {
95
+ const { routes } = setup({ config: { graphPassiveConfirm: true, graphWeightEnabled: true } });
96
+ const res = new FakeRes();
97
+ await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
98
+ assert.equal(JSON.parse(res.body).passiveConfirmEnabled, true);
99
+ });
100
+
101
+ // 只有 graphPassiveConfirm 开着不算「会继续产生新提升」:touchRecalled 里
102
+ // passive = graphPassiveConfirm && graphWeightEnabled,只看前者面板会说
103
+ // 「会自动提升」而实际一条都不会抬(评审指出)。
104
+ test("boosted-relations reports the gate as off when only graphPassiveConfirm is set", async () => {
105
+ const { routes } = setup({ config: { graphPassiveConfirm: true, graphWeightEnabled: false } });
106
+ const res = new FakeRes();
107
+ await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
108
+ assert.equal(JSON.parse(res.body).passiveConfirmEnabled, false,
109
+ "passive confirmation only bites when weight evolution is also on");
110
+ });
111
+
112
+ // 排序口径:按 gain 降序,不是按 weight 降序。不同 source 的 base 不同,
113
+ // 所以 weight 大的边 gain 可能更小。首版把排序交给 SQL 的 weight DESC 并在
114
+ // 循环里按 limit 提前 break,截断处丢掉的正是 gain 更大的边(评审指出)。
115
+ test("boosted-relations ranks by gain, not by raw weight", async () => {
116
+ const { store, routes } = setup();
117
+ const a = store.createEntity({ name: "A", type: "technology" });
118
+ const b = store.createEntity({ name: "B", type: "technology" });
119
+ const c = store.createEntity({ name: "C", type: "technology" });
120
+ // llm 边:base 0.4,weight 0.95 → gain 0.55
121
+ const llmEdge = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
122
+ store.bumpRelationWeight(llmEdge.id, 0.55);
123
+ // tag 边:base 0.3,weight 0.90 → gain 0.60(weight 更小、gain 更大)
124
+ const tagEdge = store.saveRelation({ from_entity: a.id, to_entity: c.id, relation_type: "uses", source: "tag" });
125
+ store.bumpRelationWeight(tagEdge.id, 0.60);
126
+
127
+ const res = new FakeRes();
128
+ await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
129
+ const items = JSON.parse(res.body).items;
130
+ assert.ok(items.length >= 2);
131
+ assert.ok(items[0].gain >= items[1].gain, "the list must be ordered by gain");
132
+ assert.equal(items[0].to_name, "C",
133
+ "the tag edge (weight 0.90, gain 0.60) must outrank the llm edge (weight 0.95, gain 0.55)");
134
+ });
135
+
136
+ // 截断也必须发生在按 gain 排完之后:limit=1 时留下的得是 gain 最大那条。
137
+ test("boosted-relations truncates after ranking by gain", async () => {
138
+ const { store, routes } = setup();
139
+ const a = store.createEntity({ name: "A", type: "technology" });
140
+ const b = store.createEntity({ name: "B", type: "technology" });
141
+ const c = store.createEntity({ name: "C", type: "technology" });
142
+ const llmEdge = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
143
+ store.bumpRelationWeight(llmEdge.id, 0.55);
144
+ const tagEdge = store.saveRelation({ from_entity: a.id, to_entity: c.id, relation_type: "uses", source: "tag" });
145
+ store.bumpRelationWeight(tagEdge.id, 0.60);
146
+
147
+ const res = new FakeRes();
148
+ await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED + "?limit=1"), res);
149
+ const items = JSON.parse(res.body).items;
150
+ assert.equal(items.length, 1);
151
+ assert.equal(items[0].to_name, "C",
152
+ "truncation must keep the largest gain, not the largest weight");
153
+ });
154
+
155
+ test("boosted-relations honours limit and never exceeds the hard cap", async () => {
156
+ const { store, service, routes } = setup();
157
+ for (let i = 0; i < 6; i++) {
158
+ const a = service.createEntity({ name: `L${i}a`, type: "concept" });
159
+ const b = service.createEntity({ name: `L${i}b`, type: "concept" });
160
+ const r = service.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "tag" });
161
+ store.bumpRelationWeight(r.id, 0.1);
162
+ }
163
+ const route = routes.find((r) => r.path === BOOSTED);
164
+ let res = new FakeRes();
165
+ await route.handler(req(`${BOOSTED}?limit=2`), res);
166
+ assert.equal(JSON.parse(res.body).count, 2, "limit caps the returned rows");
167
+ res = new FakeRes();
168
+ await route.handler(req(`${BOOSTED}?limit=9999`), res);
169
+ assert.equal(JSON.parse(res.body).count, 6, "an absurd limit clamps to the cap, it does not error");
170
+ });
171
+
172
+ // ego 的边要带 weight/source:面板靠它区分「这条边被用过」与「从没被用过」。
173
+ test("ego edges carry weight and source", async () => {
174
+ const { service, routes } = setup();
175
+ const a = service.createEntity({ name: "A", type: "concept" });
176
+ const b = service.createEntity({ name: "B", type: "concept" });
177
+ const rel = service.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
178
+
179
+ const res = new FakeRes();
180
+ await routes
181
+ .find((r) => r.path === "/api/dsh-mneme/semantic/graph/ego")
182
+ .handler(req("/api/dsh-mneme/semantic/graph/ego?entity=A&depth=1"), res);
183
+ assert.equal(res.statusCode, 200);
184
+ const data = JSON.parse(res.body);
185
+ const edge = data.edges.find((e) => e.id === rel.id);
186
+ assert.ok(edge, "the relation must appear among ego edges");
187
+ assert.ok(Math.abs(edge.weight - 0.4) < 1e-9, `llm edge starts at 0.4, got ${edge.weight}`);
188
+ assert.equal(edge.source, "llm");
189
+ });
@@ -0,0 +1,269 @@
1
+ // issue #24 块1 三路起点的回归(tag 桥接 / 向量 / 零分种子)。
2
+ // 这三条都在钉「调用点真的接上了」:能力早就写在 graph/anchoring.js 里,
3
+ // 之前的病是 service.js 只喂了一路 exact,另两路永远等不到自己被调用。
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { createStore } from "../src/store.js";
7
+ import { createService } from "../src/service.js";
8
+ import { createVectorIndex } from "../src/vector-index.js";
9
+ import { HOP_QUOTA } from "../src/graph/anchoring.js";
10
+
11
+ function mockEmbedder(vec = [1, 0, 0, 0]) {
12
+ return {
13
+ embedSingle: async () => vec,
14
+ embed: async (texts) => texts.map(() => vec),
15
+ schedule: () => {},
16
+ modelHash: "mock#abc",
17
+ dimension: 4
18
+ };
19
+ }
20
+
21
+ function makeService(config = {}, { withEmbedder = false } = {}) {
22
+ const store = createStore(":memory:");
23
+ const service = createService({ store, mirror: null, config });
24
+ if (withEmbedder) {
25
+ const vectorIndex = createVectorIndex({ store });
26
+ service.setEmbedder(mockEmbedder());
27
+ service.setVectorIndex(vectorIndex);
28
+ return { store, service, vectorIndex };
29
+ }
30
+ return { store, service, vectorIndex: null };
31
+ }
32
+
33
+ function putMemory(store, service, { type = "project", title, content, tags = [], importance = 3 }) {
34
+ return service.saveWithDedupe({ type, title, content, tags, importance }).memory;
35
+ }
36
+
37
+ function linkAttr(store, entityName, memory, { key = "k", value = "v", type = "technology" } = {}) {
38
+ let entity = store.findEntityByName(entityName);
39
+ if (!entity) entity = store.createEntity({ name: entityName, type });
40
+ store.saveAttr({ entity_id: entity.id, attr_key: key, attr_value: value, memory_id: memory.id });
41
+ return entity;
42
+ }
43
+
44
+ function linkEntities(store, fromName, toName, { relType = "uses", type = "technology" } = {}) {
45
+ const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type });
46
+ const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type });
47
+ store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: relType });
48
+ return { a, b };
49
+ }
50
+
51
+ // ---- Tag 桥接 -------------------------------------------------------------
52
+ // 查询「数据库迁移」既不是任何实体名,也不含实体名的子串。tag 命中一条记忆后,
53
+ // 该记忆挂联的实体就是「这次在谈的东西」,沿它级联出去的邻居记忆才有机会进池。
54
+ // 判别点:farMem 只能经 PgTuner → PgCache 这条边到达;tag 路不接线时它必然缺席。
55
+ test("tag path bridges a tag hit into an entity seed (query names no entity)", async () => {
56
+ const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
57
+ // nearMem 带 tag,标题正文都不含查询词——但 store.search 的 tags LIKE 也会命中它,
58
+ // 所以它的存在不是本条用例的判别点,判别点是 farMem。
59
+ const nearMem = putMemory(store, service, {
60
+ title: "alpha", content: "beta gamma", tags: ["数据库迁移"]
61
+ });
62
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
63
+ linkAttr(store, "PgTuner", nearMem);
64
+ linkAttr(store, "PgCache", farMem);
65
+ linkEntities(store, "PgTuner", "PgCache");
66
+
67
+ const rows = await service.searchMemories("数据库迁移", { mode: "hybrid" });
68
+ const ids = rows.map((r) => r.id);
69
+ assert.ok(ids.includes(farMem.id), "1-hop memory reached only via the tag-bridged seed");
70
+ const far = rows.find((r) => r.id === farMem.id);
71
+ assert.ok(
72
+ Math.abs(far.score - 1.0 * HOP_QUOTA[1] * 0.3) < 1e-9,
73
+ `hop-quota weighting preserved through the tag path, got ${far.score}`
74
+ );
75
+ });
76
+
77
+ // 关锚定时 tag 路一次都不许查:否则「默认关=旧单跳轴逐字节不变」这条承诺就破了。
78
+ test("tag path is not consulted when anchoring is off", async () => {
79
+ const { store, service } = makeService({ entityRecallEnabled: true });
80
+ const nearMem = putMemory(store, service, {
81
+ title: "alpha", content: "beta gamma", tags: ["数据库迁移"]
82
+ });
83
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
84
+ linkAttr(store, "PgTuner", nearMem);
85
+ linkAttr(store, "PgCache", farMem);
86
+ linkEntities(store, "PgTuner", "PgCache");
87
+
88
+ const rows = await service.searchMemories("数据库迁移", { mode: "hybrid" });
89
+ assert.ok(!rows.some((r) => r.id === farMem.id), "no cascade when anchoring is off");
90
+ });
91
+
92
+ // ---- 向量起点 -------------------------------------------------------------
93
+ // 查询里没有实体名、也没有 tag 命中,唯一能给出种子的是向量。早退条件若仍以
94
+ // 「名字没命中」收场,第三路接上了也永远不被调用——这条用例正是钉那个早退。
95
+ test("vector path seeds from near memories when the query names no entity", async () => {
96
+ const { store, service, vectorIndex } = makeService(
97
+ { entityRecallEnabled: true, graphAnchoringEnabled: true },
98
+ { withEmbedder: true }
99
+ );
100
+ const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
101
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
102
+ vectorIndex.saveEmbedding(nearMem.id, [1, 0, 0, 0]); // farMem 刻意不嵌:它只能经图轴到达
103
+ linkAttr(store, "VecSeed", nearMem);
104
+ linkAttr(store, "VecNeighbor", farMem);
105
+ linkEntities(store, "VecSeed", "VecNeighbor");
106
+
107
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
108
+ const ids = rows.map((r) => r.id);
109
+ assert.ok(ids.includes(farMem.id), "1-hop memory reached through the vector seed");
110
+ const far = rows.find((r) => r.id === farMem.id);
111
+ assert.ok(
112
+ Math.abs(far.score - HOP_QUOTA[1] * 0.3) < 1e-9,
113
+ `vector seed keeps hop-quota weighting, got ${far.score}`
114
+ );
115
+ });
116
+
117
+ // ---- 向量起点也要看关系端点 -----------------------------------------------
118
+ // 与 Tag 路同一条病:一条近邻记忆若只通过关系挂到实体上(phaseRelations 不建
119
+ // attr 行),只读 entity_attrs 的反查会漏掉它,向量路就凭空少了种子。
120
+ test("vector path seeds from relation-only memories, not just attr rows", async () => {
121
+ const { store, service, vectorIndex } = makeService(
122
+ { entityRecallEnabled: true, graphAnchoringEnabled: true },
123
+ { withEmbedder: true }
124
+ );
125
+ const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
126
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
127
+ vectorIndex.saveEmbedding(nearMem.id, [1, 0, 0, 0]);
128
+ // nearMem 不写 attr,只通过一条关系挂到 RelSeed / RelNeighbor 上。
129
+ const seed = store.createEntity({ name: "RelSeed", type: "module" });
130
+ const neighbor = store.createEntity({ name: "RelNeighbor", type: "module" });
131
+ store.saveRelation({
132
+ from_entity: seed.id, to_entity: neighbor.id, relation_type: "uses",
133
+ memory_id: nearMem.id, metadata: { source: "sleep_relation_completion" }
134
+ });
135
+ linkAttr(store, "RelNeighbor", farMem, { key: "k2" });
136
+
137
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
138
+ assert.ok(rows.some((r) => r.id === farMem.id),
139
+ "a relation-only near memory must still produce vector seeds");
140
+ });
141
+
142
+ // 对照:没有 embedder 就没有向量路,三路全空时必须老实返回空,而不是凭空造种子。
143
+ test("vector path absent leaves the axis empty (no phantom seeds)", async () => { const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
144
+ const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
145
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
146
+ linkAttr(store, "VecSeed", nearMem);
147
+ linkAttr(store, "VecNeighbor", farMem);
148
+ linkEntities(store, "VecSeed", "VecNeighbor");
149
+
150
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
151
+ assert.ok(!rows.some((r) => r.id === farMem.id), "no seed sources → no cascade");
152
+ });
153
+
154
+ // ---- 零分种子 -------------------------------------------------------------
155
+ // min-max 会把连续通路里**最弱的那条**压成 0。0 分种子仍会被 anchorSeeds 收下,
156
+ // 而下游给种子挂联记忆的是**固定满权 tier**(不看种子自己的分数)——于是「这一路
157
+ // 里最不像的那条」会以最强的姿态把记忆拉进池。
158
+ //
159
+ // 两条近邻记忆都必须拿到**正分**:若让第二条与查询正交(cos=0),它在进向量
160
+ // 路时就被 score > 0 挡掉了,min-max 根本轮不到把它压成 0——那样这条用例会
161
+ // 以「从没发生过」的方式通过,看着绿、其实什么都没验。
162
+ //
163
+ // zeroMem 必须**挂在 0 分种子自己身上**(VoZero 同时挂 nearB 与 zeroMem)。
164
+ // 首版把它挂在另一个孤立实体上,于是无论滤不滤 0 分种子它都到不了——变异自证
165
+ // 当场抓到那条用例是空转的。
166
+ test("a zero-score seed does not inject its linked memory at full tier", async () => {
167
+ const { store, service, vectorIndex } = makeService(
168
+ { entityRecallEnabled: true, graphAnchoringEnabled: true },
169
+ { withEmbedder: true }
170
+ );
171
+ const nearA = putMemory(store, service, { title: "alpha", content: "beta" });
172
+ const nearB = putMemory(store, service, { title: "gamma", content: "delta" });
173
+ const zeroMem = putMemory(store, service, { title: "iota", content: "kappa" });
174
+ vectorIndex.saveEmbedding(nearA.id, [1, 0, 0, 0]); // cos = 1.0
175
+ vectorIndex.saveEmbedding(nearB.id, [0.7, 0.7, 0, 0]); // cos ≈ 0.707,正分但更低 → min-max 压成 0
176
+ linkAttr(store, "VoSeed", nearA);
177
+ linkAttr(store, "VoZero", nearB);
178
+ linkAttr(store, "VoZero", zeroMem, { key: "k2" }); // 同一实体挂第二条记忆,只能经这颗 0 分种子到达
179
+ // VoSeed 与 VoZero 之间不建边:zeroMem 拿不到任何别的到达路径。
180
+
181
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
182
+ assert.ok(rows.some((r) => r.id === nearB.id), "the weak memory itself still comes back via vector");
183
+ assert.ok(
184
+ !rows.some((r) => r.id === zeroMem.id),
185
+ "a 0-score seed must not pull its linked memory in at full tier"
186
+ );
187
+ });
188
+
189
+ // ---- Tag 桥接的排序 -------------------------------------------------------
190
+ // 「一个实体命中了几条记忆」是这条路的强弱信号,必须在 SQL 里数。
191
+ // 首版拿 entityIdsForMemories 的 DISTINCT 结果反推计数,每个实体恒为 1,
192
+ // 排序静默失效、只剩名字序(评审指出)。判别点:limit=1 时返回的是宽实体。
193
+ // 命名上必须让「宽实体」在字母序里排后面——首版把宽实体叫 AWide,于是字母序
194
+ // 也选它,用例空转通过(变异自证抓到的)。
195
+ test("findEntitiesByTags ranks entities by how many tagged memories they cover", async () => {
196
+ const { store, service } = makeService();
197
+ const m1 = putMemory(store, service, { title: "m1", content: "c1", tags: ["数据库迁移"] });
198
+ const m2 = putMemory(store, service, { title: "m2", content: "c2", tags: ["数据库迁移"] });
199
+ // ZWide 挂两条命中记忆;ANarrow 只挂一条。名字序让 ANarrow 排前面,
200
+ // 所以只要计数生效,返回的必然是 ZWide。
201
+ linkAttr(store, "ZWide", m1);
202
+ linkAttr(store, "ZWide", m2, { key: "k2" });
203
+ linkAttr(store, "ANarrow", m1);
204
+
205
+ const hits = store.findEntitiesByTags("数据库迁移", { limit: 1 });
206
+ assert.equal(hits.length, 1);
207
+ assert.equal(hits[0].name, "ZWide",
208
+ "the entity covering more tagged memories must win over the alphabetical order");
209
+ });
210
+
211
+ // ---- Tag 锚点的两个来源 ---------------------------------------------------
212
+ // phaseRelations 只写 entity_relations.memory_id,不建 attr 行。所以「只通过关系
213
+ // 挂到实体上」的记忆,若锚点只读 entity_attrs,关系两端一个种子都生不出来。
214
+ // 判别点:这两个实体都没有任何 attr 行,只能从关系侧被看到。
215
+ test("findEntitiesByTags sees relation endpoints, not only attr rows", () => {
216
+ const { store, service } = makeService();
217
+ const mem = putMemory(store, service, { title: "pg 调优", content: "一次调优", tags: ["postgres"] });
218
+ const a = store.createEntity({ name: "PgTuner", type: "module" });
219
+ const b = store.createEntity({ name: "PgCache", type: "module" });
220
+ store.saveRelation({
221
+ from_entity: a.id, to_entity: b.id, relation_type: "uses",
222
+ memory_id: mem.id, metadata: { source: "sleep_relation_completion" }
223
+ });
224
+
225
+ const names = store.findEntitiesByTags("postgres", { limit: 5 }).map((h) => h.name).sort();
226
+ assert.deepEqual(names, ["PgCache", "PgTuner"],
227
+ "a memory linked only through a relation must seed both endpoints");
228
+ });
229
+
230
+ // 同一 (实体, 记忆) 被 attr 与关系同时覆盖时只算一条:UNION 去的是对,不是行。
231
+ test("findEntitiesByTags counts a memory once even when attr and relation both cover it", () => {
232
+ const { store, service } = makeService();
233
+ const m1 = putMemory(store, service, { title: "a", content: "b", tags: ["缓存"] });
234
+ const m2 = putMemory(store, service, { title: "c", content: "d", tags: ["缓存"] });
235
+ const wide = linkAttr(store, "ZBoth", m1);
236
+ store.saveRelation({ from_entity: wide.id, to_entity: wide.id, relation_type: "related_to", memory_id: m1.id });
237
+ linkAttr(store, "ABoth", m2);
238
+
239
+ const hits = store.findEntitiesByTags("缓存", { limit: 2 });
240
+ assert.deepEqual(hits.map((h) => h.name).sort(), ["ABoth", "ZBoth"]);
241
+ // 两边都是 1 条记忆时按名字序;若 m1 被数成 2 条,ZBoth 会排到前面。
242
+ assert.equal(hits[0].name, "ABoth",
243
+ "duplicate coverage of the same memory must not inflate the count");
244
+ });
245
+
246
+ // ---- 分块后计数仍然正确 ---------------------------------------------------
247
+ // TAG_ID_CHUNK = 500,这条用例造 600 条命中记忆强制走两块:ZBulk 每块各 300 条
248
+ // (合计 600),ABulk 只有 400 条(全在第一块)。判别点:正确累加时 ZBulk 胜出;
249
+ // 若第二块覆盖而不是累加,ZBulk 只剩 300 < 400,字母序靠前的 ABulk 会赢。
250
+ test("findEntitiesByTags accumulates counts across chunk boundaries", () => {
251
+ const { store } = makeService();
252
+ const zBulk = store.createEntity({ name: "ZBulk", type: "module" });
253
+ const aBulk = store.createEntity({ name: "ABulk", type: "module" });
254
+ const makeTagged = (i) => store.save({
255
+ type: "project", title: `t${i}`, content: `c${i}`, tags: ["分块测试"], importance: 3
256
+ });
257
+ // 600 条:前 300 条挂 ZBulk,后 300 条挂 ZBulk 的另一批 attr key,
258
+ // 其中前 400 条同时挂 ABulk(跨两块)。
259
+ for (let i = 0; i < 600; i++) {
260
+ const m = makeTagged(i);
261
+ store.saveAttr({ entity_id: zBulk.id, attr_key: `k${i % 2}`, attr_value: "v", memory_id: m.id });
262
+ if (i < 400) store.saveAttr({ entity_id: aBulk.id, attr_key: "k", attr_value: "v", memory_id: m.id });
263
+ }
264
+
265
+ const hits = store.findEntitiesByTags("分块测试", { limit: 1 });
266
+ assert.equal(hits.length, 1);
267
+ assert.equal(hits[0].name, "ZBulk",
268
+ "counts must be summed across chunks, not overwritten by the last batch");
269
+ });
@@ -135,7 +135,8 @@ test("#249: capability guide registers exactly once, and stays off when the key
135
135
  assert.equal(on.sections.length, 1, "exactly one guide section");
136
136
  assert.equal(on.sections[0].name, "memory-guide");
137
137
  assert.equal(on.sections[0].order, 150, "插件指引段的既有 order 惯例");
138
- assert.equal(on.sections[0].text, MEMORY_GUIDE_SECTION, "常量文本 → 同会话内稳定");
138
+ assert.equal(typeof on.sections[0].text, "function", "guide text resolves per prompt context so session gating can apply");
139
+ assert.equal(on.sections[0].text({}), MEMORY_GUIDE_SECTION, "normal sessions receive the same guide text");
139
140
  // 不可逆工具(memory_delete)的克制指引必须在场:这是全 guide 里唯一有数据
140
141
  // 损失后果的一句,被误删掉的回归要能立刻转红。
141
142
  assert.ok(MEMORY_GUIDE_SECTION.includes("memory_delete"), "guide keeps the irreversible-tool warning");
@@ -200,3 +200,86 @@ test("mint source: rows are countable by source after minting", () => {
200
200
  assert.equal(bySource.get("manual"), undefined, "nothing minted here fell back to the default");
201
201
  store.close();
202
202
  });
203
+
204
+ // ── Issue #512:铸造出来的记忆要能追到「哪一轮」 ──
205
+ //
206
+ // source 只说「哪条管道」,不说「哪一次运行」。同一管道跑过几百轮之后,
207
+ // 「这条 pattern 是哪一轮抽出来的」在数据面上仍然不可读——这正是 #512 要补的。
208
+ // 归因落在 memory_mints:dream/sleep 铸造的记忆没有会话区间(session/seq 都不存在),
209
+ // 硬塞合成区间就是编的,所以窗口级归因(memory_provenance)与行级归因(本表)分层。
210
+
211
+ test("issue#512: a created row remembers which run minted it", () => {
212
+ const { store, service } = setup();
213
+ applyDecisions(
214
+ [{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3 }],
215
+ service,
216
+ null,
217
+ null,
218
+ { mintSource: "dream", runId: "run-abc" }
219
+ );
220
+ const row = service.all().find((m) => m.type === "pattern");
221
+ assert.ok(row, "pattern minted");
222
+ const mints = store.mintsFor(row.id);
223
+ assert.equal(mints.length, 1, "one created row = one mint record");
224
+ assert.equal(mints[0].run_id, "run-abc");
225
+ assert.equal(mints[0].run_type, "dream");
226
+ assert.equal(mints[0].trigger_source, "autoDream");
227
+ store.close();
228
+ });
229
+
230
+ // 没有 run id 时什么都不写。旧装配(或直接调 applyDecisions 的调用方)没有 run 概念,
231
+ // 这时候编一个 id 出来比留空更坏:它会让「能追到」变成一句谎话。
232
+ test("issue#512: without a run id no mint record is fabricated", () => {
233
+ const { store, service } = setup();
234
+ applyDecisions(
235
+ [{ action: "create", type: "pattern", title: "无轮模式", content: "规律", importance: 3 }],
236
+ service,
237
+ null,
238
+ null,
239
+ { mintSource: "dream" }
240
+ );
241
+ const row = service.all().find((m) => m.type === "pattern");
242
+ assert.ok(row, "pattern still minted — 记账缺失不该拦铸造");
243
+ assert.deepEqual(store.mintsFor(row.id), []);
244
+ store.close();
245
+ });
246
+
247
+ // 只记 create。merge/conflict/update/supersede 动的是既有行,receipt_chain 已按
248
+ // (run_id, record_id) 记过一遍;再造一份就是第二套账,两边迟早对不上。
249
+ test("issue#512: only creation is recorded — edits to existing rows stay in receipt_chain", () => {
250
+ const { store, service } = setup();
251
+ const existing = service.saveWithDedupe({ type: "project", title: "既有行", content: "旧内容", importance: 3 }).memory;
252
+ applyDecisions(
253
+ [{ action: "update", ids: [existing.id], content: "新内容", importance: 4 }],
254
+ service,
255
+ null,
256
+ null,
257
+ { mintSource: "dream", runId: "run-upd" }
258
+ );
259
+ assert.deepEqual(store.mintsFor(existing.id), [], "update 不是铸造,不进 memory_mints");
260
+ store.close();
261
+ });
262
+
263
+ // 端到端:sleep 真的跑一轮,铸造出来的行要指回那一轮的 runId(不是随便一个 id)。
264
+ test("issue#512: sleep mints point back at the run that produced them", async () => {
265
+ const { store, service } = setup();
266
+ mint(service);
267
+ mint(service);
268
+ const ctx = sleepCtx(
269
+ JSON.stringify([{ action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3 }])
270
+ );
271
+ const config = { sleepModeEnabled: true, sleepPatternMinMemories: 10, sleepMaxPatternPerRun: 3 };
272
+ const result = await runSleep(ctx, service, config, ctx.logger, null, null);
273
+ assert.equal(result.phases.patterns.status, "ok", "pattern phase minted");
274
+
275
+ const row = service.all().find((m) => m.type === "pattern");
276
+ assert.ok(row, "pattern minted");
277
+ const mints = store.mintsFor(row.id);
278
+ assert.equal(mints.length, 1);
279
+ assert.equal(mints[0].run_id, result.runId, "归因要指回这一轮的 runId,不是另编一个");
280
+ assert.equal(mints[0].run_type, "sleep");
281
+ assert.equal(mints[0].trigger_source, "sleep");
282
+ // 那一轮 run 台账本身也在:两边能对上,归因才成立。
283
+ assert.equal(store.getDreamRun(result.runId).run_type, "sleep");
284
+ store.close();
285
+ });
@@ -1,6 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { createNudgeAutoRun } from "../src/pipeline-nudge-auto.js";
4
+ import { createSessionGate } from "../src/session-gate.js";
4
5
 
5
6
  // 这组守的是**触发侧**(v0.9.3):什么时候跑、跑几次、跑挂了怎么办。
6
7
  // 挖掘算法本身在 pipeline-nudge.test.js 里守,这里只守接线,所以 runNudge 全部用假的。
@@ -25,8 +26,8 @@ function fakeCtx() {
25
26
  };
26
27
  }
27
28
 
28
- function sessionAt(cwd) {
29
- return { header: { cwd } };
29
+ function sessionAt(cwd, id = undefined) {
30
+ return { ...(id ? { id } : {}), header: { cwd } };
30
31
  }
31
32
 
32
33
  /** 把 setTimeout(0) 排的活跑完:本模块刻意不用同步跑(见文件头)。 */
@@ -66,6 +67,29 @@ test("自动沉淀只认 turn/end:中途事件不排队、不预跑", async ()
66
67
  auto();
67
68
  });
68
69
 
70
+ test("#539:无痕 turn/end 不触发全项目扫描,正常会话仍可触发", async () => {
71
+ const ctx = fakeCtx();
72
+ const calls = [];
73
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
74
+ const auto = createNudgeAutoRun({
75
+ ctx,
76
+ sessionGate: gate,
77
+ runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
78
+ now: () => 1_000_000
79
+ });
80
+
81
+ ctx.emit(sessionAt("D:/proj", "private"), { type: "turn/end" });
82
+ await tick();
83
+ assert.deepEqual(calls, [], "the incognito session itself cannot schedule a scan");
84
+ assert.equal(auto.pendingCount(), 0);
85
+
86
+ ctx.emit(sessionAt("D:/proj", "captain"), { type: "turn/end" });
87
+ await tick();
88
+ assert.equal(calls.length, 1);
89
+ assert.equal(calls[0].sessionGate, gate, "later project scans still exclude incognito session files");
90
+ auto();
91
+ });
92
+
69
93
  test("自动跑永远是「只落提议、不落草稿」:materialize 恒 false", async () => {
70
94
  const ctx = fakeCtx();
71
95
  const calls = [];