recallnest 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/.env +4 -0
  2. package/CLAUDE.md +1 -1
  3. package/README.md +30 -5
  4. package/README_CN.md +30 -5
  5. package/config.json +55 -0
  6. package/connectors/examples/email-adapter.ts +73 -0
  7. package/connectors/examples/logs-adapter.ts +103 -0
  8. package/connectors/examples/rss-adapter.ts +95 -0
  9. package/logs/daily-recall.error.log +0 -0
  10. package/logs/daily-recall.log +60 -0
  11. package/logs/ingest-2026-04-11.log +110 -0
  12. package/logs/ingest-2026-04-12.log +90 -0
  13. package/logs/ingest-2026-04-13.log +87 -0
  14. package/logs/launchagent-stderr.log +0 -0
  15. package/logs/launchagent-stdout.log +0 -0
  16. package/package.json +1 -1
  17. package/src/cli.ts +30 -0
  18. package/src/connector-types.ts +57 -0
  19. package/src/conversation-importer.ts +25 -1
  20. package/src/data-checkup.ts +31 -1
  21. package/src/doctor.ts +29 -0
  22. package/src/ingest.ts +203 -0
  23. package/src/mcp-server.ts +2 -2
  24. package/src/obsidian-connector.ts +282 -0
  25. package/src/session-schema.ts +2 -2
  26. package/src/source-heartbeat.ts +108 -0
  27. package/.claude-plugin/CLAUDE.md +0 -29
  28. package/.claude-plugin/marketplace.json +0 -18
  29. package/.dockerignore +0 -11
  30. package/.github/workflows/ci.yml +0 -28
  31. package/.mcp.json +0 -9
  32. package/CHANGELOG.md +0 -51
  33. package/DEMO.md +0 -94
  34. package/Dockerfile +0 -20
  35. package/EVAL.md +0 -89
  36. package/FAILURES.md +0 -75
  37. package/OPERATIONS.md +0 -59
  38. package/ROADMAP.md +0 -245
  39. package/SKILL.md +0 -250
  40. package/assets/dashboard.png +0 -0
  41. package/assets/knowledge-graph.png +0 -0
  42. package/assets/recallnest-workbench.png +0 -0
  43. package/assets/screenshots/ui-full.png +0 -0
  44. package/assets/screenshots/ui-overview.png +0 -0
  45. package/assets/screenshots/ui-search.png +0 -0
  46. package/assets/ui/app.js +0 -1031
  47. package/assets/ui/index.html +0 -224
  48. package/assets/ui/styles.css +0 -781
  49. package/assets/wechat-qr.jpg +0 -0
  50. package/bun.lock +0 -337
  51. package/daily-reflections/2026-03-05.md +0 -15
  52. package/daily-reflections/2026-03-06.md +0 -15
  53. package/daily-reflections/2026-03-07.md +0 -7
  54. package/daily-reflections/2026-03-08.md +0 -34
  55. package/daily-reflections/2026-03-09.md +0 -35
  56. package/daily-reflections/2026-03-10.md +0 -36
  57. package/daily-reflections/2026-03-11.md +0 -35
  58. package/daily-reflections/2026-03-12.md +0 -36
  59. package/daily-reflections/2026-03-13.md +0 -35
  60. package/daily-reflections/2026-03-14.md +0 -35
  61. package/daily-reflections/2026-03-15.md +0 -35
  62. package/daily-reflections/2026-03-16.md +0 -41
  63. package/daily-reflections/2026-03-17.md +0 -10
  64. package/daily-reflections/2026-03-18.md +0 -10
  65. package/daily-reflections/2026-03-19.md +0 -10
  66. package/daily-reflections/2026-03-20.md +0 -10
  67. package/daily-reflections/2026-03-21.md +0 -10
  68. package/daily-reflections/2026-03-22.md +0 -10
  69. package/daily-reflections/2026-03-23.md +0 -10
  70. package/daily-reflections/2026-03-24.md +0 -10
  71. package/daily-reflections/2026-03-25.md +0 -32
  72. package/daily-reflections/2026-03-26.md +0 -37
  73. package/daily-reflections/2026-03-27.md +0 -35
  74. package/daily-reflections/2026-03-28.md +0 -35
  75. package/daily-reflections/2026-03-29.md +0 -35
  76. package/daily-reflections/2026-03-30.md +0 -36
  77. package/daily-reflections/2026-03-31.md +0 -35
  78. package/daily-reflections/2026-04-01.md +0 -35
  79. package/daily-reflections/2026-04-02.md +0 -35
  80. package/daily-reflections/2026-04-03.md +0 -37
  81. package/daily-reflections/2026-04-04.md +0 -37
  82. package/daily-reflections/2026-04-05.md +0 -35
  83. package/daily-reflections/2026-04-06.md +0 -36
  84. package/daily-reflections/2026-04-07.md +0 -35
  85. package/daily-reflections/2026-04-08.md +0 -35
  86. package/daily-reflections/2026-04-12.md +0 -35
  87. package/data/alias-map.example.json +0 -22
  88. package/docs/api-reference.md +0 -1291
  89. package/docs/architecture.md +0 -168
  90. package/docs/memory-boundary-contract.md +0 -226
  91. package/docs/memory-categories.md +0 -119
  92. package/docs/pipeline-audit-ultramemory.md +0 -177
  93. package/docs/plans/2026-03-08-query-expansion.md +0 -270
  94. package/docs/plans/2026-03-16-continuity-layer.md +0 -196
  95. package/docs/plans/2026-04-01-recall-boost.md +0 -100
  96. package/docs/plans/mcp-tiering-plan.md +0 -126
  97. package/docs/research/recall-quality-audit-2026-03-22.md +0 -153
  98. package/docs/self-evolution.md +0 -198
  99. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +0 -782
  100. package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +0 -1195
  101. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +0 -77
  102. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +0 -95
  103. package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +0 -398
  104. package/eval/continuity/results-2026-03-16.md +0 -117
  105. package/eval/continuity/results-2026-03-19.md +0 -453
  106. package/eval/continuity/runbook.md +0 -110
  107. package/eval/continuity/seed-cases.md +0 -54
  108. package/eval/reports/2026-03-06-abstract-query-baseline.md +0 -96
  109. package/eval/reports/2026-03-06-aws-ssh-baseline.md +0 -82
  110. package/eval/reports/2026-03-06-baseline.md +0 -82
  111. package/eval/reports/query-expansion.md +0 -138
  112. package/glama.json +0 -6
  113. package/lm +0 -19
  114. package/plan-philosophy-upgrades.md +0 -363
  115. package/plan-product-upgrades.md +0 -155
  116. package/plan-v1.2-distributable.md +0 -105
  117. package/plan.md +0 -140
  118. package/research-memory-evolution.md +0 -440
  119. package/research-rl-agent-memory.md +0 -330
  120. package/scripts/archive-distilled-junk.ts +0 -116
  121. package/scripts/backfill-anchors.ts +0 -130
  122. package/scripts/backfill-l0l1l2.ts +0 -98
  123. package/scripts/blind-scan.ts +0 -588
  124. package/scripts/cleanup-step1-exact-dups.ts +0 -117
  125. package/scripts/cleanup-step2-template-dups.ts +0 -113
  126. package/scripts/cleanup-step3-stale-content.ts +0 -149
  127. package/scripts/cleanup-step4-orphan-scopes.ts +0 -131
  128. package/scripts/delete-distilled-junk.ts +0 -88
  129. package/scripts/diagnose-vector-search.ts +0 -132
  130. package/scripts/distill-facts.ts +0 -305
  131. package/scripts/health-check.ts +0 -343
  132. package/scripts/incremental-ingest.sh +0 -71
  133. package/scripts/migrate-language.ts +0 -75
  134. package/scripts/mlp-health-check.ts +0 -382
  135. package/scripts/pull-claude-desktop.ts +0 -515
  136. package/scripts/re-ingest-cc.sh +0 -66
  137. package/scripts/rebalance-memory-health.ts +0 -181
  138. package/scripts/run-desktop-ingest.sh +0 -11
  139. package/scripts/start-server.sh +0 -12
  140. package/scripts/verify-emotion-decay.ts +0 -148
  141. package/scripts/viz-dashboard.html +0 -423
  142. package/scripts/viz-export.ts +0 -703
  143. package/src/__tests__/access-tracker-novelty.test.ts +0 -228
  144. package/src/__tests__/activity-counter.test.ts +0 -96
  145. package/src/__tests__/admission-control.test.ts +0 -206
  146. package/src/__tests__/anchor-generator.test.ts +0 -67
  147. package/src/__tests__/auto-consolidation.test.ts +0 -168
  148. package/src/__tests__/auto-recall.test.ts +0 -131
  149. package/src/__tests__/babel-integration.test.ts +0 -139
  150. package/src/__tests__/batch-dedup.test.ts +0 -65
  151. package/src/__tests__/capture-engine.test.ts +0 -1144
  152. package/src/__tests__/capture-heuristic.test.ts +0 -171
  153. package/src/__tests__/cascade-forget.test.ts +0 -210
  154. package/src/__tests__/category-min-scores.test.ts +0 -33
  155. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  156. package/src/__tests__/circuit-breaker.test.ts +0 -99
  157. package/src/__tests__/cluster-consolidation.test.ts +0 -712
  158. package/src/__tests__/confidence-metadata.test.ts +0 -125
  159. package/src/__tests__/confidence-tracker.test.ts +0 -249
  160. package/src/__tests__/conflict-advisor.test.ts +0 -206
  161. package/src/__tests__/conflict-escalation.test.ts +0 -149
  162. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  163. package/src/__tests__/conflict-output.test.ts +0 -85
  164. package/src/__tests__/conflict-store.test.ts +0 -374
  165. package/src/__tests__/consolidation-engine.test.ts +0 -181
  166. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  167. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  168. package/src/__tests__/context-composer-pins.test.ts +0 -64
  169. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  170. package/src/__tests__/context-reconstructor.test.ts +0 -65
  171. package/src/__tests__/context-renderer.test.ts +0 -91
  172. package/src/__tests__/continuity-eval.test.ts +0 -275
  173. package/src/__tests__/continuity-schema.test.ts +0 -97
  174. package/src/__tests__/conversation-importer.test.ts +0 -692
  175. package/src/__tests__/core-recall-eval.test.ts +0 -235
  176. package/src/__tests__/core-summary.test.ts +0 -109
  177. package/src/__tests__/data-checkup.test.ts +0 -225
  178. package/src/__tests__/decay-exemption.test.ts +0 -84
  179. package/src/__tests__/dedup-actions.test.ts +0 -58
  180. package/src/__tests__/dedup-guard.test.ts +0 -39
  181. package/src/__tests__/deterministic-id.test.ts +0 -39
  182. package/src/__tests__/distill-lock.test.ts +0 -176
  183. package/src/__tests__/doctor.test.ts +0 -303
  184. package/src/__tests__/dream-pipeline.test.ts +0 -232
  185. package/src/__tests__/drill-down.test.ts +0 -108
  186. package/src/__tests__/embedder.test.ts +0 -122
  187. package/src/__tests__/emotion-decay.test.ts +0 -46
  188. package/src/__tests__/emotion-detector.test.ts +0 -56
  189. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  190. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  191. package/src/__tests__/entity-resolver.test.ts +0 -58
  192. package/src/__tests__/ethics-layer.test.ts +0 -476
  193. package/src/__tests__/event-segmenter.test.ts +0 -153
  194. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  195. package/src/__tests__/frequency-tracker.test.ts +0 -113
  196. package/src/__tests__/graph-export.test.ts +0 -489
  197. package/src/__tests__/hotness-blend.test.ts +0 -76
  198. package/src/__tests__/ingest.test.ts +0 -360
  199. package/src/__tests__/interference-detector.test.ts +0 -203
  200. package/src/__tests__/kg-extractor.test.ts +0 -184
  201. package/src/__tests__/kg-store.test.ts +0 -198
  202. package/src/__tests__/llm-consolidation.test.ts +0 -226
  203. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  204. package/src/__tests__/memory-boundaries.test.ts +0 -237
  205. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -243
  206. package/src/__tests__/memory-evolution.test.ts +0 -271
  207. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  208. package/src/__tests__/memory-lint.test.ts +0 -271
  209. package/src/__tests__/memory-output.test.ts +0 -215
  210. package/src/__tests__/multi-vector.test.ts +0 -242
  211. package/src/__tests__/narrative-architecture.test.ts +0 -456
  212. package/src/__tests__/pending-review.test.ts +0 -77
  213. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  214. package/src/__tests__/ppr-traversal.test.ts +0 -256
  215. package/src/__tests__/prediction-engine.test.ts +0 -349
  216. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  217. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  218. package/src/__tests__/preference-matcher.test.ts +0 -230
  219. package/src/__tests__/preference-slots.test.ts +0 -205
  220. package/src/__tests__/prospective-memory.test.ts +0 -283
  221. package/src/__tests__/query-entity-detector.test.ts +0 -171
  222. package/src/__tests__/query-expander-alias.test.ts +0 -89
  223. package/src/__tests__/query-expander.test.ts +0 -34
  224. package/src/__tests__/recall-governor.test.ts +0 -227
  225. package/src/__tests__/recall-mode.test.ts +0 -64
  226. package/src/__tests__/result-synthesizer.test.ts +0 -198
  227. package/src/__tests__/resume-context.test.ts +0 -4898
  228. package/src/__tests__/retrieval-trace.test.ts +0 -213
  229. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  230. package/src/__tests__/retroactive-boost.test.ts +0 -223
  231. package/src/__tests__/salience-scoring.test.ts +0 -106
  232. package/src/__tests__/scope-inventory.test.ts +0 -200
  233. package/src/__tests__/scope-policy.test.ts +0 -69
  234. package/src/__tests__/session-checkpoint.test.ts +0 -259
  235. package/src/__tests__/session-distiller.test.ts +0 -611
  236. package/src/__tests__/session-gc.test.ts +0 -215
  237. package/src/__tests__/skill-engine.test.ts +0 -546
  238. package/src/__tests__/skill-promotion.test.ts +0 -349
  239. package/src/__tests__/store-scope-audit.test.ts +0 -81
  240. package/src/__tests__/supersede-chain.test.ts +0 -154
  241. package/src/__tests__/temporal-parser.test.ts +0 -241
  242. package/src/__tests__/temporal-validity.test.ts +0 -160
  243. package/src/__tests__/term-registry.test.ts +0 -121
  244. package/src/__tests__/tool-output-compressor.test.ts +0 -133
  245. package/src/__tests__/topic-tag.test.ts +0 -113
  246. package/src/__tests__/trusted-memory.test.ts +0 -828
  247. package/src/__tests__/version-manager.test.ts +0 -213
  248. package/src/__tests__/workflow-observation.test.ts +0 -400
  249. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -1,1144 +0,0 @@
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
-
455
- describe("promoteMemory", () => {
456
- it("promotes an evidence transcript entry into durable memory with provenance", async () => {
457
- const { deps, storedEntries } = createDeps();
458
- const source = await deps.store.store({
459
- text: "[用户] 用户偏好短句直说。\n\n[助手] 后续回复保持简洁。",
460
- vector: [1, 2, 3],
461
- category: "events",
462
- scope: "cc:session1",
463
- importance: 0.5,
464
- metadata: JSON.stringify({
465
- source: "cc",
466
- boundary: {
467
- layer: "evidence",
468
- authority: "transcript-ingest",
469
- conflictPolicy: "append-only",
470
- originalCategory: "preferences",
471
- },
472
- }),
473
- });
474
-
475
- const promoted = await promoteMemory(deps as any, {
476
- memoryId: source.id,
477
- text: "User prefers concise, direct replies.",
478
- category: "preferences",
479
- scope: TEST_SCOPE,
480
- canonicalKey: "user.reply.style",
481
- tags: ["writing"],
482
- });
483
-
484
- expect(promoted.disposition).toBe("promoted");
485
- expect(promoted.sourceMemoryId).toBe(source.id);
486
- expect(promoted.sourceCategory).toBe("events");
487
- expect(promoted.canonicalKey).toBe("user-reply-style");
488
- expect(storedEntries).toHaveLength(2);
489
- expect(JSON.parse(storedEntries[1].metadata)).toMatchObject({
490
- source: "agent",
491
- tags: ["writing"],
492
- capture: "promote_memory_schema_v1",
493
- boundary: {
494
- layer: "durable",
495
- authority: "structured-memory",
496
- conflictPolicy: "latest-wins",
497
- originalCategory: "preferences",
498
- note: "Structured memory writes are the durable source inside RecallNest.",
499
- },
500
- canonicalKey: "user-reply-style",
501
- promotedFrom: {
502
- memoryId: source.id,
503
- scope: "cc:session1",
504
- category: "events",
505
- boundary: {
506
- layer: "evidence",
507
- authority: "transcript-ingest",
508
- conflictPolicy: "append-only",
509
- originalCategory: "preferences",
510
- },
511
- source: "cc",
512
- },
513
- preferenceSlot: {
514
- type: "reply-style",
515
- traits: ["concise", "direct"],
516
- },
517
- });
518
- });
519
-
520
- it("infers a slot-aware canonical key when promoting an atomic brand-item preference", async () => {
521
- const { deps, storedEntries } = createDeps();
522
- const source = await deps.store.store({
523
- text: "[用户] 我喜欢吃麦当劳的麦辣鸡翅。",
524
- vector: [1, 2, 3],
525
- category: "events",
526
- scope: "cc:session-food-pref",
527
- importance: 0.5,
528
- metadata: JSON.stringify({
529
- source: "cc",
530
- boundary: {
531
- layer: "evidence",
532
- authority: "transcript-ingest",
533
- conflictPolicy: "append-only",
534
- originalCategory: "preferences",
535
- },
536
- }),
537
- });
538
-
539
- const promoted = await promoteMemory(deps as any, {
540
- memoryId: source.id,
541
- text: "我喜欢吃麦当劳的麦辣鸡翅",
542
- category: "preferences",
543
- scope: TEST_SCOPE,
544
- tags: ["food"],
545
- });
546
-
547
- expect(promoted.disposition).toBe("promoted");
548
- expect(promoted.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
549
- expect(JSON.parse(storedEntries[1].metadata)).toMatchObject({
550
- canonicalKey: "preferences:brand-item:麦当劳:麦辣鸡翅",
551
- preferenceSlot: {
552
- type: "brand-item",
553
- brand: "麦当劳",
554
- item: "麦辣鸡翅",
555
- },
556
- });
557
- });
558
-
559
- it("accepts a unique source memory prefix for promotion", async () => {
560
- const { deps } = createDeps();
561
- const source = await deps.store.store({
562
- text: "[用户] 用户偏好短句直说。",
563
- vector: [1, 2, 3],
564
- category: "events",
565
- scope: "cc:session-prefix",
566
- importance: 0.5,
567
- metadata: JSON.stringify({
568
- source: "cc",
569
- boundary: {
570
- layer: "evidence",
571
- authority: "transcript-ingest",
572
- conflictPolicy: "append-only",
573
- originalCategory: "preferences",
574
- },
575
- }),
576
- });
577
-
578
- const promoted = await promoteMemory(deps as any, {
579
- memoryId: source.id.slice(0, 8),
580
- text: "User prefers concise, direct replies.",
581
- category: "preferences",
582
- scope: TEST_SCOPE,
583
- canonicalKey: "user.reply.style.prefix",
584
- tags: ["writing"],
585
- });
586
-
587
- expect(promoted.disposition).toBe("promoted");
588
- expect(promoted.sourceMemoryId).toBe(source.id);
589
- expect(promoted.canonicalKey).toBe("user-reply-style-prefix");
590
- });
591
-
592
- it("rejects promoting an already-durable memory", async () => {
593
- const { deps } = createDeps();
594
- const durable = await deps.store.store({
595
- text: "User prefers direct replies.",
596
- vector: [1, 2, 3],
597
- category: "preferences",
598
- scope: "memory:agent",
599
- importance: 0.8,
600
- metadata: JSON.stringify({
601
- source: "agent",
602
- boundary: {
603
- layer: "durable",
604
- authority: "structured-memory",
605
- conflictPolicy: "latest-wins",
606
- originalCategory: "preferences",
607
- },
608
- }),
609
- });
610
-
611
- await expect(promoteMemory(deps as any, {
612
- memoryId: durable.id,
613
- scope: TEST_SCOPE,
614
- })).rejects.toThrow("already durable");
615
- });
616
-
617
- it("creates a conflict candidate instead of silently overwriting durable memory", async () => {
618
- const { deps, storedEntries, conflicts } = createDeps();
619
- const source = await deps.store.store({
620
- text: "[用户] 文章得更口语化,但不能太飘。",
621
- vector: [1, 2, 3],
622
- category: "events",
623
- scope: "cc:session2",
624
- importance: 0.55,
625
- metadata: JSON.stringify({
626
- source: "cc",
627
- boundary: {
628
- layer: "evidence",
629
- authority: "transcript-ingest",
630
- conflictPolicy: "append-only",
631
- originalCategory: "preferences",
632
- },
633
- }),
634
- });
635
- const durable = await deps.store.store({
636
- text: "User prefers concise, direct replies.",
637
- vector: [4, 5, 6],
638
- category: "preferences",
639
- scope: "memory:agent",
640
- importance: 0.9,
641
- metadata: JSON.stringify({
642
- source: "agent",
643
- canonicalKey: "user-reply-style",
644
- boundary: {
645
- layer: "durable",
646
- authority: "structured-memory",
647
- conflictPolicy: "latest-wins",
648
- originalCategory: "preferences",
649
- },
650
- }),
651
- });
652
-
653
- const promoted = await promoteMemory(deps as any, {
654
- memoryId: source.id,
655
- text: "User prefers colloquial writing that stays grounded and non-salesy.",
656
- category: "preferences",
657
- scope: TEST_SCOPE,
658
- canonicalKey: "user.reply.style",
659
- tags: ["writing"],
660
- });
661
-
662
- expect(promoted.disposition).toBe("conflict");
663
- expect(promoted.id).toBe(durable.id);
664
- expect(typeof promoted.conflictId).toBe("string");
665
- expect(storedEntries).toHaveLength(2);
666
- expect(storedEntries[1].text).toBe("User prefers concise, direct replies.");
667
- expect(conflicts).toHaveLength(1);
668
- expect(conflicts[0]).toMatchObject({
669
- conflictId: promoted.conflictId,
670
- canonicalKey: "user-reply-style",
671
- reason: "promotion_conflicts_with_existing_durable",
672
- status: "open",
673
- existing: {
674
- memoryId: durable.id,
675
- text: "User prefers concise, direct replies.",
676
- },
677
- incoming: {
678
- sourceMemoryId: source.id,
679
- text: "User prefers colloquial writing that stays grounded and non-salesy.",
680
- },
681
- });
682
-
683
- const repeated = await promoteMemory(deps as any, {
684
- memoryId: source.id,
685
- text: "User prefers colloquial writing that stays grounded and non-salesy.",
686
- category: "preferences",
687
- scope: TEST_SCOPE,
688
- canonicalKey: "user.reply.style",
689
- tags: ["writing"],
690
- });
691
-
692
- expect(repeated.disposition).toBe("conflict");
693
- expect(repeated.conflictId).toBe(promoted.conflictId);
694
- expect(conflicts).toHaveLength(1);
695
- });
696
-
697
- it("collapses same-slot atomic preference promotions onto the existing durable owner without opening a conflict", async () => {
698
- const { deps, storedEntries, conflicts } = createDeps();
699
- const durable = await persistMemory(deps as any, {
700
- text: "我喜欢吃麦当劳的麦辣鸡翅",
701
- category: "preferences",
702
- scope: TEST_SCOPE,
703
- source: "manual",
704
- });
705
- const source = await deps.store.store({
706
- text: "[用户] 我很喜欢吃麦当劳的麦辣鸡翅。",
707
- vector: [1, 2, 3],
708
- category: "events",
709
- scope: "cc:session-food-pref-rephrase",
710
- importance: 0.55,
711
- metadata: JSON.stringify({
712
- source: "cc",
713
- boundary: {
714
- layer: "evidence",
715
- authority: "transcript-ingest",
716
- conflictPolicy: "append-only",
717
- originalCategory: "preferences",
718
- },
719
- }),
720
- });
721
-
722
- const promoted = await promoteMemory(deps as any, {
723
- memoryId: source.id,
724
- text: "我很喜欢吃麦当劳的麦辣鸡翅",
725
- category: "preferences",
726
- scope: TEST_SCOPE,
727
- tags: ["food"],
728
- });
729
-
730
- expect(promoted.disposition).toBe("promoted");
731
- expect(promoted.id).toBe(durable.id);
732
- expect(promoted.canonicalKey).toBe("preferences:brand-item:麦当劳:麦辣鸡翅");
733
- expect(storedEntries).toHaveLength(2);
734
- expect(storedEntries[0]?.text).toBe("我喜欢吃麦当劳的麦辣鸡翅");
735
- expect(JSON.parse(storedEntries[0]?.metadata || "{}")).toMatchObject({
736
- canonicalKey: "preferences:brand-item:麦当劳:麦辣鸡翅",
737
- preferenceSlot: {
738
- type: "brand-item",
739
- brand: "麦当劳",
740
- item: "麦辣鸡翅",
741
- },
742
- provenanceHistoryCount: 1,
743
- provenanceHistory: [{
744
- memoryId: source.id,
745
- scope: "cc:session-food-pref-rephrase",
746
- category: "events",
747
- source: "cc",
748
- boundary: {
749
- layer: "evidence",
750
- authority: "transcript-ingest",
751
- conflictPolicy: "append-only",
752
- originalCategory: "preferences",
753
- },
754
- }],
755
- });
756
- expect(typeof JSON.parse(storedEntries[0]?.metadata || "{}").provenanceHistory?.[0]?.observedAt).toBe("string");
757
- expect(conflicts).toHaveLength(0);
758
- });
759
-
760
- it("collapses same-slot reply-style promotions onto the existing durable owner", async () => {
761
- const { deps, storedEntries, conflicts } = createDeps();
762
- const durable = await persistMemory(deps as any, {
763
- text: "User prefers concise, direct replies.",
764
- category: "preferences",
765
- scope: TEST_SCOPE,
766
- source: "manual",
767
- });
768
- const source = await deps.store.store({
769
- text: "[用户] 用户偏好短句直说。",
770
- vector: [1, 2, 3],
771
- category: "events",
772
- scope: "cc:session-reply-style-slot",
773
- importance: 0.55,
774
- metadata: JSON.stringify({
775
- source: "cc",
776
- boundary: {
777
- layer: "evidence",
778
- authority: "transcript-ingest",
779
- conflictPolicy: "append-only",
780
- originalCategory: "preferences",
781
- },
782
- }),
783
- });
784
-
785
- const promoted = await promoteMemory(deps as any, {
786
- memoryId: source.id,
787
- text: "User prefers direct, concise replies.",
788
- category: "preferences",
789
- scope: TEST_SCOPE,
790
- tags: ["writing"],
791
- });
792
-
793
- expect(promoted.disposition).toBe("promoted");
794
- expect(promoted.id).toBe(durable.id);
795
- expect(promoted.canonicalKey).toBe("preferences:reply-style:concise:direct");
796
- expect(JSON.parse(storedEntries[0]?.metadata || "{}")).toMatchObject({
797
- canonicalKey: "preferences:reply-style:concise:direct",
798
- preferenceSlot: {
799
- type: "reply-style",
800
- traits: ["concise", "direct"],
801
- },
802
- provenanceHistoryCount: 1,
803
- provenanceHistory: [{
804
- memoryId: source.id,
805
- scope: "cc:session-reply-style-slot",
806
- category: "events",
807
- source: "cc",
808
- }],
809
- });
810
- expect(conflicts).toHaveLength(0);
811
- });
812
-
813
- it("does not collapse descriptive draft text onto an existing reply-style durable owner", async () => {
814
- const { deps, storedEntries, conflicts } = createDeps();
815
- const durable = await persistMemory(deps as any, {
816
- text: "User prefers concise, direct replies.",
817
- category: "preferences",
818
- scope: TEST_SCOPE,
819
- source: "manual",
820
- });
821
- const source = await deps.store.store({
822
- text: "[用户] 这段文案简洁直接,先别改。",
823
- vector: [1, 2, 3],
824
- category: "events",
825
- scope: "cc:session-reply-style-note",
826
- importance: 0.55,
827
- metadata: JSON.stringify({
828
- source: "cc",
829
- boundary: {
830
- layer: "evidence",
831
- authority: "transcript-ingest",
832
- conflictPolicy: "append-only",
833
- originalCategory: "preferences",
834
- },
835
- }),
836
- });
837
-
838
- const promoted = await promoteMemory(deps as any, {
839
- memoryId: source.id,
840
- text: "这段文案简洁直接,先别改。",
841
- category: "preferences",
842
- scope: TEST_SCOPE,
843
- tags: ["writing"],
844
- });
845
-
846
- expect(promoted.disposition).toBe("promoted");
847
- expect(promoted.id).not.toBe(durable.id);
848
- expect(promoted.canonicalKey).toBe("preferences:这段文案简洁直接-先别改");
849
- expect(storedEntries).toHaveLength(3);
850
- expect(JSON.parse(storedEntries[2]?.metadata || "{}")).toMatchObject({
851
- canonicalKey: "preferences:这段文案简洁直接-先别改",
852
- promotedFrom: {
853
- memoryId: source.id,
854
- scope: "cc:session-reply-style-note",
855
- category: "events",
856
- source: "cc",
857
- },
858
- });
859
- expect(JSON.parse(storedEntries[2]?.metadata || "{}")).not.toHaveProperty("preferenceSlot");
860
- expect(conflicts).toHaveLength(0);
861
- });
862
-
863
- it("collapses same-slot tool-choice promotions onto the existing durable owner", async () => {
864
- const { deps, storedEntries, conflicts } = createDeps();
865
- const durable = await persistMemory(deps as any, {
866
- text: "Uses Bun over Node.",
867
- category: "preferences",
868
- scope: TEST_SCOPE,
869
- source: "manual",
870
- });
871
- const source = await deps.store.store({
872
- text: "[用户] 更喜欢用 Bun 而不是 Node。",
873
- vector: [1, 2, 3],
874
- category: "events",
875
- scope: "cc:session-tool-choice-slot",
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: "Prefers Bun over Node.",
891
- category: "preferences",
892
- scope: TEST_SCOPE,
893
- tags: ["tooling"],
894
- });
895
-
896
- expect(promoted.disposition).toBe("promoted");
897
- expect(promoted.id).toBe(durable.id);
898
- expect(promoted.canonicalKey).toBe("preferences:tool-choice:bun:over:node");
899
- expect(JSON.parse(storedEntries[0]?.metadata || "{}")).toMatchObject({
900
- canonicalKey: "preferences:tool-choice:bun:over:node",
901
- preferenceSlot: {
902
- type: "tool-choice",
903
- preferredTool: "bun",
904
- avoidedTool: "node",
905
- },
906
- provenanceHistoryCount: 1,
907
- provenanceHistory: [{
908
- memoryId: source.id,
909
- scope: "cc:session-tool-choice-slot",
910
- category: "events",
911
- source: "cc",
912
- }],
913
- });
914
- expect(conflicts).toHaveLength(0);
915
- });
916
-
917
- it("does not collapse narrative migration text onto an existing tool-choice durable owner", async () => {
918
- const { deps, storedEntries, conflicts } = createDeps();
919
- const durable = await persistMemory(deps as any, {
920
- text: "Prefers rg over grep.",
921
- category: "preferences",
922
- scope: TEST_SCOPE,
923
- source: "manual",
924
- });
925
- const source = await deps.store.store({
926
- text: "[用户] 文档里写了 uses Bun over Node 的迁移说明。",
927
- vector: [1, 2, 3],
928
- category: "events",
929
- scope: "cc:session-tool-choice-note",
930
- importance: 0.55,
931
- metadata: JSON.stringify({
932
- source: "cc",
933
- boundary: {
934
- layer: "evidence",
935
- authority: "transcript-ingest",
936
- conflictPolicy: "append-only",
937
- originalCategory: "preferences",
938
- },
939
- }),
940
- });
941
-
942
- const promoted = await promoteMemory(deps as any, {
943
- memoryId: source.id,
944
- text: "文档里写了 uses Bun over Node 的迁移说明。",
945
- category: "preferences",
946
- scope: TEST_SCOPE,
947
- tags: ["tooling"],
948
- });
949
-
950
- expect(promoted.disposition).toBe("promoted");
951
- expect(promoted.id).not.toBe(durable.id);
952
- expect(promoted.canonicalKey).toBe("preferences:文档里写了-uses-bun-over-node-的迁移说明");
953
- expect(storedEntries).toHaveLength(3);
954
- expect(JSON.parse(storedEntries[2]?.metadata || "{}")).toMatchObject({
955
- canonicalKey: "preferences:文档里写了-uses-bun-over-node-的迁移说明",
956
- promotedFrom: {
957
- memoryId: source.id,
958
- scope: "cc:session-tool-choice-note",
959
- category: "events",
960
- source: "cc",
961
- },
962
- });
963
- expect(JSON.parse(storedEntries[2]?.metadata || "{}")).not.toHaveProperty("preferenceSlot");
964
- expect(conflicts).toHaveLength(0);
965
- });
966
-
967
- it("does not duplicate provenance history when the same exact promotion is repeated", async () => {
968
- const { deps, storedEntries } = createDeps();
969
- await persistMemory(deps as any, {
970
- text: "我喜欢吃麦当劳的麦辣鸡翅",
971
- category: "preferences",
972
- scope: TEST_SCOPE,
973
- source: "manual",
974
- });
975
- const source = await deps.store.store({
976
- text: "[用户] 我喜欢吃麦当劳的麦辣鸡翅。",
977
- vector: [1, 2, 3],
978
- category: "events",
979
- scope: "cc:session-food-pref-exact",
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
- await promoteMemory(deps as any, {
993
- memoryId: source.id,
994
- text: "我喜欢吃麦当劳的麦辣鸡翅",
995
- category: "preferences",
996
- scope: TEST_SCOPE,
997
- });
998
- await promoteMemory(deps as any, {
999
- memoryId: source.id,
1000
- text: "我喜欢吃麦当劳的麦辣鸡翅",
1001
- category: "preferences",
1002
- scope: TEST_SCOPE,
1003
- });
1004
-
1005
- const metadata = JSON.parse(storedEntries[0]?.metadata || "{}");
1006
- expect(metadata.provenanceHistoryCount).toBe(1);
1007
- expect(metadata.provenanceHistory).toHaveLength(1);
1008
- expect(metadata.provenanceHistory[0]?.memoryId).toBe(source.id);
1009
- });
1010
-
1011
- it("reopens the same conflict record when the same resolved conflict happens again", async () => {
1012
- const { deps, conflicts } = createDeps();
1013
- const source = await deps.store.store({
1014
- text: "[用户] 文章得更口语化,但不能太飘。",
1015
- vector: [1, 2, 3],
1016
- category: "events",
1017
- scope: "cc:session-reopen",
1018
- importance: 0.55,
1019
- metadata: JSON.stringify({
1020
- source: "cc",
1021
- boundary: {
1022
- layer: "evidence",
1023
- authority: "transcript-ingest",
1024
- conflictPolicy: "append-only",
1025
- originalCategory: "preferences",
1026
- },
1027
- }),
1028
- });
1029
- await deps.store.store({
1030
- text: "User prefers concise, direct replies.",
1031
- vector: [4, 5, 6],
1032
- category: "preferences",
1033
- scope: "memory:agent",
1034
- importance: 0.9,
1035
- metadata: JSON.stringify({
1036
- source: "agent",
1037
- canonicalKey: "user-reply-style-reopen",
1038
- boundary: {
1039
- layer: "durable",
1040
- authority: "structured-memory",
1041
- conflictPolicy: "latest-wins",
1042
- originalCategory: "preferences",
1043
- },
1044
- }),
1045
- });
1046
-
1047
- const first = await promoteMemory(deps as any, {
1048
- memoryId: source.id,
1049
- text: "User prefers colloquial writing that stays grounded and non-salesy.",
1050
- category: "preferences",
1051
- scope: TEST_SCOPE,
1052
- canonicalKey: "user.reply.style.reopen",
1053
- tags: ["writing"],
1054
- });
1055
-
1056
- await deps.conflictStore.replace({
1057
- ...conflicts[0],
1058
- status: "kept-existing",
1059
- updatedAt: "2026-03-16T10:00:00.000Z",
1060
- resolvedAt: "2026-03-16T10:00:00.000Z",
1061
- resolutionNotes: "Previous review kept the existing durable memory.",
1062
- });
1063
-
1064
- const reopened = await promoteMemory(deps as any, {
1065
- memoryId: source.id,
1066
- text: "User prefers colloquial writing that stays grounded and non-salesy.",
1067
- category: "preferences",
1068
- scope: TEST_SCOPE,
1069
- canonicalKey: "user.reply.style.reopen",
1070
- tags: ["writing"],
1071
- });
1072
-
1073
- expect(reopened.disposition).toBe("conflict");
1074
- expect(reopened.conflictId).toBe(first.conflictId);
1075
- expect(conflicts).toHaveLength(1);
1076
- expect(conflicts[0]?.status).toBe("open");
1077
- expect(conflicts[0]?.reopenCount).toBe(1);
1078
- expect(typeof conflicts[0]?.lastReopenedAt).toBe("string");
1079
- expect(conflicts[0]?.resolvedAt).toBeUndefined();
1080
- expect(conflicts[0]?.resolutionNotes).toBeUndefined();
1081
- });
1082
-
1083
- it("blocks inferred cross-category promotions under the same canonicalKey", async () => {
1084
- const { deps, storedEntries, conflicts } = createDeps();
1085
- const durable = await deps.store.store({
1086
- text: "User prefers concise, direct replies.",
1087
- vector: [4, 5, 6],
1088
- category: "preferences",
1089
- scope: "memory:agent",
1090
- importance: 0.9,
1091
- metadata: JSON.stringify({
1092
- source: "agent",
1093
- canonicalKey: "acceptance-writing-tone-category-check",
1094
- boundary: {
1095
- layer: "durable",
1096
- authority: "structured-memory",
1097
- conflictPolicy: "latest-wins",
1098
- originalCategory: "preferences",
1099
- },
1100
- }),
1101
- });
1102
- const source = await deps.store.store({
1103
- text: "[用户] 语气别太端着,口语化点。",
1104
- vector: [1, 2, 3],
1105
- category: "fact",
1106
- scope: "cc:session-category-mismatch",
1107
- importance: 0.55,
1108
- metadata: JSON.stringify({
1109
- source: "cc",
1110
- boundary: {
1111
- layer: "evidence",
1112
- authority: "transcript-ingest",
1113
- conflictPolicy: "append-only",
1114
- },
1115
- }),
1116
- });
1117
-
1118
- const promoted = await promoteMemory(deps as any, {
1119
- memoryId: source.id,
1120
- canonicalKey: "acceptance-writing-tone-category-check",
1121
- scope: "memory:acceptance",
1122
- source: "agent",
1123
- tags: ["acceptance-test"],
1124
- });
1125
-
1126
- expect(promoted.disposition).toBe("conflict");
1127
- expect(promoted.id).toBe(durable.id);
1128
- expect(typeof promoted.conflictId).toBe("string");
1129
- expect(conflicts).toHaveLength(1);
1130
- expect(conflicts[0]).toMatchObject({
1131
- conflictId: promoted.conflictId,
1132
- reason: "canonical_key_conflicts_with_existing_durable",
1133
- existing: {
1134
- memoryId: durable.id,
1135
- category: "preferences",
1136
- },
1137
- incoming: {
1138
- category: "events",
1139
- sourceMemoryId: source.id,
1140
- },
1141
- });
1142
- expect(storedEntries).toHaveLength(2);
1143
- });
1144
- });