recallnest 2.3.0 → 2.5.4

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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
@@ -0,0 +1,1238 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { persistCaseMemory, persistMemory, persistMemoryBatch, persistWorkflowPattern, promoteMemory } from "../capture-engine.js";
4
+
5
+ const TEST_SCOPE = "project:test";
6
+
7
+ function createDeps() {
8
+ const storedEntries: any[] = [];
9
+ const conflicts: any[] = [];
10
+ let seq = 1;
11
+
12
+ return {
13
+ storedEntries,
14
+ conflicts,
15
+ deps: {
16
+ embedder: {
17
+ async embedPassage(text: string) {
18
+ return [text.length, 1, 0];
19
+ },
20
+ },
21
+ store: {
22
+ async store(entry: any) {
23
+ const stored = {
24
+ ...entry,
25
+ id: `00000000-0000-0000-0000-${String(seq).padStart(12, "0")}`,
26
+ timestamp: 1_700_000_000_000 + seq,
27
+ };
28
+ seq += 1;
29
+ storedEntries.push(stored);
30
+ return stored;
31
+ },
32
+ async list(_scopeFilter?: string[], category?: string, limit = 20, offset = 0) {
33
+ return storedEntries
34
+ .filter((entry) => !category || entry.category === category)
35
+ .slice(offset, offset + limit);
36
+ },
37
+ async update(id: string, updates: any) {
38
+ const index = storedEntries.findIndex((entry) => entry.id === id);
39
+ if (index < 0) return null;
40
+ storedEntries[index] = {
41
+ ...storedEntries[index],
42
+ ...updates,
43
+ timestamp: updates.timestamp ?? storedEntries[index].timestamp,
44
+ };
45
+ return storedEntries[index];
46
+ },
47
+ async getById(id: string) {
48
+ return storedEntries.find((entry) => entry.id === id) || null;
49
+ },
50
+ async get(id: string) {
51
+ const exact = storedEntries.find((entry) => entry.id === id);
52
+ if (exact) return exact;
53
+ const matches = storedEntries.filter((entry) => entry.id.startsWith(id));
54
+ if (matches.length > 1) {
55
+ throw new Error(`Ambiguous prefix "${id}" matches ${matches.length} memories. Use a longer prefix or full ID.`);
56
+ }
57
+ return matches[0] || null;
58
+ },
59
+ },
60
+ conflictStore: {
61
+ async save(record: any) {
62
+ conflicts.push(record);
63
+ return record;
64
+ },
65
+ async replace(record: any) {
66
+ const index = conflicts.findIndex((item) => item.conflictId === record.conflictId);
67
+ if (index >= 0) {
68
+ conflicts[index] = record;
69
+ } else {
70
+ conflicts.push(record);
71
+ }
72
+ return record;
73
+ },
74
+ async getOpenByFingerprint(fingerprint: string) {
75
+ return conflicts.find((item) => item.status === "open" && item.fingerprint === fingerprint) || null;
76
+ },
77
+ async getLatestByFingerprint(fingerprint: string) {
78
+ return conflicts.find((item) => item.fingerprint === fingerprint) || null;
79
+ },
80
+ },
81
+ },
82
+ };
83
+ }
84
+
85
+ describe("persistMemory", () => {
86
+ it("stores durable memory with an explicit scope", async () => {
87
+ const { deps, storedEntries } = createDeps();
88
+ const result = await persistMemory(deps as any, {
89
+ text: "User prefers dark mode",
90
+ category: "preferences",
91
+ scope: TEST_SCOPE,
92
+ source: "manual",
93
+ tags: ["ui"],
94
+ });
95
+
96
+ expect(result.resolvedScope).toBe(TEST_SCOPE);
97
+ expect(result.canonicalKey).toBe("preferences:user-prefers-dark-mode");
98
+ expect(result.disposition).toBe("stored");
99
+ expect(storedEntries[0].scope).toBe(TEST_SCOPE);
100
+ expect(JSON.parse(storedEntries[0].metadata)).toMatchObject({
101
+ source: "manual",
102
+ tags: ["ui"],
103
+ capture: "store_memory_schema_v1",
104
+ boundary: {
105
+ layer: "durable",
106
+ authority: "structured-memory",
107
+ conflictPolicy: "latest-wins",
108
+ originalCategory: "preferences",
109
+ note: "Structured memory writes are the durable source inside RecallNest.",
110
+ },
111
+ canonicalKey: "preferences:user-prefers-dark-mode",
112
+ });
113
+ });
114
+
115
+ it("rejects durable memory writes without a scope", async () => {
116
+ const { deps } = createDeps();
117
+
118
+ await expect(persistMemory(deps as any, {
119
+ text: "User prefers dark mode",
120
+ category: "preferences",
121
+ source: "manual",
122
+ })).rejects.toThrow("scope");
123
+ });
124
+
125
+ it("infers a slot-aware canonical key for atomic brand-item preferences", async () => {
126
+ const { deps, storedEntries } = createDeps();
127
+ const result = await persistMemory(deps as any, {
128
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
129
+ category: "preferences",
130
+ scope: TEST_SCOPE,
131
+ source: "manual",
132
+ });
133
+
134
+ expect(result.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
135
+ expect(JSON.parse(storedEntries[0].metadata)).toMatchObject({
136
+ canonicalKey: "preferences:brand-item:麦当劳:麦辣鸡翅",
137
+ preferenceSlot: {
138
+ type: "brand-item",
139
+ brand: "麦当劳",
140
+ item: "麦辣鸡翅",
141
+ },
142
+ });
143
+ });
144
+
145
+ it("infers a slot-aware canonical key for reply-style preferences", async () => {
146
+ const { deps, storedEntries } = createDeps();
147
+ const result = await persistMemory(deps as any, {
148
+ text: "User prefers concise, direct replies.",
149
+ category: "preferences",
150
+ scope: TEST_SCOPE,
151
+ source: "manual",
152
+ });
153
+
154
+ expect(result.canonicalKey).toBe("preferences:reply-style:concise:direct");
155
+ expect(JSON.parse(storedEntries[0].metadata)).toMatchObject({
156
+ canonicalKey: "preferences:reply-style:concise:direct",
157
+ preferenceSlot: {
158
+ type: "reply-style",
159
+ traits: ["concise", "direct"],
160
+ },
161
+ });
162
+ });
163
+
164
+ it("infers a slot-aware canonical key for tool-choice preferences", async () => {
165
+ const { deps, storedEntries } = createDeps();
166
+ const result = await persistMemory(deps as any, {
167
+ text: "Uses Bun over Node.",
168
+ category: "preferences",
169
+ scope: TEST_SCOPE,
170
+ source: "manual",
171
+ });
172
+
173
+ expect(result.canonicalKey).toBe("preferences:tool-choice:bun:over:node");
174
+ expect(JSON.parse(storedEntries[0].metadata)).toMatchObject({
175
+ canonicalKey: "preferences:tool-choice:bun:over:node",
176
+ preferenceSlot: {
177
+ type: "tool-choice",
178
+ preferredTool: "bun",
179
+ avoidedTool: "node",
180
+ },
181
+ });
182
+ });
183
+
184
+ it("does not infer slot metadata for descriptive non-preference text", async () => {
185
+ const { deps, storedEntries } = createDeps();
186
+ const draftNote = await persistMemory(deps as any, {
187
+ text: "这段文案简洁直接,先别改。",
188
+ category: "preferences",
189
+ scope: TEST_SCOPE,
190
+ source: "manual",
191
+ });
192
+ const migrationNote = await persistMemory(deps as any, {
193
+ text: "文档里写了 uses Bun over Node 的迁移说明。",
194
+ category: "preferences",
195
+ scope: TEST_SCOPE,
196
+ source: "manual",
197
+ });
198
+
199
+ expect(draftNote.canonicalKey).toBe("preferences:这段文案简洁直接-先别改");
200
+ expect(migrationNote.canonicalKey).toBe("preferences:文档里写了-uses-bun-over-node-的迁移说明");
201
+ expect(JSON.parse(storedEntries[0].metadata)).not.toHaveProperty("preferenceSlot");
202
+ expect(JSON.parse(storedEntries[1].metadata)).not.toHaveProperty("preferenceSlot");
203
+ });
204
+
205
+ it("dedupes an exact canonical durable write instead of storing again", async () => {
206
+ const { deps, storedEntries } = createDeps();
207
+ await persistMemory(deps as any, {
208
+ text: "User prefers dark mode",
209
+ category: "preferences",
210
+ scope: TEST_SCOPE,
211
+ source: "manual",
212
+ canonicalKey: "user.reply.style",
213
+ });
214
+
215
+ const result = await persistMemory(deps as any, {
216
+ text: "User prefers dark mode",
217
+ category: "preferences",
218
+ scope: TEST_SCOPE,
219
+ source: "manual",
220
+ canonicalKey: "user.reply.style",
221
+ });
222
+
223
+ expect(result.disposition).toBe("deduped");
224
+ expect(storedEntries).toHaveLength(1);
225
+ });
226
+
227
+ it("updates latest-wins categories when canonicalKey matches and text changes", async () => {
228
+ const { deps, storedEntries } = createDeps();
229
+ const first = await persistMemory(deps as any, {
230
+ text: "User prefers concise replies",
231
+ category: "preferences",
232
+ scope: TEST_SCOPE,
233
+ source: "manual",
234
+ canonicalKey: "user.reply.style",
235
+ });
236
+
237
+ const second = await persistMemory(deps as any, {
238
+ text: "User prefers concise and direct technical replies",
239
+ category: "preferences",
240
+ scope: TEST_SCOPE,
241
+ source: "manual",
242
+ canonicalKey: "user.reply.style",
243
+ });
244
+
245
+ expect(second.disposition).toBe("updated");
246
+ expect(second.id).toBe(first.id);
247
+ expect(storedEntries).toHaveLength(1);
248
+ expect(storedEntries[0].text).toContain("direct technical replies");
249
+ });
250
+
251
+ it("updates the same durable owner when the same atomic preference slot is rephrased", async () => {
252
+ const { deps, storedEntries } = createDeps();
253
+ const first = await persistMemory(deps as any, {
254
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
255
+ category: "preferences",
256
+ scope: TEST_SCOPE,
257
+ source: "manual",
258
+ });
259
+
260
+ const second = await persistMemory(deps as any, {
261
+ text: "我很喜欢吃麦当劳的麦辣鸡翅",
262
+ category: "preferences",
263
+ scope: TEST_SCOPE,
264
+ source: "manual",
265
+ });
266
+
267
+ expect(first.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
268
+ expect(second.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
269
+ expect(second.disposition).toBe("updated");
270
+ expect(second.id).toBe(first.id);
271
+ expect(storedEntries).toHaveLength(1);
272
+ });
273
+
274
+ it("creates a conflict when the same canonicalKey is reused across durable categories", async () => {
275
+ const { deps, storedEntries, conflicts } = createDeps();
276
+ const baseline = await persistMemory(deps as any, {
277
+ text: "User prefers concise technical replies.",
278
+ category: "preferences",
279
+ scope: TEST_SCOPE,
280
+ source: "manual",
281
+ canonicalKey: "user.reply.style.cross-category",
282
+ });
283
+
284
+ const result = await persistMemory(deps as any, {
285
+ text: "Reply-style observations imported as an event.",
286
+ category: "events",
287
+ scope: TEST_SCOPE,
288
+ source: "manual",
289
+ canonicalKey: "user.reply.style.cross-category",
290
+ });
291
+
292
+ expect(result.disposition).toBe("conflict");
293
+ expect(result.id).toBe(baseline.id);
294
+ expect(typeof result.conflictId).toBe("string");
295
+ expect(storedEntries).toHaveLength(1);
296
+ expect(conflicts).toHaveLength(1);
297
+ expect(conflicts[0]).toMatchObject({
298
+ conflictId: result.conflictId,
299
+ reason: "canonical_key_conflicts_with_existing_durable",
300
+ existing: {
301
+ memoryId: baseline.id,
302
+ category: "preferences",
303
+ },
304
+ incoming: {
305
+ category: "events",
306
+ text: "Reply-style observations imported as an event.",
307
+ },
308
+ });
309
+ });
310
+ });
311
+
312
+ describe("persistMemoryBatch", () => {
313
+ it("applies envelope defaults and per-item overrides", async () => {
314
+ const { deps, storedEntries } = createDeps();
315
+ const result = await persistMemoryBatch(deps as any, {
316
+ scope: "agent:codex",
317
+ source: "agent",
318
+ defaultImportance: 0.65,
319
+ memories: [
320
+ {
321
+ text: "Project RecallNest is the shared memory layer",
322
+ category: "entities",
323
+ },
324
+ {
325
+ text: "Use search_memory at task start",
326
+ category: "patterns",
327
+ importance: 0.9,
328
+ scope: "memory:manual",
329
+ source: "manual",
330
+ tags: ["workflow", "memory"],
331
+ canonicalKey: "workflow.start.search",
332
+ },
333
+ ],
334
+ });
335
+
336
+ expect(result).toHaveLength(2);
337
+ expect(storedEntries[0].scope).toBe("agent:codex");
338
+ expect(storedEntries[0].importance).toBe(0.65);
339
+ expect(storedEntries[1].scope).toBe("memory:manual");
340
+ expect(storedEntries[1].importance).toBe(0.9);
341
+ expect(result[1].canonicalKey).toBe("workflow-start-search");
342
+ expect(JSON.parse(storedEntries[1].metadata).tags).toEqual(["workflow", "memory"]);
343
+ });
344
+ });
345
+
346
+ describe("persistWorkflowPattern", () => {
347
+ it("stores a structured workflow pattern as durable patterns memory", async () => {
348
+ const { deps, storedEntries } = createDeps();
349
+ const result = await persistWorkflowPattern(deps as any, {
350
+ title: "Cross-window continuity handoff",
351
+ trigger: "When opening a fresh terminal window for the same project",
352
+ steps: [
353
+ "Call resume_context before coding",
354
+ "Review stable context and latest checkpoint",
355
+ "Save checkpoint_session before leaving the window",
356
+ ],
357
+ outcome: "The next window recovers decisions and next actions faster",
358
+ scope: TEST_SCOPE,
359
+ tools: ["resume_context", "checkpoint_session"],
360
+ tags: ["continuity"],
361
+ });
362
+
363
+ expect(result.category).toBe("patterns");
364
+ expect(result.disposition).toBe("stored");
365
+ expect(result.canonicalKey).toBe("patterns:cross-window-continuity-handoff");
366
+ expect(result.resolvedScope).toBe(TEST_SCOPE);
367
+ expect(result.tags).toEqual(["continuity", "workflow", "pattern"]);
368
+ expect(result.text).toContain("Workflow pattern: Cross-window continuity handoff");
369
+ expect(result.text).toContain("1. Call resume_context before coding");
370
+ expect(result.text).toContain("Tools: resume_context, checkpoint_session");
371
+ expect(storedEntries[0].category).toBe("patterns");
372
+ expect(JSON.parse(storedEntries[0].metadata)).toMatchObject({
373
+ source: "agent",
374
+ tags: ["continuity", "workflow", "pattern"],
375
+ capture: "workflow_pattern_schema_v1",
376
+ boundary: {
377
+ layer: "durable",
378
+ authority: "structured-memory",
379
+ conflictPolicy: "latest-wins",
380
+ originalCategory: "patterns",
381
+ note: "Structured memory writes are the durable source inside RecallNest.",
382
+ },
383
+ canonicalKey: "patterns:cross-window-continuity-handoff",
384
+ anchor: "Cross-window continuity handoff",
385
+ workflowPattern: {
386
+ title: "Cross-window continuity handoff",
387
+ trigger: "When opening a fresh terminal window for the same project",
388
+ steps: [
389
+ "Call resume_context before coding",
390
+ "Review stable context and latest checkpoint",
391
+ "Save checkpoint_session before leaving the window",
392
+ ],
393
+ outcome: "The next window recovers decisions and next actions faster",
394
+ tools: ["resume_context", "checkpoint_session"],
395
+ },
396
+ });
397
+ });
398
+ });
399
+
400
+ describe("persistCaseMemory", () => {
401
+ it("stores a structured case as durable cases memory", async () => {
402
+ const { deps, storedEntries } = createDeps();
403
+ const result = await persistCaseMemory(deps as any, {
404
+ title: "RecallNest sparse startup context cleanup",
405
+ problem: "resume_context returned noisy transcript fragments instead of a clean project handoff.",
406
+ context: "A fresh RecallNest window was loading issue chatter and unrelated pins into stable context.",
407
+ solutionSteps: [
408
+ "Filter low-signal transcript fragments from stable recall.",
409
+ "Backfill stable context from checkpoint focus, summary, and decisions.",
410
+ "Use task focus fallback only when checkpoint-backed continuity is unavailable.",
411
+ ],
412
+ outcome: "Fresh windows recover project continuity with cleaner stable context and fewer raw transcript leaks.",
413
+ scope: TEST_SCOPE,
414
+ tools: ["resume_context", "checkpoint_session"],
415
+ tags: ["continuity"],
416
+ });
417
+
418
+ expect(result.category).toBe("cases");
419
+ expect(result.canonicalKey).toBe("cases:recallnest-sparse-startup-context-cleanup");
420
+ expect(result.resolvedScope).toBe(TEST_SCOPE);
421
+ expect(result.tags).toEqual(["continuity", "case", "solution"]);
422
+ expect(result.text).toContain("Case: RecallNest sparse startup context cleanup");
423
+ expect(result.text).toContain("Problem: resume_context returned noisy transcript fragments");
424
+ expect(result.text).toContain("1. Filter low-signal transcript fragments from stable recall.");
425
+ expect(storedEntries[0].category).toBe("cases");
426
+ expect(JSON.parse(storedEntries[0].metadata)).toMatchObject({
427
+ source: "agent",
428
+ tags: ["continuity", "case", "solution"],
429
+ capture: "case_memory_schema_v1",
430
+ boundary: {
431
+ layer: "durable",
432
+ authority: "structured-memory",
433
+ conflictPolicy: "append-only",
434
+ originalCategory: "cases",
435
+ note: "Structured memory writes are the durable source inside RecallNest.",
436
+ },
437
+ canonicalKey: "cases:recallnest-sparse-startup-context-cleanup",
438
+ anchor: "RecallNest sparse startup context cleanup",
439
+ caseMemory: {
440
+ title: "RecallNest sparse startup context cleanup",
441
+ problem: "resume_context returned noisy transcript fragments instead of a clean project handoff.",
442
+ context: "A fresh RecallNest window was loading issue chatter and unrelated pins into stable context.",
443
+ solutionSteps: [
444
+ "Filter low-signal transcript fragments from stable recall.",
445
+ "Backfill stable context from checkpoint focus, summary, and decisions.",
446
+ "Use task focus fallback only when checkpoint-backed continuity is unavailable.",
447
+ ],
448
+ outcome: "Fresh windows recover project continuity with cleaner stable context and fewer raw transcript leaks.",
449
+ tools: ["resume_context", "checkpoint_session"],
450
+ },
451
+ });
452
+ });
453
+
454
+ it("A1: extracts error_signature into metadata from problem text", async () => {
455
+ const { deps, storedEntries } = createDeps();
456
+ await persistCaseMemory(deps as any, {
457
+ title: "Build broke on missing native lib",
458
+ problem: "pip install failed: xmlsec1 not found, exit code 1",
459
+ solutionSteps: ["brew install xmlsec1", "retry pip install"],
460
+ scope: TEST_SCOPE,
461
+ tools: ["pip", "brew"],
462
+ });
463
+ const meta = JSON.parse(storedEntries[0].metadata);
464
+ expect(Array.isArray(meta.error_signature)).toBe(true);
465
+ expect(meta.error_signature.some((s: string) => s.includes("xmlsec1 not found"))).toBe(true);
466
+ });
467
+
468
+ it("A1: round-trips optional debugFraming into metadata", async () => {
469
+ const { deps, storedEntries } = createDeps();
470
+ await persistCaseMemory(deps as any, {
471
+ title: "Cron change not applied",
472
+ problem: "service kept old schedule after editing crontab",
473
+ solutionSteps: ["restart the service after editing cron"],
474
+ scope: TEST_SCOPE,
475
+ tools: ["systemctl"],
476
+ debugFraming: {
477
+ rootCause: "implicit_assumption",
478
+ whyPriorFixFailed: "assumed cron reload was automatic",
479
+ defense: "runbook: 改 cron 必 restart 对应服务",
480
+ },
481
+ });
482
+ const meta = JSON.parse(storedEntries[0].metadata);
483
+ expect(meta.debugFraming).toMatchObject({
484
+ rootCause: "implicit_assumption",
485
+ whyPriorFixFailed: "assumed cron reload was automatic",
486
+ });
487
+ });
488
+
489
+ it("A1: success-only case yields no reverse-signal error_signature", async () => {
490
+ const { deps, storedEntries } = createDeps();
491
+ await persistCaseMemory(deps as any, {
492
+ title: "Routine weekly digest",
493
+ problem: "need to generate the weekly summary digest on schedule",
494
+ solutionSteps: ["run the digest generator", "review output"],
495
+ outcome: "digest generated successfully, exit code 0",
496
+ scope: TEST_SCOPE,
497
+ tools: ["digest"],
498
+ });
499
+ const meta = JSON.parse(storedEntries[0].metadata);
500
+ // problem 无错误现象 + outcome 成功态(无失败词)不纳入 → 不应抽到 "exit code 0" 反向信号
501
+ expect((meta.error_signature ?? []).some((s: string) => s.includes("exit code 0"))).toBe(false);
502
+ });
503
+ });
504
+
505
+ describe("promoteMemory", () => {
506
+ it("promotes an evidence transcript entry into durable memory with provenance", async () => {
507
+ const { deps, storedEntries } = createDeps();
508
+ const source = await deps.store.store({
509
+ text: "[用户] 用户偏好短句直说。\n\n[助手] 后续回复保持简洁。",
510
+ vector: [1, 2, 3],
511
+ category: "events",
512
+ scope: "cc:session1",
513
+ importance: 0.5,
514
+ metadata: JSON.stringify({
515
+ source: "cc",
516
+ boundary: {
517
+ layer: "evidence",
518
+ authority: "transcript-ingest",
519
+ conflictPolicy: "append-only",
520
+ originalCategory: "preferences",
521
+ },
522
+ }),
523
+ });
524
+
525
+ const promoted = await promoteMemory(deps as any, {
526
+ memoryId: source.id,
527
+ text: "User prefers concise, direct replies.",
528
+ category: "preferences",
529
+ scope: TEST_SCOPE,
530
+ canonicalKey: "user.reply.style",
531
+ tags: ["writing"],
532
+ });
533
+
534
+ expect(promoted.disposition).toBe("promoted");
535
+ expect(promoted.sourceMemoryId).toBe(source.id);
536
+ expect(promoted.sourceCategory).toBe("events");
537
+ expect(promoted.canonicalKey).toBe("user-reply-style");
538
+ expect(storedEntries).toHaveLength(2);
539
+ expect(JSON.parse(storedEntries[1].metadata)).toMatchObject({
540
+ source: "agent",
541
+ tags: ["writing"],
542
+ capture: "promote_memory_schema_v1",
543
+ boundary: {
544
+ layer: "durable",
545
+ authority: "structured-memory",
546
+ conflictPolicy: "latest-wins",
547
+ originalCategory: "preferences",
548
+ note: "Structured memory writes are the durable source inside RecallNest.",
549
+ },
550
+ canonicalKey: "user-reply-style",
551
+ promotedFrom: {
552
+ memoryId: source.id,
553
+ scope: "cc:session1",
554
+ category: "events",
555
+ boundary: {
556
+ layer: "evidence",
557
+ authority: "transcript-ingest",
558
+ conflictPolicy: "append-only",
559
+ originalCategory: "preferences",
560
+ },
561
+ source: "cc",
562
+ },
563
+ preferenceSlot: {
564
+ type: "reply-style",
565
+ traits: ["concise", "direct"],
566
+ },
567
+ });
568
+ });
569
+
570
+ it("infers a slot-aware canonical key when promoting an atomic brand-item preference", async () => {
571
+ const { deps, storedEntries } = createDeps();
572
+ const source = await deps.store.store({
573
+ text: "[用户] 我喜欢吃麦当劳的麦辣鸡翅。",
574
+ vector: [1, 2, 3],
575
+ category: "events",
576
+ scope: "cc:session-food-pref",
577
+ importance: 0.5,
578
+ metadata: JSON.stringify({
579
+ source: "cc",
580
+ boundary: {
581
+ layer: "evidence",
582
+ authority: "transcript-ingest",
583
+ conflictPolicy: "append-only",
584
+ originalCategory: "preferences",
585
+ },
586
+ }),
587
+ });
588
+
589
+ const promoted = await promoteMemory(deps as any, {
590
+ memoryId: source.id,
591
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
592
+ category: "preferences",
593
+ scope: TEST_SCOPE,
594
+ tags: ["food"],
595
+ });
596
+
597
+ expect(promoted.disposition).toBe("promoted");
598
+ expect(promoted.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
599
+ expect(JSON.parse(storedEntries[1].metadata)).toMatchObject({
600
+ canonicalKey: "preferences:brand-item:麦当劳:麦辣鸡翅",
601
+ preferenceSlot: {
602
+ type: "brand-item",
603
+ brand: "麦当劳",
604
+ item: "麦辣鸡翅",
605
+ },
606
+ });
607
+ });
608
+
609
+ it("accepts a unique source memory prefix for promotion", async () => {
610
+ const { deps } = createDeps();
611
+ const source = await deps.store.store({
612
+ text: "[用户] 用户偏好短句直说。",
613
+ vector: [1, 2, 3],
614
+ category: "events",
615
+ scope: "cc:session-prefix",
616
+ importance: 0.5,
617
+ metadata: JSON.stringify({
618
+ source: "cc",
619
+ boundary: {
620
+ layer: "evidence",
621
+ authority: "transcript-ingest",
622
+ conflictPolicy: "append-only",
623
+ originalCategory: "preferences",
624
+ },
625
+ }),
626
+ });
627
+
628
+ const promoted = await promoteMemory(deps as any, {
629
+ memoryId: source.id.slice(0, 8),
630
+ text: "User prefers concise, direct replies.",
631
+ category: "preferences",
632
+ scope: TEST_SCOPE,
633
+ canonicalKey: "user.reply.style.prefix",
634
+ tags: ["writing"],
635
+ });
636
+
637
+ expect(promoted.disposition).toBe("promoted");
638
+ expect(promoted.sourceMemoryId).toBe(source.id);
639
+ expect(promoted.canonicalKey).toBe("user-reply-style-prefix");
640
+ });
641
+
642
+ it("rejects promoting an already-durable memory", async () => {
643
+ const { deps } = createDeps();
644
+ const durable = await deps.store.store({
645
+ text: "User prefers direct replies.",
646
+ vector: [1, 2, 3],
647
+ category: "preferences",
648
+ scope: "memory:agent",
649
+ importance: 0.8,
650
+ metadata: JSON.stringify({
651
+ source: "agent",
652
+ boundary: {
653
+ layer: "durable",
654
+ authority: "structured-memory",
655
+ conflictPolicy: "latest-wins",
656
+ originalCategory: "preferences",
657
+ },
658
+ }),
659
+ });
660
+
661
+ await expect(promoteMemory(deps as any, {
662
+ memoryId: durable.id,
663
+ scope: TEST_SCOPE,
664
+ })).rejects.toThrow("already durable");
665
+ });
666
+
667
+ it("creates a conflict candidate instead of silently overwriting durable memory", async () => {
668
+ const { deps, storedEntries, conflicts } = createDeps();
669
+ const source = await deps.store.store({
670
+ text: "[用户] 文章得更口语化,但不能太飘。",
671
+ vector: [1, 2, 3],
672
+ category: "events",
673
+ scope: "cc:session2",
674
+ importance: 0.55,
675
+ metadata: JSON.stringify({
676
+ source: "cc",
677
+ boundary: {
678
+ layer: "evidence",
679
+ authority: "transcript-ingest",
680
+ conflictPolicy: "append-only",
681
+ originalCategory: "preferences",
682
+ },
683
+ }),
684
+ });
685
+ const durable = await deps.store.store({
686
+ text: "User prefers concise, direct replies.",
687
+ vector: [4, 5, 6],
688
+ category: "preferences",
689
+ scope: "memory:agent",
690
+ importance: 0.9,
691
+ metadata: JSON.stringify({
692
+ source: "agent",
693
+ canonicalKey: "user-reply-style",
694
+ boundary: {
695
+ layer: "durable",
696
+ authority: "structured-memory",
697
+ conflictPolicy: "latest-wins",
698
+ originalCategory: "preferences",
699
+ },
700
+ }),
701
+ });
702
+
703
+ const promoted = await promoteMemory(deps as any, {
704
+ memoryId: source.id,
705
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
706
+ category: "preferences",
707
+ scope: TEST_SCOPE,
708
+ canonicalKey: "user.reply.style",
709
+ tags: ["writing"],
710
+ });
711
+
712
+ expect(promoted.disposition).toBe("conflict");
713
+ expect(promoted.id).toBe(durable.id);
714
+ expect(typeof promoted.conflictId).toBe("string");
715
+ expect(storedEntries).toHaveLength(2);
716
+ expect(storedEntries[1].text).toBe("User prefers concise, direct replies.");
717
+ expect(conflicts).toHaveLength(1);
718
+ expect(conflicts[0]).toMatchObject({
719
+ conflictId: promoted.conflictId,
720
+ canonicalKey: "user-reply-style",
721
+ reason: "promotion_conflicts_with_existing_durable",
722
+ status: "open",
723
+ existing: {
724
+ memoryId: durable.id,
725
+ text: "User prefers concise, direct replies.",
726
+ },
727
+ incoming: {
728
+ sourceMemoryId: source.id,
729
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
730
+ },
731
+ });
732
+
733
+ const repeated = await promoteMemory(deps as any, {
734
+ memoryId: source.id,
735
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
736
+ category: "preferences",
737
+ scope: TEST_SCOPE,
738
+ canonicalKey: "user.reply.style",
739
+ tags: ["writing"],
740
+ });
741
+
742
+ expect(repeated.disposition).toBe("conflict");
743
+ expect(repeated.conflictId).toBe(promoted.conflictId);
744
+ expect(conflicts).toHaveLength(1);
745
+ });
746
+
747
+ it("collapses same-slot atomic preference promotions onto the existing durable owner without opening a conflict", async () => {
748
+ const { deps, storedEntries, conflicts } = createDeps();
749
+ const durable = await persistMemory(deps as any, {
750
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
751
+ category: "preferences",
752
+ scope: TEST_SCOPE,
753
+ source: "manual",
754
+ });
755
+ const source = await deps.store.store({
756
+ text: "[用户] 我很喜欢吃麦当劳的麦辣鸡翅。",
757
+ vector: [1, 2, 3],
758
+ category: "events",
759
+ scope: "cc:session-food-pref-rephrase",
760
+ importance: 0.55,
761
+ metadata: JSON.stringify({
762
+ source: "cc",
763
+ boundary: {
764
+ layer: "evidence",
765
+ authority: "transcript-ingest",
766
+ conflictPolicy: "append-only",
767
+ originalCategory: "preferences",
768
+ },
769
+ }),
770
+ });
771
+
772
+ const promoted = await promoteMemory(deps as any, {
773
+ memoryId: source.id,
774
+ text: "我很喜欢吃麦当劳的麦辣鸡翅",
775
+ category: "preferences",
776
+ scope: TEST_SCOPE,
777
+ tags: ["food"],
778
+ });
779
+
780
+ expect(promoted.disposition).toBe("promoted");
781
+ expect(promoted.id).toBe(durable.id);
782
+ expect(promoted.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
783
+ expect(storedEntries).toHaveLength(2);
784
+ expect(storedEntries[0]?.text).toBe("我喜欢吃麦当劳的麦辣鸡翅");
785
+ expect(JSON.parse(storedEntries[0]?.metadata || "{}")).toMatchObject({
786
+ canonicalKey: "preferences:brand-item:麦当劳:麦辣鸡翅",
787
+ preferenceSlot: {
788
+ type: "brand-item",
789
+ brand: "麦当劳",
790
+ item: "麦辣鸡翅",
791
+ },
792
+ provenanceHistoryCount: 1,
793
+ provenanceHistory: [{
794
+ memoryId: source.id,
795
+ scope: "cc:session-food-pref-rephrase",
796
+ category: "events",
797
+ source: "cc",
798
+ boundary: {
799
+ layer: "evidence",
800
+ authority: "transcript-ingest",
801
+ conflictPolicy: "append-only",
802
+ originalCategory: "preferences",
803
+ },
804
+ }],
805
+ });
806
+ expect(typeof JSON.parse(storedEntries[0]?.metadata || "{}").provenanceHistory?.[0]?.observedAt).toBe("string");
807
+ expect(conflicts).toHaveLength(0);
808
+ });
809
+
810
+ it("collapses same-slot reply-style promotions onto the existing durable owner", async () => {
811
+ const { deps, storedEntries, conflicts } = createDeps();
812
+ const durable = await persistMemory(deps as any, {
813
+ text: "User prefers concise, direct replies.",
814
+ category: "preferences",
815
+ scope: TEST_SCOPE,
816
+ source: "manual",
817
+ });
818
+ const source = await deps.store.store({
819
+ text: "[用户] 用户偏好短句直说。",
820
+ vector: [1, 2, 3],
821
+ category: "events",
822
+ scope: "cc:session-reply-style-slot",
823
+ importance: 0.55,
824
+ metadata: JSON.stringify({
825
+ source: "cc",
826
+ boundary: {
827
+ layer: "evidence",
828
+ authority: "transcript-ingest",
829
+ conflictPolicy: "append-only",
830
+ originalCategory: "preferences",
831
+ },
832
+ }),
833
+ });
834
+
835
+ const promoted = await promoteMemory(deps as any, {
836
+ memoryId: source.id,
837
+ text: "User prefers direct, concise replies.",
838
+ category: "preferences",
839
+ scope: TEST_SCOPE,
840
+ tags: ["writing"],
841
+ });
842
+
843
+ expect(promoted.disposition).toBe("promoted");
844
+ expect(promoted.id).toBe(durable.id);
845
+ expect(promoted.canonicalKey).toBe("preferences:reply-style:concise:direct");
846
+ expect(JSON.parse(storedEntries[0]?.metadata || "{}")).toMatchObject({
847
+ canonicalKey: "preferences:reply-style:concise:direct",
848
+ preferenceSlot: {
849
+ type: "reply-style",
850
+ traits: ["concise", "direct"],
851
+ },
852
+ provenanceHistoryCount: 1,
853
+ provenanceHistory: [{
854
+ memoryId: source.id,
855
+ scope: "cc:session-reply-style-slot",
856
+ category: "events",
857
+ source: "cc",
858
+ }],
859
+ });
860
+ expect(conflicts).toHaveLength(0);
861
+ });
862
+
863
+ it("does not collapse descriptive draft text onto an existing reply-style durable owner", async () => {
864
+ const { deps, storedEntries, conflicts } = createDeps();
865
+ const durable = await persistMemory(deps as any, {
866
+ text: "User prefers concise, direct replies.",
867
+ category: "preferences",
868
+ scope: TEST_SCOPE,
869
+ source: "manual",
870
+ });
871
+ const source = await deps.store.store({
872
+ text: "[用户] 这段文案简洁直接,先别改。",
873
+ vector: [1, 2, 3],
874
+ category: "events",
875
+ scope: "cc:session-reply-style-note",
876
+ importance: 0.55,
877
+ metadata: JSON.stringify({
878
+ source: "cc",
879
+ boundary: {
880
+ layer: "evidence",
881
+ authority: "transcript-ingest",
882
+ conflictPolicy: "append-only",
883
+ originalCategory: "preferences",
884
+ },
885
+ }),
886
+ });
887
+
888
+ const promoted = await promoteMemory(deps as any, {
889
+ memoryId: source.id,
890
+ text: "这段文案简洁直接,先别改。",
891
+ category: "preferences",
892
+ scope: TEST_SCOPE,
893
+ tags: ["writing"],
894
+ });
895
+
896
+ expect(promoted.disposition).toBe("promoted");
897
+ expect(promoted.id).not.toBe(durable.id);
898
+ expect(promoted.canonicalKey).toBe("preferences:这段文案简洁直接-先别改");
899
+ expect(storedEntries).toHaveLength(3);
900
+ expect(JSON.parse(storedEntries[2]?.metadata || "{}")).toMatchObject({
901
+ canonicalKey: "preferences:这段文案简洁直接-先别改",
902
+ promotedFrom: {
903
+ memoryId: source.id,
904
+ scope: "cc:session-reply-style-note",
905
+ category: "events",
906
+ source: "cc",
907
+ },
908
+ });
909
+ expect(JSON.parse(storedEntries[2]?.metadata || "{}")).not.toHaveProperty("preferenceSlot");
910
+ expect(conflicts).toHaveLength(0);
911
+ });
912
+
913
+ it("collapses same-slot tool-choice promotions onto the existing durable owner", async () => {
914
+ const { deps, storedEntries, conflicts } = createDeps();
915
+ const durable = await persistMemory(deps as any, {
916
+ text: "Uses Bun over Node.",
917
+ category: "preferences",
918
+ scope: TEST_SCOPE,
919
+ source: "manual",
920
+ });
921
+ const source = await deps.store.store({
922
+ text: "[用户] 更喜欢用 Bun 而不是 Node。",
923
+ vector: [1, 2, 3],
924
+ category: "events",
925
+ scope: "cc:session-tool-choice-slot",
926
+ importance: 0.55,
927
+ metadata: JSON.stringify({
928
+ source: "cc",
929
+ boundary: {
930
+ layer: "evidence",
931
+ authority: "transcript-ingest",
932
+ conflictPolicy: "append-only",
933
+ originalCategory: "preferences",
934
+ },
935
+ }),
936
+ });
937
+
938
+ const promoted = await promoteMemory(deps as any, {
939
+ memoryId: source.id,
940
+ text: "Prefers Bun over Node.",
941
+ category: "preferences",
942
+ scope: TEST_SCOPE,
943
+ tags: ["tooling"],
944
+ });
945
+
946
+ expect(promoted.disposition).toBe("promoted");
947
+ expect(promoted.id).toBe(durable.id);
948
+ expect(promoted.canonicalKey).toBe("preferences:tool-choice:bun:over:node");
949
+ expect(JSON.parse(storedEntries[0]?.metadata || "{}")).toMatchObject({
950
+ canonicalKey: "preferences:tool-choice:bun:over:node",
951
+ preferenceSlot: {
952
+ type: "tool-choice",
953
+ preferredTool: "bun",
954
+ avoidedTool: "node",
955
+ },
956
+ provenanceHistoryCount: 1,
957
+ provenanceHistory: [{
958
+ memoryId: source.id,
959
+ scope: "cc:session-tool-choice-slot",
960
+ category: "events",
961
+ source: "cc",
962
+ }],
963
+ });
964
+ expect(conflicts).toHaveLength(0);
965
+ });
966
+
967
+ it("does not collapse narrative migration text onto an existing tool-choice durable owner", async () => {
968
+ const { deps, storedEntries, conflicts } = createDeps();
969
+ const durable = await persistMemory(deps as any, {
970
+ text: "Prefers rg over grep.",
971
+ category: "preferences",
972
+ scope: TEST_SCOPE,
973
+ source: "manual",
974
+ });
975
+ const source = await deps.store.store({
976
+ text: "[用户] 文档里写了 uses Bun over Node 的迁移说明。",
977
+ vector: [1, 2, 3],
978
+ category: "events",
979
+ scope: "cc:session-tool-choice-note",
980
+ importance: 0.55,
981
+ metadata: JSON.stringify({
982
+ source: "cc",
983
+ boundary: {
984
+ layer: "evidence",
985
+ authority: "transcript-ingest",
986
+ conflictPolicy: "append-only",
987
+ originalCategory: "preferences",
988
+ },
989
+ }),
990
+ });
991
+
992
+ const promoted = await promoteMemory(deps as any, {
993
+ memoryId: source.id,
994
+ text: "文档里写了 uses Bun over Node 的迁移说明。",
995
+ category: "preferences",
996
+ scope: TEST_SCOPE,
997
+ tags: ["tooling"],
998
+ });
999
+
1000
+ expect(promoted.disposition).toBe("promoted");
1001
+ expect(promoted.id).not.toBe(durable.id);
1002
+ expect(promoted.canonicalKey).toBe("preferences:文档里写了-uses-bun-over-node-的迁移说明");
1003
+ expect(storedEntries).toHaveLength(3);
1004
+ expect(JSON.parse(storedEntries[2]?.metadata || "{}")).toMatchObject({
1005
+ canonicalKey: "preferences:文档里写了-uses-bun-over-node-的迁移说明",
1006
+ promotedFrom: {
1007
+ memoryId: source.id,
1008
+ scope: "cc:session-tool-choice-note",
1009
+ category: "events",
1010
+ source: "cc",
1011
+ },
1012
+ });
1013
+ expect(JSON.parse(storedEntries[2]?.metadata || "{}")).not.toHaveProperty("preferenceSlot");
1014
+ expect(conflicts).toHaveLength(0);
1015
+ });
1016
+
1017
+ it("does not duplicate provenance history when the same exact promotion is repeated", async () => {
1018
+ const { deps, storedEntries } = createDeps();
1019
+ await persistMemory(deps as any, {
1020
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
1021
+ category: "preferences",
1022
+ scope: TEST_SCOPE,
1023
+ source: "manual",
1024
+ });
1025
+ const source = await deps.store.store({
1026
+ text: "[用户] 我喜欢吃麦当劳的麦辣鸡翅。",
1027
+ vector: [1, 2, 3],
1028
+ category: "events",
1029
+ scope: "cc:session-food-pref-exact",
1030
+ importance: 0.55,
1031
+ metadata: JSON.stringify({
1032
+ source: "cc",
1033
+ boundary: {
1034
+ layer: "evidence",
1035
+ authority: "transcript-ingest",
1036
+ conflictPolicy: "append-only",
1037
+ originalCategory: "preferences",
1038
+ },
1039
+ }),
1040
+ });
1041
+
1042
+ await promoteMemory(deps as any, {
1043
+ memoryId: source.id,
1044
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
1045
+ category: "preferences",
1046
+ scope: TEST_SCOPE,
1047
+ });
1048
+ await promoteMemory(deps as any, {
1049
+ memoryId: source.id,
1050
+ text: "我喜欢吃麦当劳的麦辣鸡翅",
1051
+ category: "preferences",
1052
+ scope: TEST_SCOPE,
1053
+ });
1054
+
1055
+ const metadata = JSON.parse(storedEntries[0]?.metadata || "{}");
1056
+ expect(metadata.provenanceHistoryCount).toBe(1);
1057
+ expect(metadata.provenanceHistory).toHaveLength(1);
1058
+ expect(metadata.provenanceHistory[0]?.memoryId).toBe(source.id);
1059
+ });
1060
+
1061
+ it("reopens the same conflict record when the same resolved conflict happens again", async () => {
1062
+ const { deps, conflicts } = createDeps();
1063
+ const source = await deps.store.store({
1064
+ text: "[用户] 文章得更口语化,但不能太飘。",
1065
+ vector: [1, 2, 3],
1066
+ category: "events",
1067
+ scope: "cc:session-reopen",
1068
+ importance: 0.55,
1069
+ metadata: JSON.stringify({
1070
+ source: "cc",
1071
+ boundary: {
1072
+ layer: "evidence",
1073
+ authority: "transcript-ingest",
1074
+ conflictPolicy: "append-only",
1075
+ originalCategory: "preferences",
1076
+ },
1077
+ }),
1078
+ });
1079
+ await deps.store.store({
1080
+ text: "User prefers concise, direct replies.",
1081
+ vector: [4, 5, 6],
1082
+ category: "preferences",
1083
+ scope: "memory:agent",
1084
+ importance: 0.9,
1085
+ metadata: JSON.stringify({
1086
+ source: "agent",
1087
+ canonicalKey: "user-reply-style-reopen",
1088
+ boundary: {
1089
+ layer: "durable",
1090
+ authority: "structured-memory",
1091
+ conflictPolicy: "latest-wins",
1092
+ originalCategory: "preferences",
1093
+ },
1094
+ }),
1095
+ });
1096
+
1097
+ const first = await promoteMemory(deps as any, {
1098
+ memoryId: source.id,
1099
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
1100
+ category: "preferences",
1101
+ scope: TEST_SCOPE,
1102
+ canonicalKey: "user.reply.style.reopen",
1103
+ tags: ["writing"],
1104
+ });
1105
+
1106
+ await deps.conflictStore.replace({
1107
+ ...conflicts[0],
1108
+ status: "kept-existing",
1109
+ updatedAt: "2026-03-16T10:00:00.000Z",
1110
+ resolvedAt: "2026-03-16T10:00:00.000Z",
1111
+ resolutionNotes: "Previous review kept the existing durable memory.",
1112
+ });
1113
+
1114
+ const reopened = await promoteMemory(deps as any, {
1115
+ memoryId: source.id,
1116
+ text: "User prefers colloquial writing that stays grounded and non-salesy.",
1117
+ category: "preferences",
1118
+ scope: TEST_SCOPE,
1119
+ canonicalKey: "user.reply.style.reopen",
1120
+ tags: ["writing"],
1121
+ });
1122
+
1123
+ expect(reopened.disposition).toBe("conflict");
1124
+ expect(reopened.conflictId).toBe(first.conflictId);
1125
+ expect(conflicts).toHaveLength(1);
1126
+ expect(conflicts[0]?.status).toBe("open");
1127
+ expect(conflicts[0]?.reopenCount).toBe(1);
1128
+ expect(typeof conflicts[0]?.lastReopenedAt).toBe("string");
1129
+ expect(conflicts[0]?.resolvedAt).toBeUndefined();
1130
+ expect(conflicts[0]?.resolutionNotes).toBeUndefined();
1131
+ });
1132
+
1133
+ it("blocks inferred cross-category promotions under the same canonicalKey", async () => {
1134
+ const { deps, storedEntries, conflicts } = createDeps();
1135
+ const durable = await deps.store.store({
1136
+ text: "User prefers concise, direct replies.",
1137
+ vector: [4, 5, 6],
1138
+ category: "preferences",
1139
+ scope: "memory:agent",
1140
+ importance: 0.9,
1141
+ metadata: JSON.stringify({
1142
+ source: "agent",
1143
+ canonicalKey: "acceptance-writing-tone-category-check",
1144
+ boundary: {
1145
+ layer: "durable",
1146
+ authority: "structured-memory",
1147
+ conflictPolicy: "latest-wins",
1148
+ originalCategory: "preferences",
1149
+ },
1150
+ }),
1151
+ });
1152
+ const source = await deps.store.store({
1153
+ text: "[用户] 语气别太端着,口语化点。",
1154
+ vector: [1, 2, 3],
1155
+ category: "fact",
1156
+ scope: "cc:session-category-mismatch",
1157
+ importance: 0.55,
1158
+ metadata: JSON.stringify({
1159
+ source: "cc",
1160
+ boundary: {
1161
+ layer: "evidence",
1162
+ authority: "transcript-ingest",
1163
+ conflictPolicy: "append-only",
1164
+ },
1165
+ }),
1166
+ });
1167
+
1168
+ const promoted = await promoteMemory(deps as any, {
1169
+ memoryId: source.id,
1170
+ canonicalKey: "acceptance-writing-tone-category-check",
1171
+ scope: "memory:acceptance",
1172
+ source: "agent",
1173
+ tags: ["acceptance-test"],
1174
+ });
1175
+
1176
+ expect(promoted.disposition).toBe("conflict");
1177
+ expect(promoted.id).toBe(durable.id);
1178
+ expect(typeof promoted.conflictId).toBe("string");
1179
+ expect(conflicts).toHaveLength(1);
1180
+ expect(conflicts[0]).toMatchObject({
1181
+ conflictId: promoted.conflictId,
1182
+ reason: "canonical_key_conflicts_with_existing_durable",
1183
+ existing: {
1184
+ memoryId: durable.id,
1185
+ category: "preferences",
1186
+ },
1187
+ incoming: {
1188
+ category: "events",
1189
+ sourceMemoryId: source.id,
1190
+ },
1191
+ });
1192
+ expect(storedEntries).toHaveLength(2);
1193
+ });
1194
+ });
1195
+
1196
+ // Regression (2026-07-10): admission rejections used to drop admission.reason
1197
+ // entirely — MCP responses showed only "Disposition: rejected" with no way to
1198
+ // tell which of the four gates fired. rejectionReason must survive to the record.
1199
+ describe("persistMemory — admission rejection reason passthrough", () => {
1200
+ it("surfaces rejectionReason when the noise filter rejects", async () => {
1201
+ const { deps } = createDeps();
1202
+ const result = await persistMemory(deps as any, {
1203
+ text: "hello world, how are you today?",
1204
+ category: "events",
1205
+ importance: 0.7,
1206
+ scope: TEST_SCOPE,
1207
+ source: "manual",
1208
+ });
1209
+ expect(result.disposition).toBe("rejected");
1210
+ expect(result.rejectionReason).toBe("noise_detected");
1211
+ });
1212
+
1213
+ it("surfaces rejectionReason for too-short text", async () => {
1214
+ const { deps } = createDeps();
1215
+ const result = await persistMemory(deps as any, {
1216
+ text: "短",
1217
+ category: "events",
1218
+ importance: 0.7,
1219
+ scope: TEST_SCOPE,
1220
+ source: "manual",
1221
+ });
1222
+ expect(result.disposition).toBe("rejected");
1223
+ expect(result.rejectionReason).toBe("text_too_short");
1224
+ });
1225
+
1226
+ it("stores a hippo-prefixed memory instead of rejecting it as a greeting", async () => {
1227
+ const { deps } = createDeps();
1228
+ const result = await persistMemory(deps as any, {
1229
+ text: "hippo-wiki 的索引层已清零并写完 8 个现役工具正文",
1230
+ category: "events",
1231
+ importance: 0.7,
1232
+ scope: TEST_SCOPE,
1233
+ source: "manual",
1234
+ });
1235
+ expect(result.disposition).not.toBe("rejected");
1236
+ expect(result.rejectionReason).toBeUndefined();
1237
+ });
1238
+ });