recallnest 3.0.0 → 3.0.1

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