@modusensus/dsh-mneme 0.9.5 → 0.10.0

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 (81) hide show
  1. package/README.md +9 -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 +25 -5
  9. package/lib/client.js +137 -10
  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/index.js +15 -6
  17. package/lib/inject.js +9 -4
  18. package/lib/lang.js +17 -12
  19. package/lib/pipeline-nudge-auto.js +3 -0
  20. package/lib/pipeline-nudge.js +41 -8
  21. package/lib/pipeline-runner.js +15 -0
  22. package/lib/pre-inject-gate.js +14 -0
  23. package/lib/profile-extract.js +4 -1
  24. package/lib/provenance-http.js +162 -37
  25. package/lib/service.js +30 -0
  26. package/lib/session-gate.js +91 -0
  27. package/lib/settings.js +13 -1
  28. package/lib/store.js +526 -18
  29. package/lib/summarize.js +152 -15
  30. package/lib/tools.js +26 -6
  31. package/package.json +4 -2
  32. package/src/api-standalone.js +11 -3
  33. package/src/api.js +25 -5
  34. package/src/config.js +33 -1
  35. package/src/continuity.js +11 -3
  36. package/src/dream/decisions.js +88 -27
  37. package/src/dream/sleep.js +467 -81
  38. package/src/dream.js +110 -13
  39. package/src/fence.js +24 -0
  40. package/src/index.js +15 -6
  41. package/src/inject.js +9 -4
  42. package/src/lang.js +17 -12
  43. package/src/pipeline-nudge-auto.js +3 -0
  44. package/src/pipeline-nudge.js +41 -8
  45. package/src/pipeline-runner.js +15 -0
  46. package/src/pre-inject-gate.js +14 -0
  47. package/src/profile-extract.js +4 -1
  48. package/src/provenance-http.js +162 -37
  49. package/src/service.js +30 -0
  50. package/src/session-gate.js +91 -0
  51. package/src/settings.js +13 -1
  52. package/src/store.js +526 -18
  53. package/src/summarize.js +152 -15
  54. package/src/tools.js +26 -6
  55. package/test/api.test.js +153 -4
  56. package/test/client.test.js +215 -0
  57. package/test/config.test.js +8 -0
  58. package/test/conflict-reviewed.test.js +79 -0
  59. package/test/continuity.test.js +29 -1
  60. package/test/dependency-overrides.test.js +78 -0
  61. package/test/differentiate-feedback.test.js +248 -0
  62. package/test/dream-decision-evidence.test.js +314 -0
  63. package/test/dream-fence.test.js +43 -0
  64. package/test/inject-pools.test.js +2 -1
  65. package/test/mint-source.test.js +83 -0
  66. package/test/pipeline-nudge-auto.test.js +26 -2
  67. package/test/pipeline-nudge.test.js +87 -0
  68. package/test/pipeline-runner.test.js +26 -0
  69. package/test/prompt-stance.test.js +128 -0
  70. package/test/provenance-http.test.js +200 -17
  71. package/test/release-docs.test.js +7 -1
  72. package/test/security-doc.test.js +43 -4
  73. package/test/session-gate.test.js +264 -0
  74. package/test/settings.test.js +4 -0
  75. package/test/sleep-action-set.test.js +16 -11
  76. package/test/sleep-catchup.test.js +315 -0
  77. package/test/sleep-failure-backoff.test.js +156 -0
  78. package/test/sleep.test.js +293 -0
  79. package/test/standalone-api.test.js +50 -0
  80. package/test/store.test.js +220 -0
  81. package/test/summarize.test.js +172 -0
@@ -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
+ });
@@ -476,6 +476,31 @@ test("countConflictPending counts unresolved rows only; resolve unknown id is un
476
476
  store.close();
477
477
  });
478
478
 
