@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,156 @@
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 { createSleepScheduler } from "../src/dream/sleep.js";
6
+
7
+ // Issue #529(C 项):sleep 连续失败退避(sleepFailureBackoff,opt-in)。
8
+ //
9
+ // 报告里 C 的原案是「同一 snapshot_hash 连续失败就拉长间隔」,但 sleep 的
10
+ // snapshot_hash 是**跑完之后**对 phases 结果算的哈希(dream/sleep.js runSleep 里
11
+ // createHash(JSON.stringify(phases))),不是输入快照——同一批输入跑两次结果不同就
12
+ // 得到不同哈希,判据不成立。这里改用与 #292(autoDreamFailureBackoff)同构的
13
+ // 连续失败计数:有效间隔 = sleepMinIntervalHours × 2^连败数,成功清零,封顶 24h。
14
+ //
15
+ // 这些用例锁的是「退避语义」,防的回归是:
16
+ // ① 退避闸失效/写反 → 恒定失败的模型按 sleepMinIntervalHours 的固定节奏连发
17
+ // (#529 实测连续 23 轮 llm failed);
18
+ // ② 成功不清零 → 模型恢复后仍被旧失败拖着推迟,sleep 变相停摆;
19
+ // ③ 24h 封顶失效 → 指数无限翻倍(8h 基数 × 2^5 = 10 天),睡眠维护彻底静默;
20
+ // ④ 默认关路径被顺手改掉 → 未 opt-in 用户的重试间隔漂移;
21
+ // ⑤ noop 被当成失败 → LLM 通路正常、只是库里没活干的库被越退越长。
22
+ //
23
+ // 时钟与定时器全部注入(同 test/sleep-scheduler-rearm.test.js 的房型)。
24
+
25
+ function makeHarness(overrides = {}) {
26
+ let nowMs = 10_000_000;
27
+ const timers = [];
28
+ let seq = 1;
29
+ const store = createStore(":memory:");
30
+ const service = createService({ store, mirror: null, config: {} });
31
+ let runs = 0;
32
+ let outcome = () => ({ ok: false, status: "failed" });
33
+ const sched = createSleepScheduler({
34
+ service,
35
+ config: {
36
+ sleepModeEnabled: true,
37
+ sleepIdleMinutes: 5,
38
+ sleepMinIntervalHours: 1,
39
+ ...overrides
40
+ },
41
+ logger: { warn: () => {} },
42
+ onRun: async () => {
43
+ runs++;
44
+ return outcome();
45
+ },
46
+ now: () => nowMs,
47
+ setTimeoutFn: (fn, delay) => {
48
+ const t = { id: seq++, at: nowMs + delay, fn };
49
+ timers.push(t);
50
+ return t.id;
51
+ },
52
+ clearTimeoutFn: (id) => {
53
+ const i = timers.findIndex((t) => t.id === id);
54
+ if (i >= 0) timers.splice(i, 1);
55
+ }
56
+ });
57
+ const fireNext = async () => {
58
+ timers.sort((a, b) => a.at - b.at);
59
+ const t = timers.shift();
60
+ if (!t) throw new Error("no armed timer to fire");
61
+ nowMs = t.at;
62
+ await t.fn();
63
+ };
64
+ return {
65
+ sched,
66
+ store,
67
+ timers,
68
+ fireNext,
69
+ runs: () => runs,
70
+ now: () => nowMs,
71
+ setOutcome: (fn) => { outcome = fn; },
72
+ write: () => { nowMs += 1000; sched.noteWrite(); }
73
+ };
74
+ }
75
+
76
+ const HOUR = 3_600_000;
77
+
78
+ test("issue#529 默认关:失败一次后重试间隔仍是基数,不翻倍", async () => {
79
+ const h = makeHarness({ sleepFailureBackoff: false });
80
+ h.write();
81
+ await h.fireNext();
82
+ assert.equal(h.runs(), 1, "idle 到点起跑一次");
83
+ const t0 = h.now();
84
+ assert.equal(h.sched.shouldRun(t0 + HOUR - 1), false, "基数间隔内照旧不放行");
85
+ assert.equal(h.sched.shouldRun(t0 + HOUR), true, "关键:基数一到即放行——默认关不翻倍");
86
+ h.store.close();
87
+ });
88
+
89
+ test("issue#529 开启:连败把下次放行时刻指数推迟", async () => {
90
+ const h = makeHarness({ sleepFailureBackoff: true });
91
+ h.write();
92
+ await h.fireNext(); // 败 1 → 有效间隔 1h × 2^1 = 2h
93
+ assert.equal(h.runs(), 1);
94
+ const t1 = h.now();
95
+ assert.equal(h.sched.shouldRun(t1 + HOUR), false, "关键:基数到点不放行(现状会放行)");
96
+ assert.equal(h.sched.shouldRun(t1 + 2 * HOUR), true, "满 2h 才放行第 2 次");
97
+
98
+ await h.fireNext(); // 败 2 → 有效间隔 4h
99
+ assert.equal(h.runs(), 2);
100
+ const t2 = h.now();
101
+ assert.equal(h.sched.shouldRun(t2 + 3 * HOUR), false, "连败 2 次后 4h 内不放行");
102
+ assert.equal(h.sched.shouldRun(t2 + 4 * HOUR), true, "满 4h 放行第 3 次");
103
+ h.store.close();
104
+ });
105
+
106
+ test("issue#529 成功一次即清零、间隔回到基数", async () => {
107
+ const h = makeHarness({ sleepFailureBackoff: true });
108
+ h.write();
109
+ await h.fireNext(); // 败 1 → 2h
110
+ const t1 = h.now();
111
+ assert.equal(h.sched.shouldRun(t1 + HOUR), false, "前置:退避确实在挡");
112
+
113
+ h.setOutcome(() => ({ ok: true, status: "ok" }));
114
+ await h.fireNext(); // 成功 → 连败清零
115
+ const t2 = h.now();
116
+ assert.equal(h.sched.shouldRun(t2 + HOUR), true, "关键:清零后基数即放行(未清零要等 2h)");
117
+ h.store.close();
118
+ });
119
+
120
+ test("issue#529 封顶:连败 5 次推迟 24h 即止(未封顶需等 32h)", async () => {
121
+ const h = makeHarness({ sleepFailureBackoff: true });
122
+ h.write();
123
+ await h.fireNext();
124
+ for (let i = 2; i <= 5; i++) {
125
+ const wait = Math.min(HOUR * 2 ** (i - 1), 24 * HOUR); // 与实现同一口径的期望间隔
126
+ assert.equal(h.sched.shouldRun(h.now() + wait), true, `第 ${i} 次起跑前满 ${wait / HOUR}h`);
127
+ await h.fireNext();
128
+ }
129
+ assert.equal(h.runs(), 5);
130
+ const t = h.now();
131
+ assert.equal(h.sched.shouldRun(t + 24 * HOUR - 1), false, "关键:封顶后 24h 内仍不放行");
132
+ assert.equal(h.sched.shouldRun(t + 24 * HOUR), true, "封顶生效:24h 放行,不再翻倍");
133
+ h.store.close();
134
+ });
135
+
136
+ test("issue#529 noop 不算失败:没活干的库不该被退避越拖越稀", async () => {
137
+ const h = makeHarness({ sleepFailureBackoff: true });
138
+ h.setOutcome(() => ({ ok: false, status: "noop" }));
139
+ h.write();
140
+ await h.fireNext();
141
+ assert.equal(h.runs(), 1);
142
+ const t0 = h.now();
143
+ assert.equal(h.sched.shouldRun(t0 + HOUR), true, "关键:noop 后基数即放行(当作失败要等 2h)");
144
+ h.store.close();
145
+ });
146
+
147
+ test("issue#529 抛错同样计入连败", async () => {
148
+ const h = makeHarness({ sleepFailureBackoff: true });
149
+ h.setOutcome(() => { throw new Error("boom"); });
150
+ h.write();
151
+ await h.fireNext();
152
+ const t0 = h.now();
153
+ assert.equal(h.sched.shouldRun(t0 + HOUR), false, "关键:抛错也退避到 2h");
154
+ assert.equal(h.sched.shouldRun(t0 + 2 * HOUR), true);
155
+ h.store.close();
156
+ });
@@ -525,3 +525,296 @@ test("issue#250 sleep still adjudicates normally when llmAudit.enabled === false
525
525
  assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
526
526
  store.close();
527
527
  });
