@framers/agentos 0.10.10 → 0.10.12

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 (32) hide show
  1. package/README.md +2 -1
  2. package/dist/api/generateText.d.ts +10 -3
  3. package/dist/api/generateText.d.ts.map +1 -1
  4. package/dist/api/generateText.js.map +1 -1
  5. package/dist/cognition/memory/AgentMemory.d.ts +4 -0
  6. package/dist/cognition/memory/AgentMemory.d.ts.map +1 -1
  7. package/dist/cognition/memory/AgentMemory.js +56 -17
  8. package/dist/cognition/memory/AgentMemory.js.map +1 -1
  9. package/dist/cognition/memory/CognitiveMemoryManager.d.ts +6 -0
  10. package/dist/cognition/memory/CognitiveMemoryManager.d.ts.map +1 -1
  11. package/dist/cognition/memory/CognitiveMemoryManager.js +100 -8
  12. package/dist/cognition/memory/CognitiveMemoryManager.js.map +1 -1
  13. package/dist/cognition/memory/io/extension/CognitiveMemoryExtension.d.ts.map +1 -1
  14. package/dist/cognition/memory/io/extension/CognitiveMemoryExtension.js +8 -2
  15. package/dist/cognition/memory/io/extension/CognitiveMemoryExtension.js.map +1 -1
  16. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.d.ts +7 -0
  17. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.d.ts.map +1 -1
  18. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.js +84 -55
  19. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.js.map +1 -1
  20. package/dist/cognition/memory/retrieval/store/Brain.d.ts +14 -2
  21. package/dist/cognition/memory/retrieval/store/Brain.d.ts.map +1 -1
  22. package/dist/cognition/memory/retrieval/store/Brain.js +264 -15
  23. package/dist/cognition/memory/retrieval/store/Brain.js.map +1 -1
  24. package/dist/cognition/memory/retrieval/store/MemoryStore.d.ts +41 -2
  25. package/dist/cognition/memory/retrieval/store/MemoryStore.d.ts.map +1 -1
  26. package/dist/cognition/memory/retrieval/store/MemoryStore.js +676 -131
  27. package/dist/cognition/memory/retrieval/store/MemoryStore.js.map +1 -1
  28. package/dist/core/llm/providers/model-effort.d.ts +8 -4
  29. package/dist/core/llm/providers/model-effort.d.ts.map +1 -1
  30. package/dist/core/llm/providers/model-effort.js +29 -4
  31. package/dist/core/llm/providers/model-effort.js.map +1 -1
  32. package/package.json +1 -1
@@ -144,6 +144,205 @@ function blobToEmbedding(blob) {
144
144
  return null;
145
145
  return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4));
146
146
  }
