@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,264 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { Config } from "../src/config.js";
4
+ import { createSessionGate } from "../src/session-gate.js";
5
+ import { createStore } from "../src/store.js";
6
+ import { createService } from "../src/service.js";
7
+ import { createSettings } from "../src/settings.js";
8
+ import { createInjector } from "../src/inject.js";
9
+ import { MEMORY_GUIDE_SECTION } from "../src/guide.js";
10
+ import { createTools } from "../src/tools.js";
11
+ import { createSummarizer } from "../src/summarize.js";
12
+
13
+ test("session gate defaults off and gates subagents by the host's delegation marks", () => {
14
+ const defaults = Config({});
15
+ assert.equal(defaults.sessionGateEnabled, false);
16
+ assert.equal(defaults.sessionGateSubagents, true);
17
+ assert.deepEqual(defaults.sessionGateSessions, []);
18
+
19
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
20
+ const sessions = [
21
+ { id: "header-origin", header: { origin: "subagent" } },
22
+ { id: "header-depth", header: { delegationDepth: 2 } },
23
+ { id: "header-both", header: { parentSession: "captain", origin: "subagent", delegationDepth: 1 } },
24
+ { id: "request-origin", requestHeader: () => ({ config: { origin: "subagent", delegationDepth: 1 } }) }
25
+ ];
26
+ for (const session of sessions) {
27
+ assert.equal(gate.isOff(session), true, session.id);
28
+ }
29
+
30
+ const firstEvent = { id: "first-event", header: { parentSession: "captain", origin: "subagent" } };
31
+ assert.equal(gate.isOff(firstEvent), true, "header match works before any session/event was observed");
32
+ gate.observe(firstEvent);
33
+ assert.equal(gate.isOff("first-event"), true, "observed child ids remain recognizable in log scans");
34
+ });
35
+
36
+ // 形状锁:`parentSession` 是「有父会话」,不是「是子代理」。dsh-session 的
37
+ // sessions.fork() 给**主会话分支**也写 parentSession(且不写 origin / 不带
38
+ // delegationDepth),把它当判据就会让用户 fork 出来的普通会话整体失忆。
39
+ // 本机 641 个真实会话头实测:origin/delegationDepth 命中 455 个子代理、零漏检,
40
+ // parentSession 对子代理识别零贡献、只多判出 3 个 fork 主会话(各有 74–136 条
41
+ // 用户消息、694 次工具调用)。这条用例锁的就是那次误判不许回来。
42
+ test("parentSession alone never gates: forked captain sessions stay fully enabled", () => {
43
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
44
+ const forked = { id: "forked-captain", header: { parentSession: "captain", isSeeded: true, delegationDepth: 0 } };
45
+ assert.equal(gate.isOff(forked), false, "a fork of a captain session is still a captain session");
46
+ assert.equal(gate.isOff({ id: "flat-parent", parentSession: "captain" }), false);
47
+ assert.equal(gate.isOff({ id: "alias-parent", parentSessionId: "captain" }), false);
48
+ assert.equal(gate.isOff({ id: "request-parent", requestHeader: () => ({ config: { parentSession: "captain" } }) }), false);
49
+ gate.observe(forked);
50
+ assert.equal(gate.isOff("forked-captain"), false, "observing a non-child must not record it as one");
51
+ });
52
+
53
+ test("manual ids survive the subagent switch, master-off is inert, and config is read live", () => {
54
+ const cfg = { sessionGateEnabled: false, sessionGateSubagents: false, sessionGateSessions: ["manual"] };
55
+ const gate = createSessionGate(cfg);
56
+ const manual = { id: "manual" };
57
+ const child = { id: "child", header: { origin: "subagent" } };
58
+
59
+ assert.equal(gate.isOff(manual), false, "the master is the only opt-in");
60
+ cfg.sessionGateEnabled = true;
61
+ assert.equal(gate.isOff(manual), true, "manual id matches even when automatic child detection is off");
62
+ assert.equal(gate.isOff(child), false, "subagent switch independently controls automatic matching");
63
+ cfg.sessionGateSubagents = true;
64
+ assert.equal(gate.isOff(child), true, "the next call sees a live config change");
65
+ cfg.sessionGateEnabled = false;
66
+ assert.equal(gate.isOff(manual), false, "turning the master off restores normal behavior immediately");
67
+
68
+ // 复现 comment 6541389(#541):上面刚把总开关关掉,若在这里直接断言
69
+ // doesNotThrow,isEnabled() 就先把调用挡回去了,id getter 那条保护路径
70
+ // 一次都不会执行——测的是「关掉时不出事」,不是「读不到 id 时不出事」。
71
+ // 所以先开总开关,再断言两件事:不抛,且被当成普通会话(false),
72
+ // 而不是被当成「id 为空所以放行」的巧合。
73
+ cfg.sessionGateEnabled = true;
74
+ const inaccessible = {};
75
+ Object.defineProperty(inaccessible, "id", { get() { throw new Error("host getter"); } });
76
+ assert.doesNotThrow(() => gate.isOff(inaccessible));
77
+ assert.equal(gate.isOff(inaccessible), false, "an unreadable id is an ordinary session, not a gated one");
78
+ });
79
+
80
+ // #539 原文:「子会话在首次 session/event 之前就可能组装上下文,故必须直接检查
81
+ // 传入 session,不能只靠事件后记录 ID」。所以「拿不到 id」不能当作「不是无痕
82
+ // 会话」——那一行早退正是这条要求最容易失守的地方(注入面在上下文组装期跑的
83
+ // 就是这条路径)。手动名单仍然依赖 id,那是名单本身的形状,不是缺陷。
84
+ test("a subagent session without a usable id is still gated", () => {
85
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
86
+ assert.equal(gate.isOff({ header: { origin: "subagent" } }), true, "id-less subagent header");
87
+ assert.equal(gate.isOff({ header: { delegationDepth: 1 } }), true, "id-less delegation depth");
88
+ assert.equal(gate.isOff({ id: "", header: { origin: "subagent" } }), true, "empty id is the same as missing");
89
+ assert.equal(gate.isOff({}), false, "no id and no child mark is an ordinary session");
90
+ assert.equal(gate.isOff(null), false);
91
+ assert.equal(gate.isOff(undefined), false);
92
+ const offSwitch = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: false });
93
+ assert.equal(offSwitch.isOff({ header: { origin: "subagent" } }), false, "the subagent switch still wins");
94
+ });
95
+
96
+ // 记账表只服务「日志扫描拿得到 id、却解析不出头」这一条退路,所以它必须有界:
97
+ // 长驻宿主跑上几百个子代理后只涨不落会把每个 id 永久留在内存里,而淘汰最旧的
98
+ // 代价只是那一次日志过滤少认一个会话,活路径永远直接用会话对象判定。
99
+ test("tracked child ids stay bounded and eviction only costs the log-scan fallback", () => {
100
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
101
+ for (let i = 0; i < 600; i += 1) gate.observe({ id: `child-${i}`, header: { origin: "subagent" } });
102
+ assert.equal(gate.isOff("child-599"), true, "the newest child is still tracked");
103
+ assert.equal(gate.isOff("child-0"), false, "the oldest id was evicted instead of kept forever");
104
+ gate.observe({ id: "child-599", header: { origin: "subagent" } });
105
+ assert.equal(gate.isOff("child-599"), true, "re-observing the same id stays stable");
106
+ });
107
+
108
+ function injectionSetup() {
109
+ const store = createStore(":memory:");
110
+ const service = createService({ store, mirror: null, config: {} });
111
+ const settings = createSettings(store.db);
112
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 5 });
113
+ settings.setProfile("用户画像正文");
114
+ settings.setRules(["先给结论"]);
115
+ const contexts = [];
116
+ const sections = [];
117
+ const ctx = {
118
+ systemPrompt: {
119
+ context(definition) { contexts.push(definition); return () => {}; },
120
+ section(definition) { sections.push(definition); return () => {}; }
121
+ }
122
+ };
123
+ const gate = createSessionGate({
124
+ sessionGateEnabled: true,
125
+ sessionGateSubagents: true,
126
+ sessionGateSessions: ["private"]
127
+ });
128
+ createInjector(ctx, service, settings, { injectGuidanceEnabled: true }, null, gate);
129
+ return { store, contexts, sections, gate };
130
+ }
131
+
132
+ test("all system-prompt surfaces are empty for incognito and remain available to captain", () => {
133
+ const { contexts, sections, gate } = injectionSetup();
134
+ const memory = contexts.find((item) => item.name === "memory");
135
+ const userSettings = contexts.find((item) => item.name === "user-settings");
136
+ const guide = sections.find((item) => item.name === "memory-guide");
137
+ const privateContext = { agent: { session: { id: "private" } } };
138
+ const childContext = { agent: { session: { id: "worker", header: { parentSession: "captain", origin: "subagent" } } } };
139
+ const captainContext = { agent: { session: { id: "captain", header: { origin: "main" } } } };
140
+
141
+ assert.equal(memory.text(privateContext), "");
142
+ assert.equal(userSettings.text(privateContext), "");
143
+ assert.equal(guide.text(privateContext), "");
144
+ assert.equal(memory.text(childContext), "");
145
+ assert.equal(userSettings.text(childContext), "");
146
+ assert.equal(guide.text(childContext), "");
147
+
148
+ assert.match(memory.text(captainContext), /\[记忆库\]/);
149
+ assert.match(userSettings.text(captainContext), /用户画像正文/);
150
+ assert.equal(guide.text(captainContext), MEMORY_GUIDE_SECTION);
151
+ gate.observe(childContext.agent.session);
152
+ assert.equal(gate.isOff("worker"), true);
153
+ });
154
+
155
+ test("every registered memory tool rejects before side effects for an incognito session", async () => {
156
+ const store = createStore(":memory:");
157
+ const baseService = createService({ store, mirror: null, config: {} });
158
+ let serviceCalls = 0;
159
+ const service = new Proxy(baseService, {
160
+ get(target, key, receiver) {
161
+ const value = Reflect.get(target, key, receiver);
162
+ if (typeof value !== "function") return value;
163
+ return (...args) => {
164
+ serviceCalls += 1;
165
+ return value.apply(target, args);
166
+ };
167
+ }
168
+ });
169
+ const registered = [];
170
+ const registry = { register(tool) { registered.push(tool); return () => {}; } };
171
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
172
+ createTools({ tools: registry }, service, {}, null, gate);
173
+ const exec = { agent: { session: { id: "private" } } };
174
+
175
+ assert.ok(registered.length >= 11);
176
+ for (const tool of registered) {
177
+ await assert.rejects(tool.execute({}, exec), /incognito session/, tool.name);
178
+ }
179
+ assert.equal(serviceCalls, 0, "rejected calls do not reach memory service reads or writes");
180
+ assert.equal(store.count(), 0, "no memory rows were written through an executor");
181
+ assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM scope_state").get().n, 0, "set_scope_state also rejects before writing");
182
+
183
+ const captainSave = registered.find((tool) => tool.name === "memory_save");
184
+ const result = await captainSave.execute(
185
+ { type: "preference", title: "captain", content: "可用", importance: 4 },
186
+ { agent: { session: { id: "captain", header: { origin: "main" } } } }
187
+ );
188
+ assert.equal(result.action, "created", "normal captain tool use is unaffected");
189
+ assert.equal(store.count(), 1);
190
+ store.close();
191
+ });
192
+
193
+ test("retained tool executors use the latest gate after live registry reapply", async () => {
194
+ const store = createStore(":memory:");
195
+ const service = createService({ store, mirror: null, config: {} });
196
+ const registered = [];
197
+ const registry = { register(tool) { registered.push(tool); return () => {}; } };
198
+ const initialGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
199
+ createTools({ tools: registry }, service, {}, null, initialGate);
200
+ const retainedSave = registered.find((tool) => tool.name === "memory_save");
201
+ const privateExec = { agent: { session: { id: "private" } } };
202
+ const args = { type: "preference", title: "live gate", content: "check", importance: 4 };
203
+
204
+ await assert.rejects(retainedSave.execute(args, privateExec), /incognito session/);
205
+ const openGate = createSessionGate({ sessionGateEnabled: false });
206
+ createTools({ tools: registry }, service, {}, null, openGate);
207
+ assert.equal(registered.length, 11, "live reapply keeps the host's existing tool registrations");
208
+ assert.equal((await retainedSave.execute(args, privateExec)).action, "created", "off restores the original tool behavior");
209
+ const closedGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
210
+ createTools({ tools: registry }, service, {}, null, closedGate);
211
+ await assert.rejects(
212
+ retainedSave.execute({ ...args, content: "blocked" }, privateExec),
213
+ /incognito session/,
214
+ "a retained tool uses the gate from the latest live reapply"
215
+ );
216
+ assert.equal(store.count(), 1, "the re-applied gate still runs before any write");
217
+ store.close();
218
+ });
219
+
220
+ function summarySession(id, header = {}) {
221
+ return {
222
+ id,
223
+ header,
224
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
225
+ events: [
226
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "选型" }] } },
227
+ { type: "turn/end", seq: 2 }
228
+ ]
229
+ };
230
+ }
231
+
232
+ test("distillation skips incognito sessions before model or storage work", async () => {
233
+ const store = createStore(":memory:");
234
+ const service = createService({ store, mirror: null, config: {} });
235
+ const listeners = [];
236
+ const calls = [];
237
+ const ctx = {
238
+ on(name, handler) { listeners.push({ name, handler }); return () => {}; },
239
+ llm: {
240
+ stream() {
241
+ calls.push(true);
242
+ const output = JSON.stringify([{ type: "decision", title: "选择", content: "落库", importance: 4 }]);
243
+ return (async function* () {
244
+ yield { type: "block-start", block: { type: "text" } };
245
+ yield { type: "text-delta", delta: output };
246
+ yield { type: "block-end", block: { type: "text" } };
247
+ yield { type: "finish", kind: "ok" };
248
+ })();
249
+ }
250
+ }
251
+ };
252
+ const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
253
+ const summarizer = createSummarizer(ctx, service, { autoSummarize: true, distillRateLimitIntervalMs: 0 }, {}, gate);
254
+ const handler = listeners.find((item) => item.name === "session/event").handler;
255
+
256
+ await handler(summarySession("private"), { type: "turn/end", seq: 2 });
257
+ assert.equal(calls.length, 0);
258
+ assert.equal(store.count(), 0);
259
+ await handler(summarySession("captain", { origin: "main" }), { type: "turn/end", seq: 2 });
260
+ assert.equal(calls.length, 1);
261
+ assert.equal(store.count(), 1, "captain still distills normally");
262
+ summarizer.dispose();
263
+ store.close();
264
+ });
@@ -186,6 +186,8 @@ test("feature flags accept the new boolean, string, url and enum keys", () => {
186
186
  "distillRecords.retentionDays": 30,
187
187
  bm25SearchEnabled: false,
188
188
  conflictFreezeEnabled: true,
189
+ sessionGateEnabled: true,
190
+ sessionGateSubagents: false,
189
191
  trustEpistemicWeighting: true,
190
192
  reflectionFailureTracking: false,
191
193
  dreamProvider: " siliconflow ",
@@ -205,6 +207,8 @@ test("feature flags accept the new boolean, string, url and enum keys", () => {
205
207
  assert.equal(flags.conflictFreezeEnabled, true);
206
208
  assert.equal(flags.trustEpistemicWeighting, true);
207
209
  assert.equal(flags.reflectionFailureTracking, false);
210
+ assert.equal(flags.sessionGateEnabled, true);
211
+ assert.equal(flags.sessionGateSubagents, false);
208
212
  assert.equal(flags.dreamProvider, "siliconflow", "string values are trimmed");
209
213
  assert.equal(flags.dreamModel, "deepseek-chat");
210
214
  assert.equal(flags.ollamaBaseUrl, "http://127.0.0.1:11434/");
@@ -74,10 +74,12 @@ test("issue#126 supersede: replay is idempotent (archived loser, single note)",
74
74
  store.close();
75
75
  });
76
76
 
77
- test("issue#126 differentiate: both entries survive and each carries the distinctions note", () => {
77
+ test("issue#533 differentiate: both entries survive, bodies untouched, the pair lands in the ledger", () => {
78
78
  const { service, store } = setup();
79
79
  const a = makeMemory(service, "内网 SSH 端口", "内网走 22");
80
80
  const b = makeMemory(service, "外网 SSH 端口", "外网映射 2222");
81
+ // 两侧各自追加注记会让共享后缀把向量拉近——先记下原始正文,事后逐字节比对。
82
+ const before = { [a.id]: service.getById(a.id).content, [b.id]: service.getById(b.id).content };
81
83
  const snapshot = snapshotOf(store, a, b);
82
84
  const { applied } = applyDecisions(
83
85
  [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["内网直连", "外网映射"] }],
@@ -87,10 +89,12 @@ test("issue#126 differentiate: both entries survive and each carries the distinc
87
89
  for (const m of [a, b]) {
88
90
  const after = service.getById(m.id);
89
91
  assert.equal(after.archived, false, "differentiate never archives");
90
- assert.ok(after.content.includes("差异注记"), "each side gets the note");
92
+ assert.equal(after.content, before[m.id], "the body is left byte-identical");
93
+ assert.ok(!after.content.includes("差异注记"), "no note is written into the body");
91
94
  }
92
- assert.ok(service.getById(a.id).content.includes("内网直连"), "note carries the distinctions");
93
- assert.ok(service.getById(b.id).content.includes("外网映射"));
95
+ const record = service.findDifferentiatedPair(a.id, b.id);
96
+ assert.ok(record, "the verdict is recorded as a pair, not as body text");
97
+ assert.deepEqual(record.distinctions, ["内网直连", "外网映射"], "the model's distinctions survive for audit");
94
98
  store.close();
95
99
  });
96
100
 
@@ -369,20 +373,20 @@ test("issue#126r: buildOutcome records a disposition for the new actions", () =>
369
373
  assert.equal(outcome.byId.d2, "differentiated");
370
374
  });
371
375
 
372
- test("issue#126r: differentiate refreshes the cached vectors so the note reaches the index", async () => {
376
+ test("issue#533r: differentiate leaves the cached vectors alone (nothing changed to re-embed)", async () => {
373
377
  const { service, store, vectorIndex } = setup();
374
378
  const { a, b } = seedPair(service, vectorIndex, "向量甲", "向量乙");
375
- const before = vectorIndex.getEmbedding(a.id);
376
- // 让嵌入器产出与旧向量不同的结果,便于确认"确实重嵌了"。
379
+ const beforeA = vectorIndex.getEmbedding(a.id);
380
+ const beforeB = vectorIndex.getEmbedding(b.id);
381
+ // 让嵌入器产出与旧向量不同的结果:若源码又悄悄重嵌,这里立刻会变。
377
382
  const fresh = { ...embedder, embedSingle: async () => [0, 1, 0] };
378
383
  service.setEmbedder(fresh);
379
384
  const ctx = captureCtx(JSON.stringify([
380
385
  { action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }
381
386
  ]));
382
387
  await runSleep(ctx, service, baseConfig({ sleepActionSet: "full" }), ctx.logger, { embedder: fresh, vectorIndex }, null);
383
- const after = vectorIndex.getEmbedding(a.id);
384
- assert.notDeepEqual(after, before, "the cached vector was recomputed after the content note");
385
- assert.deepEqual(after, [0, 1, 0], "it carries the fresh embedding");
388
+ assert.deepEqual(vectorIndex.getEmbedding(a.id), beforeA, "side A keeps its vector");
389
+ assert.deepEqual(vectorIndex.getEmbedding(b.id), beforeB, "side B keeps its vector");
386
390
  store.close();
387
391
  });
388
392
 
@@ -397,7 +401,8 @@ test("issue#126r: the full tier discovers cross-type pairs (differentiate would
397
401
  ]));
398
402
  const result = await runSleep(ctx, service, baseConfig({ sleepActionSet: "full" }), ctx.logger, { embedder, vectorIndex }, null);
399
403
  assert.equal(result.phases.conflicts.applied, 1, "the cross-type pair was adjudicated");
400
- assert.ok(service.getById(proj.id).content.includes("差异注记"), "differentiate reached the cross-type pair");
404
+ assert.ok(service.findDifferentiatedPair(proj.id, pref.id), "differentiate reached the cross-type pair");
405
+ assert.equal(service.getById(proj.id).content, "项目用 8080", "the project body is untouched");
401
406
  store.close();
402
407
  });
403
408
 
@@ -0,0 +1,315 @@
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
+ import { createVectorIndex } from "../src/vector-index.js";
6
+ import { createSleepScheduler, runSleep } from "../src/dream/sleep.js";
7
+
8
+ // Issue #529(续排):本轮被预算截断(conflict 相位还有没进这一轮的对)且送出去的每一对
9
+ // 都落地了时,调度器不该等满一个 sleepMinIntervalHours(默认 8h)才跑下一轮——池子没清空,
10
+ // 剩下的对只能等到下一次到点,等于把"分批"变成"隔夜慢慢分批"。
11
+ //
12
+ // 这一组用例锁的是续排语义,防的回归是:
13
+ // ① 判据拿 status 反推 → 全判 keep 的轮次 applied 恒为 0、status 是 noop(永远到不了 ok),
14
+ // 续排在最该追的场景静默失效;
15
+ // ② 只把冷却项改成 0、不在回调里补挂闹钟 → 续排只追一轮就断,剩下的对又回到隔夜等;
16
+ // ③ 不限制连击 → 用户持续写入时池子进得比追得快,变成永不落地的每五分钟一轮,
17
+ // 而审计上每轮都"有产出",肉眼很难发现;
18
+ // ④ 失败轮也追 → 与连败退避抢同一只闹钟,退避被自己踩空。
19
+
20
+ const embedder = {
21
+ embedSingle: async () => [1, 0, 0],
22
+ embed: async () => [1, 0, 0],
23
+ schedule: () => {},
24
+ modelHash: "mock#1",
25
+ dimension: 3
26
+ };
27
+
28
+ function mockCtx(responder) {
29
+ let call = 0;
30
+ return {
31
+ logger: { warn: () => {}, info: () => {} },
32
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
33
+ llm: {
34
+ async *stream(options) {
35
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
36
+ let reply = "[]";
37
+ if (options.purpose === "sleep-conflict") reply = responder(userText, ++call);
38
+ yield { type: "text-delta", index: 0, text: reply };
39
+ yield { type: "finish", reason: { kind: "stop" } };
40
+ }
41
+ }
42
+ };
43
+ }
44
+
45
+ function setup() {
46
+ const store = createStore(":memory:");
47
+ const service = createService({ store, mirror: null, config: {} });
48
+ const vectorIndex = createVectorIndex({ store });
49
+ service.setEmbedder(embedder);
50
+ service.setVectorIndex(vectorIndex);
51
+ return { store, service, vectorIndex };
52
+ }
53
+
54
+ // 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1,findPotentialConflicts 生成 C(n,2) 对,
55
+ // 去重阶段按相似度留下 n/2 对(每记忆至多一对)。
56
+ function seedSimilarPairs(service, vectorIndex, n) {
57
+ for (let i = 0; i < n; i++) {
58
+ const m = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 }).memory;
59
+ vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
60
+ }
61
+ }
62
+
63
+ function baseConfig(overrides = {}) {
64
+ return {
65
+ sleepModeEnabled: true,
66
+ sleepIdleMinutes: 5,
67
+ sleepMinIntervalHours: 8,
68
+ sleepConflictStrictness: "normal",
69
+ sleepArchiveDays: 30,
70
+ sleepCompressDays: 90,
71
+ sleepPatternMinMemories: 100,
72
+ sleepMaxPatternPerRun: 3,
73
+ ...overrides
74
+ };
75
+ }
76
+
77
+ // 与 test/sleep-failure-backoff.test.js 同款房型:时钟与定时器全部注入。
78
+ function makeHarness(overrides = {}) {
79
+ let nowMs = 10_000_000;
80
+ const timers = [];
81
+ let seq = 1;
82
+ const store = createStore(":memory:");
83
+ const service = createService({ store, mirror: null, config: {} });
84
+ let runs = 0;
85
+ let outcome = () => ({ ok: true, status: "noop" });
86
+ // 运行**期间**要发生的事(写入 / 耗时)——用来复现"跑完那一刻已挂着的表只剩几秒"。
87
+ let duringRun = null;
88
+ const sched = createSleepScheduler({
89
+ service,
90
+ config: { sleepModeEnabled: true, sleepIdleMinutes: 5, sleepMinIntervalHours: 1, ...overrides },
91
+ logger: { warn: () => {} },
92
+ onRun: async () => {
93
+ runs++;
94
+ if (duringRun) await duringRun();
95
+ return outcome();
96
+ },
97
+ now: () => nowMs,
98
+ setTimeoutFn: (fn, delay) => {
99
+ const t = { id: seq++, at: nowMs + delay, fn };
100
+ timers.push(t);
101
+ return t.id;
102
+ },
103
+ clearTimeoutFn: (id) => {
104
+ const i = timers.findIndex((t) => t.id === id);
105
+ if (i >= 0) timers.splice(i, 1);
106
+ }
107
+ });
108
+ const fireNext = async () => {
109
+ timers.sort((a, b) => a.at - b.at);
110
+ const t = timers.shift();
111
+ if (!t) throw new Error("no armed timer to fire");
112
+ nowMs = t.at;
113
+ await t.fn();
114
+ };
115
+ return {
116
+ sched,
117
+ store,
118
+ timers,
119
+ fireNext,
120
+ runs: () => runs,
121
+ now: () => nowMs,
122
+ setOutcome: (fn) => { outcome = fn; },
123
+ setDuringRun: (fn) => { duringRun = fn; },
124
+ write: () => { nowMs += 1000; sched.noteWrite(); },
125
+ advance: (ms) => { nowMs += ms; }
126
+ };
127
+ }
128
+
129
+ const MIN = 60_000;
130
+ const HOUR = 3_600_000;
131
+
132
+ test("issue#529 续排:clean + 还有没进这一轮的对 ⇒ 下一个闹钟钉在静默窗口,不等最小间隔", async () => {
133
+ const h = makeHarness();
134
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
135
+ h.write();
136
+ await h.fireNext();
137
+ assert.equal(h.runs(), 1, "静默到点起跑一轮");
138
+ assert.equal(h.timers.length, 1, "关键:追 backlog 的轮次跑完必须自己补挂闹钟(此前一条都不挂)");
139
+ const delay = h.timers[0].at - h.now();
140
+ assert.equal(delay, 5 * MIN + 1000, "下限是一个静默窗口,不是 1 秒也不是 1 小时");
141
+
142
+ await h.fireNext();
143
+ assert.equal(h.runs(), 2, "关键:闹钟真的起跑了第二轮(只补挂不起跑等于没追)");
144
+ h.store.close();
145
+ });
146
+
147
+ test("issue#529 续排:不 clean 时不追,间隔回到最小间隔", async () => {
148
+ const h = makeHarness();
149
+ h.setOutcome(() => ({ ok: true, status: "degraded", catchup: false, backlog: true }));
150
+ h.write();
151
+ await h.fireNext();
152
+ assert.equal(h.runs(), 1);
153
+ assert.equal(h.timers.length, 0, "有对没落地 ⇒ 不补挂短闹钟,等下一次写入重新起算");
154
+ const t = h.now();
155
+ assert.equal(h.sched.shouldRun(t + HOUR - 1), false, "冷却仍是基数间隔");
156
+ assert.equal(h.sched.shouldRun(t + HOUR), true);
157
+ h.store.close();
158
+ });
159
+
160
+ test("issue#529 续排:运行期间用户写入挂的表要在收尾时重挂,不能剩几秒就开下一批", async () => {
161
+ const h = makeHarness();
162
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
163
+ h.write();
164
+ // 运行一开始就按静默窗口挂好了表(at = 起跑时刻 + 5min + 1s)。
165
+ const armedAt = h.timers[0].at;
166
+ h.setDuringRun(async () => {
167
+ // 运行早期用户又写了一次:noteWrite 撤表重挂,新表从**那一刻**起算。
168
+ h.advance(MIN);
169
+ h.write();
170
+ // 这一轮本身跑了很久(LLM 往返 + 落库),早就越过那只表的到点时刻。真实环境里
171
+ // 这只表此刻已经可以立即触发——收尾若不重挂,下一批就是"几秒后开跑",
172
+ // 「让库安静一个静默窗口再追」形同虚设。
173
+ h.advance(10 * MIN);
174
+ });
175
+ await h.fireNext();
176
+ assert.equal(h.runs(), 1);
177
+ assert.ok(h.now() > armedAt, "前置:这一轮确实跑过了原来那只表的到点时刻");
178
+ assert.equal(h.timers.length, 1, "收尾必须只剩(重挂的)一只表");
179
+ assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000, "关键:从**收尾这一刻**重算整一个静默窗口,不是沿用运行期挂的旧表");
180
+ h.store.close();
181
+ });
182
+
183
+ test("issue#529 续排:台账写入失败要让这一轮不 clean,不能每个静默窗口重问同一个冲突", async () => {
184
+ const { service, store, vectorIndex } = setup();
185
+ seedSimilarPairs(service, vectorIndex, 8);
186
+ // 留痕写不进去:这一对既没被记成"已审",下一轮还会原样回到候选池。若仍判 clean,
187
+ // 续排会一个静默窗口问一次 LLM、直到连击上限,而审计上每轮都是干净的成功轮。
188
+ service.saveConflictReviewed = () => {
189
+ throw new Error("ledger unavailable");
190
+ };
191
+ const ctx = mockCtx(() => "[]");
192
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 });
193
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
194
+ assert.ok(result.phases.conflicts.ledgerFailed > 0, "前置:确实有留痕写入失败");
195
+ assert.equal(result.phases.conflicts.clean, false, "关键:没记上账就不算落地");
196
+ assert.equal(result.catchup, false, "不 clean ⇒ 不追下一批,回到正常节奏");
197
+ store.close();
198
+ });
199
+
200
+ test("issue#529 续排:失败轮不追(与连败退避互斥)", async () => {
201
+ const h = makeHarness({ sleepFailureBackoff: true });
202
+ h.setOutcome(() => ({ ok: false, status: "failed", catchup: true, backlog: true }));
203
+ h.write();
204
+ await h.fireNext();
205
+ // 失败本身仍然要补挂(#187:不补挂就永远等不到下一轮),但补的是**退避后的**长表,
206
+ // 不是续排那张静默窗口的短表——这才是"互斥"的可观测形态。
207
+ assert.equal(h.timers.length, 1, "失败轮按 #187 仍补挂一张表");
208
+ assert.equal(h.timers[0].at - h.now(), 2 * HOUR + 1000, "补的是 1h × 2^1 的退避表,不是 5 分钟的续排表");
209
+ const t = h.now();
210
+ assert.equal(h.sched.shouldRun(t + 2 * HOUR - 1), false, "退避仍然生效(1h 基数 × 2^1)");
211
+ assert.equal(h.sched.shouldRun(t + 2 * HOUR), true);
212
+ h.store.close();
213
+ });
214
+
215
+ test("issue#529 续排:连击到上限即收手,闹钟回到正常间隔", async () => {
216
+ const h = makeHarness();
217
+ // 用 runSleep 的真实返回形状:全判 keep 的一轮 status 是 noop,而 ok 只在
218
+ // ok/degraded 时为真——桩里写 ok:true 会让 maybeSchedule 走进与真实路径不同的分支
219
+ // (!ran 那条),于是「到顶后没有定时器」测的其实是桩的偏差,不是实现的行为。
220
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
221
+ h.write();
222
+ await h.fireNext();
223
+ // 24 是实现里的 CATCHUP_STREAK_CAP:每轮都 clean 但池子永远追不完时,必须自己刹住。
224
+ for (let i = 2; i <= 24; i++) {
225
+ assert.equal(h.timers.length, 1, `第 ${i} 轮之前应当有一只已挂好的闹钟`);
226
+ await h.fireNext();
227
+ }
228
+ assert.equal(h.runs(), 24, "连追 24 轮");
229
+ const t = h.now();
230
+ assert.equal(h.sched.shouldRun(t + 5 * MIN), false, "收手后回到最小间隔,不再按静默窗口放行");
231
+ assert.equal(h.sched.shouldRun(t + HOUR), true);
232
+ // 收手后既不能是「一直按静默窗口重排」(空转链),也不能是「什么都不挂」
233
+ // (#187:CD 到期成了没有监听者的时刻)。正确形态是回到正常的最小间隔。
234
+ assert.equal(h.timers.length, 1, "按 #187 补挂一张表,但不能是续排的短表");
235
+ assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "补的是正常最小间隔,不是 5 分钟的续排表");
236
+ h.store.close();
237
+ });
238
+
239
+ test("issue#529 续排:全判 keep 的一轮 status=noop 仍是 clean,续排判据不依赖 status", async () => {
240
+ const { service, store, vectorIndex } = setup();
241
+ seedSimilarPairs(service, vectorIndex, 8);
242
+ const ctx = mockCtx(() => "[]");
243
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 });
244
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
245
+ assert.equal(result.phases.conflicts.applied, 0, "全判 keep ⇒ 没有任何落地");
246
+ assert.equal(result.phases.conflicts.status, "noop", "关键:这一轮永远到不了 ok(拿 status 当判据就死在这里)");
247
+ assert.ok(result.phases.conflicts.perRunSkipped > 0, "前置:本轮确实被预算截断");
248
+ assert.equal(result.phases.conflicts.clean, true, "送出去的每一对都留了台账 ⇒ clean");
249
+ assert.equal(result.catchup, true, "关键:noop 轮次也要能追");
250
+ assert.equal(result.backlog, true);
251
+ store.close();
252
+ });
253
+
254
+ test("issue#529 续排:坏批让这一轮不 clean,即使还有 backlog 也不追", async () => {
255
+ const { service, store, vectorIndex } = setup();
256
+ seedSimilarPairs(service, vectorIndex, 8);
257
+ let call = 0;
258
+ // 第一批吐空(stream 正常结束但没有 JSON 数组 ⇒ 这批 failed),后面的批正常。
259
+ const ctx = mockCtx(() => (++call === 1 ? "" : "[]"));
260
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 });
261
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
262
+ assert.equal(result.backlog, true, "前置:确实有对没进这一轮");
263
+ assert.equal(result.phases.conflicts.clean, false, "有批没走通 ⇒ 不 clean");
264
+ assert.equal(result.catchup, false, "关键:不 clean 时宁可少追一轮,也不要在失败状态里把间隔压到静默窗口");
265
+ store.close();
266
+ });
267
+
268
+ test("issue#529 续排:连击到顶收手之后,下一次正常触发要能重新起算(刹车不能变成永久闸)", async () => {
269
+ const h = makeHarness();
270
+ h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true }));
271
+ h.write();
272
+ await h.fireNext();
273
+ for (let i = 2; i <= 24; i++) await h.fireNext();
274
+ assert.equal(h.runs(), 24);
275
+ assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "前置:到顶后已收手,闹钟回到正常间隔");
276
+
277
+ // 收手若只把 catchupWanted 置 false、**不把连击清零**,这个进程此后每一轮都会被同一道闸
278
+ // 挡下(streak 只增不减),"等下一个静默窗口重新起算"就成了一句空话。
279
+ h.write();
280
+ for (let i = 0; i < 5 && h.runs() < 25; i++) await h.fireNext();
281
+ assert.equal(h.runs(), 25, "关键:收手之后仍能被正常触发救回来");
282
+ assert.equal(h.timers.length, 1, "重新起算后又该补挂续排闹钟");
283
+ assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000, "补的是静默窗口的短表,说明连击已从 0 重新算");
284
+ h.store.close();
285
+ });
286
+
287
+ test("issue#529 续排:一轮抛错要把连击一并清零,不能把上一轮的进度带进下一次起算", async () => {
288
+ const h = makeHarness();
289
+ // 同上面两条:runSleep 在 status==="noop" 时给 ok:false,桩要照它写。
290
+ const clean = () => ({ ok: false, status: "noop", catchup: true, backlog: true });
291
+ h.setOutcome(clean);
292
+ h.write();
293
+ await h.fireNext();
294
+ // 先追到 23(上限 24 的前一格):此时连击若不被清掉,下一轮 clean 就会立刻撞上限。
295
+ for (let i = 2; i <= 23; i++) await h.fireNext();
296
+ assert.equal(h.runs(), 23);
297
+
298
+ h.setOutcome(() => {
299
+ throw new Error("boom");
300
+ });
301
+ h.write();
302
+ await h.fireNext();
303
+ assert.equal(h.runs(), 24, "第 24 轮抛错");
304
+ assert.equal(h.timers.length, 1, "抛错仍按 #187 补挂一张表(补的是退避长表)");
305
+ assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "抛错后回落到最小间隔,续排的短表不再挂");
306
+
307
+ // 关键:抛错回合若不把 catchupStreak 清零,接下来这一轮 clean 的连击会从 23 累到 24,
308
+ // 于是"刚被救回来"的进程立刻又被同一道闸挡下。
309
+ h.setOutcome(clean);
310
+ await h.fireNext();
311
+ assert.equal(h.runs(), 25, "抛错后的第一次干净轮次仍要真的跑");
312
+ assert.equal(h.timers.length, 1, "关键:连击已清零,这一轮又能挂上续排闹钟");
313
+ assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000);
314
+ h.store.close();
315
+ });