recallnest 3.0.0 → 4.0.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 (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -1,252 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
-
3
- import {
4
- CLUSTER_INSIGHT_PROMPT,
5
- CLUSTER_PATTERN_PROMPT,
6
- PROMPT_ECHO_MARKERS,
7
- SYNTHESIS_MAX_LEN,
8
- allocateClusterBudget,
9
- numberClusterTexts,
10
- parseSynthesisJson,
11
- validateSynthesis,
12
- } from "../synthesis-contract.js";
13
-
14
- // ---------------------------------------------------------------------------
15
- // 输入预算分配(R3:老路径 slice(0,2000) 让 64.2% 的簇被截,p10 只有 13.8% 的源进得了模型)
16
- // ---------------------------------------------------------------------------
17
-
18
- describe("allocateClusterBudget", () => {
19
- it("总量不超预算", () => {
20
- const texts = Array.from({ length: 20 }, () => "x".repeat(5000));
21
- const out = allocateClusterBudget(texts, 1000);
22
- expect(out.reduce((s, t) => s + t.length, 0)).toBeLessThanOrEqual(1000);
23
- });
24
-
25
- it("短成员整条保留,长成员才被截", () => {
26
- const out = allocateClusterBudget(["短", "y".repeat(999)], 100);
27
- expect(out[0]).toBe("短");
28
- expect(out[1].length).toBeLessThan(999);
29
- });
30
-
31
- it("短成员用不完的份额让给长成员,而不是浪费掉", () => {
32
- // 均分是 50/50;第一条只用 1 个字符,第二条应拿到远超 50 的份额。
33
- const out = allocateClusterBudget(["短", "y".repeat(999)], 100);
34
- expect(out[1].length).toBeGreaterThan(50);
35
- });
36
-
37
- it("靠后的成员不会被整条丢掉——这正是老 slice(0,2000) 的失效形态", () => {
38
- // 老路径把 168 条拼成一条长文本再从头切,排在后面的成员一个字都进不了模型。
39
- const texts = Array.from({ length: 50 }, (_, i) => `第${i}条`.repeat(200));
40
- const out = allocateClusterBudget(texts, 2000);
41
- expect(out.length).toBe(50);
42
- expect(out.every(t => t.length > 0)).toBe(true);
43
- // 最后一条必须真的携带自己的内容,而不是空壳
44
- expect(out[49]).toContain("第49条");
45
- });
46
-
47
- it("空簇返回空数组", () => {
48
- expect(allocateClusterBudget([], 100)).toEqual([]);
49
- });
50
- });
51
-
52
- describe("numberClusterTexts", () => {
53
- it("编号从 1 开始,与 evidence 的语义一致", () => {
54
- expect(numberClusterTexts(["a", "b"])).toBe("[1] a\n[2] b");
55
- });
56
- });
57
-
58
- // ---------------------------------------------------------------------------
59
- // JSON 解析
60
- // ---------------------------------------------------------------------------
61
-
62
- describe("parseSynthesisJson", () => {
63
- it("裸 JSON", () => {
64
- expect(parseSynthesisJson('{"has":false}')).toEqual({ has: false });
65
- });
66
-
67
- it("markdown 围栏", () => {
68
- expect(parseSynthesisJson('```json\n{"has":false}\n```')).toEqual({ has: false });
69
- });
70
-
71
- it("带前言的回复也能抠出对象", () => {
72
- expect(parseSynthesisJson('好的,结果如下:\n{"has":false}\n以上。')).toEqual({ has: false });
73
- });
74
-
75
- it("拿不到 JSON 时返回 null 而不是抛异常", () => {
76
- expect(parseSynthesisJson("完全不是 JSON")).toBeNull();
77
- expect(parseSynthesisJson(null)).toBeNull();
78
- });
79
-
80
- it("顶层是数组不算合法(契约要的是对象)", () => {
81
- expect(parseSynthesisJson("[1,2,3]")).toBeNull();
82
- });
83
- });
84
-
85
- // ---------------------------------------------------------------------------
86
- // 写库前校验 —— 每条 reject 分支都要有一条测试撑着,
87
- // 因为这一层的全部意义就是「LLM 说了什么不算数」。
88
- // ---------------------------------------------------------------------------
89
-
90
- const base = { textField: "conclusion", memberCount: 5, minEvidence: 1 };
91
-
92
- describe("validateSynthesis", () => {
93
- it("合格输出:去重 + 升序", () => {
94
- const v = validateSynthesis({
95
- ...base,
96
- parsed: { has: true, conclusion: "必须先初始化再分派,否则读到的是初始化前的值", evidence: [3, 1, 3] },
97
- });
98
- expect(v.status).toBe("ok");
99
- if (v.status === "ok") {
100
- expect(v.output.evidence).toEqual([1, 3]);
101
- expect(v.output.text).toContain("必须先初始化");
102
- }
103
- });
104
-
105
- it("has:false 是弃权,不是失败——这条路径老 generateL0 根本没有", () => {
106
- expect(validateSynthesis({ ...base, parsed: { has: false } }).status).toBe("abstained");
107
- });
108
-
109
- it("解析不出 JSON → unparsable", () => {
110
- const v = validateSynthesis({ ...base, parsed: null });
111
- expect(v).toEqual({ status: "rejected", reason: "unparsable" });
112
- });
113
-
114
- it("has 不是 boolean → 拒绝(不猜它的意图)", () => {
115
- for (const has of ["true", 1, null, undefined]) {
116
- const v = validateSynthesis({ ...base, parsed: { has, conclusion: "随便什么", evidence: [1] } });
117
- expect(v).toEqual({ status: "rejected", reason: "flag-not-boolean" });
118
- }
119
- });
120
-
121
- it("说有结论却没给正文 → empty-text", () => {
122
- expect(validateSynthesis({ ...base, parsed: { has: true, evidence: [1] } }))
123
- .toEqual({ status: "rejected", reason: "empty-text" });
124
- expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: " ", evidence: [1] } }))
125
- .toEqual({ status: "rejected", reason: "empty-text" });
126
- });
127
-
128
- it("长度越界两侧都拒", () => {
129
- expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "太短", evidence: [1] } }))
130
- .toEqual({ status: "rejected", reason: "too-short" });
131
- expect(validateSynthesis({
132
- ...base,
133
- parsed: { has: true, conclusion: "长".repeat(SYNTHESIS_MAX_LEN + 1), evidence: [1] },
134
- })).toEqual({ status: "rejected", reason: "too-long" });
135
- });
136
-
137
- it("evidence 缺失或不是数组 → evidence-missing", () => {
138
- expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本" } }))
139
- .toEqual({ status: "rejected", reason: "evidence-missing" });
140
- expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: "1,2" } }))
141
- .toEqual({ status: "rejected", reason: "evidence-missing" });
142
- });
143
-
144
- it("编号全部越界 → evidence-out-of-range(模型在编号,不是证据不够)", () => {
145
- const v = validateSynthesis({
146
- ...base,
147
- parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: [99, 0, -1] },
148
- });
149
- expect(v).toEqual({ status: "rejected", reason: "evidence-out-of-range" });
150
- });
151
-
152
- it("pattern 侧只给 1 个合法编号 → evidence-too-few(跨条目按定义要 2 条)", () => {
153
- const v = validateSynthesis({
154
- parsed: { has: true, pattern: "一条长度足够越过下限的跨条目结论文本", evidence: [2] },
155
- textField: "pattern",
156
- memberCount: 5,
157
- minEvidence: 2,
158
- });
159
- expect(v).toEqual({ status: "rejected", reason: "evidence-too-few" });
160
- });
161
-
162
- it("重复编号不算两条证据([2,2] 去重后只剩 1 个)", () => {
163
- const v = validateSynthesis({
164
- parsed: { has: true, pattern: "一条长度足够越过下限的跨条目结论文本", evidence: [2, 2] },
165
- textField: "pattern",
166
- memberCount: 5,
167
- minEvidence: 2,
168
- });
169
- expect(v).toEqual({ status: "rejected", reason: "evidence-too-few" });
170
- });
171
-
172
- it("数字字符串编号可接受(模型常把编号写成字符串)", () => {
173
- const v = validateSynthesis({
174
- ...base,
175
- parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: ["2", 3] },
176
- });
177
- expect(v.status).toBe("ok");
178
- if (v.status === "ok") expect(v.output.evidence).toEqual([2, 3]);
179
- });
180
-
181
- it("非整数编号不算合法", () => {
182
- const v = validateSynthesis({
183
- ...base,
184
- parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: [1.5, "abc"] },
185
- });
186
- expect(v).toEqual({ status: "rejected", reason: "evidence-out-of-range" });
187
- });
188
- });
189
-
190
- // ---------------------------------------------------------------------------
191
- // 提示词回声 —— R4 的直接回归:库里曾有 28 条记忆的正文就是系统提示词原文
192
- // ---------------------------------------------------------------------------
193
-
194
- describe("提示词回声拦截(R4 回归)", () => {
195
- it("拦下那 28 条真实泄漏的原文形态", () => {
196
- const leaked = "保真规则:端口号/IP/URL/文件路径/API名称 → 原样保留;函数名/事件名/配置项 → 逐项保留不概括。";
197
- const v = validateSynthesis({ ...base, parsed: { has: true, conclusion: leaked, evidence: [1, 2] } });
198
- expect(v).toEqual({ status: "rejected", reason: "prompt-echo" });
199
- });
200
-
201
- it("新提示词自己的句子被回声也拦得住", () => {
202
- const v = validateSynthesis({
203
- ...base,
204
- parsed: { has: true, conclusion: "提炼不出来就说没有。宁可不产出,也不要硬凑。", evidence: [1] },
205
- });
206
- expect(v).toEqual({ status: "rejected", reason: "prompt-echo" });
207
- });
208
-
209
- it("正常结论不被误杀(标记表宁可漏也不误杀真结论)", () => {
210
- const normal = "批量删记忆要显式关掉 cascade,否则同 scope 内相似度高的条目会被连带降权。";
211
- expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: normal, evidence: [1] } }).status).toBe("ok");
212
- });
213
-
214
- it("标记全部真的出现在提示词里——否则这道闸是摆设", () => {
215
- // 反向断言:防有人改提示词时把某条标记的原文改掉,闸门静默失效而测试照绿。
216
- const corpus = CLUSTER_INSIGHT_PROMPT + "\n" + CLUSTER_PATTERN_PROMPT;
217
- const legacyOnly = new Set(["保真规则", "端口号/IP/URL/文件路径", "记忆索引助手", "记忆模式发现助手"]);
218
- for (const marker of PROMPT_ECHO_MARKERS) {
219
- if (legacyOnly.has(marker)) continue; // 老提示词的标记,本仓已无原文,保留是为了拦历史形态
220
- expect(corpus).toContain(marker);
221
- }
222
- });
223
- });
224
-
225
- // ---------------------------------------------------------------------------
226
- // 提示词本身的契约:三件必须写死的事
227
- // ---------------------------------------------------------------------------
228
-
229
- describe("提示词契约", () => {
230
- it("两个提示词都必须给出弃权出口", () => {
231
- for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
232
- expect(p).toContain('has": false');
233
- }
234
- });
235
-
236
- it("两个提示词都必须显式禁掉性向归因措辞(56.1% 的直接来源)", () => {
237
- for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
238
- expect(p).toContain("倾向于");
239
- expect(p).toContain("性格");
240
- }
241
- });
242
-
243
- it("两个提示词都必须要求 evidence 编号", () => {
244
- for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
245
- expect(p).toContain("evidence");
246
- }
247
- });
248
-
249
- it("pattern 侧必须写死 ≥2 条支撑(旧提示词写了但实现不校验,等于没写)", () => {
250
- expect(CLUSTER_PATTERN_PROMPT).toContain("至少 2 条");
251
- });
252
- });
@@ -1,369 +0,0 @@
1
- /**
2
- * Evidence → durable promotion for dream-synthesized conclusions.
3
- *
4
- * The layering RecallNest works under is "evidence retained → current projection →
5
- * eligible for recall → ranked result", and until now a synthesized conclusion could only
6
- * ever occupy the first of those. `buildDerivedBoundary` stamps every cluster insight and
7
- * cross-memory pattern `layer: "evidence"` on purpose — a model re-reading its own
8
- * memories is a lead to its sources, not authority over them — but
9
- * `shouldUseStableMemoryResult` rejects the evidence layer outright, so there was no path
10
- * from a well-supported conclusion to anything downstream could lean on.
11
- *
12
- * These tests cover the road that closes that gap, and the guardrails on it: the synthesis
13
- * row is never touched, re-running revises instead of duplicating, and anything whose
14
- * support cannot be checked is abstained on rather than waved through.
15
- */
16
-
17
- import { describe, expect, it } from "bun:test";
18
-
19
- import {
20
- DEFAULT_SYNTHESIS_PROMOTE_CONFIG,
21
- MIN_PROMOTABLE_CONTRACT_VERSION,
22
- formatSynthesisPromoteResult,
23
- scanSynthesisPromotions,
24
- type PromoteRequest,
25
- type SynthesisPromoteScanDeps,
26
- } from "../memory-promotion.js";
27
- import { shouldUseStableMemoryResult } from "../memory-boundaries.js";
28
- import type { MemoryEntry } from "../store.js";
29
-
30
- // ---------------------------------------------------------------------------
31
- // Fixture
32
- // ---------------------------------------------------------------------------
33
-
34
- const SCOPE = "memory";
35
-
36
- interface RowSpec {
37
- id: string;
38
- text: string;
39
- category?: string;
40
- importance?: number;
41
- /** Omit for a plain (non-synthesis) row. */
42
- synthesis?: {
43
- contract?: number | null;
44
- evidenceMemories?: unknown;
45
- originalCategory?: string;
46
- };
47
- status?: string;
48
- promotedTo?: string;
49
- }
50
-
51
- function row(spec: RowSpec): MemoryEntry {
52
- const metadata: Record<string, unknown> = {
53
- evolution: { status: spec.status ?? "active", version: 1, accessCount: 0 },
54
- };
55
-
56
- if (spec.synthesis) {
57
- metadata.boundary = {
58
- layer: "evidence",
59
- authority: "distillation",
60
- conflictPolicy: "append-only",
61
- originalCategory: spec.synthesis.originalCategory ?? "cases",
62
- note: "Dream-synthesized derivative: a lead to its sources, never authority over them.",
63
- };
64
- if (spec.synthesis.contract !== null) {
65
- metadata.synthesis_contract = spec.synthesis.contract ?? MIN_PROMOTABLE_CONTRACT_VERSION;
66
- }
67
- if (spec.synthesis.evidenceMemories !== undefined) {
68
- metadata.evidenceMemories = spec.synthesis.evidenceMemories;
69
- }
70
- } else {
71
- metadata.boundary = {
72
- layer: "durable",
73
- authority: "structured-memory",
74
- conflictPolicy: "latest-wins",
75
- };
76
- }
77
-
78
- if (spec.promotedTo) metadata.promotedTo = spec.promotedTo;
79
-
80
- return {
81
- id: spec.id,
82
- text: spec.text,
83
- vector: [],
84
- category: (spec.category ?? "cases") as MemoryEntry["category"],
85
- scope: SCOPE,
86
- importance: spec.importance ?? 0.8,
87
- timestamp: Date.parse("2026-08-20T00:00:00.000Z"),
88
- metadata: JSON.stringify(metadata),
89
- };
90
- }
91
-
92
- /** Two live evidence rows every well-formed synthesis in these tests points at. */
93
- const EVIDENCE = [
94
- row({ id: "ev-alpha", text: "第一次踩坑:换机没 push,第二天分叉" }),
95
- row({ id: "ev-beta", text: "第二次:hooks symlink 化之后工作区常态脏" }),
96
- ];
97
-
98
- function buildDeps(rows: MemoryEntry[]): {
99
- deps: SynthesisPromoteScanDeps;
100
- promoted: PromoteRequest[];
101
- } {
102
- const all = [...EVIDENCE, ...rows];
103
- const byId = new Map(all.map((e) => [e.id, e]));
104
- const promoted: PromoteRequest[] = [];
105
-
106
- return {
107
- promoted,
108
- deps: {
109
- store: {
110
- async list() {
111
- return all;
112
- },
113
- async getById(id: string) {
114
- return byId.get(id) ?? null;
115
- },
116
- } as any,
117
- async promote(req) {
118
- promoted.push(req);
119
- return {
120
- id: `durable-${req.memoryId}`,
121
- text: byId.get(req.memoryId)?.text ?? "",
122
- category: req.category,
123
- importance: req.importance,
124
- scope: req.scope,
125
- resolvedScope: req.scope,
126
- canonicalKey: `key:${req.memoryId}`,
127
- storedAt: new Date().toISOString(),
128
- disposition: "stored",
129
- sourceMemoryId: req.memoryId,
130
- sourceCategory: "cases",
131
- } as any;
132
- },
133
- },
134
- };
135
- }
136
-
137
- function goodSynthesis(over: Partial<RowSpec> = {}): MemoryEntry {
138
- return row({
139
- id: "syn-good",
140
- text: "换机交接的失败都同一个形状:脏工作区被当成临时状态,真相源迁走后它变成常态",
141
- importance: 0.85,
142
- ...over,
143
- synthesis: {
144
- contract: MIN_PROMOTABLE_CONTRACT_VERSION,
145
- evidenceMemories: ["ev-alpha", "ev-beta"],
146
- originalCategory: "patterns",
147
- ...(over.synthesis ?? {}),
148
- },
149
- });
150
- }
151
-
152
- // ---------------------------------------------------------------------------
153
-
154
- describe("synthesis promotion: what qualifies", () => {
155
- it("promotes a contract-compliant conclusion with enough live evidence", async () => {
156
- const { deps, promoted } = buildDeps([goodSynthesis()]);
157
-
158
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
159
-
160
- expect(result.candidates).toHaveLength(1);
161
- expect(result.promoted).toBe(1);
162
- expect(result.candidates[0]).toMatchObject({
163
- seedId: "syn-good",
164
- category: "patterns",
165
- contractVersion: MIN_PROMOTABLE_CONTRACT_VERSION,
166
- evidenceIds: ["ev-alpha", "ev-beta"],
167
- });
168
- // Promotion goes through the shared promoteMemory path, not a private writer.
169
- expect(promoted).toEqual([
170
- { memoryId: "syn-good", category: "patterns", scope: SCOPE, importance: 0.85 },
171
- ]);
172
- });
173
-
174
- it("defaults to dry-run and writes nothing", async () => {
175
- const { deps, promoted } = buildDeps([goodSynthesis()]);
176
-
177
- const result = await scanSynthesisPromotions(deps, SCOPE);
178
-
179
- expect(DEFAULT_SYNTHESIS_PROMOTE_CONFIG.dryRun).toBe(true);
180
- expect(result.dryRun).toBe(true);
181
- expect(result.candidates).toHaveLength(1);
182
- expect(result.promoted).toBe(0);
183
- expect(promoted).toEqual([]);
184
- expect(result.candidates[0]?.promoted).toBeNull();
185
- });
186
-
187
- it("ignores rows that are not synthesized derivatives", async () => {
188
- // A hand-written durable memory is already on the durable layer; promoting it would be
189
- // meaningless, and promoteMemory itself rejects non-evidence sources.
190
- const { deps } = buildDeps([
191
- row({ id: "handwritten", text: "Alice 用 Bun,不用 npm" }),
192
- goodSynthesis(),
193
- ]);
194
-
195
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
196
-
197
- expect(result.scannedSyntheses).toBe(1);
198
- expect(result.candidates.map((c) => c.seedId)).toEqual(["syn-good"]);
199
- });
200
-
201
- it("takes the durable category from the synthesis boundary", async () => {
202
- const { deps, promoted } = buildDeps([
203
- goodSynthesis({ synthesis: { originalCategory: "preferences" } as any }),
204
- ]);
205
-
206
- await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
207
-
208
- expect(promoted[0]?.category).toBe("preferences");
209
- });
210
- });
211
-
212
- describe("synthesis promotion: what it refuses, and says so", () => {
213
- it("skips derivatives written before the synthesis contract", async () => {
214
- // The 4,749 rows already in the database predate validation entirely — they were never
215
- // checked for abstention, evidence, or prompt echo. Promoting them would launder
216
- // unvalidated output into durable memory.
217
- const { deps, promoted } = buildDeps([
218
- goodSynthesis({ id: "syn-legacy", synthesis: { contract: null } as any }),
219
- goodSynthesis({ id: "syn-v1", synthesis: { contract: 1 } as any }),
220
- ]);
221
-
222
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
223
-
224
- expect(result.skipped.pre_contract).toBe(2);
225
- expect(result.candidates).toHaveLength(0);
226
- expect(promoted).toEqual([]);
227
- });
228
-
229
- it("abstains when the declared evidence cannot be resolved at all", async () => {
230
- // Not judging is the right answer when the provenance needed for the judgement is
231
- // missing — the same abstention rule the cross-source promotion check uses.
232
- const { deps, promoted } = buildDeps([
233
- goodSynthesis({ id: "syn-dangling", synthesis: { evidenceMemories: ["gone-1", "gone-2"] } as any }),
234
- ]);
235
-
236
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
237
-
238
- expect(result.skipped.evidence_unresolvable).toBe(1);
239
- expect(promoted).toEqual([]);
240
- });
241
-
242
- it("abstains when the evidence field is missing or malformed", async () => {
243
- const { deps } = buildDeps([
244
- goodSynthesis({ id: "syn-noevidence", synthesis: { evidenceMemories: undefined } as any }),
245
- goodSynthesis({ id: "syn-badevidence", synthesis: { evidenceMemories: "not-an-array" } as any }),
246
- ]);
247
-
248
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
249
-
250
- expect(result.skipped.evidence_unresolvable).toBe(2);
251
- expect(result.candidates).toHaveLength(0);
252
- });
253
-
254
- it("counts duplicate evidence ids once", async () => {
255
- // "Three similar cases" and "three distinct episodes" differ in dimension, not degree;
256
- // the same source listed twice is one source.
257
- const { deps } = buildDeps([
258
- goodSynthesis({ id: "syn-dup", synthesis: { evidenceMemories: ["ev-alpha", "ev-alpha"] } as any }),
259
- ]);
260
-
261
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
262
-
263
- expect(result.skipped.too_few_distinct_evidence).toBe(1);
264
- expect(result.candidates).toHaveLength(0);
265
- });
266
-
267
- it("does not count evidence that is no longer active", async () => {
268
- const superseded = row({ id: "ev-gone", text: "已被推翻的旧结论", status: "superseded" });
269
- const { deps } = buildDeps([
270
- superseded,
271
- goodSynthesis({ id: "syn-stale", synthesis: { evidenceMemories: ["ev-alpha", "ev-gone"] } as any }),
272
- ]);
273
-
274
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
275
-
276
- // One live source left, below the floor of two.
277
- expect(result.skipped.too_few_distinct_evidence).toBe(1);
278
- expect(result.candidates).toHaveLength(0);
279
- });
280
-
281
- it("skips a conclusion below the importance floor", async () => {
282
- const { deps } = buildDeps([goodSynthesis({ id: "syn-weak", importance: 0.4 })]);
283
-
284
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
285
-
286
- expect(result.skipped.below_importance).toBe(1);
287
- expect(result.candidates).toHaveLength(0);
288
- });
289
-
290
- it("skips a conclusion already promoted", async () => {
291
- const { deps } = buildDeps([goodSynthesis({ id: "syn-done", promotedTo: "durable-syn-done" })]);
292
-
293
- const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
294
-
295
- expect(result.skipped.already_promoted).toBe(1);
296
- expect(result.candidates).toHaveLength(0);
297
- });
298
-
299
- it("reports the rejection breakdown even when nothing is promoted", async () => {
300
- // Otherwise "found nothing" and "filtered everything out" read identically.
301
- const { deps } = buildDeps([
302
- goodSynthesis({ id: "syn-legacy", synthesis: { contract: 1 } as any }),
303
- goodSynthesis({ id: "syn-weak", importance: 0.2 }),
304
- ]);
305
-
306
- const text = formatSynthesisPromoteResult(await scanSynthesisPromotions(deps, SCOPE));
307
-
308
- expect(text).toContain("2 synthesized conclusion(s) examined");
309
- expect(text).toContain("pre_contract=1");
310
- expect(text).toContain("below_importance=1");
311
- expect(text).toContain("No promotion candidates found");
312
- });
313
- });
314
-
315
- describe("synthesis promotion: the guarantees around it", () => {
316
- it("never modifies the synthesized row — evidence stays readable as evidence", async () => {
317
- const synthesis = goodSynthesis();
318
- const before = synthesis.metadata;
319
- const { deps } = buildDeps([synthesis]);
320
-
321
- await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
322
-
323
- // Archive-first: the conclusion keeps its evidence boundary and its support set, so a
324
- // later reader can still tell what the durable copy was derived from.
325
- expect(synthesis.metadata).toBe(before);
326
- expect(JSON.parse(synthesis.metadata!).boundary.layer).toBe("evidence");
327
- });
328
-
329
- it("re-scanning promotes the same seed through the same idempotent path", async () => {
330
- // Duplicate suppression lives in promoteMemory -> writeDurableEntry's canonicalKey
331
- // dedup, so a second scan revises rather than piling up a parallel memory. What this
332
- // asserts is that the scan keeps routing through it instead of growing its own writer.
333
- const { deps, promoted } = buildDeps([goodSynthesis()]);
334
-
335
- await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
336
- await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
337
-
338
- expect(promoted).toHaveLength(2);
339
- expect(new Set(promoted.map((p) => p.memoryId)).size).toBe(1);
340
- });
341
-
342
- it("makes the promoted copy eligible for stable memory while the source stays out", async () => {
343
- // This is the whole point. The synthesized row is refused by
344
- // shouldUseStableMemoryResult; the durable copy promoteMemory writes is not.
345
- const synthesis = goodSynthesis({ synthesis: { originalCategory: "preferences" } as any });
346
-
347
- expect(
348
- shouldUseStableMemoryResult({
349
- category: "preferences",
350
- scope: SCOPE,
351
- metadata: synthesis.metadata,
352
- }),
353
- ).toBe(false);
354
-
355
- const promotedMetadata = JSON.stringify({
356
- evolution: { status: "active", version: 1 },
357
- boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
358
- promotedFrom: { memoryId: synthesis.id, scope: SCOPE, category: "cases" },
359
- });
360
-
361
- expect(
362
- shouldUseStableMemoryResult({
363
- category: "preferences",
364
- scope: SCOPE,
365
- metadata: promotedMetadata,
366
- }),
367
- ).toBe(true);
368
- });
369
- });