479
+ // Issue #533:differentiate 的裁决账本。正文不再追加注记,改由这张表记住「这一对
480
+ // 已经判过、两条都留」,候选构造阶段读它避免同一对每轮重判。
481
+ test("Issue #533: differentiated_pairs survives a reopen and normalizes pair order", () => {
482
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-diffpair-"));
483
+ const path = join(dir, "memory.db");
484
+ const s1 = createStore(path);
485
+ // 送审顺序与存储顺序相反也要落到同一行(账本按 id 排序归一)。
486
+ s1.saveDifferentiatedPair({ run_id: "r1", memory_a: "mb", memory_b: "ma", hash_a: "hb", hash_b: "ha", distinctions: ["只看甲", "只看乙"] });
487
+ assert.equal(s1.listDifferentiatedPairs().length, 1);
488
+ s1.close();
489
+ const s2 = createStore(path);
490
+ const found = s2.findDifferentiatedPair("ma", "mb");
491
+ assert.ok(found, "lookups work in either argument order");
492
+ assert.equal(found.memory_a, "ma", "the row is stored sorted");
493
+ assert.equal(found.hash_a, "ha", "hashes travel with their side");
494
+ assert.deepEqual(found.distinctions, ["只看甲", "只看乙"]);
495
+ assert.equal(s2.listDifferentiatedPairs().length, 1, "re-saving the same pair updates instead of duplicating");
496
+ s2.saveDifferentiatedPair({ run_id: "r2", memory_a: "ma", memory_b: "mb", hash_a: "ha2", hash_b: "hb2" });
497
+ assert.equal(s2.listDifferentiatedPairs().length, 1, "still one row for one pair");
498
+ const updated = s2.findDifferentiatedPair("ma", "mb");
499
+ assert.equal(updated.hash_a, "ha2", "the refresh overwrites the hashes");
500
+ assert.equal(updated.distinctions, undefined, "an empty distinctions lands as NULL, not as an empty array");
501
+ s2.close();
502
+ });
503
+
479
504
  test("conflict_pending table persists across store reopen", () => {
480
505
  const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-conflict-"));
481
506
  const path = join(dir, "memory.db");
@@ -502,3 +527,198 @@ test("Issue #128: needsEmbedding skips archived and forgotten rows", () => {
502
527
  assert.deepEqual(ids, [active.id], "only the active row is a backfill candidate");
503
528
  store.close();
504
529
  });
530
+
531
+ // Issue #495:listDreamRuns 的 excludeRunType 是**下推**条件而不是调用方后置过滤——
532
+ // 后置过滤在 limit 生效之后就晚了:5 个名额已经被 organize 占掉,再筛只能得到空。
533
+ // 三条一起钉:默认不过滤(审计面要看全量)、指定值真的排除、白名单之外的值不生效
534
+ // (这个参数拼进 SQL 的 != 条件,任意外来字符串不能改变查询形状)。
535
+ test("Issue #495: listDreamRuns excludes a run type by pushdown, and only accepts known types", () => {
536
+ const store = openMemory();
537
+ store.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "auto", receipt: "r1" });
538
+ store.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r2" });
539
+ store.saveDreamRun({ created_at: "2026-01-03T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r3" });
540
+
541
+ assert.equal(store.listDreamRuns().length, 3, "no filter by default — the audit surface keeps every run type");
542
+ assert.equal(store.listDreamRuns({ limit: 1 })[0].run_type, "organize", "limit applies to the full set");
543
+
544
+ const onlyConsolidation = store.listDreamRuns({ limit: 1, excludeRunType: "organize" });
545
+ assert.equal(onlyConsolidation.length, 1);
546
+ assert.equal(onlyConsolidation[0].run_type, "auto", "the pushdown must apply before LIMIT, not after");
547
+
548
+ // 白名单之外的值按「不过滤」处理,不静默返回空集。诚实说:这条断言钉得住
549
+ // 「未知值 ⇒ 不过滤」这个可观测契约,钉不住白名单本身——把 includes 判断换成裸绑定,
550
+ // 四条断言仍然全绿(不存在的 run_type 本来也筛不掉任何行)。白名单的价值是纵深:
551
+ // 值来自调用方时只允许已知的三个字面量进 SQL 的 != 条件,查询形状不随外部字符串改变。
552
+ assert.equal(store.listDreamRuns({ excludeRunType: "auto' OR 1=1 --" }).length, 3,
553
+ "a hostile value is bound as a parameter and filters nothing, never widens the query");
554
+ assert.equal(store.listDreamRuns({ excludeRunType: "nope" }).length, 3);
555
+ store.close();
556
+ });
557
+
558
+ // ============================ #512 蒸馏 run 归因
559
+
560
+ test("#512 迁移:老库补上 distill_run_id 列与两张新表,存量链接留 NULL 不猜", () => {
561
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run-"));
562
+ const dbPath = join(dir, "legacy.db");
563
+ try {
564
+ // 存量库:#399 时代的形状——memory_provenance 没有 distill_run_id,也没有
565
+ // distill_runs 与 memory_mints。这一行代表「改造之前就落盘的链接」,它没有 run 可指。
566
+ const legacy = new DatabaseSync(dbPath);
567
+ legacy.exec("CREATE TABLE memories (" +
568
+ "id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, " +
569
+ "tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3, " +
570
+ "forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, " +
571
+ "source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);");
572
+ legacy.exec("CREATE TABLE memory_provenance (" +
573
+ "memory_id TEXT NOT NULL, session_id TEXT NOT NULL, " +
574
+ "seq_start INTEGER NOT NULL, seq_end INTEGER NOT NULL, created_at TEXT NOT NULL);");
575
+ legacy.exec("INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at) " +
576
+ "VALUES ('old1', 'history', '老记忆', '内容', '[]', 3, 0, 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');");
577
+ legacy.exec("INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) " +
578
+ "VALUES ('old1', 's-old', 1, 5, '2026-01-02T00:00:00.000Z');");
579
+ legacy.close();
580
+
581
+ const store = createStore(dbPath);
582
+ const cols = store.db.prepare("PRAGMA table_info(memory_provenance)").all().map((c) => c.name);
583
+ assert.ok(cols.includes("distill_run_id"), "存量库重开即补上 distill_run_id");
584
+ for (const table of ["distill_runs", "memory_mints"]) {
585
+ const row = store.db.prepare(
586
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?"
587
+ ).get(table);
588
+ assert.ok(row, "存量库重开即建 " + table);
589
+ }
590
+
591
+ const links = store.provenanceFor("old1");
592
+ assert.equal(links.length, 1);
593
+ assert.equal(
594
+ links[0].distill_run_id,
595
+ null,
596
+ "历史链接保持 NULL:反推一个合成 run id 比不填更坏(读数正常、实际是猜的)"
597
+ );
598
+ assert.equal(store.mintsFor("old1").length, 0, "历史记忆不补铸造记录——那时根本没有 run 概念");
599
+ store.close();
600
+ } finally {
601
+ rmSync(dir, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ test("#512 迁移幂等:重开已升级的库不重复 ALTER、不动已落盘的 run 归因", () => {
606
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run2-"));
607
+ const dbPath = join(dir, "upgraded.db");
608
+ try {
609
+ const first = createStore(dbPath);
610
+ const m = first.save({ type: "history", title: "t", content: "c" });
611
+ first.saveDistillRun({
612
+ id: "run-a",
613
+ status: "ok",
614
+ run_type: "autoSummarize",
615
+ trigger_source: "autoSummarize",
616
+ session_id: "s1",
617
+ seq_start: 1,
618
+ seq_end: 2,
619
+ memory_count: 1
620
+ });
621
+ first.saveMemoryProvenance({
622
+ memoryId: m.id, sessionId: "s1", seqStart: 1, seqEnd: 2,
623
+ createdAt: "2026-11-01T00:00:00.000Z", distillRunId: "run-a"
624
+ });
625
+ first.close();
626
+
627
+ const second = createStore(dbPath);
628
+ const cols = second.db.prepare("PRAGMA table_info(memory_provenance)")
629
+ .all().filter((c) => c.name === "distill_run_id");
630
+ assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
631
+ assert.equal(second.provenanceFor(m.id)[0].distill_run_id, "run-a", "重开不动已落盘的归因");
632
+ assert.equal(second.getDistillRun("run-a").status, "ok");
633
+ second.close();
634
+ } finally {
635
+ rmSync(dir, { recursive: true, force: true });
636
+ }
637
+ });
638
+
639
+ test("#512 distill_runs:同 id replay 覆写而不重复", () => {
640
+ const store = openMemory();
641
+ store.saveDistillRun({
642
+ id: "r1", status: "failed", error: "boom",
643
+ run_type: "autoSummarize", trigger_source: "autoSummarize",
644
+ session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 0,
645
+ created_at: "2026-11-01T00:00:00.000Z"
646
+ });
647
+ // 同一轮重放(等价于宿主重启后重试同一逻辑轮):必须覆写,不能变成两行。
648
+ store.saveDistillRun({
649
+ id: "r1", status: "ok",
650
+ run_type: "autoSummarize", trigger_source: "autoSummarize",
651
+ session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 2,
652
+ created_at: "2026-11-01T00:00:00.000Z"
653
+ });
654
+ const runs = store.listDistillRuns();
655
+ assert.equal(runs.length, 1, "一轮只有一行");
656
+ assert.equal(runs[0].status, "ok", "replay 覆写成最新的事实");
657
+ assert.equal(runs[0].error, undefined, "覆写清掉上一次的错误");
658
+ assert.equal(runs[0].memory_count, 2);
659
+ assert.equal(runs[0].audit_id, undefined, "没有 audit 行时是 undefined,不是 0");
660
+ store.close();
661
+ });
662
+
663
+ test("#512 distill_runs:audit_id 事后补注,补不存在的 run 静默返回 0", () => {
664
+ const store = openMemory();
665
+ store.saveDistillRun({ id: "r2", status: "ok", run_type: "autoSummarize", trigger_source: "autoSummarize" });
666
+ assert.equal(store.attachDistillRunAudit("r2", 42), 1);
667
+ assert.equal(store.getDistillRun("r2").audit_id, 42);
668
+ assert.equal(store.attachDistillRunAudit("missing", 42), 0, "行不存在时不动任何东西");
669
+ assert.equal(store.attachDistillRunAudit("r2", null), 0, "非整数 audit id 一律不写");
670
+ store.close();
671
+ });
672
+
673
+ test("#512 memory_mints:同一 (记忆, run) 只记一次,删记忆时显式清掉归因", () => {
674
+ const store = openMemory();
675
+ const m = store.save({ type: "pattern", title: "抽出来的规律", content: "c", source: "dream" });
676
+ const mint = { memoryId: m.id, runId: "run-d", runType: "dream", triggerSource: "autoDream", createdAt: "2026-11-01T00:00:00.000Z" };
677
+ assert.equal(store.saveMemoryMint(mint), 1);
678
+ assert.equal(store.saveMemoryMint(mint), 0, "INSERT OR IGNORE:重放不重复记一次铸造");
679
+ const rows = store.mintsFor(m.id);
680
+ assert.equal(rows.length, 1);
681
+ assert.equal(rows[0].run_type, "dream");
682
+ assert.equal(rows[0].trigger_source, "autoDream");
683
+
684
+ store.remove(m.id);
685
+ assert.equal(
686
+ store.db.prepare("SELECT count(*) AS c FROM memory_mints WHERE memory_id = ?").get(m.id).c,
687
+ 0,
688
+ "记忆没了,归因不许悬空(CASCADE 之外仍显式清,与 memory_provenance 同一口径)"
689
+ );
690
+ store.close();
691
+ });
692
+
693
+ // ============================ #493 补充:失败计数持久化(读侧失效)
694
+ // 这张表只服务一道默认关的护栏(summarizeParseFailureMaxRetries),但它的语义有三处
695
+ // 容易写错、且错法都不可见:计数不落库(重启即清零)、失效不在读侧(几周前的失败
696
+ // 直接熔断当前窗口)、清零写成 0(「从没失败过」与「刚清零」变得不可区分)。
697
+ test("#493 补充:解析失败计数持久化、读侧带失效窗口、清零是真 DELETE", () => {
698
+ const store = openMemory();
699
+ const sid = "s-streak";
700
+ assert.equal(store.getDistillParseFailureStreak(sid), 0, "没有记录就是 0,不是 undefined");
701
+
702
+ assert.equal(store.bumpDistillParseFailureStreak(sid), 1);
703
+ assert.equal(store.bumpDistillParseFailureStreak(sid), 2, "累计加一");
704
+ assert.equal(store.getDistillParseFailureStreak(sid), 2);
705
+
706
+ // 读侧失效:时间戳挪到 30 天前,7 天窗口下必须当 0(不判失效时它仍是 2)。
707
+ const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
708
+ store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, sid);
709
+ assert.equal(store.getDistillParseFailureStreak(sid), 2, "staleAfterMs 缺省不判失效(调用方显式要才判)");
710
+ assert.equal(store.getDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
711
+ "过期的 streak 必须读成 0:拿几周前的失败去熔断当前窗口,护栏就成了新的误伤路径");
712
+
713
+ // 失效之后再加一,必须从 1 起(不是在旧值上 +1)——这条是本块实现的首版 bug,
714
+ // 被 summarize.test.js 的端到端用例抓出来:SQL 里写 streak + 1 会让失效判断失效。
715
+ assert.equal(store.bumpDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 1,
716
+ "过期的旧行不许把「新的第一次失败」加成 2");
717
+
718
+ // 清零是真删除:没有任何在途失败时不该留一行 0。
719
+ assert.equal(store.clearDistillParseFailureStreak(sid), 1);
720
+ assert.equal(store.getDistillParseFailureStreak(sid), 0);
721
+ assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_parse_failures").get().c, 0,
722
+ "清零是 DELETE,不是写 0——写 0 会让「从没失败过」与「刚清零」不可区分");
723
+ store.close();
724
+ });