@modusensus/dsh-mneme 0.12.0 → 0.13.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 (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
@@ -5,6 +5,7 @@ import { Config } from "../src/config.js";
5
5
  import { createStore } from "../src/store.js";
6
6
  import { createService } from "../src/service.js";
7
7
  import { createVectorIndex } from "../src/vector-index.js";
8
+ import { parseReceipt } from "../src/dream.js";
8
9
 
9
10
  // Mock embedder: every query maps to [1,0,0] so vectors are identical unless a
10
11
  // test pre-seeds a custom vector via vectorIndex.saveEmbedding.
@@ -818,3 +819,600 @@ test("issue#536 review ⑤: a batch that throws does not take the rest of the ru
818
819
  assert.equal(result.phases.conflicts.batches, 2, "抛错的批次不吞掉轮次的形状");
819
820
  store.close();
820
821
  });
822
+
823
+ // ------------------------------------------------------------ affective (#619)
824
+
825
+ // Only the affective phase gets a real answer; every other LLM phase says "[]"
826
+ // so this file keeps testing one thing at a time.
827
+ function affectiveCtx(payload) {
828
+ return {
829
+ logger: { warn: () => {}, info: () => {} },
830
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "affective-model" }) },
831
+ llm: {
832
+ async *stream(options) {
833
+ const reply = options.purpose === "sleep-affective"
834
+ ? JSON.stringify(payload)
835
+ : "[]";
836
+ yield { type: "text-delta", index: 0, text: reply };
837
+ yield { type: "finish", reason: { kind: "stop" } };
838
+ }
839
+ }
840
+ };
841
+ }
842
+
843
+ function affectiveConfig(overrides = {}) {
844
+ return baseConfig({ sleepAffectiveEnabled: true, sleepPatternMinMemories: 10, ...overrides });
845
+ }
846
+
847
+ function linkSession(service, memoryId, sessionId) {
848
+ service.saveMemoryProvenance({ memoryId, sessionId, seqStart: 1, seqEnd: 2, createdAt: new Date().toISOString() });
849
+ }
850
+
851
+ test("sleep: affective phase stays off unless explicitly enabled", async () => {
852
+ const { service, store } = setup();
853
+ service.createEntity({ name: "桉桉", type: "person" });
854
+ service.createEntity({ name: "答辩", type: "concept" });
855
+ makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
856
+ const ctx = affectiveCtx([]);
857
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
858
+ assert.equal(result.phases.affective.status, "skipped");
859
+ assert.equal(result.phases.affective.reason, "disabled");
860
+ store.close();
861
+ });
862
+
863
+ test("sleep: a one-session emotion lands as affective_context, and a trait claim is downgraded", async () => {
864
+ // 这条是整个 #619 的主 claim:模型说这是「稳定特质」,但证据只覆盖一个会话
865
+ // ⇒ 必须降级成场合边,绝不能铸出「他是焦虑的人」。
866
+ const { service, store } = setup();
867
+ const person = service.createEntity({ name: "桉桉", type: "person" });
868
+ const target = service.createEntity({ name: "答辩", type: "concept" });
869
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
870
+ linkSession(service, mem.id, "session-A");
871
+ const ctx = affectiveCtx([
872
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: [mem.id], why: "提到答辩就焦虑" }
873
+ ]);
874
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
875
+ const phase = result.phases.affective;
876
+ assert.equal(phase.status, "ok");
877
+ assert.equal(phase.downgraded, 1, "trait claim on one session must be counted as downgraded");
878
+ assert.equal(phase.traits, 0, "no trait pattern minted");
879
+ assert.equal(service.all().filter((m) => m.type === "pattern").length, 0, "nothing minted as a trait");
880
+ const rels = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
881
+ assert.equal(rels.length, 1, "emotion landed as a relation instead");
882
+ assert.equal(rels[0].to_entity, target.id);
883
+ assert.equal(rels[0].source, "llm");
884
+ assert.equal(rels[0].metadata.attribution, "occasion");
885
+ assert.equal(rels[0].metadata.downgradedFrom, "trait");
886
+ store.close();
887
+ });
888
+
889
+ test("sleep: an emotion backed by enough independent sessions mints a trait pattern", async () => {
890
+ const { service, store } = setup();
891
+ service.createEntity({ name: "桉桉", type: "person" });
892
+ service.createEntity({ name: "答辩", type: "concept" });
893
+ const ids = [];
894
+ for (const session of ["s1", "s2", "s3"]) {
895
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
896
+ linkSession(service, mem.id, session);
897
+ ids.push(mem.id);
898
+ }
899
+ const ctx = affectiveCtx([
900
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次独立答辩前都这样" }
901
+ ]);
902
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
903
+ const phase = result.phases.affective;
904
+ assert.equal(phase.downgraded, 0, "three independent sessions is a real trait");
905
+ assert.equal(phase.traits, 1);
906
+ assert.equal(phase.applied, 1, "trait went through applyDecisions");
907
+ const patterns = service.all().filter((m) => m.type === "pattern");
908
+ assert.equal(patterns.length, 1, "one trait pattern minted");
909
+ assert.match(patterns[0].title, /稳定倾向/);
910
+ assert.match(patterns[0].title, /答辩/, "标题必须带对象,否则人对什么焦虑都看不出来");
911
+ assert.match(patterns[0].content, /3 个相互独立/);
912
+ store.close();
913
+ });
914
+
915
+ test("sleep: two different targets mint two patterns instead of one swallowing the other", async () => {
916
+ // #619 / Ephor 第 5 轮:标题原本不含 target,而 applyCreate 走
917
+ // saveWithDedupe,按 (type, title) 找同题行。于是「对答辩长期焦虑」与
918
+ // 「对面试长期焦虑」共用同一个标题,第二条被判为重复——不报错、不进
919
+ // failures、applied 还如实显示写入了。一条真实结论就这样静默消失,
920
+ // 正好落在本相位「降级不丢弃」这条原则的反面。
921
+ const { service, store } = setup();
922
+ service.createEntity({ name: "桉桉", type: "person" });
923
+ service.createEntity({ name: "答辩", type: "concept" });
924
+ service.createEntity({ name: "面试", type: "concept" });
925
+ const ids = { 答辩: [], 面试: [] };
926
+ for (const target of ["答辩", "面试"]) {
927
+ for (const session of ["s1", "s2", "s3"]) {
928
+ const mem = makeMemory(service, `${target}-${session}`, `第 ${session} 次${target}前都失眠`);
929
+ linkSession(service, mem.id, `${target}-${session}`);
930
+ ids[target].push(mem.id);
931
+ }
932
+ }
933
+ const ctx = affectiveCtx([
934
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids.答辩, why: "三次答辩前都这样" },
935
+ { person: "桉桉", target: "面试", emotion: "焦虑", attribution: "trait", evidence: ids.面试, why: "三次面试前都这样" }
936
+ ]);
937
+ const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 2 }), ctx.logger, { embedder, vectorIndex: null }, null);
938
+ const phase = result.phases.affective;
939
+ assert.equal(phase.traits, 2, "两条 trait 候选");
940
+ assert.equal(phase.failures.length, 0, "没有失败");
941
+ assert.equal(phase.applied, 2, "两条都真的落库了");
942
+ const patterns = service.all().filter((m) => m.type === "pattern");
943
+ assert.equal(patterns.length, 2, "两个对象各一条,第二条不许被静默合并掉");
944
+ const titles = patterns.map((m) => m.title).sort();
945
+ assert.ok(titles.some((t) => t.includes("答辩")), "答辩那条在");
946
+ assert.ok(titles.some((t) => t.includes("面试")), "面试那条也在 —— 这条以前会消失");
947
+ assert.notEqual(titles[0], titles[1], "标题必须不同,否则去重键仍会撞");
948
+ store.close();
949
+ });
950
+
951
+ test("sleep: an occasion edge is marked when the same emotion is later confirmed as a trait", async () => {
952
+ // #619 / Ephor 第 5 轮 C:先有 occasion 边,后来证据够了判成 trait。
953
+ // 边不改(它表示「这次场合观察到过」,这句仍然是真的),但要标上
954
+ // promotedToTrait——否则同一件事实在图上是「一次性」、在 pattern 里是
955
+ // 「长期」,读归因覆盖率的人拿到两份互相矛盾的记录,而这不会自愈。
956
+ const { service, store } = setup();
957
+ service.createEntity({ name: "桉桉", type: "person" });
958
+ service.createEntity({ name: "答辩", type: "concept" });
959
+ const mem0 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
960
+ linkSession(service, mem0.id, "s0");
961
+ const ctxOccasion = affectiveCtx([
962
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem0.id] }
963
+ ]);
964
+ const first = await runSleep(ctxOccasion, service, affectiveConfig(), ctxOccasion.logger, { embedder, vectorIndex: null }, null);
965
+ assert.equal(first.phases.affective.relations, 1, "先落一条 occasion 边");
966
+ const ids = [];
967
+ for (const session of ["s1", "s2", "s3"]) {
968
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
969
+ linkSession(service, mem.id, session);
970
+ ids.push(mem.id);
971
+ }
972
+ const ctxTrait = affectiveCtx([
973
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次独立答辩前都这样" }
974
+ ]);
975
+ const second = await runSleep(ctxTrait, service, affectiveConfig(), ctxTrait.logger, { embedder, vectorIndex: null }, null);
976
+ assert.equal(second.phases.affective.traits, 1, "够格铸 trait");
977
+ const person = service.findEntityByName("桉桉");
978
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
979
+ assert.equal(edges.length, 1, "仍然是同一条边");
980
+ assert.equal(edges[0].metadata.sessionCount, 1, "边的场合数不因后来升格而改写");
981
+ assert.equal(edges[0].metadata.promotedToTrait, true, "但必须标上升格过,否则两份记录互相打脸");
982
+ store.close();
983
+ });
984
+
985
+ test("sleep: unknown entities and fabricated evidence are reported in dropped, not silently discarded", async () => {
986
+ const { service, store } = setup();
987
+ service.createEntity({ name: "桉桉", type: "person" });
988
+ service.createEntity({ name: "答辩", type: "concept" });
989
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
990
+ linkSession(service, mem.id, "session-A");
991
+ const ctx = affectiveCtx([
992
+ { person: "不存在的人", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] },
993
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: ["fake-zzz"] },
994
+ { person: "桉桉", target: "答辩", emotion: "", attribution: "occasion", evidence: [mem.id] }
995
+ ]);
996
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
997
+ const phase = result.phases.affective;
998
+ assert.equal(phase.relations, 0, "nothing landed");
999
+ assert.equal(phase.dropped.length, 3, "every rejection is accounted for");
1000
+ const reasons = phase.dropped.map((d) => d.reason).sort();
1001
+ assert.deepEqual(reasons, ["no emotion", "no real evidence", "unknown entity"]);
1002
+ store.close();
1003
+ });
1004
+
1005
+ test("sleep: the affective phase never adds a related_to edge", async () => {
1006
+ // 变异自证的反面:如果情感边走的是 related_to(旧兜底行为),这条会挂。
1007
+ const { service, store } = setup();
1008
+ service.createEntity({ name: "桉桉", type: "person" });
1009
+ service.createEntity({ name: "答辩", type: "concept" });
1010
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1011
+ linkSession(service, mem.id, "session-A");
1012
+ const before = service.all().map((m) => m.id).length;
1013
+ const ctx = affectiveCtx([
1014
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1015
+ ]);
1016
+ await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1017
+ const person = service.findEntityByName("桉桉");
1018
+ const rels = service.getRelations(person.id);
1019
+ assert.equal(rels.filter((r) => r.relation_type === "related_to").length, 0, "no related_to smuggled in");
1020
+ assert.equal(rels.filter((r) => r.relation_type === "affective_context").length, 1);
1021
+ assert.equal(service.all().length, before, "no memory minted for a single-occasion emotion");
1022
+ store.close();
1023
+ });
1024
+
1025
+ test("sleep: replaying the same segment merges into the existing edge instead of stacking", async () => {
1026
+ // #619 / Ephor 第 2 条:entity_relations 没有 UNIQUE,saveRelation 每次新插。
1027
+ // 探针拿同一批段反复回放时,重复边会把 sessionCount 抬高、主指标失真。
1028
+ // 而判重不能只「丢弃」:同一场合后来又看到更多证据时,那条边上的
1029
+ // sessionCount 会永远停在第一次,将来读归因覆盖率会一直读到 1。
1030
+ const { service, store } = setup();
1031
+ service.createEntity({ name: "桉桉", type: "person" });
1032
+ service.createEntity({ name: "答辩", type: "concept" });
1033
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1034
+ linkSession(service, mem.id, "session-A");
1035
+ const ctx = affectiveCtx([
1036
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1037
+ ]);
1038
+ const first = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1039
+ assert.equal(first.phases.affective.relations, 1, "第一轮写了一条");
1040
+ const second = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1041
+ assert.equal(second.phases.affective.relations, 0, "第二轮不再新插边");
1042
+ assert.ok(second.phases.affective.merged >= 1, "第二轮认出了同一场合,走合并而不是丢弃");
1043
+ const person = service.findEntityByName("桉桉");
1044
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1045
+ assert.equal(edges.length, 1, "库里仍只有一条边");
1046
+ assert.equal(edges[0].metadata.sessionCount, 1, "同一会话不会把场合数吹大");
1047
+ assert.deepEqual(edges[0].metadata.evidence, [mem.id], "证据不变");
1048
+ store.close();
1049
+ });
1050
+
1051
+ test("sleep: two memories from one session merge into one edge, not two", async () => {
1052
+ // 同一会话里的两条记忆各说一句「他很焦虑」,证据天然不相交,但它们是
1053
+ // 同一个场合。按 evidence 交集判会各写一条,把边数与场合数一起吹大。
1054
+ const { service, store } = setup();
1055
+ service.createEntity({ name: "桉桉", type: "person" });
1056
+ service.createEntity({ name: "答辩", type: "concept" });
1057
+ const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1058
+ const m2 = makeMemory(service, "答辩前一晚", "越想越慌,睡不着");
1059
+ linkSession(service, m1.id, "session-A");
1060
+ linkSession(service, m2.id, "session-A");
1061
+ const ctx = affectiveCtx([
1062
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id] },
1063
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m2.id] }
1064
+ ]);
1065
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1066
+ assert.equal(result.phases.affective.relations, 1, "同一会话只该有一条边");
1067
+ assert.ok(result.phases.affective.merged >= 1, "第二条按同场合合并进来");
1068
+ const person = service.findEntityByName("桉桉");
1069
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1070
+ assert.equal(edges.length, 1);
1071
+ assert.equal(edges[0].metadata.sessionCount, 1, "两条记忆都在同一会话,场合数仍是 1");
1072
+ assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort(), "证据取并集");
1073
+ store.close();
1074
+ });
1075
+
1076
+ test("sleep: a later occasion extends the existing edge instead of being dropped", async () => {
1077
+ // 第一轮 1 个会话写边,第二轮攒到 2 个会话(含新会话)。如果判成重复丢掉,
1078
+ // 边上的 sessionCount 会冻结在 1;将来用它算归因覆盖率就一直读到 1。
1079
+ const { service, store } = setup();
1080
+ service.createEntity({ name: "桉桉", type: "person" });
1081
+ service.createEntity({ name: "答辩", type: "concept" });
1082
+ const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1083
+ const m2 = makeMemory(service, "第二次答辩前", "又要答辩了,还是焦虑");
1084
+ linkSession(service, m1.id, "session-A");
1085
+ linkSession(service, m2.id, "session-B");
1086
+ const first = affectiveCtx([
1087
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id] }
1088
+ ]);
1089
+ const r1 = await runSleep(first, service, affectiveConfig(), first.logger, { embedder, vectorIndex: null }, null);
1090
+ assert.equal(r1.phases.affective.relations, 1);
1091
+ const second = affectiveCtx([
1092
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id, m2.id] }
1093
+ ]);
1094
+ const r2 = await runSleep(second, service, affectiveConfig(), second.logger, { embedder, vectorIndex: null }, null);
1095
+ assert.equal(r2.phases.affective.relations, 0, "不是新边");
1096
+ assert.ok(r2.phases.affective.merged >= 1, "是合并");
1097
+ const person = service.findEntityByName("桉桉");
1098
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1099
+ assert.equal(edges.length, 1);
1100
+ assert.equal(edges[0].metadata.sessionCount, 2, "场合数跟着证据涨到 2");
1101
+ assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort());
1102
+ store.close();
1103
+ });
1104
+
1105
+ test("sleep: reordering the evidence list does not smuggle in a second edge", async () => {
1106
+ // 判重键如果只看 evidence[0],模型把 [m1, m2] 写成 [m2, m1] 就会再插一条。
1107
+ // LLM 给 evidence 的顺序本来就不稳定,parseJsonArray 也不排序——同一条边
1108
+ // 换个顺序就重复写,sessionCount 会被抬得比真实场合数高,正好毁掉主指标。
1109
+ const { service, store } = setup();
1110
+ service.createEntity({ name: "桉桉", type: "person" });
1111
+ service.createEntity({ name: "答辩", type: "concept" });
1112
+ const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1113
+ const m2 = makeMemory(service, "答辩前一晚", "越想越慌,睡不着");
1114
+ linkSession(service, m1.id, "session-A");
1115
+ linkSession(service, m2.id, "session-A");
1116
+ const forward = affectiveCtx([
1117
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id, m2.id] }
1118
+ ]);
1119
+ const first = await runSleep(forward, service, affectiveConfig(), forward.logger, { embedder, vectorIndex: null }, null);
1120
+ assert.equal(first.phases.affective.relations, 1, "第一轮写了一条");
1121
+ const reversed = affectiveCtx([
1122
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m2.id, m1.id] }
1123
+ ]);
1124
+ const second = await runSleep(reversed, service, affectiveConfig(), reversed.logger, { embedder, vectorIndex: null }, null);
1125
+ assert.equal(second.phases.affective.relations, 0, "只换了证据顺序,不能再写一条");
1126
+ assert.ok(second.phases.affective.merged >= 1, "认成同一场合,走合并");
1127
+ const person = service.findEntityByName("桉桉");
1128
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1129
+ assert.equal(edges.length, 1, "库里仍只有一条边");
1130
+ assert.equal(edges[0].metadata.sessionCount, 1, "同一会话,场合数仍是 1");
1131
+ assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort(), "并集后证据不多不少");
1132
+ store.close();
1133
+ });
1134
+
1135
+ test("sleep: two emotions in the same memory are two edges, not a duplicate", async () => {
1136
+ // 判重键如果不带情绪,「答辩前又焦虑又期待」里的第二条会被当成重复丢掉,
1137
+ // dropped 里还写着 duplicate edge——读起来像「本来就重复」,实际是丢了一条
1138
+ // 真信息。本相位的承诺是降级不丢弃,重复和丢弃不能混成一个理由。
1139
+ const { service, store } = setup();
1140
+ service.createEntity({ name: "桉桉", type: "person" });
1141
+ service.createEntity({ name: "答辩", type: "concept" });
1142
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他又焦虑又有点期待");
1143
+ linkSession(service, mem.id, "session-A");
1144
+ const ctx = affectiveCtx([
1145
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] },
1146
+ { person: "桉桉", target: "答辩", emotion: "期待", attribution: "occasion", evidence: [mem.id] }
1147
+ ]);
1148
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1149
+ assert.equal(result.phases.affective.relations, 2, "两种情绪各写一条");
1150
+ assert.equal(result.phases.affective.dropped.filter((d) => d.reason === "duplicate edge").length, 0, "不许把第二种情绪当重复");
1151
+ const person = service.findEntityByName("桉桉");
1152
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1153
+ assert.deepEqual(edges.map((r) => r.metadata.emotion).sort(), ["期待", "焦虑"], "两条边各带自己的情绪词");
1154
+ store.close();
1155
+ });
1156
+
1157
+ test("sleep: an edge write shows up in the receipt instead of reading as zero", async () => {
1158
+ // #619 / Ephor: receipt 的倒数第二位是 applied,而 affective 相位写边走
1159
+ // saveRelation、不进 applied。不改的话,一轮明明写了边,审计行却是 `:0`——
1160
+ // 「跑了但什么都没做」,探针会把有写入的轮次记成空轮。
1161
+ const { service, store } = setup();
1162
+ service.createEntity({ name: "桉桉", type: "person" });
1163
+ service.createEntity({ name: "答辩", type: "concept" });
1164
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1165
+ linkSession(service, mem.id, "session-A");
1166
+ const ctx = affectiveCtx([
1167
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1168
+ ]);
1169
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1170
+ assert.equal(result.phases.affective.relations, 1, "恰好一条边落地,否则这条用例没在验东西");
1171
+ assert.equal(result.phases.affective.applied, 0, "边不是 decision,applied 仍是 0");
1172
+ const parts = result.receipt.split(":");
1173
+ assert.equal(parts.length, 8, "receipt 仍是八段");
1174
+ // 段序:dsh-mneme / run / runId / status / snapshotHash / inputCount / applied / summaryFlag
1175
+ assert.equal(parts[6], "1", "写入计数必须非零,读成 0 等于谎报本轮没做过事");
1176
+ store.close();
1177
+ });
1178
+
1179
+ test("sleep: a merge-only run also shows up in the receipt", async () => {
1180
+ // #619 / Ephor 第 4 轮:只合并的那一轮 relations 与 edgeWrites 都是 0,
1181
+ // 但它确实改了库(证据并集、sessionCount 重算)。receipt 若读成 `:0`,
1182
+ // 就是上一处修过的同一个谎——只是换了个形状。合并也算写。
1183
+ const { service, store } = setup();
1184
+ service.createEntity({ name: "桉桉", type: "person" });
1185
+ service.createEntity({ name: "答辩", type: "concept" });
1186
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1187
+ linkSession(service, mem.id, "session-A");
1188
+ const ctx = affectiveCtx([
1189
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1190
+ ]);
1191
+ const first = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1192
+ assert.equal(first.receipt.split(":")[6], "1", "第一轮新写边,计数 1");
1193
+ const second = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1194
+ assert.equal(second.phases.affective.relations, 0, "第二轮没有新边");
1195
+ assert.equal(second.phases.affective.edgeWrites, 0, "第二轮没有新写");
1196
+ assert.equal(second.phases.affective.merged, 1, "第二轮走的是合并");
1197
+ assert.equal(second.phases.affective.status, "ok", "合并过就是 ok,不是 noop");
1198
+ const parts = second.receipt.split(":");
1199
+ assert.equal(parts.length, 8, "receipt 仍是八段");
1200
+ assert.equal(parts[6], "1", "只合并的一轮同样改了库,必须非零");
1201
+ store.close();
1202
+ });
1203
+
1204
+ test("sleep: an aborted affective phase reports aborted, not ok", async () => {
1205
+ // 中途被打断且已经写了边,既不能报 ok(没跑完),也不能报 noop(确实写了)。
1206
+ const { service, store } = setup();
1207
+ service.createEntity({ name: "桉桉", type: "person" });
1208
+ service.createEntity({ name: "答辩", type: "concept" });
1209
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1210
+ linkSession(service, mem.id, "session-A");
1211
+ const controller = new AbortController();
1212
+ const ctx = affectiveCtx([
1213
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1214
+ ]);
1215
+ const realStream = ctx.llm.stream;
1216
+ ctx.llm.stream = async function* (options) {
1217
+ yield* realStream.call(this, options);
1218
+ // 模型答完、写边之前用户回到电脑前
1219
+ if (options.purpose === "sleep-affective") controller.abort();
1220
+ };
1221
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, controller.signal);
1222
+ const phase = result.phases.affective;
1223
+ assert.equal(phase.status, "aborted", "打断不是 ok");
1224
+ assert.equal(phase.reason, "user activity");
1225
+ assert.ok(phase.edgeWrites >= 0, "打断也把已写的条数报出来");
1226
+ // 明细字段在每条返回路径上都得在。只有正常的返回带 mergedList 的话,
1227
+ // 消费方在 aborted/failed 两条路径上读明细会静默拿到 undefined,不报错。
1228
+ assert.ok(Array.isArray(phase.mergedList), "aborted 分支也要带 mergedList,缺了是静默失明");
1229
+ store.close();
1230
+ });
1231
+
1232
+ test("sleep: a trait that clears the threshold mints exactly one pattern and counts it", async () => {
1233
+ // 这条与上一条配对:同样的三会话证据,上一条说「够格就铸」,这条看「铸了几个、
1234
+ // 计数是否如实」。Ephor 第 4 条的 `if (ok)` 缺 else 已补成 failed 分支,但那条
1235
+ // 分支在合法决策下不可达(决策是我们按合法形状拼的),不写假用例假装测过它。
1236
+ const { service, store } = setup();
1237
+ service.createEntity({ name: "桉桉", type: "person" });
1238
+ service.createEntity({ name: "答辩", type: "concept" });
1239
+ const ids = [];
1240
+ for (const session of ["s1", "s2", "s3"]) {
1241
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1242
+ linkSession(service, mem.id, session);
1243
+ ids.push(mem.id);
1244
+ }
1245
+ const ctx = affectiveCtx([
1246
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1247
+ ]);
1248
+ const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1249
+ const phase = result.phases.affective;
1250
+ assert.equal(phase.status, "ok");
1251
+ assert.equal(phase.traits, 1, "识别出一个 trait");
1252
+ assert.equal(phase.applied, 1, "它真的被铸出来了");
1253
+ store.close();
1254
+ });
1255
+
1256
+ test("sleep: replaying the same trait rewrites the pattern instead of growing it", async () => {
1257
+ // #619 / Ephor 第 6 轮:trait 标题由模板拼成,同一 (人, 对象, 情绪) 天然同题。
1258
+ // applyCreate 走 saveWithDedupe,命中同题活跃行时默认 appendContent——把同一段
1259
+ // 结论再粘一遍。pattern 是注入面消费的东西,重放三轮正文就从 161 字涨到 549 字,
1260
+ // 还带一串重复的 [证据: …] 尾巴。修法:pattern 走 `_overwrite`,原地刷新。
1261
+ const { service, store } = setup();
1262
+ service.createEntity({ name: "桉桉", type: "person" });
1263
+ service.createEntity({ name: "答辩", type: "concept" });
1264
+ const ids = [];
1265
+ for (const session of ["s1", "s2", "s3"]) {
1266
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1267
+ linkSession(service, mem.id, session);
1268
+ ids.push(mem.id);
1269
+ }
1270
+ const payload = (ctx) => ctx;
1271
+ const lengths = [];
1272
+ for (let round = 0; round < 3; round += 1) {
1273
+ const ctx = affectiveCtx([
1274
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1275
+ ]);
1276
+ void payload(ctx);
1277
+ await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1278
+ const patterns = service.all().filter((m) => m.type === "pattern");
1279
+ assert.equal(patterns.length, 1, `round ${round}: 仍然只有一行 pattern`);
1280
+ lengths.push(patterns[0].content.length);
1281
+ }
1282
+ assert.equal(lengths[1], lengths[0], "第二轮不许把正文再粘一遍");
1283
+ assert.equal(lengths[2], lengths[0], "第三轮同样");
1284
+ const pattern = service.all().find((m) => m.type === "pattern");
1285
+ assert.ok(!pattern.content.includes("---"), "没有 appendContent 的分隔符");
1286
+ assert.equal((pattern.content.match(/\[证据/g) ?? []).length, 1, "证据尾巴只有一条");
1287
+ store.close();
1288
+ });
1289
+
1290
+ test("sleep: a refresh-only trait run reports refreshed, not ok with zero mints", async () => {
1291
+ // 同一批证据重放:第二轮没铸新行,只是把既有 pattern 原地刷新。这时若报
1292
+ // status:"ok" + applied:0,读审计的人会以为「说明这一轮没东西」,而实际上是
1293
+ // 一次真实写入;若报 noop,又等于说整池看过了什么都没有。给它自己的名字。
1294
+ const { service, store } = setup();
1295
+ service.createEntity({ name: "桉桉", type: "person" });
1296
+ service.createEntity({ name: "答辩", type: "concept" });
1297
+ const ids = [];
1298
+ for (const session of ["s1", "s2", "s3"]) {
1299
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1300
+ linkSession(service, mem.id, session);
1301
+ ids.push(mem.id);
1302
+ }
1303
+ const payload = [
1304
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1305
+ ];
1306
+ const first = await (async () => {
1307
+ const ctx = affectiveCtx(payload);
1308
+ return runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1309
+ })();
1310
+ assert.equal(first.phases.affective.status, "ok", "第一轮真的铸了");
1311
+ assert.equal(first.phases.affective.applied, 1);
1312
+ const second = await (async () => {
1313
+ const ctx = affectiveCtx(payload);
1314
+ return runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1315
+ })();
1316
+ const phase = second.phases.affective;
1317
+ assert.equal(phase.applied, 0, "第二轮没有新铸");
1318
+ assert.equal(phase.refreshedTraits, 1, "刷新了一条");
1319
+ assert.equal(phase.status, "refreshed", "刷新不是 noop,也不是一次新铸");
1320
+ assert.equal(second.status, "ok", "整轮仍算有产出");
1321
+ // #619 / Ephor 第七轮 ①:相位说「改了」,整轮 receipt 也必须说「改了」。此前
1322
+ // totalApplied 只加 applied + edgeWrites + merged,refreshedTraits 不在表里,
1323
+ // 于是这一轮 receipt 第 7 段是 0——「改了库读成 0」,正是上一轮刚修掉的形态。
1324
+ const parsed = parseReceipt(second.receipt);
1325
+ assert.ok(parsed, `receipt 必须可解析(实测 ${second.receipt})`);
1326
+ assert.equal(parsed.applied, 1, "刷新一条要算一次真实写入,不能被读成 0");
1327
+ // ③ 同一个场景:刷新不是出生,不该再往 memory_mints 添一行。
1328
+ const pattern = service.all().find((m) => m.type === "pattern");
1329
+ assert.equal(store.mintsFor(pattern.id).length, 1, "两轮下来只有一条出生记录");
1330
+ store.close();
1331
+ });
1332
+
1333
+ test("sleep: the affective prompt does not corrupt JSON while filling the threshold", async () => {
1334
+ // 占位符原来是一个裸的 `N`,而 prompt 正文里还写着「只输出 JSON 数组」。
1335
+ // replaceAll("N", …) 于是把 JSON 换成了 JSO3——模型收到的是「只输出 JSO3 数组」,
1336
+ // 而这一相位的唯一出口就是那个数组。这不是理论风险:zh 与 en 两版都中。
1337
+ const { service, store } = setup();
1338
+ service.createEntity({ name: "桉桉", type: "person" });
1339
+ service.createEntity({ name: "答辩", type: "concept" });
1340
+ makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
1341
+ const seen = [];
1342
+ const ctx = {
1343
+ logger: { warn: () => {}, info: () => {} },
1344
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "affective-model" }) },
1345
+ llm: {
1346
+ async *stream(options) {
1347
+ if (options.purpose === "sleep-affective") {
1348
+ const system = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
1349
+ seen.push(system);
1350
+ }
1351
+ yield { type: "text-delta", index: 0, text: "[]" };
1352
+ yield { type: "finish", reason: { kind: "stop" } };
1353
+ }
1354
+ }
1355
+ };
1356
+ await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1357
+ assert.equal(seen.length, 1, "情感相位确实调了一次模型");
1358
+ const system = seen[0];
1359
+ assert.ok(!system.includes("JSO3"), `prompt 里的 JSON 被阈值替换吃掉了:${system.slice(0, 200)}`);
1360
+ assert.ok(system.includes("JSON"), "「只输出 JSON 数组」这句必须原样到达模型");
1361
+ assert.ok(!system.includes("{{MIN_SESSIONS}}"), "占位符必须被填掉,不能把字面量发给模型");
1362
+ assert.ok(system.includes("3"), "阈值默认 3 要出现在 prompt 里");
1363
+ store.close();
1364
+ });
1365
+
1366
+ test("sleep: evidence without provenance still counts as the same occasion", async () => {
1367
+ // memory_save 写下的记忆没有 provenance,sessionsFor 返回空集。纯会话判据在
1368
+ // 这种情况下永远判不出「同一场合」,同一批证据会在每一轮 sleep 里再写一条边,
1369
+ // 把 sessionCount 抬得比真实场合数高。落不着会话键时退回 evidence 交集。
1370
+ const { service, store } = setup();
1371
+ const person = service.createEntity({ name: "桉桉", type: "person" });
1372
+ const target = service.createEntity({ name: "答辩", type: "concept" });
1373
+ const mem = makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
1374
+ const payload = [
1375
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id], why: "就这一次" }
1376
+ ];
1377
+ const run = () => {
1378
+ const ctx = affectiveCtx(payload);
1379
+ return runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1380
+ };
1381
+ const first = await run();
1382
+ assert.equal(first.phases.affective.relations, 1, "第一轮写了一条边");
1383
+ const second = await run();
1384
+ assert.equal(second.phases.affective.relations, 0, "第二轮不许再写一条");
1385
+ assert.equal(second.phases.affective.merged, 1, "认成同一场合,合并进既有边");
1386
+ const rels = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1387
+ assert.equal(rels.length, 1, "库里仍只有一条");
1388
+ assert.equal(rels[0].to_entity, target.id);
1389
+ store.close();
1390
+ });
1391
+
1392
+ test("sleep: trait candidates pushed out by the cap are recorded as dropped", async () => {
1393
+ // traits.slice(0, maxTraits) 把超出的候选截掉,它们既不铸、也不降级、也不进
1394
+ // dropped,而 traits 仍把它们算进候选数——读者会以为都处理过了。与边的
1395
+ // `relation cap` 同一口径记一笔。
1396
+ const { service, store } = setup();
1397
+ service.createEntity({ name: "桉桉", type: "person" });
1398
+ const payload = [];
1399
+ for (const [i, target] of ["答辩", "面试", "汇报", "截止日期"].entries()) {
1400
+ service.createEntity({ name: target, type: "concept" });
1401
+ const ids = [];
1402
+ for (const session of ["s1", "s2", "s3"]) {
1403
+ const mem = makeMemory(service, `${target}-${session}-${i}`, `第 ${session} 次${target}前都失眠`);
1404
+ linkSession(service, mem.id, `${target}-${session}`);
1405
+ ids.push(mem.id);
1406
+ }
1407
+ payload.push({ person: "桉桉", target, emotion: "焦虑", attribution: "trait", evidence: ids, why: `三次${target}前都这样` });
1408
+ }
1409
+ const ctx = affectiveCtx(payload);
1410
+ const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 2, sleepPatternMinMemories: 20 }), ctx.logger, { embedder, vectorIndex: null }, null);
1411
+ const phase = result.phases.affective;
1412
+ assert.equal(phase.traits, 4, "四条 trait 候选都被识别出来");
1413
+ assert.equal(phase.applied, 2, "上限内两条落库");
1414
+ const capped = phase.dropped.filter((d) => d.reason === "trait cap");
1415
+ assert.equal(capped.length, 2, "被上限截掉的两条必须记账,不能静默消失");
1416
+ assert.ok(capped.every((d) => typeof d.target === "string" && d.target), "每条都要能看出是哪条结论被截");
1417
+ store.close();
1418
+ });
@@ -531,6 +531,30 @@ test("GET /memories supports include_archived and the occurred_at window", async
531
531
  }