147
+ const WeakRefImplementation = typeof WeakRef === 'function' ? WeakRef : undefined;
148
+ const FinalizationRegistryImplementation = typeof FinalizationRegistry === 'function' ? FinalizationRegistry : undefined;
149
+ function createReference(target) {
150
+ if (WeakRefImplementation)
151
+ return new WeakRefImplementation(target);
152
+ // Compatibility fallback for runtimes without WeakRef. MemoryStore.dispose
153
+ // deterministically removes the strong registration.
154
+ return { deref: () => target };
155
+ }
156
+ const namespaceCoordinators = new WeakMap();
157
+ const vectorStoreNamespaces = new WeakMap();
158
+ function getNamespaceCoordinator(namespace) {
159
+ let coordinator = namespaceCoordinators.get(namespace);
160
+ if (!coordinator) {
161
+ coordinator = {
162
+ deleteEpoch: 0,
163
+ pendingOperations: 0,
164
+ operations: new Map(),
165
+ stores: new Set(),
166
+ };
167
+ namespaceCoordinators.set(namespace, coordinator);
168
+ }
169
+ return coordinator;
170
+ }
171
+ function compactNamespaceCoordinator(namespace, coordinator) {
172
+ for (const reference of coordinator.stores) {
173
+ if (!reference.deref())
174
+ coordinator.stores.delete(reference);
175
+ }
176
+ if (coordinator.stores.size === 0 &&
177
+ coordinator.pendingOperations === 0 &&
178
+ coordinator.operations.size === 0 &&
179
+ namespaceCoordinators.get(namespace) === coordinator) {
180
+ namespaceCoordinators.delete(namespace);
181
+ }
182
+ }
183
+ const storeFinalizer = FinalizationRegistryImplementation
184
+ ? new FinalizationRegistryImplementation((registration) => {
185
+ const coordinator = namespaceCoordinators.get(registration.namespace);
186
+ if (!coordinator)
187
+ return;
188
+ coordinator.stores.delete(registration.reference);
189
+ compactNamespaceCoordinator(registration.namespace, coordinator);
190
+ })
191
+ : null;
192
+ function registerCoordinationTarget(namespace, target) {
193
+ const coordinator = getNamespaceCoordinator(namespace);
194
+ const reference = createReference(target);
195
+ const registration = { namespace, reference };
196
+ coordinator.stores.add(reference);
197
+ storeFinalizer?.register(target, registration, reference);
198
+ return registration;
199
+ }
200
+ function unregisterCoordinationTarget(registration) {
201
+ storeFinalizer?.unregister(registration.reference);
202
+ const coordinator = namespaceCoordinators.get(registration.namespace);
203
+ if (!coordinator)
204
+ return;
205
+ coordinator.stores.delete(registration.reference);
206
+ compactNamespaceCoordinator(registration.namespace, coordinator);
207
+ }
208
+ function vectorStoreNamespace(vectorStore, collectionPrefix) {
209
+ let namespaces = vectorStoreNamespaces.get(vectorStore);
210
+ if (!namespaces) {
211
+ namespaces = new Map();
212
+ vectorStoreNamespaces.set(vectorStore, namespaces);
213
+ }
214
+ let namespace = namespaces.get(collectionPrefix);
215
+ if (!namespace) {
216
+ namespace = {};
217
+ namespaces.set(collectionPrefix, namespace);
218
+ }
219
+ return namespace;
220
+ }
221
+ function markTraceDeleted(namespace, traceId, authoritative = true) {
222
+ const coordinator = getNamespaceCoordinator(namespace);
223
+ for (const reference of coordinator.stores) {
224
+ const target = reference.deref();
225
+ if (target)
226
+ target.markDeleted(traceId, authoritative);
227
+ else
228
+ coordinator.stores.delete(reference);
229
+ }
230
+ }
231
+ function releaseDeleteAuthority(namespace, traceId) {
232
+ const coordinator = getNamespaceCoordinator(namespace);
233
+ for (const reference of coordinator.stores) {
234
+ const target = reference.deref();
235
+ if (target)
236
+ target.releaseDeleteAuthority(traceId);
237
+ else
238
+ coordinator.stores.delete(reference);
239
+ }
240
+ }
241
+ async function evictDeletedTrace(namespace, traceId, fallback, requiredTarget) {
242
+ const coordinator = getNamespaceCoordinator(namespace);
243
+ const targets = new Set();
244
+ for (const reference of coordinator.stores) {
245
+ const target = reference.deref();
246
+ if (target)
247
+ targets.add(target);
248
+ else
249
+ coordinator.stores.delete(reference);
250
+ }
251
+ if (requiredTarget)
252
+ targets.add(requiredTarget);
253
+ const outcomes = await Promise.all([...targets].map((target) => target.evictDeleted(traceId, fallback)));
254
+ return outcomes.every(Boolean);
255
+ }
256
+ function hasDeletedTrace(namespace, traceId) {
257
+ const coordinator = getNamespaceCoordinator(namespace);
258
+ for (const reference of coordinator.stores) {
259
+ const target = reference.deref();
260
+ if (target?.isDeleted(traceId))
261
+ return true;
262
+ if (!target)
263
+ coordinator.stores.delete(reference);
264
+ }
265
+ return false;
266
+ }
267
+ function hasAuthoritativeDelete(namespace, traceId) {
268
+ const coordinator = getNamespaceCoordinator(namespace);
269
+ for (const reference of coordinator.stores) {
270
+ const target = reference.deref();
271
+ if (target?.isDeleteAuthoritative(traceId))
272
+ return true;
273
+ if (!target)
274
+ coordinator.stores.delete(reference);
275
+ }
276
+ return false;
277
+ }
278
+ async function reviveTrace(namespace, trace, embedding, source, sourceAlreadyWritten = true) {
279
+ const coordinator = getNamespaceCoordinator(namespace);
280
+ const targets = [];
281
+ for (const reference of coordinator.stores) {
282
+ const target = reference.deref();
283
+ if (target)
284
+ targets.push(target);
285
+ else
286
+ coordinator.stores.delete(reference);
287
+ }
288
+ // Fan-out is intentionally limited to deletion-to-active transitions.
289
+ // General active-update cache coherence has different latency and provider
290
+ // cost semantics and remains outside this deletion-safety coordinator.
291
+ if (!targets.some((target) => target.isDeleted(trace.id))) {
292
+ return source.revive(trace, embedding, sourceAlreadyWritten);
293
+ }
294
+ const outcomes = await Promise.all(targets.map((target) => target.revive(trace, embedding, sourceAlreadyWritten && target === source)));
295
+ return outcomes.every(Boolean);
296
+ }
297
+ function getDeleteEpoch(namespace) {
298
+ return getNamespaceCoordinator(namespace).deleteEpoch;
299
+ }
300
+ function isTraceDeletePending(namespace, traceId) {
301
+ const coordinator = namespaceCoordinators.get(namespace);
302
+ return (coordinator?.operations.get(traceId)?.pendingDeletes ?? 0) > 0;
303
+ }
304
+ async function withTraceOperation(namespace, traceId, kind, operation) {
305
+ const coordinator = getNamespaceCoordinator(namespace);
306
+ let state = coordinator.operations.get(traceId);
307
+ if (!state) {
308
+ state = { tail: Promise.resolve(), pending: 0, pendingDeletes: 0 };
309
+ coordinator.operations.set(traceId, state);
310
+ }
311
+ const operationState = state;
312
+ const blockedByDelete = kind === 'normal' && operationState.pendingDeletes > 0;
313
+ if (kind === 'delete') {
314
+ operationState.pendingDeletes += 1;
315
+ coordinator.deleteEpoch += 1;
316
+ markTraceDeleted(namespace, traceId);
317
+ }
318
+ const previous = operationState.tail;
319
+ let release;
320
+ const completion = new Promise((resolve) => {
321
+ release = resolve;
322
+ });
323
+ operationState.tail = previous.then(() => completion, () => completion);
324
+ operationState.pending += 1;
325
+ coordinator.pendingOperations += 1;
326
+ await previous.catch(() => undefined);
327
+ try {
328
+ return await operation({
329
+ blockedByDelete,
330
+ isDeletePending: () => kind === 'normal' && operationState.pendingDeletes > 0,
331
+ });
332
+ }
333
+ finally {
334
+ if (kind === 'delete')
335
+ operationState.pendingDeletes -= 1;
336
+ operationState.pending -= 1;
337
+ coordinator.pendingOperations -= 1;
338
+ release?.();
339
+ if (operationState.pending === 0 &&
340
+ coordinator.operations.get(traceId) === operationState) {
341
+ coordinator.operations.delete(traceId);
342
+ }
343
+ compactNamespaceCoordinator(namespace, coordinator);
344
+ }
345
+ }
147
346
  // ---------------------------------------------------------------------------
148
347
  // MemoryStore
149
348
  // ---------------------------------------------------------------------------
@@ -155,6 +354,8 @@ export class MemoryStore {
155
354
  this.embeddingCache = new Map();
156
355
  /** Process-local deletion barrier for asynchronous vector refreshes and hydration. */
157
356
  this.tombstonedTraceIds = new Set();
357
+ /** Tombstones whose local delete has not been durably confirmed. */
358
+ this.authoritativeTombstoneIds = new Set();
158
359
  /** Track concrete scopes we have seen, so retrieval never falls back to a fake wildcard scope. */
159
360
  this.knownScopes = new Map();
160
361
  /**
@@ -166,19 +367,52 @@ export class MemoryStore {
166
367
  this.brain = null;
167
368
  /** Whether {@link MemoryStore.ensureHydratedFromBrain} has already run for this instance. */
168
369
  this.brainHydrated = false;
370
+ this.disposed = false;
371
+ /** Brain attachment is immutable once any memory operation has started. */
372
+ this.brainAttachmentLocked = false;
169
373
  this.config = config;
170
374
  this.decay = config.decayConfig ?? DEFAULT_DECAY_CONFIG;
171
375
  this.mechanismsEngine = config.mechanismsEngine;
376
+ this.coordinationTarget = {
377
+ isDeleted: (traceId) => this.tombstonedTraceIds.has(traceId),
378
+ isDeleteAuthoritative: (traceId) => this.authoritativeTombstoneIds.has(traceId),
379
+ markDeleted: (traceId, authoritative) => this.markDeletedLocally(traceId, authoritative),
380
+ releaseDeleteAuthority: (traceId) => this.authoritativeTombstoneIds.delete(traceId),
381
+ evictDeleted: (traceId, fallback) => this.evictDeletedLocally(traceId, fallback),
382
+ revive: (trace, embedding, source) => this.reviveLocally(trace, embedding, source),
383
+ };
384
+ this.coordinationNamespace = vectorStoreNamespace(config.vectorStore, config.collectionPrefix);
385
+ this.coordinationRegistration = registerCoordinationTarget(this.coordinationNamespace, this.coordinationTarget);
172
386
  }
