@framers/agentos 0.10.9 → 0.10.11

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 (24) hide show
  1. package/dist/cognition/memory/AgentMemory.d.ts +4 -0
  2. package/dist/cognition/memory/AgentMemory.d.ts.map +1 -1
  3. package/dist/cognition/memory/AgentMemory.js +56 -17
  4. package/dist/cognition/memory/AgentMemory.js.map +1 -1
  5. package/dist/cognition/memory/CognitiveMemoryManager.d.ts +6 -0
  6. package/dist/cognition/memory/CognitiveMemoryManager.d.ts.map +1 -1
  7. package/dist/cognition/memory/CognitiveMemoryManager.js +105 -9
  8. package/dist/cognition/memory/CognitiveMemoryManager.js.map +1 -1
  9. package/dist/cognition/memory/io/extension/CognitiveMemoryExtension.d.ts.map +1 -1
  10. package/dist/cognition/memory/io/extension/CognitiveMemoryExtension.js +8 -2
  11. package/dist/cognition/memory/io/extension/CognitiveMemoryExtension.js.map +1 -1
  12. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.d.ts +7 -0
  13. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.d.ts.map +1 -1
  14. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.js +84 -55
  15. package/dist/cognition/memory/pipeline/consolidation/ConsolidationPipeline.js.map +1 -1
  16. package/dist/cognition/memory/retrieval/store/Brain.d.ts +14 -2
  17. package/dist/cognition/memory/retrieval/store/Brain.d.ts.map +1 -1
  18. package/dist/cognition/memory/retrieval/store/Brain.js +264 -15
  19. package/dist/cognition/memory/retrieval/store/Brain.js.map +1 -1
  20. package/dist/cognition/memory/retrieval/store/MemoryStore.d.ts +49 -2
  21. package/dist/cognition/memory/retrieval/store/MemoryStore.d.ts.map +1 -1
  22. package/dist/cognition/memory/retrieval/store/MemoryStore.js +702 -124
  23. package/dist/cognition/memory/retrieval/store/MemoryStore.js.map +1 -1
  24. 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,39 +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
- // Write-through to Brain for durability.
379
- // The SQL row mirrors the in-memory cache so traces survive restart.
380
- if (this.brain) {
381
- try {
382
- const { dialect } = this.brain.features;
383
- 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'), [
384
- this.brain.brainId,
385
- trace.id,
386
- trace.type,
387
- trace.scope,
388
- trace.content,
389
- embeddingToBlob(embedding), // durable so recall survives a fresh instance (see ensureHydratedFromBrain)
390
- trace.encodingStrength,
391
- trace.createdAt,
392
- trace.lastAccessedAt,
393
- trace.retrievalCount,
394
- JSON.stringify(trace.tags),
395
- JSON.stringify(trace.emotionalContext),
396
- JSON.stringify({
397
- scopeId: trace.scopeId,
398
- provenance: trace.provenance,
399
- entities: trace.entities,
400
- stability: trace.stability,
401
- importance: trace.importance,
402
- associatedTraceIds: trace.associatedTraceIds,
403
- structuredData: trace.structuredData,
404
- }),
405
- ]);
406
- }
407
- catch {
408
- // Write-through is best-effort — in-memory store is primary
409
- }
410
- }
891
+ return embedding;
411
892
  }
412
893
  // =========================================================================
413
894
  // Query
