recallnest 1.4.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 (261) hide show
  1. package/.claude-plugin/CLAUDE.md +29 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.env.example +3 -0
  4. package/.github/workflows/ci.yml +28 -0
  5. package/.mcp.json +9 -0
  6. package/CHANGELOG.md +51 -0
  7. package/CLAUDE.md +42 -0
  8. package/DEMO.md +94 -0
  9. package/EVAL.md +89 -0
  10. package/FAILURES.md +75 -0
  11. package/LICENSE +21 -0
  12. package/OPERATIONS.md +59 -0
  13. package/README.md +427 -0
  14. package/README_CN.md +425 -0
  15. package/ROADMAP.md +198 -0
  16. package/assets/recallnest-workbench.png +0 -0
  17. package/assets/ui/app.js +807 -0
  18. package/assets/ui/index.html +154 -0
  19. package/assets/ui/styles.css +605 -0
  20. package/assets/wechat-qr.jpg +0 -0
  21. package/config.json.example +38 -0
  22. package/daily-recall.sh +87 -0
  23. package/daily-reflections/2026-03-05.md +15 -0
  24. package/daily-reflections/2026-03-06.md +15 -0
  25. package/daily-reflections/2026-03-07.md +7 -0
  26. package/daily-reflections/2026-03-08.md +34 -0
  27. package/daily-reflections/2026-03-09.md +35 -0
  28. package/daily-reflections/2026-03-10.md +36 -0
  29. package/daily-reflections/2026-03-11.md +35 -0
  30. package/daily-reflections/2026-03-12.md +36 -0
  31. package/daily-reflections/2026-03-13.md +35 -0
  32. package/daily-reflections/2026-03-14.md +35 -0
  33. package/daily-reflections/2026-03-15.md +35 -0
  34. package/daily-reflections/2026-03-16.md +41 -0
  35. package/daily-reflections/2026-03-17.md +10 -0
  36. package/daily-reflections/2026-03-18.md +10 -0
  37. package/daily-reflections/2026-03-19.md +10 -0
  38. package/daily-reflections/2026-03-20.md +10 -0
  39. package/daily-reflections/2026-03-21.md +10 -0
  40. package/daily-reflections/2026-03-22.md +10 -0
  41. package/daily-reflections/2026-03-23.md +10 -0
  42. package/daily-reflections/2026-03-24.md +10 -0
  43. package/daily-reflections/2026-03-25.md +32 -0
  44. package/daily-reflections/2026-03-26.md +37 -0
  45. package/daily-reflections/2026-03-27.md +35 -0
  46. package/daily-reflections/2026-03-28.md +35 -0
  47. package/daily-reflections/2026-03-29.md +35 -0
  48. package/daily-reflections/2026-03-30.md +36 -0
  49. package/daily-reflections/2026-03-31.md +35 -0
  50. package/daily-reflections/2026-04-01.md +35 -0
  51. package/docs/api-reference.md +1291 -0
  52. package/docs/architecture.md +168 -0
  53. package/docs/memory-boundary-contract.md +226 -0
  54. package/docs/memory-categories.md +119 -0
  55. package/docs/pipeline-audit-ultramemory.md +177 -0
  56. package/docs/plans/2026-03-08-query-expansion.md +270 -0
  57. package/docs/plans/2026-03-16-continuity-layer.md +196 -0
  58. package/docs/plans/mcp-tiering-plan.md +126 -0
  59. package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
  60. package/docs/self-evolution.md +198 -0
  61. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
  62. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
  63. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
  64. package/eval/cases.json +85 -0
  65. package/eval/continuity/case-seeds.json +90 -0
  66. package/eval/continuity/cases.json +302 -0
  67. package/eval/continuity/memory-seeds.json +26 -0
  68. package/eval/continuity/pattern-seeds.json +100 -0
  69. package/eval/continuity/results-2026-03-16.md +117 -0
  70. package/eval/continuity/results-2026-03-19.md +453 -0
  71. package/eval/continuity/runbook.md +110 -0
  72. package/eval/continuity/seed-cases.md +54 -0
  73. package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
  74. package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
  75. package/eval/reports/2026-03-06-baseline.md +82 -0
  76. package/eval/reports/query-expansion.md +138 -0
  77. package/integrations/README.md +57 -0
  78. package/integrations/claude-code/README.md +133 -0
  79. package/integrations/claude-code/claude-md-snippet.md +31 -0
  80. package/integrations/claude-code/setup.sh +66 -0
  81. package/integrations/claude-code/smoke-continuity.sh +452 -0
  82. package/integrations/codex/README.md +49 -0
  83. package/integrations/codex/agents-md-snippet.md +31 -0
  84. package/integrations/codex/setup.sh +58 -0
  85. package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
  86. package/integrations/examples/langchain/memory-chain.py +111 -0
  87. package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
  88. package/integrations/gemini-cli/README.md +57 -0
  89. package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
  90. package/integrations/gemini-cli/setup.sh +64 -0
  91. package/integrations/shared/install-managed-block.sh +51 -0
  92. package/lm +19 -0
  93. package/package.json +50 -0
  94. package/plan-v1.2-distributable.md +105 -0
  95. package/scripts/archive-distilled-junk.ts +116 -0
  96. package/scripts/backfill-l0l1l2.ts +98 -0
  97. package/scripts/blind-scan.ts +588 -0
  98. package/scripts/cleanup-step1-exact-dups.ts +117 -0
  99. package/scripts/cleanup-step2-template-dups.ts +113 -0
  100. package/scripts/cleanup-step3-stale-content.ts +149 -0
  101. package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
  102. package/scripts/delete-distilled-junk.ts +88 -0
  103. package/scripts/distill-facts.ts +305 -0
  104. package/scripts/health-check.ts +343 -0
  105. package/scripts/incremental-ingest.sh +71 -0
  106. package/scripts/mlp-health-check.ts +382 -0
  107. package/scripts/pull-claude-desktop.ts +515 -0
  108. package/scripts/re-ingest-cc.sh +66 -0
  109. package/scripts/rebalance-memory-health.ts +181 -0
  110. package/scripts/run-desktop-ingest.sh +11 -0
  111. package/scripts/start-server.sh +12 -0
  112. package/scripts/viz-dashboard.html +423 -0
  113. package/scripts/viz-export.ts +703 -0
  114. package/src/__tests__/access-tracker-novelty.test.ts +228 -0
  115. package/src/__tests__/auto-consolidation.test.ts +168 -0
  116. package/src/__tests__/auto-recall.test.ts +131 -0
  117. package/src/__tests__/batch-dedup.test.ts +65 -0
  118. package/src/__tests__/capture-engine.test.ts +1142 -0
  119. package/src/__tests__/capture-heuristic.test.ts +171 -0
  120. package/src/__tests__/cascade-forget.test.ts +210 -0
  121. package/src/__tests__/category-min-scores.test.ts +33 -0
  122. package/src/__tests__/confidence-tracker.test.ts +196 -0
  123. package/src/__tests__/conflict-advisor.test.ts +206 -0
  124. package/src/__tests__/conflict-escalation.test.ts +149 -0
  125. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  126. package/src/__tests__/conflict-output.test.ts +85 -0
  127. package/src/__tests__/conflict-store.test.ts +374 -0
  128. package/src/__tests__/consolidation-engine.test.ts +181 -0
  129. package/src/__tests__/context-composer-pins.test.ts +64 -0
  130. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  131. package/src/__tests__/context-renderer.test.ts +91 -0
  132. package/src/__tests__/continuity-eval.test.ts +275 -0
  133. package/src/__tests__/continuity-schema.test.ts +97 -0
  134. package/src/__tests__/core-recall-eval.test.ts +235 -0
  135. package/src/__tests__/core-summary.test.ts +109 -0
  136. package/src/__tests__/dedup-actions.test.ts +58 -0
  137. package/src/__tests__/deterministic-id.test.ts +39 -0
  138. package/src/__tests__/doctor.test.ts +303 -0
  139. package/src/__tests__/drill-down.test.ts +108 -0
  140. package/src/__tests__/embedder.test.ts +122 -0
  141. package/src/__tests__/entity-resolver.test.ts +58 -0
  142. package/src/__tests__/event-segmenter.test.ts +153 -0
  143. package/src/__tests__/hotness-blend.test.ts +76 -0
  144. package/src/__tests__/ingest.test.ts +360 -0
  145. package/src/__tests__/kg-extractor.test.ts +184 -0
  146. package/src/__tests__/kg-store.test.ts +198 -0
  147. package/src/__tests__/llm-consolidation.test.ts +226 -0
  148. package/src/__tests__/memory-boundaries.test.ts +237 -0
  149. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  150. package/src/__tests__/memory-output.test.ts +215 -0
  151. package/src/__tests__/multi-vector.test.ts +242 -0
  152. package/src/__tests__/ppr-traversal.test.ts +256 -0
  153. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  154. package/src/__tests__/preference-matcher.test.ts +230 -0
  155. package/src/__tests__/preference-slots.test.ts +106 -0
  156. package/src/__tests__/prospective-memory.test.ts +283 -0
  157. package/src/__tests__/query-entity-detector.test.ts +171 -0
  158. package/src/__tests__/query-expander.test.ts +34 -0
  159. package/src/__tests__/recall-mode.test.ts +64 -0
  160. package/src/__tests__/result-synthesizer.test.ts +198 -0
  161. package/src/__tests__/resume-context.test.ts +4743 -0
  162. package/src/__tests__/retrieval-trace.test.ts +213 -0
  163. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  164. package/src/__tests__/retroactive-boost.test.ts +223 -0
  165. package/src/__tests__/scope-inventory.test.ts +200 -0
  166. package/src/__tests__/scope-policy.test.ts +69 -0
  167. package/src/__tests__/session-checkpoint.test.ts +259 -0
  168. package/src/__tests__/session-gc.test.ts +215 -0
  169. package/src/__tests__/store-scope-audit.test.ts +81 -0
  170. package/src/__tests__/temporal-parser.test.ts +241 -0
  171. package/src/__tests__/term-registry.test.ts +121 -0
  172. package/src/__tests__/tool-output-compressor.test.ts +133 -0
  173. package/src/__tests__/version-manager.test.ts +213 -0
  174. package/src/__tests__/workflow-observation.test.ts +400 -0
  175. package/src/access-tracker.ts +316 -0
  176. package/src/adaptive-retrieval.ts +64 -0
  177. package/src/api-server.ts +667 -0
  178. package/src/asset-sync.ts +79 -0
  179. package/src/auto-consolidation.ts +133 -0
  180. package/src/auto-gc.ts +124 -0
  181. package/src/auto-recall.ts +95 -0
  182. package/src/capture-engine.ts +931 -0
  183. package/src/capture-heuristic.ts +177 -0
  184. package/src/cascade-forget.ts +135 -0
  185. package/src/chunker.ts +253 -0
  186. package/src/cli.ts +1917 -0
  187. package/src/confidence-tracker.ts +166 -0
  188. package/src/conflict-advisor.ts +433 -0
  189. package/src/conflict-engine.ts +181 -0
  190. package/src/conflict-escalation.ts +138 -0
  191. package/src/conflict-lifecycle.ts +67 -0
  192. package/src/conflict-output.ts +218 -0
  193. package/src/conflict-schema.ts +130 -0
  194. package/src/conflict-store.ts +127 -0
  195. package/src/consolidation-engine.ts +256 -0
  196. package/src/context-composer-pins.ts +183 -0
  197. package/src/context-composer-queries.ts +121 -0
  198. package/src/context-composer-scope.ts +56 -0
  199. package/src/context-composer-stable-selection.ts +200 -0
  200. package/src/context-composer-stable.ts +217 -0
  201. package/src/context-composer-task-fallbacks.ts +36 -0
  202. package/src/context-composer-task-ranking.ts +197 -0
  203. package/src/context-composer-task-results.ts +137 -0
  204. package/src/context-composer-task-selection.ts +342 -0
  205. package/src/context-composer-text.ts +57 -0
  206. package/src/context-composer.ts +300 -0
  207. package/src/context-renderer.ts +179 -0
  208. package/src/decay-engine.ts +189 -0
  209. package/src/doctor.ts +543 -0
  210. package/src/embedder.ts +557 -0
  211. package/src/entity-resolver.ts +131 -0
  212. package/src/eval.ts +632 -0
  213. package/src/event-segmenter.ts +216 -0
  214. package/src/ingest.ts +1822 -0
  215. package/src/kg-extractor.ts +217 -0
  216. package/src/kg-store.ts +353 -0
  217. package/src/legacy-scope-review.ts +53 -0
  218. package/src/llm-client.ts +629 -0
  219. package/src/llm-consolidation.ts +168 -0
  220. package/src/mcp-server.ts +1337 -0
  221. package/src/memory-assets.ts +475 -0
  222. package/src/memory-boundaries.ts +354 -0
  223. package/src/memory-health-rebalance.ts +220 -0
  224. package/src/memory-output.ts +474 -0
  225. package/src/memory-schema.ts +184 -0
  226. package/src/multi-vector.ts +234 -0
  227. package/src/noise-filter.ts +155 -0
  228. package/src/ppr-traversal.ts +279 -0
  229. package/src/preference-matcher.ts +174 -0
  230. package/src/preference-slots.ts +259 -0
  231. package/src/prospective-memory.ts +198 -0
  232. package/src/query-entity-detector.ts +207 -0
  233. package/src/query-expander.ts +98 -0
  234. package/src/result-synthesizer.ts +114 -0
  235. package/src/retrieval-profiles.ts +101 -0
  236. package/src/retrieval-trace.ts +94 -0
  237. package/src/retriever.ts +1338 -0
  238. package/src/retroactive-boost.ts +154 -0
  239. package/src/rif.ts +73 -0
  240. package/src/runtime-config.ts +172 -0
  241. package/src/schema-utils.ts +56 -0
  242. package/src/scope-inventory.ts +310 -0
  243. package/src/scope-policy.ts +142 -0
  244. package/src/session-engine.ts +168 -0
  245. package/src/session-output.ts +96 -0
  246. package/src/session-schema.ts +60 -0
  247. package/src/session-store.ts +293 -0
  248. package/src/stderr-log.ts +16 -0
  249. package/src/store.ts +903 -0
  250. package/src/temporal-parser.ts +318 -0
  251. package/src/term-registry.ts +791 -0
  252. package/src/tool-output-compressor.ts +115 -0
  253. package/src/tracker.ts +57 -0
  254. package/src/ui-server.ts +430 -0
  255. package/src/version-manager.ts +172 -0
  256. package/src/workflow-observation-engine.ts +316 -0
  257. package/src/workflow-observation-managed.ts +83 -0
  258. package/src/workflow-observation-output.ts +86 -0
  259. package/src/workflow-observation-schema.ts +87 -0
  260. package/src/workflow-observation-scope-review.ts +409 -0
  261. package/src/workflow-observation-store.ts +69 -0