173
387
  /**
174
388
  * Attach a Brain for durable write-through persistence.
175
389
  * Once attached, all store/softDelete/recordAccess operations also
176
- * write to the brain's `memory_traces` table.
390
+ * write to the brain's `memory_traces` table. Initial attachment must happen
391
+ * before any memory operation; after that, only another handle with the same
392
+ * coordination token can be supplied.
177
393
  *
178
394
  * @param brain - Brain instance (already initialized with schema)
395
+ * @throws When attachment is late or would switch backing resources.
179
396
  */
180
397
  setBrain(brain) {
398
+ this.assertNotDisposed();
399
+ if (this.brain) {
400
+ if (this.brain.coordinationToken !== brain.coordinationToken) {
401
+ throw new Error('MemoryStore.setBrain: cannot switch to a different backing resource');
402
+ }
403
+ this.brain = brain;
404
+ return;
405
+ }
406
+ if (this.brainAttachmentLocked) {
407
+ throw new Error('MemoryStore.setBrain: attach a Brain before starting memory operations');
408
+ }
409
+ const previousRegistration = this.coordinationRegistration;
181
410
  this.brain = brain;
411
+ this.coordinationNamespace = brain.coordinationToken;
412
+ this.coordinationRegistration = registerCoordinationTarget(this.coordinationNamespace, this.coordinationTarget);
413
+ if (previousRegistration) {
414
+ unregisterCoordinationTarget(previousRegistration);
415
+ }
182
416
  }
183
417
  /**
184
418
  * Access the attached Brain for export/import operations.
@@ -187,6 +421,190 @@ export class MemoryStore {
187
421
  getBrain() {
188
422
  return this.brain;
189
423
  }
424
+ /**
425
+ * Remove this store from sibling coordination. Recall and mutation methods
426
+ * reject after disposal; diagnostic cache accessors remain available.
427
+ */
428
+ dispose() {
429
+ if (this.disposed)
430
+ return;
431
+ if (this.coordinationRegistration) {
432
+ unregisterCoordinationTarget(this.coordinationRegistration);
433
+ this.coordinationRegistration = null;
434
+ }
435
+ this.disposed = true;
436
+ }
437
+ assertNotDisposed() {
438
+ if (this.disposed) {
439
+ throw new Error('MemoryStore: operation attempted after dispose');
440
+ }
441
+ }
442
+ beginOperation() {
443
+ this.assertNotDisposed();
444
+ this.brainAttachmentLocked = true;
445
+ }
446
+ markDeletedLocally(traceId, authoritative = true) {
447
+ // Passive sibling coordination is still resource-specific state. Once it
448
+ // arrives, this store cannot safely migrate that state to another Brain.
449
+ this.brainAttachmentLocked = true;
450
+ this.tombstonedTraceIds.add(traceId);
451
+ if (authoritative)
452
+ this.authoritativeTombstoneIds.add(traceId);
453
+ this.embeddingCache.delete(traceId);
454
+ const trace = this.traceCache.get(traceId);
455
+ if (trace) {
456
+ trace.isActive = false;
457
+ trace.updatedAt = Date.now();
458
+ }
459
+ }
460
+ async evictDeletedLocally(traceId, fallback) {
461
+ this.markDeletedLocally(traceId, false);
462
+ const trace = this.traceCache.get(traceId) ?? fallback;
463
+ this.embeddingCache.delete(traceId);
464
+ if (!trace)
465
+ return true;
466
+ const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
467
+ try {
468
+ const result = await this.config.vectorStore.delete(collection, [traceId]);
469
+ return (result.failedCount ?? 0) === 0;
470
+ }
471
+ catch {
472
+ return false;
473
+ }
474
+ }
475
+ async reviveLocally(trace, embedding, source) {
476
+ this.brainAttachmentLocked = true;
477
+ if (source) {
478
+ const cached = this.traceCache.get(trace.id);
479
+ if (cached)
480
+ cached.isActive = true;
481
+ this.tombstonedTraceIds.delete(trace.id);
482
+ this.authoritativeTombstoneIds.delete(trace.id);
483
+ return true;
484
+ }
485
+ const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
486
+ try {
487
+ const result = await this.config.vectorStore.upsert(collection, [{
488
+ id: trace.id,
489
+ textContent: trace.content,
490
+ embedding,
491
+ metadata: traceToMetadata(trace),
492
+ }]);
493
+ if ((result?.failedCount ?? 0) > 0)
494
+ return false;
495
+ const revivedTrace = {
496
+ ...trace,
497
+ entities: [...trace.entities],
498
+ tags: [...trace.tags],
499
+ provenance: { ...trace.provenance },
500
+ emotionalContext: { ...trace.emotionalContext },
501
+ associatedTraceIds: [...trace.associatedTraceIds],
502
+ };
503
+ this.traceCache.set(trace.id, revivedTrace);
504
+ this.embeddingCache.set(trace.id, [...embedding]);
505
+ this.registerScope(trace.scope, trace.scopeId);
506
+ this.tombstonedTraceIds.delete(trace.id);
507
+ this.authoritativeTombstoneIds.delete(trace.id);
508
+ return true;
509
+ }
510
+ catch {
511
+ // Keep the sibling tombstone when its vector index cannot be revived.
512
+ return false;
513
+ }
514
+ }
515
+ async getDurableTraceRows(traceIds, brain = this.brain) {
516
+ const rowsById = new Map();
517
+ if (!brain || traceIds.length === 0)
518
+ return { ok: true, rows: rowsById };
519
+ try {
520
+ const uniqueIds = [...new Set(traceIds)];
521
+ for (let offset = 0; offset < uniqueIds.length; offset += 400) {
522
+ const chunk = uniqueIds.slice(offset, offset + 400);
523
+ const placeholders = chunk.map(() => '?').join(', ');
524
+ const rows = await brain.all(`SELECT id, type, scope, content, embedding, strength, created_at,
525
+ last_accessed, retrieval_count, tags, emotions, metadata, deleted
526
+ FROM memory_traces
527
+ WHERE brain_id = ? AND id IN (${placeholders})`, [brain.brainId, ...chunk]);
528
+ for (const row of rows)
529
+ rowsById.set(row.id, row);
530
+ }
531
+ return { ok: true, rows: rowsById };
532
+ }
533
+ catch {
534
+ return { ok: false, rows: new Map() };
535
+ }
536
+ }
537
+ async reconcileDurableTraceStates(traceIds, namespace, brain) {
538
+ if (!brain || traceIds.length === 0)
539
+ return true;
540
+ const initial = await this.getDurableTraceRows(traceIds, brain);
541
+ if (!initial.ok)
542
+ return false;
543
+ let validationAvailable = true;
544
+ const stateChanges = new Set();
545
+ for (const traceId of new Set(traceIds)) {
546
+ const row = initial.rows.get(traceId);
547
+ if (!row || row.deleted === 1 || hasDeletedTrace(namespace, traceId)) {
548
+ stateChanges.add(traceId);
549
+ }
550
+ }
551
+ await Promise.all([...stateChanges].map((traceId) => withTraceOperation(namespace, traceId, 'normal', async ({ blockedByDelete }) => {
552
+ if (blockedByDelete)
553
+ return;
554
+ const confirmed = await this.getDurableTraceRows([traceId], brain);
555
+ if (!confirmed.ok) {
556
+ validationAvailable = false;
557
+ return;
558
+ }
559
+ const row = confirmed.rows.get(traceId);
560
+ if (!row) {
561
+ // Once a Brain is attached, provider-only documents are
562
+ // unvalidated. Re-read while holding the per-trace queue before
563
+ // fencing so a concurrent store cannot publish its durable row
564
+ // between validation and this local tombstone.
565
+ markTraceDeleted(namespace, traceId, false);
566
+ return;
567
+ }
568
+ const trace = this.rowToTrace(row);
569
+ if (row.deleted === 1) {
570
+ markTraceDeleted(namespace, row.id, false);
571
+ releaseDeleteAuthority(namespace, row.id);
572
+ // Cross-process validation is deliberately non-destructive.
573
+ // A concurrent revival can write its new vector before its
574
+ // active SQL row; deleting here based on the older tombstone
575
+ // would erase that fresh vector. Explicit softDelete/retry owns
576
+ // provider cleanup, while this durable fence blocks recall.
577
+ return;
578
+ }
579
+ if (!hasDeletedTrace(namespace, row.id) ||
580
+ hasAuthoritativeDelete(namespace, row.id)) {
581
+ return;
582
+ }
583
+ const embedding = blobToEmbedding(row.embedding);
584
+ if (!embedding || !isUsableEmbedding(embedding))
585
+ return;
586
+ await reviveTrace(namespace, trace, embedding, this.coordinationTarget, false);
587
+ })));
588
+ return validationAvailable;
589
+ }
590
+ /**
591
+ * Return the subset of cached trace IDs that remain recallable after a
592
+ * fail-closed durable-state check. Used by non-vector recall paths such as
593
+ * graph association injection so a tombstone cannot bypass vector filters.
594
+ */
595
+ async filterRecallableTraceIds(traceIds) {
596
+ this.beginOperation();
597
+ const uniqueIds = [...new Set(traceIds)];
598
+ const validated = await this.reconcileDurableTraceStates(uniqueIds, this.coordinationNamespace, this.brain);
599
+ if (!validated)
600
+ return new Set();
601
+ return new Set(uniqueIds.filter((traceId) => {
602
+ const trace = this.traceCache.get(traceId);
603
+ return Boolean(trace?.isActive &&
604
+ !this.tombstonedTraceIds.has(traceId) &&
605
+ !isTraceDeletePending(this.coordinationNamespace, traceId));
606
+ }));
607
+ }
190
608
  /**
191
609
  * Hydrate the in-memory vector store + scope registry from the attached
192
610
  * Brain on first use. The Brain is the durable backing store, but the vector
@@ -197,59 +615,81 @@ export class MemoryStore {
197
615
  * best-effort: a SQL/schema error must never break the query path.
198
616
  */
