agent-working-memory 0.7.17 → 0.8.6

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 (188) hide show
  1. package/README.md +156 -15
  2. package/dist/adapters/common.d.ts.map +1 -1
  3. package/dist/adapters/common.js +289 -202
  4. package/dist/adapters/common.js.map +1 -1
  5. package/dist/adapters/http.js +22 -22
  6. package/dist/api/routes.d.ts +1 -1
  7. package/dist/api/routes.d.ts.map +1 -1
  8. package/dist/api/routes.js +258 -79
  9. package/dist/api/routes.js.map +1 -1
  10. package/dist/cli/migrate.d.ts +42 -0
  11. package/dist/cli/migrate.d.ts.map +1 -0
  12. package/dist/cli/migrate.js +268 -0
  13. package/dist/cli/migrate.js.map +1 -0
  14. package/dist/cli.js +147 -103
  15. package/dist/cli.js.map +1 -1
  16. package/dist/coordination/circuit-breaker.d.ts +24 -0
  17. package/dist/coordination/circuit-breaker.d.ts.map +1 -0
  18. package/dist/coordination/circuit-breaker.js +70 -0
  19. package/dist/coordination/circuit-breaker.js.map +1 -0
  20. package/dist/coordination/failure-modes.d.ts +21 -0
  21. package/dist/coordination/failure-modes.d.ts.map +1 -0
  22. package/dist/coordination/failure-modes.js +49 -0
  23. package/dist/coordination/failure-modes.js.map +1 -0
  24. package/dist/coordination/peer-decisions.js +7 -7
  25. package/dist/coordination/routes.d.ts.map +1 -1
  26. package/dist/coordination/routes.js +44 -1
  27. package/dist/coordination/routes.js.map +1 -1
  28. package/dist/coordination/schema.d.ts.map +1 -1
  29. package/dist/coordination/schema.js +18 -0
  30. package/dist/coordination/schema.js.map +1 -1
  31. package/dist/coordination/schemas.d.ts +9 -9
  32. package/dist/coordination/stale.d.ts +9 -1
  33. package/dist/coordination/stale.d.ts.map +1 -1
  34. package/dist/coordination/stale.js +53 -9
  35. package/dist/coordination/stale.js.map +1 -1
  36. package/dist/core/embeddings.d.ts +16 -10
  37. package/dist/core/embeddings.d.ts.map +1 -1
  38. package/dist/core/embeddings.js +54 -38
  39. package/dist/core/embeddings.js.map +1 -1
  40. package/dist/core/ml-worker-entry.d.ts +17 -0
  41. package/dist/core/ml-worker-entry.d.ts.map +1 -0
  42. package/dist/core/ml-worker-entry.js +193 -0
  43. package/dist/core/ml-worker-entry.js.map +1 -0
  44. package/dist/core/ml-worker.d.ts +59 -0
  45. package/dist/core/ml-worker.d.ts.map +1 -0
  46. package/dist/core/ml-worker.js +253 -0
  47. package/dist/core/ml-worker.js.map +1 -0
  48. package/dist/core/query-expander.d.ts +13 -15
  49. package/dist/core/query-expander.d.ts.map +1 -1
  50. package/dist/core/query-expander.js +48 -54
  51. package/dist/core/query-expander.js.map +1 -1
  52. package/dist/core/reranker.d.ts +9 -19
  53. package/dist/core/reranker.d.ts.map +1 -1
  54. package/dist/core/reranker.js +33 -35
  55. package/dist/core/reranker.js.map +1 -1
  56. package/dist/core/salience.d.ts +32 -5
  57. package/dist/core/salience.d.ts.map +1 -1
  58. package/dist/core/salience.js +201 -28
  59. package/dist/core/salience.js.map +1 -1
  60. package/dist/core/write-pipeline.d.ts +10 -2
  61. package/dist/core/write-pipeline.d.ts.map +1 -1
  62. package/dist/core/write-pipeline.js +218 -49
  63. package/dist/core/write-pipeline.js.map +1 -1
  64. package/dist/engine/activation.d.ts +2 -2
  65. package/dist/engine/activation.d.ts.map +1 -1
  66. package/dist/engine/activation.js +238 -115
  67. package/dist/engine/activation.js.map +1 -1
  68. package/dist/engine/confidence.d.ts +62 -0
  69. package/dist/engine/confidence.d.ts.map +1 -0
  70. package/dist/engine/confidence.js +100 -0
  71. package/dist/engine/confidence.js.map +1 -0
  72. package/dist/engine/connections.d.ts +47 -8
  73. package/dist/engine/connections.d.ts.map +1 -1
  74. package/dist/engine/connections.js +83 -22
  75. package/dist/engine/connections.js.map +1 -1
  76. package/dist/engine/consolidation-scheduler.d.ts +46 -10
  77. package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
  78. package/dist/engine/consolidation-scheduler.js +170 -50
  79. package/dist/engine/consolidation-scheduler.js.map +1 -1
  80. package/dist/engine/consolidation.d.ts +16 -2
  81. package/dist/engine/consolidation.d.ts.map +1 -1
  82. package/dist/engine/consolidation.js +143 -44
  83. package/dist/engine/consolidation.js.map +1 -1
  84. package/dist/engine/eval.d.ts +2 -2
  85. package/dist/engine/eval.d.ts.map +1 -1
  86. package/dist/engine/eval.js +9 -9
  87. package/dist/engine/eval.js.map +1 -1
  88. package/dist/engine/eviction.d.ts +4 -4
  89. package/dist/engine/eviction.d.ts.map +1 -1
  90. package/dist/engine/eviction.js +15 -15
  91. package/dist/engine/eviction.js.map +1 -1
  92. package/dist/engine/retraction.d.ts +93 -5
  93. package/dist/engine/retraction.d.ts.map +1 -1
  94. package/dist/engine/retraction.js +238 -19
  95. package/dist/engine/retraction.js.map +1 -1
  96. package/dist/engine/staging.d.ts +1 -1
  97. package/dist/engine/staging.d.ts.map +1 -1
  98. package/dist/engine/staging.js +3 -3
  99. package/dist/engine/staging.js.map +1 -1
  100. package/dist/hooks/sidecar.d.ts +1 -1
  101. package/dist/hooks/sidecar.d.ts.map +1 -1
  102. package/dist/hooks/sidecar.js +4 -4
  103. package/dist/hooks/sidecar.js.map +1 -1
  104. package/dist/index.js +48 -31
  105. package/dist/index.js.map +1 -1
  106. package/dist/mcp.js +167 -141
  107. package/dist/mcp.js.map +1 -1
  108. package/dist/storage/factory.d.ts +55 -0
  109. package/dist/storage/factory.d.ts.map +1 -0
  110. package/dist/storage/factory.js +135 -0
  111. package/dist/storage/factory.js.map +1 -0
  112. package/dist/storage/pglite-schema.d.ts +20 -0
  113. package/dist/storage/pglite-schema.d.ts.map +1 -0
  114. package/dist/storage/pglite-schema.js +165 -0
  115. package/dist/storage/pglite-schema.js.map +1 -0
  116. package/dist/storage/pglite.d.ts +187 -0
  117. package/dist/storage/pglite.d.ts.map +1 -0
  118. package/dist/storage/pglite.js +1114 -0
  119. package/dist/storage/pglite.js.map +1 -0
  120. package/dist/storage/sqlite.d.ts +117 -0
  121. package/dist/storage/sqlite.d.ts.map +1 -1
  122. package/dist/storage/sqlite.js +357 -8
  123. package/dist/storage/sqlite.js.map +1 -1
  124. package/dist/storage/store.d.ts +63 -0
  125. package/dist/storage/store.d.ts.map +1 -0
  126. package/dist/storage/store.js +27 -0
  127. package/dist/storage/store.js.map +1 -0
  128. package/dist/types/engram.d.ts +92 -2
  129. package/dist/types/engram.d.ts.map +1 -1
  130. package/dist/types/engram.js.map +1 -1
  131. package/package.json +3 -1
  132. package/src/adapters/claude-code.ts +218 -218
  133. package/src/adapters/codex.ts +252 -252
  134. package/src/adapters/common.ts +498 -411
  135. package/src/adapters/cursor.ts +135 -135
  136. package/src/adapters/http.ts +93 -93
  137. package/src/adapters/index.ts +31 -31
  138. package/src/adapters/types.ts +75 -75
  139. package/src/api/index.ts +3 -3
  140. package/src/api/routes.ts +970 -714
  141. package/src/cli/migrate.ts +307 -0
  142. package/src/cli.ts +755 -719
  143. package/src/coordination/circuit-breaker.ts +83 -0
  144. package/src/coordination/events.ts +90 -90
  145. package/src/coordination/failure-modes.ts +50 -0
  146. package/src/coordination/peer-decisions.ts +105 -105
  147. package/src/coordination/plugin-loader.ts +60 -60
  148. package/src/coordination/plugin.ts +44 -44
  149. package/src/coordination/routes.ts +56 -1
  150. package/src/coordination/schema.ts +13 -0
  151. package/src/coordination/stale.ts +80 -11
  152. package/src/coordination/types.ts +311 -311
  153. package/src/coordination/write-mutex.ts +69 -69
  154. package/src/core/auto-tagger.ts +168 -168
  155. package/src/core/decay.ts +63 -63
  156. package/src/core/embeddings.ts +110 -93
  157. package/src/core/index.ts +5 -5
  158. package/src/core/logger.ts +36 -36
  159. package/src/core/ml-worker-entry.ts +194 -0
  160. package/src/core/ml-worker.ts +281 -0
  161. package/src/core/query-expander.ts +122 -128
  162. package/src/core/reranker.ts +119 -125
  163. package/src/core/salience.ts +200 -33
  164. package/src/core/write-pipeline.ts +521 -343
  165. package/src/engine/activation.ts +235 -109
  166. package/src/engine/confidence.ts +120 -0
  167. package/src/engine/connections.ts +162 -103
  168. package/src/engine/consolidation-scheduler.ts +242 -125
  169. package/src/engine/consolidation.ts +138 -45
  170. package/src/engine/eval.ts +102 -102
  171. package/src/engine/eviction.ts +101 -101
  172. package/src/engine/index.ts +8 -8
  173. package/src/engine/retraction.ts +366 -116
  174. package/src/engine/staging.ts +74 -74
  175. package/src/hooks/sidecar.ts +5 -5
  176. package/src/index.ts +226 -212
  177. package/src/mcp.ts +1145 -1121
  178. package/src/storage/factory.ts +147 -0
  179. package/src/storage/index.ts +3 -3
  180. package/src/storage/pglite-schema.ts +166 -0
  181. package/src/storage/pglite.ts +1363 -0
  182. package/src/storage/sqlite.ts +402 -7
  183. package/src/storage/store.ts +80 -0
  184. package/src/types/agent.ts +67 -67
  185. package/src/types/checkpoint.ts +46 -46
  186. package/src/types/engram.ts +101 -3
  187. package/src/types/eval.ts +100 -100
  188. package/src/types/index.ts +6 -6