@@ -0,0 +1,931 @@
1
+ import type { Embedder } from "./embedder.js";
2
+ import {
3
+ type CaseMemoryInput,
4
+ CaseMemoryInputSchema,
5
+ type CaptureMemoryInput,
6
+ CaptureMemoryInputSchema,
7
+ type PromoteMemoryInput,
8
+ PromoteMemoryInputSchema,
9
+ type StoredCaseMemoryRecord,
10
+ type StoredPromotedMemoryRecord,
11
+ type StoredWorkflowPatternRecord,
12
+ type StoredMemoryRecord,
13
+ type StoreMemoryInput,
14
+ StoreMemoryInputSchema,
15
+ type WorkflowPatternInput,
16
+ WorkflowPatternInputSchema,
17
+ type DurableMemoryCategory,
18
+ DURABLE_MEMORY_CATEGORIES,
19
+ type WriteDisposition,
20
+ } from "./memory-schema.js";
21
+ import { deterministicId, type MemoryEntry, type MemoryStore } from "./store.js";
22
+ import {
23
+ buildDefaultCanonicalKey,
24
+ buildStructuredMemoryBoundary,
25
+ extractBoundaryMetadata,
26
+ extractCanonicalKey,
27
+ extractPromotedFrom,
28
+ extractProvenanceHistory,
29
+ getConflictPolicyForCategory,
30
+ isDurableMemoryScope,
31
+ isTranscriptScope,
32
+ normalizeCanonicalKey,
33
+ parseMetadataObject,
34
+ } from "./memory-boundaries.js";
35
+ import { buildConflictCandidateRecord, buildConflictFingerprint, reopenConflictCandidate } from "./conflict-engine.js";
36
+ import type { ConflictCandidateStore } from "./conflict-store.js";
37
+ import {
38
+ inferPreferenceSlot,
39
+ samePreferenceSlot,
40
+ type AtomicBrandItemPreferenceSlot,
41
+ type PreferenceSlot,
42
+ type ReplyStylePreferenceSlot,
43
+ type ToolChoicePreferenceSlot,
44
+ } from "./preference-slots.js";
45
+ import { matchPreference, applyPreferenceMatch } from "./preference-matcher.js";
46
+ import type { LLMClient } from "./llm-client.js";
47
+ import type { KGExtractor } from "./kg-extractor.js";
48
+
49
+ type StoreDeps = Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "list" | "update" | "getById" | "get" | "vectorSearch">>;
50
+ type ConflictStoreDeps = Pick<ConflictCandidateStore, "save" | "replace" | "getOpenByFingerprint" | "getLatestByFingerprint">;
51
+
52
+ interface PersistMemoryDeps {
53
+ store: StoreDeps;
54
+ embedder: Pick<Embedder, "embedPassage">;
55
+ conflictStore?: ConflictStoreDeps;
56
+ /** Tier 3.6: Optional LLM for preference matching */
57
+ llm?: LLMClient | null;
58
+ /** Tier 4.1: Optional KG triple extractor (async, non-blocking) */
59
+ kgExtractor?: KGExtractor | null;
60
+ }
61
+
62
+ interface DurableWriteInput {
63
+ text: string;
64
+ vector: number[];
65
+ category: DurableMemoryCategory;
66
+ scope: string;
67
+ importance: number;
68
+ metadata: string;
69
+ canonicalKey: string;
70
+ promotedFrom?: string;
71
+ source: "manual" | "agent" | "api";
72
+ sourceCategory?: string;
73
+ sourceBoundary?: ReturnType<typeof extractBoundaryMetadata>;
74
+ }
75
+
76
+ const CANONICAL_SCAN_LIMIT = 1000;
77
+ const PROVENANCE_HISTORY_LIMIT = 20;
78
+ const DURABLE_CATEGORY_SET = new Set<string>(DURABLE_MEMORY_CATEGORIES);
79
+
80
+ function resolveScope(input: { scope?: string; source: string }): string {
81
+ if (!input.scope) {
82
+ throw new Error("scope is required for durable memory writes");
83
+ }
84
+ return input.scope;
85
+ }
86
+
87
+ function normalizeMemoryText(text: string): string {
88
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
89
+ }
90
+
91
+ function resolveCanonicalKey(params: {
92
+ category: DurableMemoryCategory;
93
+ text?: string;
94
+ title?: string;
95
+ canonicalKey?: string;
96
+ }): string {
97
+ const explicit = params.canonicalKey ? normalizeCanonicalKey(params.canonicalKey) : "";
98
+ if (explicit) return explicit;
99
+ return buildDefaultCanonicalKey({
100
+ category: params.category,
101
+ text: params.text,
102
+ title: params.title,
103
+ });
104
+ }
105
+
106
+ function mergeTags(...groups: string[][]): string[] {
107
+ const merged: string[] = [];
108
+ const seen = new Set<string>();
109
+ for (const group of groups) {
110
+ for (const item of group) {
111
+ const normalized = item.trim().toLowerCase();
112
+ if (!normalized || seen.has(normalized)) continue;
113
+ seen.add(normalized);
114
+ merged.push(item.trim());
115
+ }
116
+ }
117
+ return merged.slice(0, 8);
118
+ }
119
+
120
+ function buildStructuredMetadata(params: {
121
+ source: string;
122
+ tags: string[];
123
+ capture: string;
124
+ category: DurableMemoryCategory;
125
+ canonicalKey: string;
126
+ extra?: Record<string, unknown>;
127
+ }): string {
128
+ return JSON.stringify({
129
+ source: params.source,
130
+ tags: params.tags,
131
+ capture: params.capture,
132
+ boundary: buildStructuredMemoryBoundary(params.category),
133
+ canonicalKey: params.canonicalKey,
134
+ ...params.extra,
135
+ });
136
+ }
137
+
138
+ function buildPreferenceSlotExtra(
139
+ category: DurableMemoryCategory,
140
+ text: string,
141
+ ): Record<string, unknown> | undefined {
142
+ if (category !== "preferences") return undefined;
143
+ const slot = inferPreferenceSlot(text);
144
+ return slot ? { preferenceSlot: slot } : undefined;
145
+ }
146
+
147
+ function mergeExtra(...extras: Array<Record<string, unknown> | undefined>): Record<string, unknown> | undefined {
148
+ const merged = Object.assign({}, ...extras.filter(Boolean));
149
+ return Object.keys(merged).length > 0 ? merged : undefined;
150
+ }
151
+
152
+ function extractPreferenceSlot(metadata?: string): PreferenceSlot | null {
153
+ const parsed = parseMetadataObject(metadata);
154
+ const slot = parsed?.preferenceSlot;
155
+ if (!slot || typeof slot !== "object") return null;
156
+
157
+ const record = slot as Record<string, unknown>;
158
+ if (
159
+ record.type === "brand-item" &&
160
+ typeof record.brand === "string" &&
161
+ typeof record.item === "string"
162
+ ) {
163
+ return {
164
+ type: "brand-item",
165
+ brand: record.brand,
166
+ item: record.item,
167
+ };
168
+ }
169
+
170
+ if (
171
+ record.type === "reply-style" &&
172
+ Array.isArray(record.traits)
173
+ ) {
174
+ const traits = record.traits
175
+ .filter((value): value is string => typeof value === "string")
176
+ .map((value) => value.trim().toLowerCase())
177
+ .filter(Boolean);
178
+ if (traits.length === 0) return null;
179
+ return {
180
+ type: "reply-style",
181
+ traits: Array.from(new Set(traits)).sort(),
182
+ } satisfies ReplyStylePreferenceSlot;
183
+ }
184
+
185
+ if (
186
+ record.type === "tool-choice" &&
187
+ typeof record.preferredTool === "string" &&
188
+ typeof record.avoidedTool === "string"
189
+ ) {
190
+ const preferredTool = record.preferredTool.trim().toLowerCase();
191
+ const avoidedTool = record.avoidedTool.trim().toLowerCase();
192
+ if (!preferredTool || !avoidedTool || preferredTool === avoidedTool) return null;
193
+ return {
194
+ type: "tool-choice",
195
+ preferredTool,
196
+ avoidedTool,
197
+ } satisfies ToolChoicePreferenceSlot;
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ function parsePreferenceSlotFromCanonicalKey(canonicalKey?: string | null): PreferenceSlot | null {
204
+ if (!canonicalKey || !canonicalKey.startsWith("preferences:")) return null;
205
+
206
+ if (canonicalKey.startsWith("preferences:brand-item:")) {
207
+ const [, , brand, ...rest] = canonicalKey.split(":");
208
+ const item = rest.join(":");
209
+ if (!brand || !item) return null;
210
+ return {
211
+ type: "brand-item",
212
+ brand,
213
+ item,
214
+ } satisfies AtomicBrandItemPreferenceSlot;
215
+ }
216
+
217
+ if (canonicalKey.startsWith("preferences:reply-style:")) {
218
+ const [, , ...traits] = canonicalKey.split(":");
219
+ const normalizedTraits = traits
220
+ .map((value) => value.trim().toLowerCase())
221
+ .filter(Boolean);
222
+ if (normalizedTraits.length === 0) return null;
223
+ return {
224
+ type: "reply-style",
225
+ traits: Array.from(new Set(normalizedTraits)).sort(),
226
+ } satisfies ReplyStylePreferenceSlot;
227
+ }
228
+
229
+ if (canonicalKey.startsWith("preferences:tool-choice:")) {
230
+ const [, , preferredTool, marker, ...rest] = canonicalKey.split(":");
231
+ const avoidedTool = rest.join(":").trim().toLowerCase();
232
+ if (marker !== "over" || !preferredTool || !avoidedTool) return null;
233
+ return {
234
+ type: "tool-choice",
235
+ preferredTool: preferredTool.trim().toLowerCase(),
236
+ avoidedTool,
237
+ } satisfies ToolChoicePreferenceSlot;
238
+ }
239
+
240
+ return null;
241
+ }
242
+
243
+ function shouldAutoCollapsePromotionConflict(
244
+ params: DurableWriteInput,
245
+ existing: MemoryEntry,
246
+ ): boolean {
247
+ if (!params.promotedFrom || params.category !== "preferences") {
248
+ return false;
249
+ }
250
+
251
+ const incomingSlot =
252
+ extractPreferenceSlot(params.metadata) ||
253
+ inferPreferenceSlot(params.text) ||
254
+ parsePreferenceSlotFromCanonicalKey(params.canonicalKey);
255
+
256
+ const existingSlot =
257
+ extractPreferenceSlot(existing.metadata) ||
258
+ inferPreferenceSlot(existing.text) ||
259
+ parsePreferenceSlotFromCanonicalKey(extractCanonicalKey(existing.metadata));
260
+
261
+ return samePreferenceSlot(existingSlot, incomingSlot);
262
+ }
263
+
264
+ function mergePromotionObservationMetadata(
265
+ existingMetadata: string | undefined,
266
+ incomingMetadata: string,
267
+ observedAt: string,
268
+ ): {
269
+ metadata: string;
270
+ changed: boolean;
271
+ } {
272
+ const parsed = parseMetadataObject(existingMetadata) ?? {};
273
+ const incomingObservation = extractPromotedFrom(incomingMetadata);
274
+ if (!incomingObservation) {
275
+ return {
276
+ metadata: existingMetadata || JSON.stringify(parsed),
277
+ changed: false,
278
+ };
279
+ }
280
+
281
+ const mergedHistory = [...extractProvenanceHistory(existingMetadata)];
282
+ const existingIndex = mergedHistory.findIndex((item) => item.memoryId === incomingObservation.memoryId);
283
+
284
+ if (existingIndex >= 0) {
285
+ const current = mergedHistory[existingIndex];
286
+ mergedHistory[existingIndex] = {
287
+ ...incomingObservation,
288
+ ...current,
289
+ observedAt: current?.observedAt || observedAt,
290
+ };
291
+ } else {
292
+ mergedHistory.push({
293
+ ...incomingObservation,
294
+ observedAt,
295
+ });
296
+ }
297
+
298
+ const nextMetadata = JSON.stringify({
299
+ ...parsed,
300
+ provenanceHistory: mergedHistory.slice(-PROVENANCE_HISTORY_LIMIT),
301
+ provenanceHistoryCount: mergedHistory.length,
302
+ });
303
+
304
+ return {
305
+ metadata: nextMetadata,
306
+ changed: nextMetadata !== (existingMetadata || "{}"),
307
+ };
308
+ }
309
+
310
+ async function appendPromotionObservationToExistingEntry(
311
+ store: StoreDeps,
312
+ existing: MemoryEntry,
313
+ incomingMetadata: string,
314
+ ): Promise<MemoryEntry> {
315
+ if (!store.update) return existing;
316
+
317
+ const timestamp = Date.now();
318
+ const observedAt = new Date(timestamp).toISOString();
319
+ const merged = mergePromotionObservationMetadata(existing.metadata, incomingMetadata, observedAt);
320
+ if (!merged.changed) {
321
+ return existing;
322
+ }
323
+
324
+ const updated = await store.update(existing.id, {
325
+ metadata: merged.metadata,
326
+ timestamp,
327
+ });
328
+ if (!updated) {
329
+ throw new Error(`Failed to append promotion provenance to durable memory ${existing.id}`);
330
+ }
331
+ return updated;
332
+ }
333
+
334
+ function buildWorkflowPatternText(input: WorkflowPatternInput): string {
335
+ const lines = [
336
+ `Workflow pattern: ${input.title}`,
337
+ `Use when: ${input.trigger}`,
338
+ "Steps:",
339
+ ...input.steps.map((step, index) => `${index + 1}. ${step}`),
340
+ ];
341
+ if (input.tools.length > 0) {
342
+ lines.push(`Tools: ${input.tools.join(", ")}`);
343
+ }
344
+ if (input.outcome) {
345
+ lines.push(`Outcome: ${input.outcome}`);
346
+ }
347
+ return lines.join("\n");
348
+ }
349
+
350
+ function buildCaseMemoryText(input: CaseMemoryInput): string {
351
+ const lines = [
352
+ `Case: ${input.title}`,
353
+ `Problem: ${input.problem}`,
354
+ ];
355
+ if (input.context) {
356
+ lines.push(`Context: ${input.context}`);
357
+ }
358
+ lines.push("Solution:");
359
+ lines.push(...input.solutionSteps.map((step, index) => `${index + 1}. ${step}`));
360
+ if (input.tools.length > 0) {
361
+ lines.push(`Tools: ${input.tools.join(", ")}`);
362
+ }
363
+ if (input.outcome) {
364
+ lines.push(`Outcome: ${input.outcome}`);
365
+ }
366
+ return lines.join("\n");
367
+ }
368
+
369
+ function toStoredRecord(
370
+ input: StoreMemoryInput,
371
+ entry: { id: string; timestamp: number; scope: string },
372
+ disposition: WriteDisposition,
373
+ canonicalKey: string,
374
+ conflictId?: string,
375
+ ): StoredMemoryRecord {
376
+ return {
377
+ ...input,
378
+ canonicalKey,
379
+ resolvedScope: entry.scope,
380
+ id: entry.id,
381
+ storedAt: new Date(entry.timestamp).toISOString(),
382
+ disposition,
383
+ ...(conflictId ? { conflictId } : {}),
384
+ };
385
+ }
386
+
387
+ function ensureDurableCategory(value: string): DurableMemoryCategory {
388
+ if (!DURABLE_CATEGORY_SET.has(value)) {
389
+ return "events";
390
+ }
391
+ return value as DurableMemoryCategory;
392
+ }
393
+
394
+ async function findCanonicalMatches(
395
+ store: StoreDeps,
396
+ canonicalKey: string,
397
+ ): Promise<MemoryEntry[]> {
398
+ if (!store.list) return [];
399
+ const entries = await store.list(undefined, undefined, CANONICAL_SCAN_LIMIT, 0);
400
+ return entries.filter((entry) => {
401
+ const boundary = extractBoundaryMetadata(entry.metadata);
402
+ if (boundary?.layer !== "durable") return false;
403
+ return extractCanonicalKey(entry.metadata) === canonicalKey;
404
+ });
405
+ }
406
+
407
+ async function writeDurableEntry(
408
+ deps: PersistMemoryDeps,
409
+ params: DurableWriteInput,
410
+ ): Promise<{
411
+ entry: MemoryEntry;
412
+ disposition: "stored" | "updated" | "deduped" | "promoted" | "conflict";
413
+ conflictId?: string;
414
+ }> {
415
+ const matches = await findCanonicalMatches(deps.store, params.canonicalKey);
416
+ const categoryMatches = matches.filter((entry) => entry.category === params.category);
417
+ const crossCategoryMatches = matches.filter((entry) => entry.category !== params.category);
418
+ const normalizedIncoming = normalizeMemoryText(params.text);
419
+ const exact = categoryMatches.find((entry) => normalizeMemoryText(entry.text) === normalizedIncoming);
420
+
421
+ if (crossCategoryMatches.length > 0 && deps.conflictStore) {
422
+ const latest = [...crossCategoryMatches].sort((a, b) => b.timestamp - a.timestamp)[0];
423
+ const fingerprint = buildConflictFingerprint({
424
+ canonicalKey: params.canonicalKey,
425
+ existingMemoryId: latest.id,
426
+ incomingText: params.text,
427
+ sourceMemoryId: params.promotedFrom,
428
+ });
429
+ const conflict = await ensureConflictCandidate(deps.conflictStore, {
430
+ canonicalKey: params.canonicalKey,
431
+ category: params.category,
432
+ fingerprint,
433
+ reason: "canonical_key_conflicts_with_existing_durable",
434
+ existing: {
435
+ memoryId: latest.id,
436
+ text: latest.text,
437
+ category: latest.category,
438
+ scope: latest.scope,
439
+ importance: latest.importance,
440
+ metadata: latest.metadata || "{}",
441
+ boundary: extractBoundaryMetadata(latest.metadata),
442
+ canonicalKey: extractCanonicalKey(latest.metadata) || params.canonicalKey,
443
+ },
444
+ incoming: {
445
+ text: params.text,
446
+ category: params.category,
447
+ scope: params.scope,
448
+ importance: params.importance,
449
+ metadata: params.metadata,
450
+ source: params.source,
451
+ ...(params.promotedFrom ? { sourceMemoryId: params.promotedFrom } : {}),
452
+ ...(params.sourceCategory ? { sourceCategory: params.sourceCategory } : {}),
453
+ ...(params.sourceBoundary ? { sourceBoundary: params.sourceBoundary } : {}),
454
+ },
455
+ });
456
+
457
+ return {
458
+ entry: latest,
459
+ disposition: "conflict",
460
+ conflictId: conflict.conflictId,
461
+ };
462
+ }
463
+
464
+ if (exact) {
465
+ const entry = params.promotedFrom
466
+ ? await appendPromotionObservationToExistingEntry(deps.store, exact, params.metadata)
467
+ : exact;
468
+ return {
469
+ entry,
470
+ disposition: params.promotedFrom ? "promoted" : "deduped",
471
+ };
472
+ }
473
+
474
+ const conflictPolicy = getConflictPolicyForCategory(params.category);
475
+ if (conflictPolicy === "latest-wins" && categoryMatches.length > 0 && deps.store.update) {
476
+ const latest = [...categoryMatches].sort((a, b) => b.timestamp - a.timestamp)[0];
477
+ if (params.promotedFrom) {
478
+ if (shouldAutoCollapsePromotionConflict(params, latest)) {
479
+ const entry = await appendPromotionObservationToExistingEntry(deps.store, latest, params.metadata);
480
+ return {
481
+ entry,
482
+ disposition: "promoted",
483
+ };
484
+ }
485
+
486
+ if (deps.conflictStore) {
487
+ const fingerprint = buildConflictFingerprint({
488
+ canonicalKey: params.canonicalKey,
489
+ existingMemoryId: latest.id,
490
+ incomingText: params.text,
491
+ sourceMemoryId: params.promotedFrom,
492
+ });
493
+ const conflict = await ensureConflictCandidate(deps.conflictStore, {
494
+ canonicalKey: params.canonicalKey,
495
+ category: params.category,
496
+ fingerprint,
497
+ reason: "promotion_conflicts_with_existing_durable",
498
+ existing: {
499
+ memoryId: latest.id,
500
+ text: latest.text,
501
+ category: latest.category,
502
+ scope: latest.scope,
503
+ importance: latest.importance,
504
+ metadata: latest.metadata || "{}",
505
+ boundary: extractBoundaryMetadata(latest.metadata),
506
+ canonicalKey: extractCanonicalKey(latest.metadata) || params.canonicalKey,
507
+ },
508
+ incoming: {
509
+ text: params.text,
510
+ category: params.category,
511
+ scope: params.scope,
512
+ importance: params.importance,
513
+ metadata: params.metadata,
514
+ source: params.source,
515
+ sourceMemoryId: params.promotedFrom,
516
+ sourceCategory: params.sourceCategory,
517
+ sourceBoundary: params.sourceBoundary,
518
+ },
519
+ });
520
+
521
+ return {
522
+ entry: latest,
523
+ disposition: "conflict",
524
+ conflictId: conflict.conflictId,
525
+ };
526
+ }
527
+ }
528
+
529
+ const updated = await deps.store.update(latest.id, {
530
+ text: params.text,
531
+ vector: params.vector,
532
+ importance: params.importance,
533
+ category: params.category,
534
+ metadata: params.metadata,
535
+ timestamp: Date.now(),
536
+ });
537
+ if (!updated) {
538
+ throw new Error(`Failed to update canonical memory ${latest.id}`);
539
+ }
540
+ return {
541
+ entry: updated,
542
+ disposition: params.promotedFrom ? "promoted" : "updated",
543
+ };
544
+ }
545
+
546
+ const stored = await deps.store.store({
547
+ id: params.canonicalKey
548
+ ? deterministicId(params.scope, params.canonicalKey)
549
+ : undefined,
550
+ text: params.text,
551
+ vector: params.vector,
552
+ category: params.category,
553
+ scope: params.scope,
554
+ importance: params.importance,
555
+ metadata: params.metadata,
556
+ });
557
+
558
+ return {
559
+ entry: stored,
560
+ disposition: params.promotedFrom ? "promoted" : "stored",
561
+ };
562
+ }
563
+
564
+ async function ensureConflictCandidate(
565
+ conflictStore: ConflictStoreDeps,
566
+ input: Parameters<typeof buildConflictCandidateRecord>[0],
567
+ ) {
568
+ const parsed = buildConflictCandidateRecord(input);
569
+ const open = await conflictStore.getOpenByFingerprint(parsed.fingerprint);
570
+ if (open) {
571
+ return open;
572
+ }
573
+
574
+ const latest = await conflictStore.getLatestByFingerprint(parsed.fingerprint);
575
+ if (latest) {
576
+ return conflictStore.replace(reopenConflictCandidate({
577
+ ...latest,
578
+ category: parsed.category,
579
+ reason: parsed.reason,
580
+ existing: parsed.existing,
581
+ incoming: parsed.incoming,
582
+ updatedAt: parsed.updatedAt,
583
+ }));
584
+ }
585
+
586
+ return conflictStore.save(parsed);
587
+ }
588
+
589
+ function inferPromotedCategory(
590
+ input: PromoteMemoryInput,
591
+ sourceEntry: MemoryEntry,
592
+ ): DurableMemoryCategory {
593
+ if (input.category) return input.category;
594
+
595
+ const boundary = extractBoundaryMetadata(sourceEntry.metadata);
596
+ if (boundary?.originalCategory) return boundary.originalCategory;
597
+
598
+ return ensureDurableCategory(sourceEntry.category);
599
+ }
600
+
601
+ function buildStoreMemoryMetadata(input: StoreMemoryInput, canonicalKey: string): string {
602
+ return buildStructuredMetadata({
603
+ source: input.source,
604
+ tags: input.tags,
605
+ capture: "store_memory_schema_v1",
606
+ category: input.category,
607
+ canonicalKey,
608
+ extra: buildPreferenceSlotExtra(input.category, input.text),
609
+ });
610
+ }
611
+
612
+ function buildWorkflowPatternMetadata(
613
+ input: WorkflowPatternInput,
614
+ tags: string[],
615
+ canonicalKey: string,
616
+ ): string {
617
+ return buildStructuredMetadata({
618
+ source: input.source,
619
+ tags,
620
+ capture: "workflow_pattern_schema_v1",
621
+ category: "patterns",
622
+ canonicalKey,
623
+ extra: {
624
+ workflowPattern: {
625
+ title: input.title,
626
+ trigger: input.trigger,
627
+ steps: input.steps,
628
+ outcome: input.outcome,
629
+ tools: input.tools,
630
+ },
631
+ },
632
+ });
633
+ }
634
+
635
+ function buildCaseMemoryMetadata(
636
+ input: CaseMemoryInput,
637
+ tags: string[],
638
+ canonicalKey: string,
639
+ ): string {
640
+ return buildStructuredMetadata({
641
+ source: input.source,
642
+ tags,
643
+ capture: "case_memory_schema_v1",
644
+ category: "cases",
645
+ canonicalKey,
646
+ extra: {
647
+ caseMemory: {
648
+ title: input.title,
649
+ problem: input.problem,
650
+ context: input.context,
651
+ solutionSteps: input.solutionSteps,
652
+ outcome: input.outcome,
653
+ tools: input.tools,
654
+ },
655
+ },
656
+ });
657
+ }
658
+
659
+ function buildPromotionMetadata(
660
+ input: PromoteMemoryInput,
661
+ canonicalKey: string,
662
+ category: DurableMemoryCategory,
663
+ sourceEntry: MemoryEntry,
664
+ text: string,
665
+ ): string {
666
+ const sourceMetadata = parseMetadataObject(sourceEntry.metadata);
667
+ return buildStructuredMetadata({
668
+ source: input.source,
669
+ tags: input.tags,
670
+ capture: "promote_memory_schema_v1",
671
+ category,
672
+ canonicalKey,
673
+ extra: mergeExtra(
674
+ buildPreferenceSlotExtra(category, text),
675
+ {
676
+ promotedFrom: {
677
+ memoryId: sourceEntry.id,
678
+ scope: sourceEntry.scope,
679
+ category: sourceEntry.category,
680
+ boundary: extractBoundaryMetadata(sourceEntry.metadata),
681
+ source: sourceMetadata?.source,
682
+ },
683
+ },
684
+ ),
685
+ });
686
+ }
687
+
688
+ export async function persistMemory(
689
+ deps: PersistMemoryDeps,
690
+ rawInput: unknown,
691
+ ): Promise<StoredMemoryRecord> {
692
+ const input = StoreMemoryInputSchema.parse(rawInput);
693
+ const vector = await deps.embedder.embedPassage(input.text);
694
+ const resolvedScope = resolveScope(input);
695
+ const canonicalKey = resolveCanonicalKey({
696
+ category: input.category,
697
+ text: input.text,
698
+ canonicalKey: input.canonicalKey,
699
+ });
700
+
701
+ // Tier 3.6: For preferences, check if similar preference already exists.
702
+ // If matched, merge into existing or skip — avoids duplicate accumulation.
703
+ if (
704
+ input.category === "preferences" &&
705
+ deps.store.vectorSearch
706
+ ) {
707
+ const matchResult = await matchPreference(
708
+ input.text,
709
+ vector,
710
+ resolvedScope,
711
+ deps.store as MemoryStore,
712
+ deps.llm ?? null,
713
+ );
714
+ if (matchResult.action !== "create") {
715
+ const applied = await applyPreferenceMatch(
716
+ matchResult,
717
+ deps.store as MemoryStore,
718
+ resolvedScope,
719
+ );
720
+ if (applied.handled) {
721
+ const entry = applied.entry ?? {
722
+ id: "skipped",
723
+ text: input.text,
724
+ vector,
725
+ category: input.category,
726
+ scope: resolvedScope,
727
+ importance: input.importance,
728
+ timestamp: Date.now(),
729
+ metadata: "{}",
730
+ };
731
+ return toStoredRecord(
732
+ input,
733
+ entry,
734
+ matchResult.action === "merge" ? "updated" : "deduped",
735
+ canonicalKey,
736
+ );
737
+ }
738
+ }
739
+ }
740
+
741
+ const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
742
+ text: input.text,
743
+ vector,
744
+ category: input.category,
745
+ scope: resolvedScope,
746
+ importance: input.importance,
747
+ metadata: buildStoreMemoryMetadata(input, canonicalKey),
748
+ canonicalKey,
749
+ source: input.source,
750
+ });
751
+
752
+ // Tier 4.1: Async KG triple extraction (non-blocking)
753
+ if (deps.kgExtractor && disposition !== "deduped") {
754
+ deps.kgExtractor
755
+ .extractAndStore(input.text, entry.id, resolvedScope)
756
+ .catch(() => {}); // Silently ignore — KG extraction must never block memory writes
757
+ }
758
+
759
+ return toStoredRecord(input, entry, disposition, canonicalKey, conflictId);
760
+ }
761
+
762
+ export async function persistWorkflowPattern(
763
+ deps: PersistMemoryDeps,
764
+ rawInput: unknown,
765
+ ): Promise<StoredWorkflowPatternRecord> {
766
+ const input = WorkflowPatternInputSchema.parse(rawInput);
767
+ const resolvedScope = resolveScope(input);
768
+ const tags = mergeTags(input.tags, ["workflow", "pattern"]);
769
+ const text = buildWorkflowPatternText(input);
770
+ const vector = await deps.embedder.embedPassage(text);
771
+ const canonicalKey = resolveCanonicalKey({
772
+ category: "patterns",
773
+ title: input.title,
774
+ text,
775
+ canonicalKey: input.canonicalKey,
776
+ });
777
+ const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
778
+ text,
779
+ vector,
780
+ category: "patterns",
781
+ scope: resolvedScope,
782
+ importance: input.importance,
783
+ metadata: buildWorkflowPatternMetadata(input, tags, canonicalKey),
784
+ canonicalKey,
785
+ source: input.source,
786
+ });
787
+
788
+ return {
789
+ ...input,
790
+ tags,
791
+ canonicalKey,
792
+ category: "patterns",
793
+ text,
794
+ resolvedScope: entry.scope,
795
+ id: entry.id,
796
+ storedAt: new Date(entry.timestamp).toISOString(),
797
+ disposition,
798
+ ...(conflictId ? { conflictId } : {}),
799
+ };
800
+ }
801
+
802
+ export async function persistCaseMemory(
803
+ deps: PersistMemoryDeps,
804
+ rawInput: unknown,
805
+ ): Promise<StoredCaseMemoryRecord> {
806
+ const input = CaseMemoryInputSchema.parse(rawInput);
807
+ const resolvedScope = resolveScope(input);
808
+ const tags = mergeTags(input.tags, ["case", "solution"]);
809
+ const text = buildCaseMemoryText(input);
810
+ const vector = await deps.embedder.embedPassage(text);
811
+ const canonicalKey = resolveCanonicalKey({
812
+ category: "cases",
813
+ title: input.title,
814
+ text,
815
+ canonicalKey: input.canonicalKey,
816
+ });
817
+ const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
818
+ text,
819
+ vector,
820
+ category: "cases",
821
+ scope: resolvedScope,
822
+ importance: input.importance,
823
+ metadata: buildCaseMemoryMetadata(input, tags, canonicalKey),
824
+ canonicalKey,
825
+ source: input.source,
826
+ });
827
+
828
+ return {
829
+ ...input,
830
+ tags,
831
+ canonicalKey,
832
+ category: "cases",
833
+ text,
834
+ resolvedScope: entry.scope,
835
+ id: entry.id,
836
+ storedAt: new Date(entry.timestamp).toISOString(),
837
+ disposition,
838
+ ...(conflictId ? { conflictId } : {}),
839
+ };
840
+ }
841
+
842
+ export async function promoteMemory(
843
+ deps: PersistMemoryDeps,
844
+ rawInput: unknown,
845
+ ): Promise<StoredPromotedMemoryRecord> {
846
+ const input = PromoteMemoryInputSchema.parse(rawInput);
847
+ const sourceEntry = deps.store.get
848
+ ? await deps.store.get(input.memoryId)
849
+ : deps.store.getById
850
+ ? await deps.store.getById(input.memoryId)
851
+ : null;
852
+
853
+ if (!sourceEntry) {
854
+ throw new Error(`Memory ${input.memoryId} not found`);
855
+ }
856
+
857
+ const boundary = extractBoundaryMetadata(sourceEntry.metadata);
858
+ const looksDurableWithoutBoundary = !boundary && isDurableMemoryScope(sourceEntry.scope);
859
+ if ((boundary?.layer && boundary.layer !== "evidence") || (looksDurableWithoutBoundary && !isTranscriptScope(sourceEntry.scope))) {
860
+ const currentLayer = boundary?.layer || "durable";
861
+ throw new Error(`Memory ${input.memoryId} is already ${currentLayer}; promote_memory is only for evidence entries.`);
862
+ }
863
+
864
+ const category = inferPromotedCategory(input, sourceEntry);
865
+ const text = input.text || sourceEntry.text;
866
+ const canonicalKey = resolveCanonicalKey({
867
+ category,
868
+ text,
869
+ canonicalKey: input.canonicalKey,
870
+ });
871
+ const vector = await deps.embedder.embedPassage(text);
872
+ const resolvedScope = resolveScope(input);
873
+ const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
874
+ text,
875
+ vector,
876
+ category,
877
+ scope: resolvedScope,
878
+ importance: input.importance,
879
+ metadata: buildPromotionMetadata(input, canonicalKey, category, sourceEntry, text),
880
+ canonicalKey,
881
+ promotedFrom: sourceEntry.id,
882
+ source: input.source,
883
+ sourceCategory: sourceEntry.category,
884
+ sourceBoundary: boundary,
885
+ });
886
+
887
+ return {
888
+ text,
889
+ category,
890
+ importance: input.importance,
891
+ scope: input.scope,
892
+ source: input.source,
893
+ tags: input.tags,
894
+ canonicalKey,
895
+ resolvedScope: entry.scope,
896
+ id: entry.id,
897
+ storedAt: new Date(entry.timestamp).toISOString(),
898
+ disposition,
899
+ sourceMemoryId: sourceEntry.id,
900
+ sourceCategory: sourceEntry.category,
901
+ ...(conflictId ? { conflictId } : {}),
902
+ };
903
+ }
904
+
905
+ export async function persistMemoryBatch(
906
+ deps: PersistMemoryDeps,
907
+ rawInput: unknown,
908
+ ): Promise<StoredMemoryRecord[]> {
909
+ const input = CaptureMemoryInputSchema.parse(rawInput);
910
+ const normalizedItems = input.memories.map((memory) =>
911
+ StoreMemoryInputSchema.parse({
912
+ text: memory.text,
913
+ category: memory.category,
914
+ importance: memory.importance ?? input.defaultImportance,
915
+ scope: memory.scope ?? input.scope,
916
+ source: memory.source ?? input.source,
917
+ tags: memory.tags ?? [],
918
+ canonicalKey: memory.canonicalKey,
919
+ }),
920
+ );
921
+
922
+ const persisted: StoredMemoryRecord[] = [];
923
+ for (const memory of normalizedItems) {
924
+ persisted.push(await persistMemory(deps, memory));
925
+ }
926
+ return persisted;
927
+ }
928
+
929
+ export function normalizeCaptureInput(rawInput: unknown): CaptureMemoryInput {
930
+ return CaptureMemoryInputSchema.parse(rawInput);
931
+ }