@@ -416,8 +897,11 @@ export class MemoryStore {
416
897
  * Query memory traces with cognitive scoring.
417
898
  */
418
899
  async query(queryText, currentMood, options = {}) {
900
+ this.beginOperation();
419
901
  const now = Date.now();
420
902
  const topK = options.topK ?? 20;
903
+ const namespace = this.coordinationNamespace;
904
+ const brain = this.brain;
421
905
  // Cold-start durability: load durable traces from the attached Brain into
422
906
  // the in-memory index (+ scope registry) before the first query, so recall
423
907
  // survives a fresh MemoryStore instance (e.g. a new request-scoped facade).
@@ -496,6 +980,19 @@ export class MemoryStore {
496
980
  // Search across scopes
497
981
  const allCandidates = [];
498
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
+ }
499
996
  for (const { scope, scopeId } of scopes) {
500
997
  const collection = collectionName(this.config.collectionPrefix, scope, scopeId);
501
998
  try {
@@ -504,9 +1001,14 @@ export class MemoryStore {
504
1001
  filter: metadataFilter,
505
1002
  includeMetadata: true,
506
1003
  });
1004
+ const durableStateAvailable = await this.reconcileDurableTraceStates(results.documents.map((document) => document.id), namespace, brain);
1005
+ if (!durableStateAvailable)
1006
+ continue;
507
1007
  for (const result of results.documents) {
508
- if (this.tombstonedTraceIds.has(result.id))
1008
+ if (this.tombstonedTraceIds.has(result.id) ||
1009
+ isTraceDeletePending(namespace, result.id)) {
509
1010
  continue;
1011
+ }
510
1012
  const tracePartial = metadataToTracePartial(result.metadata ?? {});
511
1013
  const cached = this.traceCache.get(result.id);
512
1014
  const trace = cached ??
@@ -571,13 +1073,16 @@ export class MemoryStore {
571
1073
  weights: effectiveWeights,
572
1074
  };
573
1075
  const scoringStart = Date.now();
574
- const scored = scoreAndRankTraces(allCandidates, scoringContext).slice(0, topK);
575
- 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);
576
1081
  const scoringMs = Date.now() - scoringStart;
577
1082
  // Cognitive mechanisms: RIF + FOK
578
1083
  if (this.mechanismsEngine && scored.length > 0) {
579
1084
  const cutoff = scored[scored.length - 1].retrievalScore;
580
- this.mechanismsEngine.onRetrieval(scored, allCandidates, cutoff, []);
1085
+ this.mechanismsEngine.onRetrieval(scored, activeCandidates, cutoff, []);
581
1086
  }
582
1087
  return { scored, partial, timings: { vectorSearchMs, scoringMs } };
583
1088
  }
@@ -589,6 +1094,16 @@ export class MemoryStore {
589
1094
  * Updates decay parameters via spaced repetition.
590
1095
  */
591
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) {
592
1107
  const trace = this.traceCache.get(traceId);
593
1108
  if (this.tombstonedTraceIds.has(traceId) || !trace?.isActive)
594
1109
  return null;
@@ -645,7 +1160,7 @@ export class MemoryStore {
645
1160
  }
646
1161
  // Deletion can race the asynchronous embedding or vector refresh above.
647
1162
  // If it won, remove any document the late refresh may have restored.
648
- if (!trace.isActive) {
1163
+ if (isDeletePending() || !trace.isActive) {
649
1164
  try {
650
1165
  await this.config.vectorStore.delete(collection, [traceId]);
651
1166
  }
@@ -656,9 +1171,9 @@ export class MemoryStore {
656
1171
  return null;
657
1172
  }
658
1173
  // Write-through: update access metadata in the durable SQL store
659
- if (this.brain) {
1174
+ if (brain) {
660
1175
  try {
661
- 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]);
662
1177
  }
663
1178
  catch {
664
1179
  // Best-effort persistence
@@ -680,23 +1195,29 @@ export class MemoryStore {
680
1195
  * topK) and does not guarantee completeness.
681
1196
  */
682
1197
  async getByScope(scope, scopeId, type) {
1198
+ this.beginOperation();
1199
+ const namespace = this.coordinationNamespace;
1200
+ const brain = this.brain;
683
1201
  await this.ensureHydratedFromBrain();
684
- // Return from cache + filter
685
- const results = [];
686
- for (const trace of this.traceCache.values()) {
687
- if (!this.tombstonedTraceIds.has(trace.id) &&
688
- trace.isActive &&
689
- trace.scope === scope &&
690
- trace.scopeId === scopeId) {
691
- if (!type || trace.type === type) {
692
- results.push(trace);
693
- }
694
- }
695
- }
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();
696
1218
  // Fallback: if cache is empty for this scope, query the vector store.
697
1219
  if (results.length === 0) {
698
1220
  try {
699
- const collection = collectionName(this.config.collectionPrefix, scope, scopeId);
700
1221
  const dim = this.config.embeddingDimension ?? 1536;
701
1222
  const zeroVector = new Array(dim).fill(0);
702
1223
  const filter = { isActive: 1 };
@@ -709,9 +1230,14 @@ export class MemoryStore {
709
1230
  includeMetadata: true,
710
1231
  includeTextContent: true,
711
1232
  });
1233
+ const providerStateAvailable = await this.reconcileDurableTraceStates(queryResult.documents.map((document) => document.id), namespace, brain);
1234
+ if (!providerStateAvailable)
1235
+ return [];
712
1236
  for (const doc of queryResult.documents) {
713
- if (this.tombstonedTraceIds.has(doc.id))
1237
+ if (this.tombstonedTraceIds.has(doc.id) ||
1238
+ isTraceDeletePending(namespace, doc.id)) {
714
1239
  continue;
1240
+ }
715
1241
  if (!doc.metadata)
716
1242
  continue;
717
1243
  const cached = this.traceCache.get(doc.id);
@@ -742,7 +1268,9 @@ export class MemoryStore {
742
1268
  // Vector store query may fail (collection not found, etc.); return empty.
743
1269
  }
744
1270
  }
745
- return results;
1271
+ return results.filter((trace) => trace.isActive &&
1272
+ !this.tombstonedTraceIds.has(trace.id) &&
1273
+ !isTraceDeletePending(namespace, trace.id));
746
1274
  }
747
1275
  /**
748
1276
  * Re-upsert a trace's metadata to the vector store using the cached
@@ -755,10 +1283,22 @@ export class MemoryStore {
755
1283
  * not be durably updated.
756
1284
  */
757
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) {
758
1295
  const trace = this.traceCache.get(traceId);
759
1296
  const embedding = this.embeddingCache.get(traceId);
760
- if (this.tombstonedTraceIds.has(traceId) || !trace || !embedding)
1297
+ if (this.tombstonedTraceIds.has(traceId) ||
1298
+ !trace?.isActive ||
1299
+ !embedding) {
761
1300
  return;
1301
+ }
762
1302
  const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
763
1303
  const doc = {
764
1304
  id: trace.id,
@@ -768,6 +1308,9 @@ export class MemoryStore {
768
1308
  };
769
1309
  try {
770
1310
  await this.config.vectorStore.upsert(collection, [doc]);
1311
+ if (isDeletePending() || !trace.isActive) {
1312
+ await this.config.vectorStore.delete(collection, [traceId]);
1313
+ }
771
1314
  }
772
1315
  catch {
773
1316
  // Best-effort persistence — the in-memory mutation already
@@ -775,30 +1318,46 @@ export class MemoryStore {
775
1318
  }
776
1319
  }
777
1320
  /**
778
- * 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.
779
1326
  */
780
1327
  async softDelete(traceId) {
781
- // Establish the deletion barrier before any awaited lookup or provider
782
- // operation so a concurrent hydration or metadata refresh cannot win.
783
- 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) {
784
1334
  let trace = this.traceCache.get(traceId);
785
1335
  // A caller may delete before the first recall hydrates this process-local
786
1336
  // cache. Load just the requested row so we can invalidate the correct
787
- // vector collection without paying the full hydration cost.
788
- if (!trace && this.brain) {
1337
+ // vector collection without paying the full hydration cost. Tombstoned
1338
+ // rows are included deliberately: a prior delete whose vector eviction
1339
+ // failed (see below) must still be able to re-derive the collection and
1340
+ // retry — filtering them out made that failure permanent, because a
1341
+ // fresh instance found no row, skipped the vector delete entirely, and
1342
+ // the stale document stayed recallable forever.
1343
+ if (!trace && brain) {
789
1344
  try {
790
- const row = await this.brain.get(`SELECT id, type, scope, content, embedding, strength, created_at,
791
- last_accessed, retrieval_count, tags, emotions, metadata
1345
+ const row = await brain.get(`SELECT id, type, scope, content, embedding, strength, created_at,
1346
+ last_accessed, retrieval_count, tags, emotions, metadata, deleted
792
1347
  FROM memory_traces
793
- WHERE brain_id = ? AND id = ? AND deleted = 0`, [this.brain.brainId, traceId]);
1348
+ WHERE brain_id = ? AND id = ?`, [brain.brainId, traceId]);
794
1349
  if (row) {
795
1350
  trace = this.rowToTrace(row);
796
- this.traceCache.set(trace.id, trace);
797
- const embedding = blobToEmbedding(row.embedding);
798
- if (embedding && isUsableEmbedding(embedding)) {
799
- this.embeddingCache.set(trace.id, embedding);
1351
+ // A tombstoned row is loaded solely to name the collection for
1352
+ // (re-)eviction — never recached, so it cannot re-enter recall.
1353
+ if (row.deleted !== 1) {
1354
+ this.traceCache.set(trace.id, trace);
1355
+ const embedding = blobToEmbedding(row.embedding);
1356
+ if (embedding && isUsableEmbedding(embedding)) {
1357
+ this.embeddingCache.set(trace.id, embedding);
1358
+ }
1359
+ this.registerScope(trace.scope, trace.scopeId);
800
1360
  }
801
- this.registerScope(trace.scope, trace.scopeId);
802
1361
  }
803
1362
  }
804
1363
  catch {
@@ -809,27 +1368,36 @@ export class MemoryStore {
809
1368
  trace.isActive = false;
810
1369
  trace.updatedAt = Date.now();
811
1370
  }
812
- // Write-through: mark trace as deleted in the durable SQL store
813
- 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) {
814
1376
  try {
815
- 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
+ }
816
1384
  }
817
1385
  catch {
818
- // Best-effort persistence
1386
+ durableDeleteError = true;
819
1387
  }
820
1388
  }
821
- // The vector store is a derived recall index, so removing this document
822
- // preserves soft-delete semantics while preventing stale same-process
823
- // recall. The durable Brain row remains as the tombstone source of truth.
824
- if (trace) {
825
- const collection = collectionName(this.config.collectionPrefix, trace.scope, trace.scopeId);
826
- try {
827
- await this.config.vectorStore.delete(collection, [traceId]);
828
- }
829
- catch {
830
- // Query and getByScope also reject cached tombstones defensively.
831
- }
832
- 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');
833
1401
  }
834
1402
  }
835
1403
  /**
@@ -838,6 +1406,16 @@ export class MemoryStore {
838
1406
  getTrace(traceId) {
839
1407
  return this.traceCache.get(traceId);
840
1408
  }
1409
+ /**
1410
+ * Whether this store has soft-deleted the trace in this process. Lifecycle
1411
+ * hooks that re-activate traces leaving working memory MUST consult this so
1412
+ * a tombstone's `isActive` flag is never resurrected — a resurrected flag
1413
+ * let the spaced-repetition sweep re-embed and re-upsert deleted memories
1414
+ * back into shared vector recall.
1415
+ */
1416
+ isDeleted(traceId) {
1417
+ return this.tombstonedTraceIds.has(traceId);
1418
+ }
841
1419
  /**
842
1420
  * Get trace count.
843
1421
  */