199
617
  async ensureHydratedFromBrain() {
200
- if (this.brainHydrated || !this.brain)
618
+ if (this.brainHydrated)
619
+ return;
620
+ const brain = this.brain;
621
+ if (!brain)
201
622
  return;
202
623
  this.brainHydrated = true; // set first: a failure must not retry on every query
624
+ const namespace = this.coordinationNamespace;
625
+ const hydrationDeleteEpoch = getDeleteEpoch(namespace);
203
626
  try {
204
627
  // Bound the per-instance load: a fresh facade (and on request-scoped
205
628
  // hosts, every request) hydrates, so we cap at the most-recent traces to
206
629
  // keep the SQL read + vector upserts bounded for very long-lived scopes.
207
630
  // A process-level facade cache (so hydration runs once, not per request)
208
631
  // is the follow-up optimisation; the cap keeps the un-cached path safe.
209
- const rows = await this.brain.all(`SELECT id, type, scope, content, embedding, strength, created_at,
632
+ const rows = await brain.all(`SELECT id, type, scope, content, embedding, strength, created_at,
210
633
  last_accessed, retrieval_count, tags, emotions, metadata
211
634
  FROM memory_traces
212
635
  WHERE brain_id = ? AND deleted = 0
213
636
  ORDER BY created_at DESC
214
- LIMIT ?`, [this.brain.brainId, MemoryStore.HYDRATION_LIMIT]);
637
+ LIMIT ?`, [brain.brainId, MemoryStore.HYDRATION_LIMIT]);
215
638
  for (const row of rows) {
216
- if (this.tombstonedTraceIds.has(row.id) || this.traceCache.has(row.id))
217
- continue;
218
- const embedding = blobToEmbedding(row.embedding);
219
- // Legacy rows (persisted before durable embeddings) stored null and are
220
- // skipped — they become recallable once re-mentioned + re-stored.
221
- if (embedding == null || !isUsableEmbedding(embedding))
222
- continue;
223
- const trace = this.rowToTrace(row);
224
- const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
225
- try {
226
- const exists = this.config.vectorStore.collectionExists
227
- ? await this.config.vectorStore.collectionExists(collection)
228
- : true;
229
- if (!exists) {
230
- await this.config.vectorStore.createCollection?.(collection, this.config.embeddingDimension ?? embedding.length, { overwriteIfExists: false });
639
+ await withTraceOperation(namespace, row.id, 'normal', async ({ blockedByDelete, isDeletePending }) => {
640
+ if (blockedByDelete ||
641
+ this.tombstonedTraceIds.has(row.id) ||
642
+ this.traceCache.has(row.id)) {
643
+ return;
231
644
  }
232
- }
233
- catch {
234
- // Provider auto-creates the collection or does not expose existence checks.
235
- }
236
- if (this.tombstonedTraceIds.has(row.id))
237
- continue;
238
- await this.config.vectorStore.upsert(collection, [
239
- { id: trace.id, textContent: trace.content, embedding, metadata: traceToMetadata(trace) },
240
- ]);
241
- if (this.tombstonedTraceIds.has(row.id)) {
645
+ // A delete may finish after the bulk SELECT but before this row's
646
+ // operation slot. Recheck only when deletion activity occurred.
647
+ if (getDeleteEpoch(namespace) !== hydrationDeleteEpoch) {
648
+ const stillActive = await brain.get('SELECT id FROM memory_traces WHERE brain_id = ? AND id = ? AND deleted = 0', [brain.brainId, row.id]);
649
+ if (!stillActive)
650
+ return;
651
+ }
652
+ const embedding = blobToEmbedding(row.embedding);
653
+ // Legacy rows persisted without embeddings become recallable once
654
+ // they are mentioned and stored again.
655
+ if (embedding == null || !isUsableEmbedding(embedding))
656
+ return;
657
+ const trace = this.rowToTrace(row);
658
+ const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
242
659
  try {
243
- await this.config.vectorStore.delete(collection, [row.id]);
660
+ const exists = this.config.vectorStore.collectionExists
661
+ ? await this.config.vectorStore.collectionExists(collection)
662
+ : true;
663
+ if (!exists) {
664
+ await this.config.vectorStore.createCollection?.(collection, this.config.embeddingDimension ?? embedding.length, { overwriteIfExists: false });
665
+ }
244
666
  }
245
667
  catch {
246
- // Query and getByScope also enforce the process-local tombstone.
668
+ // Provider auto-creates the collection or omits existence checks.
247
669
  }
248
- continue;
249
- }
250
- this.traceCache.set(trace.id, trace);
251
- this.embeddingCache.set(trace.id, embedding);
252
- this.registerScope(trace.scope, trace.scopeId);
670
+ const upsertResult = await this.config.vectorStore.upsert(collection, [
671
+ {
672
+ id: trace.id,
673
+ textContent: trace.content,
674
+ embedding,
675
+ metadata: traceToMetadata(trace),
676
+ },
677
+ ]);
678
+ if ((upsertResult?.failedCount ?? 0) > 0)
679
+ return;
680
+ if (isDeletePending() || this.tombstonedTraceIds.has(row.id)) {
681
+ try {
682
+ await this.config.vectorStore.delete(collection, [row.id]);
683
+ }
684
+ catch {
685
+ // The queued delete retries after this operation releases.
686
+ }
687
+ return;
688
+ }
689
+ this.traceCache.set(trace.id, trace);
690
+ this.embeddingCache.set(trace.id, embedding);
691
+ this.registerScope(trace.scope, trace.scopeId);
692
+ });
253
693
  }
254
694
  }
255
695
  catch {
@@ -304,6 +744,29 @@ export class MemoryStore {
304
744
  * and record as episodic memory in the knowledge graph.
305
745
  */
306
746
  async store(trace) {
747
+ this.beginOperation();
748
+ const namespace = this.coordinationNamespace;
749
+ const brain = this.brain;
750
+ await withTraceOperation(namespace, trace.id, 'normal', async ({ blockedByDelete, isDeletePending }) => {
751
+ if (blockedByDelete) {
752
+ throw new Error('MemoryStore.store: trace deletion is already in progress');
753
+ }
754
+ const embedding = await this.storeWithTraceLock(trace, brain, namespace);
755
+ if (!isDeletePending()) {
756
+ await reviveTrace(namespace, trace, embedding, this.coordinationTarget);
757
+ }
758
+ });
759
+ }
760
+ async storeWithTraceLock(trace, brain, namespace) {
761
+ // Calling store with an existing ID is the explicit revival operation.
762
+ // Build active metadata without mutating a caller-provided cached
763
+ // tombstone until the vector provider has accepted the write.
764
+ const activeUpdatedAt = Date.now();
765
+ const activeTrace = {
766
+ ...trace,
767
+ isActive: true,
768
+ updatedAt: activeUpdatedAt,
769
+ };
307
770
  const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
308
771
  // Generate embedding
309
772
  const embeddingResponse = await this.config.embeddingManager.generateEmbeddings({
@@ -332,9 +795,59 @@ export class MemoryStore {
332
795
  id: trace.id,
333
796
  textContent: trace.content,
334
797
  embedding,
335
- metadata: traceToMetadata(trace),
798
+ metadata: traceToMetadata(activeTrace),
336
799
  };
337
- await this.config.vectorStore.upsert(collection, [doc]);
800
+ const upsertResult = await this.config.vectorStore.upsert(collection, [doc]);
801
+ if ((upsertResult?.failedCount ?? 0) > 0) {
802
+ throw new Error(`MemoryStore.store: vector provider rejected trace ${trace.id}; ` +
803
+ `${upsertResult.failedCount} document write failed`);
804
+ }
805
+ // The Brain is authoritative whenever attached. Persist before publishing
806
+ // the active trace to caches or secondary graph indexes; otherwise a
807
+ // failed write would look successful locally and then be fenced as a
808
+ // provider-only document on the next durable recall.
809
+ if (brain) {
810
+ try {
811
+ const { dialect } = brain.features;
812
+ const result = await brain.run(dialect.insertOrReplace('memory_traces', ['brain_id', 'id', 'type', 'scope', 'content', 'embedding', 'strength', 'created_at', 'last_accessed', 'retrieval_count', 'tags', 'emotions', 'metadata', 'deleted'], ['?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '0'], 'brain_id, id'), [
813
+ brain.brainId,
814
+ trace.id,
815
+ trace.type,
816
+ trace.scope,
817
+ trace.content,
818
+ embeddingToBlob(embedding),
819
+ trace.encodingStrength,
820
+ trace.createdAt,
821
+ trace.lastAccessedAt,
822
+ trace.retrievalCount,
823
+ JSON.stringify(trace.tags),
824
+ JSON.stringify(trace.emotionalContext),
825
+ JSON.stringify({
826
+ scopeId: trace.scopeId,
827
+ provenance: trace.provenance,
828
+ entities: trace.entities,
829
+ stability: trace.stability,
830
+ importance: trace.importance,
831
+ associatedTraceIds: trace.associatedTraceIds,
832
+ structuredData: trace.structuredData,
833
+ }),
834
+ ]);
835
+ if (result.changes === 0) {
836
+ throw new Error('durable write affected no rows');
837
+ }
838
+ }
839
+ catch {
840
+ trace.isActive = false;
841
+ // The SQL error is ambiguous: another process may already have
842
+ // committed a successful store for the same trace. Fence this
843
+ // process non-authoritatively, but do not erase a shared provider
844
+ // vector that could belong to that successful writer.
845
+ markTraceDeleted(namespace, trace.id, false);
846
+ throw new Error('MemoryStore.store: durable trace write failed');
847
+ }
848
+ }
849
+ trace.isActive = true;
850
+ trace.updatedAt = activeUpdatedAt;
338
851
  // Record in knowledge graph as episodic memory. Step 13: thread
339
852
  // `trace.entities` through as `entityIds` (slugified for deterministic
340
853
  // lookup). Previously hardcoded to `[]`, which silenced the sixth
@@ -375,42 +888,7 @@ export class MemoryStore {
375
888
  this.traceCache.set(trace.id, trace);
376
889
  this.embeddingCache.set(trace.id, embedding);
377
890
  this.registerScope(trace.scope, trace.scopeId);
378
- // Re-storing an id is an explicit revival: lift the deletion barrier so
379
- // the fresh trace is not filtered out of recall as a stale tombstone.
380
- this.tombstonedTraceIds.delete(trace.id);
381
- // Write-through to Brain for durability.
382
- // The SQL row mirrors the in-memory cache so traces survive restart.
383
- if (this.brain) {
384
- try {
385
- const { dialect } = this.brain.features;
386
- await this.brain.run(dialect.insertOrReplace('memory_traces', ['brain_id', 'id', 'type', 'scope', 'content', 'embedding', 'strength', 'created_at', 'last_accessed', 'retrieval_count', 'tags', 'emotions', 'metadata', 'deleted'], ['?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '?', '0'], 'brain_id, id'), [
387
- this.brain.brainId,
388
- trace.id,
389
- trace.type,
390
- trace.scope,
391
- trace.content,
392
- embeddingToBlob(embedding), // durable so recall survives a fresh instance (see ensureHydratedFromBrain)
393
- trace.encodingStrength,
394
- trace.createdAt,
395
- trace.lastAccessedAt,
396
- trace.retrievalCount,
397
- JSON.stringify(trace.tags),
398
- JSON.stringify(trace.emotionalContext),
399
- JSON.stringify({
400
- scopeId: trace.scopeId,
401
- provenance: trace.provenance,
402
- entities: trace.entities,
403
- stability: trace.stability,
404
- importance: trace.importance,
405
- associatedTraceIds: trace.associatedTraceIds,
406
- structuredData: trace.structuredData,
407
- }),
408
- ]);
409
- }
410
- catch {
411
- // Write-through is best-effort — in-memory store is primary
412
- }
413
- }
891
+ return embedding;
414
892
  }
415
893
  // =========================================================================
416
894
  // Query
@@ -419,8 +897,11 @@ export class MemoryStore {
419
897
  * Query memory traces with cognitive scoring.
420
898
  */
421
899
  async query(queryText, currentMood, options = {}) {
900
+ this.beginOperation();
422
901
  const now = Date.now();
423
902
  const topK = options.topK ?? 20;
903
+ const namespace = this.coordinationNamespace;
904
+ const brain = this.brain;
424
905
  // Cold-start durability: load durable traces from the attached Brain into
425
906
  // the in-memory index (+ scope registry) before the first query, so recall
426
907
  // survives a fresh MemoryStore instance (e.g. a new request-scoped facade).
@@ -499,6 +980,19 @@ export class MemoryStore {
499
980
  // Search across scopes
500
981
  const allCandidates = [];
501
982
  const vectorSearchStart = Date.now();
983
+ if (this.tombstonedTraceIds.size > 0) {
984
+ const localStateAvailable = await this.reconcileDurableTraceStates([...this.tombstonedTraceIds], namespace, brain);
985
+ if (!localStateAvailable) {
986
+ return {
987
+ scored: [],
988
+ partial: [],
989
+ timings: {
990
+ vectorSearchMs: Date.now() - vectorSearchStart,
991
+ scoringMs: 0,
992
+ },
993
+ };
994
+ }
995
+ }
502
996
  for (const { scope, scopeId } of scopes) {
503
997
  const collection = collectionName(this.config.collectionPrefix, scope, scopeId);
504
998
  try {
@@ -507,9 +1001,14 @@ export class MemoryStore {
507
1001
  filter: metadataFilter,
508
1002
  includeMetadata: true,
509
1003
  });
1004
+ const durableStateAvailable = await this.reconcileDurableTraceStates(results.documents.map((document) => document.id), namespace, brain);
1005
+ if (!durableStateAvailable)
1006
+ continue;
510
1007
  for (const result of results.documents) {
511
- if (this.tombstonedTraceIds.has(result.id))
1008
+ if (this.tombstonedTraceIds.has(result.id) ||
1009
+ isTraceDeletePending(namespace, result.id)) {
512
1010
  continue;
1011
+ }
513
1012
  const tracePartial = metadataToTracePartial(result.metadata ?? {});
514
1013
  const cached = this.traceCache.get(result.id);
515
1014
  const trace = cached ??
@@ -574,13 +1073,16 @@ export class MemoryStore {
574
1073
  weights: effectiveWeights,
575
1074
  };
576
1075
  const scoringStart = Date.now();
577
- const scored = scoreAndRankTraces(allCandidates, scoringContext).slice(0, topK);
578
- const partial = detectPartiallyRetrieved(allCandidates, now, scoringContext);
1076
+ const activeCandidates = allCandidates.filter((candidate) => candidate.trace.isActive &&
1077
+ !this.tombstonedTraceIds.has(candidate.trace.id) &&
1078
+ !isTraceDeletePending(namespace, candidate.trace.id));
1079
+ const scored = scoreAndRankTraces(activeCandidates, scoringContext).slice(0, topK);
1080
+ const partial = detectPartiallyRetrieved(activeCandidates, now, scoringContext);
579
1081
  const scoringMs = Date.now() - scoringStart;
580
1082
  // Cognitive mechanisms: RIF + FOK
581
1083
  if (this.mechanismsEngine && scored.length > 0) {
582
1084
  const cutoff = scored[scored.length - 1].retrievalScore;
583
- this.mechanismsEngine.onRetrieval(scored, allCandidates, cutoff, []);
1085
+ this.mechanismsEngine.onRetrieval(scored, activeCandidates, cutoff, []);
584
1086
  }
585
1087
  return { scored, partial, timings: { vectorSearchMs, scoringMs } };
586
1088
  }
@@ -592,6 +1094,16 @@ export class MemoryStore {
592
1094
  * Updates decay parameters via spaced repetition.
593
1095
  */
594
1096
  async recordAccess(traceId) {
1097
+ this.beginOperation();
1098
+ const namespace = this.coordinationNamespace;
1099
+ const brain = this.brain;
1100
+ return withTraceOperation(namespace, traceId, 'normal', async ({ blockedByDelete, isDeletePending }) => {
1101
+ if (blockedByDelete)
1102
+ return null;
1103
+ return this.recordAccessWithTraceLock(traceId, isDeletePending, brain);
1104
+ });
1105
+ }
1106
+ async recordAccessWithTraceLock(traceId, isDeletePending, brain) {
595
1107
  const trace = this.traceCache.get(traceId);
596
1108
  if (this.tombstonedTraceIds.has(traceId) || !trace?.isActive)
597
1109
  return null;
@@ -648,7 +1160,7 @@ export class MemoryStore {
648
1160
  }
649
1161
  // Deletion can race the asynchronous embedding or vector refresh above.
650
1162
  // If it won, remove any document the late refresh may have restored.
651
- if (!trace.isActive) {
1163
+ if (isDeletePending() || !trace.isActive) {
652
1164
  try {
653
1165
  await this.config.vectorStore.delete(collection, [traceId]);
654
1166
  }
@@ -659,9 +1171,9 @@ export class MemoryStore {
659
1171
  return null;
660
1172
  }
661
1173
  // Write-through: update access metadata in the durable SQL store
662
- if (this.brain) {
1174
+ if (brain) {
663
1175
  try {
664
- await this.brain.run('UPDATE memory_traces SET last_accessed = ?, retrieval_count = ?, strength = ? WHERE brain_id = ? AND id = ? AND deleted = 0', [trace.lastAccessedAt, trace.retrievalCount, trace.encodingStrength, this.brain.brainId, traceId]);
1176
+ await brain.run('UPDATE memory_traces SET last_accessed = ?, retrieval_count = ?, strength = ? WHERE brain_id = ? AND id = ? AND deleted = 0', [trace.lastAccessedAt, trace.retrievalCount, trace.encodingStrength, brain.brainId, traceId]);
665
1177
  }
666
1178
  catch {
667
1179
  // Best-effort persistence
@@ -683,23 +1195,29 @@ export class MemoryStore {
683
1195
  * topK) and does not guarantee completeness.
684
1196
  */
685
1197
  async getByScope(scope, scopeId, type) {
1198
+ this.beginOperation();
1199
+ const namespace = this.coordinationNamespace;
1200
+ const brain = this.brain;
686
1201
  await this.ensureHydratedFromBrain();
687
- // Return from cache + filter
688
- const results = [];
689
- for (const trace of this.traceCache.values()) {
690
- if (!this.tombstonedTraceIds.has(trace.id) &&
691
- trace.isActive &&
692
- trace.scope === scope &&
693
- trace.scopeId === scopeId) {
694
- if (!type || trace.type === type) {
695
- results.push(trace);
696
- }
697
- }
698
- }
1202
+ const collectCachedResults = () => [...this.traceCache.values()].filter((trace) => !this.tombstonedTraceIds.has(trace.id) &&
1203
+ trace.isActive &&
1204
+ trace.scope === scope &&
1205
+ trace.scopeId === scopeId &&
1206
+ (!type || trace.type === type));
1207
+ let results = collectCachedResults();
1208
+ const collection = collectionName(this.config.collectionPrefix, scope, scopeId);
1209
+ const cachedStateAvailable = await this.reconcileDurableTraceStates([
1210
+ ...results.map((trace) => trace.id),
1211
+ ...this.tombstonedTraceIds,
1212
+ ], namespace, brain);
1213
+ if (!cachedStateAvailable)
1214
+ return [];
1215
+ // Reconciliation can revive a trace while waiting behind an in-flight
1216
+ // store. Refresh the snapshot so that revival is visible in this call.
1217
+ results = collectCachedResults();
699
1218
  // Fallback: if cache is empty for this scope, query the vector store.
700
1219
  if (results.length === 0) {
701
1220
  try {
702
- const collection = collectionName(this.config.collectionPrefix, scope, scopeId);
703
1221
  const dim = this.config.embeddingDimension ?? 1536;
704
1222
  const zeroVector = new Array(dim).fill(0);
705
1223
  const filter = { isActive: 1 };
@@ -712,9 +1230,14 @@ export class MemoryStore {
712
1230
  includeMetadata: true,
713
1231
  includeTextContent: true,
714
1232
  });
1233
+ const providerStateAvailable = await this.reconcileDurableTraceStates(queryResult.documents.map((document) => document.id), namespace, brain);
1234
+ if (!providerStateAvailable)
1235
+ return [];
715
1236
  for (const doc of queryResult.documents) {
716
- if (this.tombstonedTraceIds.has(doc.id))
1237
+ if (this.tombstonedTraceIds.has(doc.id) ||
1238
+ isTraceDeletePending(namespace, doc.id)) {
717
1239
  continue;
1240
+ }
718
1241
  if (!doc.metadata)
719
1242
  continue;
720
1243
  const cached = this.traceCache.get(doc.id);
@@ -745,7 +1268,9 @@ export class MemoryStore {
745
1268
  // Vector store query may fail (collection not found, etc.); return empty.
746
1269
  }
747
1270
  }
748
- return results;
1271
+ return results.filter((trace) => trace.isActive &&
1272
+ !this.tombstonedTraceIds.has(trace.id) &&
1273
+ !isTraceDeletePending(namespace, trace.id));
749
1274
  }
750
1275
  /**
751
1276
  * Re-upsert a trace's metadata to the vector store using the cached
@@ -758,10 +1283,22 @@ export class MemoryStore {
758
1283
  * not be durably updated.
759
1284
  */
760
1285
  async persistTraceMetadata(traceId) {
1286
+ this.beginOperation();
1287
+ const namespace = this.coordinationNamespace;
1288
+ await withTraceOperation(namespace, traceId, 'normal', async ({ blockedByDelete, isDeletePending }) => {
1289
+ if (blockedByDelete)
1290
+ return;
1291
+ await this.persistTraceMetadataWithTraceLock(traceId, isDeletePending);
1292
+ });
1293
+ }
1294
+ async persistTraceMetadataWithTraceLock(traceId, isDeletePending) {
761
1295
  const trace = this.traceCache.get(traceId);
762
1296
  const embedding = this.embeddingCache.get(traceId);
763
- if (this.tombstonedTraceIds.has(traceId) || !trace || !embedding)
1297
+ if (this.tombstonedTraceIds.has(traceId) ||
1298
+ !trace?.isActive ||
1299
+ !embedding) {
764
1300
  return;
1301
+ }
765
1302
  const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
766
1303
  const doc = {
767
1304
  id: trace.id,
@@ -771,6 +1308,9 @@ export class MemoryStore {
771
1308
  };
772
1309
  try {
773
1310
  await this.config.vectorStore.upsert(collection, [doc]);
1311
+ if (isDeletePending() || !trace.isActive) {
1312
+ await this.config.vectorStore.delete(collection, [traceId]);
1313
+ }
774
1314
  }
775
1315
  catch {
776
1316
  // Best-effort persistence — the in-memory mutation already
@@ -778,12 +1318,19 @@ export class MemoryStore {
778
1318
  }
779
1319
  }
780
1320
  /**
781
- * Soft-delete a trace.
1321
+ * Soft-delete a trace and preserve the local barrier if durable persistence
1322
+ * cannot confirm the tombstone.
1323
+ *
1324
+ * @throws When an attached Brain cannot persist the tombstone or the trace
1325
+ * has no durable row to update.
782
1326
  */
783
1327
  async softDelete(traceId) {
784
- // Establish the deletion barrier before any awaited lookup or provider
785
- // operation so a concurrent hydration or metadata refresh cannot win.
786
- this.tombstonedTraceIds.add(traceId);
1328
+ this.beginOperation();
1329
+ const namespace = this.coordinationNamespace;
1330
+ const brain = this.brain;
1331
+ await withTraceOperation(namespace, traceId, 'delete', async () => this.softDeleteWithTraceLock(traceId, namespace, brain));
1332
+ }
1333
+ async softDeleteWithTraceLock(traceId, namespace, brain) {
787
1334
  let trace = this.traceCache.get(traceId);
788
1335
  // A caller may delete before the first recall hydrates this process-local
789
1336
  // cache. Load just the requested row so we can invalidate the correct
@@ -793,12 +1340,12 @@ export class MemoryStore {
793
1340
  // retry — filtering them out made that failure permanent, because a
794
1341
  // fresh instance found no row, skipped the vector delete entirely, and
795
1342
  // the stale document stayed recallable forever.
796
- if (!trace && this.brain) {
1343
+ if (!trace && brain) {
797
1344
  try {
798
- const row = await this.brain.get(`SELECT id, type, scope, content, embedding, strength, created_at,
1345
+ const row = await brain.get(`SELECT id, type, scope, content, embedding, strength, created_at,
799
1346
  last_accessed, retrieval_count, tags, emotions, metadata, deleted
800
1347
  FROM memory_traces
801
- WHERE brain_id = ? AND id = ?`, [this.brain.brainId, traceId]);
1348
+ WHERE brain_id = ? AND id = ?`, [brain.brainId, traceId]);
802
1349
  if (row) {
803
1350
  trace = this.rowToTrace(row);
804
1351
  // A tombstoned row is loaded solely to name the collection for
@@ -821,38 +1368,36 @@ export class MemoryStore {
821
1368
  trace.isActive = false;
822
1369
  trace.updatedAt = Date.now();
823
1370
  }
824
- // Write-through: mark trace as deleted in the durable SQL store
825
- if (this.brain) {
1371
+ // Write-through: mark trace as deleted in the durable SQL store. Keep the
1372
+ // local delete authoritative until this succeeds so a transient SQL error
1373
+ // cannot be misread as an external revival on the next recall.
1374
+ let durableDeleteError = false;
1375
+ if (brain) {
826
1376
  try {
827
- await this.brain.run('UPDATE memory_traces SET deleted = 1 WHERE brain_id = ? AND id = ?', [this.brain.brainId, traceId]);
1377
+ const result = await brain.run('UPDATE memory_traces SET deleted = 1 WHERE brain_id = ? AND id = ?', [brain.brainId, traceId]);
1378
+ if (result.changes > 0) {
1379
+ releaseDeleteAuthority(namespace, traceId);
1380
+ }
1381
+ else {
1382
+ durableDeleteError = true;
1383
+ }
828
1384
  }
829
1385
  catch {
830
- // Best-effort persistence
1386
+ durableDeleteError = true;
831
1387
  }
832
1388
  }
833
- // The vector store is a derived recall index, so removing this document
834
- // preserves soft-delete semantics while preventing stale same-process
835
- // recall. The durable Brain row remains as the tombstone source of truth.
836
- if (trace) {
837
- const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
838
- let evicted = false;
839
- try {
840
- const result = await this.config.vectorStore.delete(collection, [traceId]);
841
- // Providers like Qdrant report failures through the result instead of
842
- // throwing; a reported failure leaves the document live in the shared
843
- // index, where a fresh process (whose caches carry no tombstone) would
844
- // recall it as active. The durable tombstone keeps this retryable via
845
- // the lookup above.
846
- evicted = (result.failedCount ?? 0) === 0;
847
- }
848
- catch {
849
- // Query and getByScope also reject cached tombstones defensively.
850
- }
851
- if (!evicted) {
852
- console.warn(`[MemoryStore] vector eviction incomplete for trace ${traceId} in ${collection}; ` +
853
- 'the soft-deleted document may persist in a shared index until softDelete is retried');
854
- }
855
- this.embeddingCache.delete(traceId);
1389
+ // Every live store attached to this backing resource owns an independent
1390
+ // hot vector index. Evict all of them before the delete operation resolves.
1391
+ const evicted = await evictDeletedTrace(namespace, traceId, trace, this.coordinationTarget);
1392
+ if (!evicted) {
1393
+ const location = trace
1394
+ ? collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId)
1395
+ : 'an unresolved collection';
1396
+ console.warn(`[MemoryStore] vector eviction incomplete for trace ${traceId} in ${location}; ` +
1397
+ 'the soft-deleted document may persist in a shared index until softDelete is retried');
1398
+ }
1399
+ if (durableDeleteError) {
1400
+ throw new Error('MemoryStore.softDelete: durable tombstone write failed');
856
1401
  }
857
1402
  }
858
1403
  /**