528
+ // ------------------------------------------------------------ issue #529: input budget
529
+
530
+ // seedSimilarPairs:造 n 条同类型、两两相似度都不低于阈值的记忆,且每条只可能与
531
+ // 一条配成对(相似度按 i 递减)。用来验证「池子大于单批上限」时分批是否真的分开了。
532
+ // 这些向量不是正交的——cosine 只在 [1,0,0] 与 [s, sin, 0] 之间算,因此给每对单独
533
+ // 一个角度,与 seedConflictPair 同一套构造。
534
+ function seedSimilarPairs(service, vectorIndex, n) {
535
+ const mems = [];
536
+ for (let i = 0; i < n; i++) {
537
+ const m = makeMemory(service, `主题${i}`, `内容${i}`, "project");
538
+ mems.push(m);
539
+ }
540
+ // 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1(> 0.85),findPotentialConflicts
541
+ // 会生成 C(n,2) 对,去重阶段按相似度留下 n/2 对(每记忆至多一对)。
542
+ for (const m of mems) vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
543
+ return mems;
544
+ }
545
+
546
+ // 记录**冲突相位**每次往返送进去的候选对数(user 消息里每个候选行以 "id=" 开头)。
547
+ // 必须按 purpose 过滤:同一轮里 pattern 相位也会调 LLM,把它的往返算进来会让
548
+ // 「送了几批」这个断言凭空多一批。
549
+ function countingCtx(responder, batchSizes = []) {
550
+ let call = 0;
551
+ const ctx = {
552
+ logger: { warn: () => {}, info: () => {} },
553
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
554
+ llm: {
555
+ async *stream(options) {
556
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
557
+ let reply = "[]";
558
+ if (options.purpose === "sleep-conflict") {
559
+ batchSizes.push((userText.match(/id=/g) ?? []).length);
560
+ reply = responder(userText, ++call);
561
+ }
562
+ yield { type: "text-delta", index: 0, text: reply };
563
+ yield { type: "finish", reason: { kind: "stop" } };
564
+ }
565
+ }
566
+ };
567
+ return { ctx, batchSizes, calls: () => call };
568
+ }
569
+
570
+ test("issue#529 conflict pairs are sent in bounded batches, not one giant prompt", async () => {
571
+ const { service, store, vectorIndex } = setup();
572
+ seedSimilarPairs(service, vectorIndex, 8);
573
+ const { ctx, batchSizes } = countingCtx(() => "[]");
574
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
575
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
576
+ assert.ok(batchSizes.length > 1, `expected several batches, got ${batchSizes.length}`);
577
+ assert.ok(batchSizes.every((n) => n > 0 && n <= 4), `每批不超过 2 对(每对 2 个 id): ${JSON.stringify(batchSizes)}`);
578
+ assert.deepEqual(batchSizes.slice(-2), [4, 4], "每对 2 个 id ⇒ 2 对 = 4 个 id 行");
579
+ assert.equal(result.phases.conflicts.batches, batchSizes.length, "审计里的批次数与真实往返次数一致");
580
+ store.close();
581
+ });
582
+
583
+ test("issue#529 a failing batch does not kill the whole run (degraded, not failed)", async () => {
584
+ const { service, store, vectorIndex } = setup();
585
+ seedSimilarPairs(service, vectorIndex, 8);
586
+ const { ctx } = countingCtx((_text, n) => {
587
+ // 第一批吐空(stream 正常结束但无文本 ⇒ llm failed),后面的批正常。
588
+ return n === 1 ? "" : "[]";
589
+ });
590
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
591
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
592
+ assert.equal(result.phases.conflicts.status, "degraded", "坏批只记 degraded,不判死整轮");
593
+ assert.ok(result.phases.conflicts.batches > 1, "后面的批照样跑完了");
594
+ store.close();
595
+ });
596
+
597
+ test("issue#529 a judged pair is not re-sent while neither side changed (keep leaves a trace)", async () => {
598
+ const { service, store, vectorIndex } = setup();
599
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
600
+ const config = baseConfig();
601
+ // 第一轮:判 keep(= 模型说这两条不冲突)。这曾经是彻底的 no-op。
602
+ const first = await runSleep(mockCtx(() => "[]"), service, config, { warn: () => {} }, { embedder, vectorIndex }, null);
603
+ assert.equal(first.phases.conflicts.status, "noop", "没有动作 ⇒ noop");
604
+ const ledger = service.listConflictReviewed();
605
+ assert.equal(ledger.length, 1, "判 keep 也要在台账里留一行");
606
+ assert.equal(ledger[0].outcome, "keep");
607
+ assert.deepEqual([ledger[0].memory_a, ledger[0].memory_b].sort(), [a.id, b.id].sort());
608
+
609
+ // 第二轮:两侧都没动 ⇒ 这对已经在台账里,不该再送 LLM。
610
+ const secondCtx = countingCtx(() => "[]");
611
+ const second = await runSleep(secondCtx.ctx, service, config, secondCtx.ctx.logger, { embedder, vectorIndex }, null);
612
+ assert.equal(secondCtx.calls(), 0, "同一对、两侧都没更新 ⇒ 不再送审");
613
+ assert.equal(second.phases.conflicts.ledgerSkipped, 1);
614
+
615
+ // 第三轮:动一下其中一侧(内容更新 ⇒ updated_at 前进),它就该重新入池。
616
+ // service.update 会把该行的向量作废并异步重算;测试里的 mock embedder 只支持
617
+ // 「一条一条 embed」的形式(embedSingle),批量 embed 的返回形状不是它覆盖的范围,
618
+ // 所以这里按既有夹具的做法手工补一次向量——被测的是台账判据,不是回填路径。
619
+ service.update(b.id, { content: "内容B 关于主题X(改)" });
620
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
621
+ const thirdCtx = countingCtx(() => "[]");
622
+ const third = await runSleep(thirdCtx.ctx, service, config, thirdCtx.ctx.logger, { embedder, vectorIndex }, null);
623
+ assert.equal(thirdCtx.calls(), 1, "任一侧更新过 ⇒ 重新送审");
624
+ assert.equal(third.phases.conflicts.ledgerSkipped, 0);
625
+ store.close();
626
+ });
627
+
628
+ test("issue#529 per-run cap bounds the pairs adjudicated in one run", async () => {
629
+ const { service, store, vectorIndex } = setup();
630
+ seedSimilarPairs(service, vectorIndex, 12);
631
+ const { ctx, calls } = countingCtx(() => "[]");
632
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
633
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
634
+ assert.ok(calls() <= 2, `每轮最多 4 对 ÷ 每批 2 对 = 2 次往返,实际 ${calls()}`);
635
+ assert.ok(result.phases.conflicts.perRunSkipped > 0, "超出本轮预算的对要被记下来");
636
+ store.close();
637
+ });
638
+
639
+ test("issue#529 user activity between batches aborts instead of reporting noop", async () => {
640
+ const { service, store, vectorIndex } = setup();
641
+ seedSimilarPairs(service, vectorIndex, 12);
642
+ const controller = new AbortController();
643
+ // 第一批返回之后用户恢复活动(noteWrite → abort)。此时第二、三批根本没送出去,
644
+ // 把它们算成「看过、没冲突」(noop)是这一相位最容易犯的谎。
645
+ const { ctx } = countingCtx(() => {
646
+ controller.abort();
647
+ return "[]";
648
+ });
649
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 12 });
650
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, controller.signal);
651
+ assert.equal(result.phases.conflicts.status, "aborted", "中途被打断不是 noop");
652
+ assert.equal(result.phases.conflicts.batches, 6, "计划批次数照实记(不是实际送出的批次数)");
653
+ // 整轮层也要如实:deriveStatus 此前只认 failed/error,aborted 相位会被静默折成
654
+ // noop,审计行于是把「没跑完」记成「跑过、没什么事」——与相位层同一个谎。
655
+ assert.equal(result.status, "aborted", "整轮状态沿用相位的 aborted,不折回 noop");
656
+ store.close();
657
+ });
658
+
659
+ test("issue#529 failed batches still report pairs/batches (audit used to lose them)", async () => {
660
+ const { service, store, vectorIndex } = setup();
661
+ seedSimilarPairs(service, vectorIndex, 4);
662
+ const { ctx } = countingCtx(() => "");
663
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
664
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
665
+ const phase = result.phases.conflicts;
666
+ assert.equal(phase.status, "failed", "全部批次失败 ⇒ 整轮 failed");
667
+ assert.ok(Number.isInteger(phase.pairs) && phase.pairs > 0, "失败轮也带 pairs");
668
+ assert.ok(Number.isInteger(phase.batches) && phase.batches > 0, "失败轮也带 batches");
669
+ store.close();
670
+ });
671
+
672
+
673
+ // ------------------------------------------- issue #536 review: CodeRabbit 三条
674
+
675
+ // 把一条记忆的 created_at / updated_at 一起回拨。update 决策有 24h 保护期,
676
+ // 台账判据比的是 updated_at——两处都要动,只改一个会让用例互相干扰。
677
+ function backdate(store, id, hours) {
678
+ const ts = new Date(Date.now() - hours * 3600000).toISOString();
679
+ store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ts, ts, id);
680
+ }
681
+
682
+ // 造一对向量完全相同的记忆(similarity = 1)。
683
+ function seedIdenticalPair(service, vectorIndex, dir, tag) {
684
+ const a = makeMemory(service, `${tag}A`, `${tag} 内容A`, "project");
685
+ const b = makeMemory(service, `${tag}B`, `${tag} 内容B`, "project");
686
+ vectorIndex.saveEmbedding(a.id, dir);
687
+ vectorIndex.saveEmbedding(b.id, dir);
688
+ return { a, b };
689
+ }
690
+
691
+ test("issue#536 review ①: an already-judged pair must not eat a memory's pairing slot", async () => {
692
+ const { service, store, vectorIndex } = setup();
693
+ // a-b 相似度 1.0,a-c 与 b-c 都是 0.9:按相似度排序先去重的旧写法会先选中 a-b,
694
+ // 再把 a-b 当"已审"丢掉,于是 a 的名额被一个注定被过滤的对占住,a-c 进不来。
695
+ const a = makeMemory(service, "主题X", "内容A 关于主题X", "project");
696
+ const b = makeMemory(service, "主题X副本", "内容B 关于主题X", "project");
697
+ const c = makeMemory(service, "主题X变体", "内容C 关于主题X", "project");
698
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
699
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
700
+ vectorIndex.saveEmbedding(c.id, [0.9, Math.sqrt(1 - 0.81), 0]);
701
+ // a-b 已经判过(keep),且两侧此后都没动过 ⇒ 本轮不该再送。
702
+ service.saveConflictReviewed({
703
+ run_id: "seed",
704
+ memory_a: a.id,
705
+ memory_b: b.id,
706
+ outcome: "keep",
707
+ judged_at: new Date(Date.now() + 60000).toISOString()
708
+ });
709
+
710
+ const { ctx, calls } = countingCtx(() => "[]");
711
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
712
+ assert.equal(calls(), 1, "台账预筛必须发生在去重之前,a 要能和 c 配对送出去");
713
+ assert.equal(result.phases.conflicts.ledgerSkipped, 1, "被台账挡下的对数照实记");
714
+ store.close();
715
+ });
716
+
717
+ test("issue#536 review ②: reflectionUpdateMaxPerRun stays a whole-run cap across batches", async () => {
718
+ const { service, store, vectorIndex } = setup();
719
+ // 两对向量正交,互不干扰 ⇒ 去重后正好两对,每批一对。
720
+ const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
721
+ const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
722
+ for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
723
+
724
+ // 每批都提一条 update:整轮上限 1,所以只有先跑的那批能落地。
725
+ const { ctx, calls } = countingCtx((text) => {
726
+ const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
727
+ const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
728
+ return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
729
+ });
730
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 1 });
731
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
732
+ assert.equal(calls(), 2, "两对各自成批,都要送");
733
+ const updated = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
734
+ assert.equal(updated.length, 1, "update 是整轮上限,不是每批上限——只能落地一条");
735
+ assert.equal(result.phases.conflicts.batches, 2);
736
+ store.close();
737
+ });
738
+
739
+ test("issue#536 review ③: a decision that never landed must not be recorded as judged", async () => {
740
+ const { service, store, vectorIndex } = setup();
741
+ const { a } = seedConflictPair(service, vectorIndex, 1.0);
742
+ // 提一条"不改任何字段"的 update:validateDecisions 会把它跳过(skipInvalid),
743
+ // 该条决策没有落地。若照旧把整批送审对都写进台账,这一对就会被判成"已审"
744
+ // 而永远不再送——沉默地永久压制一个真实冲突。
745
+ const { ctx, calls } = countingCtx(() =>
746
+ JSON.stringify([{ action: "update", ids: [a.id], content: "内容A 关于主题X" }])
747
+ );
748
+ const config = baseConfig({ reflectionUpdateMinAgeHours: 0 });
749
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
750
+ assert.equal(calls(), 1, "这一对确实被送出去判过");
751
+ assert.equal(service.listConflictReviewed().length, 0, "没落地的决策不能记成已审");
752
+ assert.ok(result.phases.conflicts.skipped.length > 0, "跳过明细照常进审计");
753
+ store.close();
754
+ });
755
+
756
+ test("issue#536 review ④: judged_at lives in the store's clock domain, not the wall clock", async () => {
757
+ const { service, store, vectorIndex } = setup();
758
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
759
+ // 造一次「库里的单调时钟比墙钟快」——src/store.js 的 nowIso 在同一毫秒内的连续
760
+ // 写入上逐次 +1ms,高频写入的库完全可能领先墙钟几毫秒(CI 的 test-host-legacy
761
+ // 腿抓到过这次真 flake)。这里直接把两侧推到墙钟之后,等价于那个状态。
762
+ const ahead = new Date(Date.now() + 5000).toISOString();
763
+ for (const id of [a.id, b.id]) {
764
+ store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ahead, ahead, id);
765
+ }
766
+
767
+ // 第一轮判 keep ⇒ 记台账。
768
+ const first = await runSleep(mockCtx(() => "[]"), service, baseConfig(), { warn: () => {} }, { embedder, vectorIndex }, null);
769
+ assert.equal(first.phases.conflicts.status, "noop");
770
+ const ledger = service.listConflictReviewed();
771
+ assert.equal(ledger.length, 1, "判 keep 要留一行");
772
+ assert.ok(
773
+ ledger[0].judged_at >= ahead,
774
+ "judged_at 不能早于被测行自己的 updated_at(" + ledger[0].judged_at + " vs " + ahead + ")"
775
+ );
776
+
777
+ // 第二轮:两侧确实没动过 ⇒ 不许重送。若 judged_at 取了墙钟,这里会读成
778
+ // 「判过之后又被改过」,判 keep 的死循环原样复活。
779
+ const second = countingCtx(() => "[]");
780
+ await runSleep(second.ctx, service, baseConfig(), second.ctx.logger, { embedder, vectorIndex }, null);
781
+ assert.equal(second.calls(), 0, "时钟领先墙钟时,已审的对仍然不许重送");
782
+ store.close();
783
+ });
784
+
785
+ test("issue#536 review ⑤: a batch that throws does not take the rest of the run with it", async () => {
786
+ const { service, store, vectorIndex } = setup();
787
+ // 两对正交向量 ⇒ 两批。让**第一批**的 LLM 往返直接抛(不是返回错误:返回错误那条
788
+ // 通路本来就有失败返回,早被前面的用例覆盖了)。要验的是:adjudicateBatch 内部
789
+ // 任何未被兜住的异常,都只折损它自己那一批,后面的批次照跑、之前的汇总不丢。
790
+ // applyDecisions 把 service.update 的异常吞进 failures(decisions.js:532-540),
791
+ // 所以打桩 service 的方法测不到这条守卫,必须在 LLM 这一层抛。
792
+ const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
793
+ const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
794
+ for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
795
+ const { ctx, calls } = countingCtx((text) => {
796
+ const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
797
+ const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
798
+ return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
799
+ });
800
+ const realStream = ctx.llm.stream;
801
+ let thrown = false;
802
+ ctx.llm.stream = async function* (options) {
803
+ if (!thrown && options.purpose === "sleep-conflict") {
804
+ thrown = true;
805
+ throw new Error("boom inside adjudicateBatch");
806
+ }
807
+ yield* realStream.call(this, options);
808
+ };
809
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 2 });
810
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
811
+ assert.equal(thrown, true, "第一批确实抛了,否则这条用例什么也没验");
812
+ assert.equal(calls(), 1, "第一批抛错后第二批仍然要跑");
813
+ assert.notEqual(result.phases.conflicts.status, "failed", "只要有一批活下来,就不是整轮 failed");
814
+ // 抛的是第一批(哪个 pair 先送取决于排序,所以两条都问,只要求"恰好一条活下来"):
815
+ // 断言落在结果上,不落在顺序上,免得排序一变这条用例就假红。
816
+ const landed = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
817
+ assert.equal(landed.length, 1, "第二批照常落地,且只落地一批");
818
+ assert.equal(result.phases.conflicts.batches, 2, "抛错的批次不吞掉轮次的形状");
819
+ store.close();
820
+ });
@@ -1268,3 +1268,53 @@ test("GET /memory/provenance: 409 beats 503 — feature off is more specific tha
1268
1268
  close();
1269
1269
  }