532
532
  });
533
533
 
534
+ test("GET /memories carries typeCounts: the full distribution without the type filter (#597)", async () => {
535
+ const { base, auth, service, close } = await setup();
536
+ try {
537
+ service.saveWithDedupe({ type: "preference", title: "p", content: "c" });
538
+ service.saveWithDedupe({ type: "project", title: "j1", content: "c" });
539
+ service.saveWithDedupe({ type: "project", title: "j2", content: "c" });
540
+
541
+ // total 跟着 type 筛;typeCounts 是同一份筛选、**不带 type** 的分布——
542
+ // 选中一类时其它类的数字仍在(面板分类栏不再塌剩两枚)。
543
+ const filtered = await (await fetch(`${base}/memories?type=project`, { headers: auth })).json();
544
+ assert.equal(filtered.total, 2, "total honors the type filter");
545
+ assert.deepEqual(filtered.typeCounts, { preference: 1, project: 2 }, "distribution ignores the type filter");
546
+
547
+ const all = await (await fetch(`${base}/memories`, { headers: auth })).json();
548
+ assert.equal(
549
+ Object.keys(all.typeCounts).reduce((n, k) => n + all.typeCounts[k], 0),
550
+ all.total,
551
+ "distribution sums to the unfiltered total"
552
+ );
553
+ } finally {
554
+ close();
555
+ }
556
+ });
557
+
534
558
  test("GET /search honors the occurred_at window", async () => {
535
559
  const { base, auth, close } = await setup();
536
560
  try {