@@ -1,343 +1,521 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Unified write pipeline — shared implementation of the three write-time
5
- * memory rules (R1/R2/R3) that distinguish AWM as a "memory" system
6
- * (selective retention) rather than "storage" (retrieve-all-then-dump):
7
- *
8
- * R1 — Reinforce on duplicate. Repeat = stronger memory. When a new
9
- * write shares the EXACT same concept as an existing engram,
10
- * boost that engram's confidence + access count instead of
11
- * creating a new near-duplicate. One strong engram beats N weak
12
- * ones.
13
- *
14
- * R2 — Pick the RIGHT match. Skip the match if it's already
15
- * superseded, unhealthy (confidence < 0.3), or not in active
16
- * stage. If the match is superseded, reinforce the SUPERSEDER
17
- * instead (the "we fixed it, now we know better" chain).
18
- *
19
- * R3 — Corrections override. When the write's eventType is `surprise`
20
- * or `friction` AND the matched engram is the same concept, the
21
- * new write SUPERSEDES the matched engram instead of reinforcing
22
- * it. Fresh truth beats old habit.
23
- *
24
- * Critical implementation detail (lesson from LoCoMo 2026-05-12): the
25
- * match-vs-create pivot is **concept equality**, not raw novelty.
26
- * Thresholding on novelty alone collapses distinct facts that happen to
27
- * share template language (e.g. 419 conversation turns all prefixed
28
- * "[session_3] Caroline: ..." merged into 7 engrams, recall coverage
29
- * halved). Concept equality is the sharp signal: same concept means
30
- * the writer is restating the same topic.
31
- *
32
- * Disposition (active/staging/discard) is still set from evaluateSalience
33
- * for the CREATE path. REINFORCE writes never reach staging — they just
34
- * touch an existing engram. SUPERSEDE writes follow the disposition of
35
- * the new engram (typically active because corrections are high-salience).
36
- */
37
-
38
- import type { EngramStore } from '../storage/sqlite.js';
39
- import type { ConnectionEngine } from '../engine/connections.js';
40
- import type { Engram, MemoryClass, MemoryType } from '../types/engram.js';
41
- import {
42
- evaluateSalience,
43
- computeNoveltyWithMatch,
44
- detectUserFeedback,
45
- type SalienceEventType,
46
- type SalienceResult,
47
- type NoveltyResult,
48
- } from './salience.js';
49
- import { embed } from './embeddings.js';
50
- import { DEFAULT_AGENT_CONFIG } from '../types/agent.js';
51
-
52
- /** Confidence floor below which a matched engram is treated as "decaying out". */
53
- export const HEALTHY_CONFIDENCE_FLOOR = 0.3;
54
-
55
- /** Confidence delta applied on reinforcement. Bounded by REINFORCE_CONFIDENCE_CEIL. */
56
- export const REINFORCE_CONFIDENCE_DELTA = 0.05;
57
- export const REINFORCE_CONFIDENCE_CEIL = 0.95;
58
-
59
- /** Default disposition-confidence priors when the caller doesn't supply one. */
60
- const CONFIDENCE_PRIORS: Record<string, number> = {
61
- decision: 0.65,
62
- friction: 0.60,
63
- causal: 0.60,
64
- surprise: 0.55,
65
- user_feedback: 0.70,
66
- observation: 0.45,
67
- };
68
-
69
- export type WriteAction = 'create' | 'reinforce' | 'supersede';
70
-
71
- export interface WriteInput {
72
- agentId: string;
73
- concept: string;
74
- content: string;
75
- /** Tags as the caller wants them stored (already assembled). */
76
- tags?: string[];
77
- memoryClass?: MemoryClass;
78
- memoryType?: MemoryType;
79
- eventType?: SalienceEventType;
80
- surprise?: number;
81
- decisionMade?: boolean;
82
- causalDepth?: number;
83
- resolutionEffort?: number;
84
- /** Confidence override. When unset, defaults from disposition + eventType priors. */
85
- confidence?: number;
86
- /** Explicit supersession requested by caller (independent of correction-on-match). */
87
- supersedes?: string;
88
- /** Workspace scope for cross-agent novelty (v0.5.4+ stores only). */
89
- workspace?: string | null;
90
- /** Set to false to skip the reinforce/supersede branching and always create. */
91
- enableReinforcement?: boolean;
92
- }
93
-
94
- export interface WriteResult {
95
- action: WriteAction;
96
- /**
97
- * For action='create' or 'supersede': the newly created engram.
98
- * For action='reinforce': the EXISTING engram that was reinforced
99
- * (its confidence and access_count have been bumped in place).
100
- */
101
- engram: Engram;
102
- /** Salience result — present for create/supersede; null for reinforce (no new salience evaluation). */
103
- salience: SalienceResult | null;
104
- /** Novelty + match info, useful for caller logging. */
105
- noveltyResult: NoveltyResult;
106
- /** Reinforcement detail — present only for action='reinforce'. */
107
- reinforce?: {
108
- previousConfidence: number;
109
- newConfidence: number;
110
- previousAccessCount: number;
111
- };
112
- /** Supersession detail present only for action='supersede'. */
113
- supersedeOf?: { id: string };
114
- }
115
-
116
- export interface WritePipelineEngines {
117
- store: EngramStore;
118
- connectionEngine: ConnectionEngine;
119
- }
120
-
121
- /**
122
- * Run a write through the unified pipeline.
123
- *
124
- * Side effects (always):
125
- * - Compute novelty + best match
126
- * - Evaluate salience for audit
127
- *
128
- * Side effects (action-dependent):
129
- * - REINFORCE: touchEngram + updateConfidence on the matched engram;
130
- * no new engram is created.
131
- * - SUPERSEDE: createEngram with supersedes=matched.id, then call
132
- * supersedeEngram. Async embed + enqueue follow.
133
- * - CREATE: createEngram, async embed + enqueue. If salience says
134
- * staging, updateStage to 'staging'.
135
- *
136
- * The caller is responsible for:
137
- * - Tag assembly (callers know their own metadata format)
138
- * - Temporal adjacency edges
139
- * - Episode assignment
140
- * - Auto-checkpoint tracking
141
- * - Decision propagation
142
- *
143
- * Set process.env.AWM_WRITE_PIPELINE=off to revert to legacy create-only
144
- * behavior (the same write inputs but every write creates a new engram).
145
- */
146
- export function performWrite(
147
- engines: WritePipelineEngines,
148
- input: WriteInput,
149
- ): WriteResult {
150
- const { store, connectionEngine } = engines;
151
- const enableReinforcement = input.enableReinforcement !== false
152
- && process.env.AWM_WRITE_PIPELINE !== 'off';
153
-
154
- const noveltyResult = computeNoveltyWithMatch(
155
- store, input.agentId, input.concept, input.content, input.workspace ?? null,
156
- );
157
-
158
- // Effective event type auto-promote user-feedback content.
159
- const effectiveEventType: SalienceEventType =
160
- input.eventType ?? (detectUserFeedback(input.content) ? 'user_feedback' : 'observation');
161
-
162
- // Effective memory class — auto-canonical for user-feedback and verified findings.
163
- let effectiveMemoryClass: MemoryClass | undefined = input.memoryClass;
164
- if (!effectiveMemoryClass && effectiveEventType === 'user_feedback') {
165
- effectiveMemoryClass = 'canonical';
166
- }
167
-
168
- const salience = evaluateSalience({
169
- content: input.content,
170
- eventType: effectiveEventType,
171
- surprise: input.surprise,
172
- decisionMade: input.decisionMade,
173
- causalDepth: input.causalDepth,
174
- resolutionEffort: input.resolutionEffort,
175
- novelty: noveltyResult.novelty,
176
- memoryClass: effectiveMemoryClass,
177
- });
178
-
179
- // -- Reinforce / Supersede branching --
180
- if (enableReinforcement && noveltyResult.matchedEngramId) {
181
- const matched = store.getEngram(noveltyResult.matchedEngramId);
182
- if (matched) {
183
- const newConcept = (input.concept ?? '').toLowerCase().trim();
184
- const matchedConcept = (matched.concept ?? '').toLowerCase().trim();
185
- const sameConcept = newConcept === matchedConcept && newConcept.length > 0;
186
-
187
- if (sameConcept) {
188
- const isCorrectionSignal = effectiveEventType === 'surprise'
189
- || effectiveEventType === 'friction';
190
-
191
- if (isCorrectionSignal) {
192
- // R3 — supersede the matched engram with the new write
193
- return createNewEngram(engines, input, salience, noveltyResult, {
194
- effectiveEventType,
195
- effectiveMemoryClass,
196
- supersedesId: matched.id,
197
- });
198
- }
199
-
200
- // R2health check on the matched engram
201
- const isHealthy = matched.stage === 'active'
202
- && matched.confidence >= HEALTHY_CONFIDENCE_FLOOR
203
- && matched.supersededBy == null;
204
-
205
- if (isHealthy) {
206
- // R1 reinforce
207
- return reinforceMatched(store, matched, noveltyResult, salience);
208
- }
209
-
210
- // Unhealthy match but it was superseded — try to reinforce the superseder
211
- if (matched.supersededBy) {
212
- const superseder = store.getEngram(matched.supersededBy);
213
- if (superseder && superseder.stage === 'active'
214
- && superseder.confidence >= HEALTHY_CONFIDENCE_FLOOR
215
- && superseder.supersededBy == null) {
216
- return reinforceMatched(store, superseder, noveltyResult, salience);
217
- }
218
- }
219
-
220
- // Otherwise fall through to create new
221
- }
222
- }
223
- }
224
-
225
- // -- Default: create new engram --
226
- return createNewEngram(engines, input, salience, noveltyResult, {
227
- effectiveEventType,
228
- effectiveMemoryClass,
229
- supersedesId: input.supersedes,
230
- });
231
- }
232
-
233
- function reinforceMatched(
234
- store: EngramStore,
235
- matched: Engram,
236
- noveltyResult: NoveltyResult,
237
- salience: SalienceResult,
238
- ): WriteResult {
239
- const previousConfidence = matched.confidence;
240
- const previousAccessCount = matched.accessCount;
241
- const newConfidence = Math.min(
242
- REINFORCE_CONFIDENCE_CEIL,
243
- previousConfidence + REINFORCE_CONFIDENCE_DELTA,
244
- );
245
- store.updateConfidence(matched.id, newConfidence);
246
- store.touchEngram(matched.id);
247
-
248
- // Return the engram with the updated values reflected (the DB write
249
- // happened above; the in-memory object is one snapshot behind).
250
- const refreshed: Engram = {
251
- ...matched,
252
- confidence: newConfidence,
253
- accessCount: previousAccessCount + 1,
254
- lastAccessed: new Date(),
255
- };
256
-
257
- return {
258
- action: 'reinforce',
259
- engram: refreshed,
260
- salience: null,
261
- noveltyResult,
262
- reinforce: { previousConfidence, newConfidence, previousAccessCount },
263
- };
264
- }
265
-
266
- function createNewEngram(
267
- engines: WritePipelineEngines,
268
- input: WriteInput,
269
- salience: SalienceResult,
270
- noveltyResult: NoveltyResult,
271
- meta: {
272
- effectiveEventType: SalienceEventType;
273
- effectiveMemoryClass: MemoryClass | undefined;
274
- supersedesId: string | undefined;
275
- },
276
- ): WriteResult {
277
- const { store, connectionEngine } = engines;
278
-
279
- const isLowSalience = salience.disposition === 'discard';
280
-
281
- // Confidence: caller wins, then disposition-aware prior, then fall back
282
- // to eventType prior.
283
- const confidence = input.confidence
284
- ?? (isLowSalience
285
- ? 0.25
286
- : salience.disposition === 'staging'
287
- ? 0.40
288
- : CONFIDENCE_PRIORS[meta.effectiveEventType] ?? 0.45);
289
-
290
- const tags = [...(input.tags ?? [])];
291
- if (isLowSalience && !tags.includes('low-salience')) tags.push('low-salience');
292
-
293
- const engram = store.createEngram({
294
- agentId: input.agentId,
295
- concept: input.concept,
296
- content: input.content,
297
- tags,
298
- salience: salience.score,
299
- confidence,
300
- salienceFeatures: salience.features,
301
- reasonCodes: salience.reasonCodes,
302
- memoryClass: meta.effectiveMemoryClass,
303
- memoryType: input.memoryType,
304
- ttl: salience.disposition === 'staging' ? DEFAULT_AGENT_CONFIG.stagingTtlMs : undefined,
305
- supersedes: meta.supersedesId,
306
- });
307
-
308
- if (salience.disposition === 'staging') {
309
- store.updateStage(engram.id, 'staging');
310
- }
311
-
312
- // Supersession side-effects: mark the old engram, add causal edge.
313
- if (meta.supersedesId) {
314
- try {
315
- const oldEngram = store.getEngram(meta.supersedesId);
316
- if (oldEngram) {
317
- store.supersedeEngram(meta.supersedesId, engram.id);
318
- store.upsertAssociation(engram.id, oldEngram.id, 0.8, 'causal', 0.9);
319
- }
320
- } catch { /* supersession is best-effort */ }
321
- }
322
-
323
- // Connection discovery only for non-staged writes (active or low-salience)
324
- if (salience.disposition === 'active' || isLowSalience) {
325
- try { connectionEngine.enqueue(engram.id); } catch { /* non-fatal */ }
326
- }
327
-
328
- // Async embednever blocks the response, failure non-fatal
329
- embed(`${input.concept} ${input.content}`)
330
- .then(vec => {
331
- try { store.updateEmbedding(engram.id, vec); } catch { /* engram may be evicted */ }
332
- })
333
- .catch(() => { /* embed failure tolerated */ });
334
-
335
- const action: WriteAction = meta.supersedesId ? 'supersede' : 'create';
336
- return {
337
- action,
338
- engram,
339
- salience,
340
- noveltyResult,
341
- supersedeOf: meta.supersedesId ? { id: meta.supersedesId } : undefined,
342
- };
343
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Unified write pipeline — shared implementation of the three write-time
5
+ * memory rules (R1/R2/R3) that distinguish AWM as a "memory" system
6
+ * (selective retention) rather than "storage" (retrieve-all-then-dump):
7
+ *
8
+ * R1 — Reinforce on duplicate. Repeat = stronger memory. When a new
9
+ * write shares the EXACT same concept as an existing engram,
10
+ * boost that engram's confidence + access count instead of
11
+ * creating a new near-duplicate. One strong engram beats N weak
12
+ * ones.
13
+ *
14
+ * R2 — Pick the RIGHT match. Skip the match if it's already
15
+ * superseded, unhealthy (confidence < 0.3), or not in active
16
+ * stage. If the match is superseded, reinforce the SUPERSEDER
17
+ * instead (the "we fixed it, now we know better" chain).
18
+ *
19
+ * R3 — Corrections override. When the write's eventType is `surprise`
20
+ * or `friction` AND the matched engram is the same concept, the
21
+ * new write SUPERSEDES the matched engram instead of reinforcing
22
+ * it. Fresh truth beats old habit.
23
+ *
24
+ * Critical implementation detail (lesson from LoCoMo 2026-05-12): the
25
+ * match-vs-create pivot is **concept equality**, not raw novelty.
26
+ * Thresholding on novelty alone collapses distinct facts that happen to
27
+ * share template language (e.g. 419 conversation turns all prefixed
28
+ * "[session_3] Caroline: ..." merged into 7 engrams, recall coverage
29
+ * halved). Concept equality is the sharp signal: same concept means
30
+ * the writer is restating the same topic.
31
+ *
32
+ * Disposition (active/staging/discard) is still set from evaluateSalience
33
+ * for the CREATE path. REINFORCE writes never reach staging — they just
34
+ * touch an existing engram. SUPERSEDE writes follow the disposition of
35
+ * the new engram (typically active because corrections are high-salience).
36
+ */
37
+
38
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
39
+ import type { ConnectionEngine } from '../engine/connections.js';
40
+ import type { Engram, MemoryClass, MemoryType } from '../types/engram.js';
41
+ import {
42
+ evaluateSalience,
43
+ computeNoveltyWithMatch,
44
+ detectUserFeedback,
45
+ type SalienceEventType,
46
+ type SalienceResult,
47
+ type NoveltyResult,
48
+ } from './salience.js';
49
+ import { embed } from './embeddings.js';
50
+ import { DEFAULT_AGENT_CONFIG } from '../types/agent.js';
51
+
52
+ /** Confidence floor below which a matched engram is treated as "decaying out". */
53
+ export const HEALTHY_CONFIDENCE_FLOOR = 0.3;
54
+
55
+ /** Confidence delta applied on reinforcement. Bounded by REINFORCE_CONFIDENCE_CEIL. */
56
+ export const REINFORCE_CONFIDENCE_DELTA = 0.05;
57
+ export const REINFORCE_CONFIDENCE_CEIL = 0.95;
58
+
59
+ /** Default disposition-confidence priors when the caller doesn't supply one. */
60
+ const CONFIDENCE_PRIORS: Record<string, number> = {
61
+ decision: 0.65,
62
+ friction: 0.60,
63
+ causal: 0.60,
64
+ surprise: 0.55,
65
+ user_feedback: 0.70,
66
+ observation: 0.45,
67
+ };
68
+
69
+ export type WriteAction = 'create' | 'reinforce' | 'supersede';
70
+
71
+ export interface WriteInput {
72
+ agentId: string;
73
+ concept: string;
74
+ content: string;
75
+ /** Tags as the caller wants them stored (already assembled). */
76
+ tags?: string[];
77
+ memoryClass?: MemoryClass;
78
+ memoryType?: MemoryType;
79
+ eventType?: SalienceEventType;
80
+ surprise?: number;
81
+ decisionMade?: boolean;
82
+ causalDepth?: number;
83
+ resolutionEffort?: number;
84
+ /** Confidence override. When unset, defaults from disposition + eventType priors. */
85
+ confidence?: number;
86
+ /** Explicit supersession requested by caller (independent of correction-on-match). */
87
+ supersedes?: string;
88
+ /** Workspace scope for cross-agent novelty (v0.5.4+ stores only). */
89
+ workspace?: string | null;
90
+ /** Set to false to skip the reinforce/supersede branching and always create. */
91
+ enableReinforcement?: boolean;
92
+ /** Optional story-time / sequence ordering (0.8 Cluster A). */
93
+ sequence?: number;
94
+ /** Typed cross-record links — stored alongside the engram (0.8 Cluster A). */
95
+ references?: import('../types/engram.js').EngramReference[];
96
+ /** Force embedding for structural-class writes. structural skips by default
97
+ * because it's deterministically retrieved; opt in if a caller wants
98
+ * cognitive recall over a structural engram. (0.8 Cluster A) */
99
+ embed?: boolean;
100
+ }
101
+
102
+ export interface WriteResult {
103
+ action: WriteAction;
104
+ /**
105
+ * For action='create' or 'supersede': the newly created engram.
106
+ * For action='reinforce': the EXISTING engram that was reinforced
107
+ * (its confidence and access_count have been bumped in place).
108
+ */
109
+ engram: Engram;
110
+ /** Salience result — present for create/supersede; null for reinforce (no new salience evaluation). */
111
+ salience: SalienceResult | null;
112
+ /** Novelty + match info, useful for caller logging. */
113
+ noveltyResult: NoveltyResult;
114
+ /** Reinforcement detail — present only for action='reinforce'. */
115
+ reinforce?: {
116
+ previousConfidence: number;
117
+ newConfidence: number;
118
+ previousAccessCount: number;
119
+ };
120
+ /** Supersession detail — present only for action='supersede'. */
121
+ supersedeOf?: { id: string };
122
+ }
123
+
124
+ export interface WritePipelineEngines {
125
+ store: EngramStore;
126
+ connectionEngine: ConnectionEngine;
127
+ }
128
+
129
+ /**
130
+ * Run a write through the unified pipeline.
131
+ *
132
+ * Side effects (always):
133
+ * - Compute novelty + best match
134
+ * - Evaluate salience for audit
135
+ *
136
+ * Side effects (action-dependent):
137
+ * - REINFORCE: touchEngram + updateConfidence on the matched engram;
138
+ * no new engram is created.
139
+ * - SUPERSEDE: createEngram with supersedes=matched.id, then call
140
+ * supersedeEngram. Async embed + enqueue follow.
141
+ * - CREATE: createEngram, async embed + enqueue. If salience says
142
+ * staging, updateStage to 'staging'.
143
+ *
144
+ * The caller is responsible for:
145
+ * - Tag assembly (callers know their own metadata format)
146
+ * - Temporal adjacency edges
147
+ * - Episode assignment
148
+ * - Auto-checkpoint tracking
149
+ * - Decision propagation
150
+ *
151
+ * Set process.env.AWM_WRITE_PIPELINE=off to revert to legacy create-only
152
+ * behavior (the same write inputs but every write creates a new engram).
153
+ */
154
+ export async function performWrite(
155
+ engines: WritePipelineEngines,
156
+ input: WriteInput,
157
+ ): Promise<WriteResult> {
158
+ const { store, connectionEngine } = engines;
159
+ const enableReinforcement = input.enableReinforcement !== false
160
+ && process.env.AWM_WRITE_PIPELINE !== 'off';
161
+
162
+ // Profiling: AWM_PROFILE_WRITE=1 logs ms-per-phase to stderr (v0.8.2).
163
+ // Off by default; zero cost when unset.
164
+ const profile = process.env.AWM_PROFILE_WRITE === '1';
165
+ const startTotal = profile ? performance.now() : 0;
166
+ let tNovelty = 0, tCreate = 0, tEmbed = 0;
167
+
168
+ // Pre-embed once (v0.8.5): the embedding is needed for cosine-based novelty
169
+ // and is also the embedding we'll store on the engram. Computing it once
170
+ // here costs ~50-100ms but saves the post-write async embed pass (which
171
+ // used to run after createEngram). Net cost is the same; we just pay it
172
+ // synchronously up front in exchange for a backend-agnostic novelty signal.
173
+ //
174
+ // If embed fails (model not loaded, hardware issue), we silently fall back
175
+ // to BM25-only novelty and write without an embedding (the async embed
176
+ // hook below will retry).
177
+ // Disable via AWM_NOVELTY_EMBED=0 to keep writes ultra-fast at the cost
178
+ // of cross-backend novelty consistency.
179
+ const tStartEmbed = profile ? performance.now() : 0;
180
+ let prewriteEmbedding: number[] | null = null;
181
+ if (process.env.AWM_NOVELTY_EMBED !== '0') {
182
+ try {
183
+ prewriteEmbedding = await embed(`${input.concept} ${input.content}`);
184
+ } catch { /* fall through — async embed will retry later */ }
185
+ }
186
+ if (profile) tEmbed = performance.now() - tStartEmbed;
187
+
188
+ const tStartNovelty = profile ? performance.now() : 0;
189
+ const noveltyResult = await computeNoveltyWithMatch(
190
+ store, input.agentId, input.concept, input.content,
191
+ input.workspace ?? null,
192
+ prewriteEmbedding,
193
+ );
194
+ if (profile) tNovelty = performance.now() - tStartNovelty;
195
+
196
+ // Effective event type — auto-promote user-feedback content.
197
+ const effectiveEventType: SalienceEventType =
198
+ input.eventType ?? (detectUserFeedback(input.content) ? 'user_feedback' : 'observation');
199
+
200
+ // Effective memory class auto-canonical for user-feedback and verified findings.
201
+ let effectiveMemoryClass: MemoryClass | undefined = input.memoryClass;
202
+ if (!effectiveMemoryClass && effectiveEventType === 'user_feedback') {
203
+ effectiveMemoryClass = 'canonical';
204
+ }
205
+
206
+ const salience = evaluateSalience({
207
+ content: input.content,
208
+ eventType: effectiveEventType,
209
+ surprise: input.surprise,
210
+ decisionMade: input.decisionMade,
211
+ causalDepth: input.causalDepth,
212
+ resolutionEffort: input.resolutionEffort,
213
+ novelty: noveltyResult.novelty,
214
+ memoryClass: effectiveMemoryClass,
215
+ });
216
+
217
+ // -- Reinforce / Supersede branching --
218
+ const tStartCreate = profile ? performance.now() : 0;
219
+ let result: WriteResult | null = null;
220
+ if (enableReinforcement && noveltyResult.matchedEngramId) {
221
+ const matched = await store.getEngram(noveltyResult.matchedEngramId);
222
+ if (matched) {
223
+ const newConcept = (input.concept ?? '').toLowerCase().trim();
224
+ const matchedConcept = (matched.concept ?? '').toLowerCase().trim();
225
+ const sameConcept = newConcept === matchedConcept && newConcept.length > 0;
226
+
227
+ if (sameConcept) {
228
+ const isCorrectionSignal = effectiveEventType === 'surprise'
229
+ || effectiveEventType === 'friction';
230
+
231
+ if (isCorrectionSignal) {
232
+ // R3 — supersede the matched engram with the new write.
233
+ // Force `active` disposition (v0.8.5): the user explicitly flagged
234
+ // this write as a correction (eventType=surprise/friction). Without
235
+ // this override, cosine-based novelty (which correctly recognizes
236
+ // the correction's semantic similarity to the engram it's
237
+ // correcting) would push salience low and the correction would
238
+ // land in 'staging'. That broke the R2 superseder-reinforce chain
239
+ // for later writes (the chain requires stage='active'). The user's
240
+ // explicit correction intent must win over the duplicate-detection
241
+ // signal. R3 corrections always go active.
242
+ const correctionSalience: SalienceResult = {
243
+ ...salience,
244
+ disposition: 'active' as const,
245
+ reasonCodes: [...salience.reasonCodes, 'correction:override-active'],
246
+ };
247
+ result = await createNewEngram(engines, input, correctionSalience, noveltyResult, {
248
+ effectiveEventType,
249
+ effectiveMemoryClass,
250
+ supersedesId: matched.id,
251
+ }, prewriteEmbedding);
252
+ } else {
253
+ // R2 health check on the matched engram
254
+ const isHealthy = matched.stage === 'active'
255
+ && matched.confidence >= HEALTHY_CONFIDENCE_FLOOR
256
+ && matched.supersededBy == null;
257
+
258
+ if (isHealthy) {
259
+ // R1 — reinforce (and merge new content into matched engram, v0.8.5)
260
+ result = await reinforceMatched(store, matched, noveltyResult, salience, input.content, input.concept);
261
+ } else if (matched.supersededBy) {
262
+ // Unhealthy match but it was superseded — try to reinforce the superseder
263
+ const superseder = await store.getEngram(matched.supersededBy);
264
+ if (superseder && superseder.stage === 'active'
265
+ && superseder.confidence >= HEALTHY_CONFIDENCE_FLOOR
266
+ && superseder.supersededBy == null) {
267
+ result = await reinforceMatched(store, superseder, noveltyResult, salience, input.content, input.concept);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ // -- Default: create new engram (if no reinforce/supersede branch fired) --
276
+ if (!result) {
277
+ result = await createNewEngram(engines, input, salience, noveltyResult, {
278
+ effectiveEventType,
279
+ effectiveMemoryClass,
280
+ supersedesId: input.supersedes,
281
+ }, prewriteEmbedding);
282
+ }
283
+ if (profile) tCreate = performance.now() - tStartCreate;
284
+
285
+ if (profile) {
286
+ const total = performance.now() - startTotal;
287
+ // Single-line stderr log; cheap to grep, easy to disable.
288
+ // Format: [write] action=create novelty=42.1ms create=18.3ms total=60.4ms agent=x id=y
289
+ // eslint-disable-next-line no-console
290
+ console.error(
291
+ `[awm-write] action=${result.action} embed=${tEmbed.toFixed(1)}ms novelty=${tNovelty.toFixed(1)}ms create=${tCreate.toFixed(1)}ms total=${total.toFixed(1)}ms agent=${input.agentId} id=${result.engram.id}`,
292
+ );
293
+ }
294
+
295
+ return result;
296
+ }
297
+
298
+ /**
299
+ * Reinforce-merge upper bound on content length (chars). When an engram's
300
+ * merged content would exceed this, we drop the OLDEST reinforced segment(s)
301
+ * to make room for the new one. Recency wins because later reinforces
302
+ * usually elaborate on the topic with more specific keywords. Configurable
303
+ * via `AWM_REINFORCE_MAX_CONTENT_LEN`.
304
+ *
305
+ * Default lowered to 1500 chars (~375 tokens) in v0.8.5 follow-up after
306
+ * test:tokens showed 4000-char cap produced 3.5× baseline AWM context size
307
+ * on concept-collision corpora (e.g. concept="${task} conversation"). 1500
308
+ * is large enough to hold ~3–4 reinforced segments without blowing token
309
+ * budget on recall.
310
+ */
311
+ const REINFORCE_MAX_CONTENT_LEN = Number(process.env.AWM_REINFORCE_MAX_CONTENT_LEN ?? 1500);
312
+ const REINFORCE_SEPARATOR = '\n\n--- reinforced ---\n';
313
+
314
+ /**
315
+ * Merge new content into an existing engram on reinforce (v0.8.5).
316
+ *
317
+ * Prior behavior: reinforce-on-duplicate kept ONLY the first write's
318
+ * content. Subsequent same-concept writes bumped confidence + accessCount
319
+ * but their content was discarded. When the new content carried valuable
320
+ * keyword detail (later writes elaborating on the topic), that information
321
+ * was lost — confirmed via scripts/trace-recall-divergence.ts on
322
+ * test:tokens, where multi-turn auth-assistant content (HS256, refresh
323
+ * tokens, 15 min, 7 day) consolidated into the FIRST auth-assistant turn's
324
+ * content (just "I'll set up JWT auth with jsonwebtoken...") losing all
325
+ * keyword info.
326
+ *
327
+ * Behavior: append the new content with a separator, unless it's already a
328
+ * substring of the existing content (true repeat no info gain). When the
329
+ * projected length exceeds REINFORCE_MAX_CONTENT_LEN, drop the OLDEST
330
+ * reinforced segment(s) until it fits. The first segment (original write)
331
+ * is also evictable if subsequent reinforces have replaced it with more
332
+ * specific content.
333
+ *
334
+ * Returns `{ merged, appended }` so callers can skip the re-embed + DB
335
+ * update when nothing changed.
336
+ */
337
+ function mergeReinforcedContent(existing: string, addition: string): { merged: string; appended: boolean } {
338
+ const trimmed = (addition ?? '').trim();
339
+ if (!trimmed) return { merged: existing, appended: false };
340
+ // Already-covered: new content is a substring of existing — true repeat.
341
+ if (existing.includes(trimmed)) return { merged: existing, appended: false };
342
+
343
+ // Split into segments on the separator so we can drop oldest on overflow.
344
+ const segments = existing.split(REINFORCE_SEPARATOR);
345
+ segments.push(trimmed);
346
+ let projected = segments.join(REINFORCE_SEPARATOR);
347
+
348
+ // Drop oldest segments until the projected content fits the cap. Always
349
+ // keep the new content (segments[last]) — if even the new addition alone
350
+ // exceeds the cap, we keep it anyway since recency drives value.
351
+ while (projected.length > REINFORCE_MAX_CONTENT_LEN && segments.length > 1) {
352
+ segments.shift();
353
+ projected = segments.join(REINFORCE_SEPARATOR);
354
+ }
355
+ return { merged: projected, appended: true };
356
+ }
357
+
358
+ async function reinforceMatched(
359
+ store: EngramStore,
360
+ matched: Engram,
361
+ noveltyResult: NoveltyResult,
362
+ salience: SalienceResult,
363
+ /** The new write's content, so we can merge it into the matched engram (v0.8.5). */
364
+ newContent: string = '',
365
+ /** The new write's concept, used for re-embed text when content changes. */
366
+ newConceptHint: string = '',
367
+ ): Promise<WriteResult> {
368
+ const previousConfidence = matched.confidence;
369
+ const previousAccessCount = matched.accessCount;
370
+ const newConfidence = Math.min(
371
+ REINFORCE_CONFIDENCE_CEIL,
372
+ previousConfidence + REINFORCE_CONFIDENCE_DELTA,
373
+ );
374
+ await store.updateConfidence(matched.id, newConfidence);
375
+ await store.touchEngram(matched.id);
376
+
377
+ // v0.8.5: merge new content into existing engram so reinforce-on-duplicate
378
+ // doesn't throw away later writes' information. Re-embed the merged
379
+ // content so semantic recall reflects the accumulated knowledge.
380
+ let mergedContent = matched.content;
381
+ if (newContent && process.env.AWM_REINFORCE_MERGE_CONTENT !== '0') {
382
+ const result = mergeReinforcedContent(matched.content, newContent);
383
+ if (result.appended) {
384
+ mergedContent = result.merged;
385
+ try {
386
+ await store.updateContent(matched.id, mergedContent);
387
+ // Re-embed the merged content so cosine recall surfaces the new
388
+ // info. Use concept + merged-content; embedding model truncates
389
+ // beyond 512 tokens but the topic anchor (early content) drives
390
+ // the vector for retrieval purposes.
391
+ const conceptForEmbed = newConceptHint || matched.concept;
392
+ const newVec = await embed(`${conceptForEmbed} ${mergedContent}`);
393
+ await store.updateEmbedding(matched.id, newVec);
394
+ } catch { /* merge is best-effort — confidence bump already landed */ }
395
+ }
396
+ }
397
+
398
+ // Return the engram with the updated values reflected (the DB write
399
+ // happened above; the in-memory object is one snapshot behind).
400
+ const refreshed: Engram = {
401
+ ...matched,
402
+ content: mergedContent,
403
+ confidence: newConfidence,
404
+ accessCount: previousAccessCount + 1,
405
+ lastAccessed: new Date(),
406
+ };
407
+
408
+ return {
409
+ action: 'reinforce',
410
+ engram: refreshed,
411
+ salience: null,
412
+ noveltyResult,
413
+ reinforce: { previousConfidence, newConfidence, previousAccessCount },
414
+ };
415
+ }
416
+
417
+ async function createNewEngram(
418
+ engines: WritePipelineEngines,
419
+ input: WriteInput,
420
+ salience: SalienceResult,
421
+ noveltyResult: NoveltyResult,
422
+ meta: {
423
+ effectiveEventType: SalienceEventType;
424
+ effectiveMemoryClass: MemoryClass | undefined;
425
+ supersedesId: string | undefined;
426
+ },
427
+ /** Pre-computed embedding from performWrite (v0.8.5). If non-null, used
428
+ * directly + skips the post-create async embed pass. */
429
+ prewriteEmbedding: number[] | null = null,
430
+ ): Promise<WriteResult> {
431
+ const { store, connectionEngine } = engines;
432
+
433
+ const isLowSalience = salience.disposition === 'discard';
434
+
435
+ // Confidence: caller wins, then disposition-aware prior, then fall back
436
+ // to eventType prior.
437
+ const confidence = input.confidence
438
+ ?? (isLowSalience
439
+ ? 0.25
440
+ : salience.disposition === 'staging'
441
+ ? 0.40
442
+ : CONFIDENCE_PRIORS[meta.effectiveEventType] ?? 0.45);
443
+
444
+ const tags = [...(input.tags ?? [])];
445
+ if (isLowSalience && !tags.includes('low-salience')) tags.push('low-salience');
446
+
447
+ const engram = await store.createEngram({
448
+ agentId: input.agentId,
449
+ concept: input.concept,
450
+ content: input.content,
451
+ tags,
452
+ salience: salience.score,
453
+ confidence,
454
+ salienceFeatures: salience.features,
455
+ reasonCodes: salience.reasonCodes,
456
+ memoryClass: meta.effectiveMemoryClass,
457
+ memoryType: input.memoryType,
458
+ ttl: salience.disposition === 'staging' ? DEFAULT_AGENT_CONFIG.stagingTtlMs : undefined,
459
+ supersedes: meta.supersedesId,
460
+ sequence: input.sequence,
461
+ references: input.references,
462
+ embedding: prewriteEmbedding && prewriteEmbedding.length > 0
463
+ ? prewriteEmbedding
464
+ : undefined,
465
+ });
466
+
467
+ if (salience.disposition === 'staging') {
468
+ await store.updateStage(engram.id, 'staging');
469
+ }
470
+
471
+ // Supersession side-effects: mark the old engram, add causal edge.
472
+ if (meta.supersedesId) {
473
+ try {
474
+ const oldEngram = await store.getEngram(meta.supersedesId);
475
+ if (oldEngram) {
476
+ await store.supersedeEngram(meta.supersedesId, engram.id);
477
+ await store.upsertAssociation(engram.id, oldEngram.id, 0.8, 'causal', 0.9);
478
+ }
479
+ } catch { /* supersession is best-effort */ }
480
+ }
481
+
482
+ // Connection discovery — only for non-staged writes (active or low-salience).
483
+ // Structural engrams skip connection discovery: they're event-log records,
484
+ // not observations the agent needs to think about (0.8 Cluster A).
485
+ //
486
+ // v0.8.2: enqueueAndMaybeFlush queues for the next consolidation cycle
487
+ // (cheap, no event-loop blocking). For cold-start agents (fewer than
488
+ // AWM_CONNECTION_COLD_START_THRESHOLD active engrams, default 10), the
489
+ // queue drains inline as a background task so the first few writes still
490
+ // build a useful association graph before the next consolidation fires.
491
+ const isStructural = meta.effectiveMemoryClass === 'structural';
492
+ if ((salience.disposition === 'active' || isLowSalience) && !isStructural) {
493
+ try { connectionEngine.enqueueAndMaybeFlush(engram.id, input.agentId); } catch { /* non-fatal */ }
494
+ }
495
+
496
+ // Async embed — never blocks the response, failure non-fatal.
497
+ // Structural engrams skip embedding by default (deterministic retrieval only).
498
+ // Caller can override by passing `embed: true` on the write input.
499
+ //
500
+ // v0.8.5: skip the async embed entirely when performWrite already
501
+ // pre-computed the embedding for cosine-based novelty. The embedding
502
+ // was passed to createEngram above; no re-embed needed.
503
+ const alreadyEmbedded = prewriteEmbedding != null && prewriteEmbedding.length > 0;
504
+ const shouldEmbed = (!isStructural || input.embed === true) && !alreadyEmbedded;
505
+ if (shouldEmbed) {
506
+ embed(`${input.concept} ${input.content}`)
507
+ .then(async vec => {
508
+ try { await store.updateEmbedding(engram.id, vec); } catch { /* engram may be evicted */ }
509
+ })
510
+ .catch(() => { /* embed failure tolerated */ });
511
+ }
512
+
513
+ const action: WriteAction = meta.supersedesId ? 'supersede' : 'create';
514
+ return {
515
+ action,
516
+ engram,
517
+ salience,
518
+ noveltyResult,
519
+ supersedeOf: meta.supersedesId ? { id: meta.supersedesId } : undefined,
520
+ };
521
+ }