1270
1270
  });
1271
+
1272
+ // --- #524 方向 2:?sessionId=<sid> 列该会话的蒸馏窗口 -----------------------
1273
+ // 与宿主内 API 是同一份口径(共用 provenance-http.js),这里只钉两张皮的那条差
1274
+ // 异之外的部分真的走通了:一行一窗口、按 seq_start 排、truncated 说出来。
1275
+ test("GET /memory/provenance?sessionId= lists a session's distill windows", async () => {
1276
+ const { base, auth, store, close } = await setup({ config: RECORDS_ON });
1277
+ try {
1278
+ const a = await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite", importance: 5 });
1279
+ const b = await saveMemory(base, auth, { type: "decision", title: "备选", content: "JSON 文件", importance: 3 });
1280
+ const idA = a.memory?.id ?? a.id;
1281
+ const idB = b.memory?.id ?? b.id;
1282
+ // 同一窗口派生两条记忆:答复里必须并成一行(link_count=2),不是两行。
1283
+ store.saveMemoryProvenance({ memoryId: idA, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
1284
+ store.saveMemoryProvenance({ memoryId: idB, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
1285
+ store.saveMemoryProvenance({ memoryId: idA, sessionId: "s1", seqStart: 1, seqEnd: 3, createdAt: "t1" });
1286
+ store.saveMemoryProvenance({ memoryId: idA, sessionId: "s2", seqStart: 50, seqEnd: 51, createdAt: "t3" });
1287
+
1288
+ const res = await fetch(`${base}/memory/provenance?sessionId=s1`, { headers: auth });
1289
+ assert.equal(res.status, 200);
1290
+ const body = await res.json();
1291
+ assert.equal(body.sessionId, "s1");
1292
+ assert.equal(body.memoryId, undefined, "两个方向的认领键不许同时出现");
1293
+ assert.equal(body.truncated, false);
1294
+ assert.deepEqual(body.windows.map((w) => [w.seq_start, w.seq_end]), [[1, 3], [10, 12]],
1295
+ "同一 (seq_start, seq_end) 并成一行,按 seq_start 升序;别的会话的窗口不出现");
1296
+ assert.deepEqual(body.windows.map((w) => w.link_count), [1, 2]);
1297
+ } finally {
1298
+ close();
1299
+ }
1300
+ });
1301
+
1302
+ test("GET /memory/provenance: both selectors at once is 400, and limit is bounded", async () => {
1303
+ const { base, auth, close } = await setup({ config: RECORDS_ON });
1304
+ try {
1305
+ const both = await fetch(`${base}/memory/provenance?id=m1&sessionId=s1`, { headers: auth });
1306
+ assert.equal(both.status, 400, "两个主键都给不许静默择一");
1307
+ assert.equal((await both.json()).error, "ambiguous-id");
1308
+
1309
+ for (const bad of ["0", "-1", "1.5", "abc", "1e3", "101"]) {
1310
+ const res = await fetch(`${base}/memory/provenance?sessionId=s1&limit=${bad}`, { headers: auth });
1311
+ assert.equal(res.status, 400, `limit=${bad} 必须 400`);
1312
+ assert.equal((await res.json()).error, "invalid-limit");
1313
+ }
1314
+
1315
+ const missing = await fetch(`${base}/memory/provenance`, { headers: auth });
1316
+ assert.equal(missing.status, 400, "两个主键都缺仍是 400 missing-id");
1317
+ } finally {
1318
+ close();
1319
+ }
1320
+ });