@seanhogg/builderforce-memory 2026.9.34 → 2026.10.2

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 (97) hide show
  1. package/dist/errors/SSMError.d.ts +1 -1
  2. package/dist/errors/SSMError.d.ts.map +1 -1
  3. package/dist/errors/SSMError.js.map +1 -1
  4. package/dist/handoff/index.d.ts +5 -5
  5. package/dist/handoff/index.js +5 -5
  6. package/dist/handoff/note.d.ts +1 -1
  7. package/dist/handoff/note.js +1 -1
  8. package/dist/handoff/pack.d.ts +13 -4
  9. package/dist/handoff/pack.d.ts.map +1 -1
  10. package/dist/handoff/pack.js +31 -10
  11. package/dist/handoff/pack.js.map +1 -1
  12. package/dist/memory/MemoryStore.d.ts +120 -181
  13. package/dist/memory/MemoryStore.d.ts.map +1 -1
  14. package/dist/memory/MemoryStore.js +201 -389
  15. package/dist/memory/MemoryStore.js.map +1 -1
  16. package/dist/memory/SemanticRecall.d.ts +64 -0
  17. package/dist/memory/SemanticRecall.d.ts.map +1 -0
  18. package/dist/memory/SemanticRecall.js +188 -0
  19. package/dist/memory/SemanticRecall.js.map +1 -0
  20. package/dist/memory/records.d.ts +26 -0
  21. package/dist/memory/records.d.ts.map +1 -0
  22. package/dist/memory/records.js +71 -0
  23. package/dist/memory/records.js.map +1 -0
  24. package/dist/memory/ttlSweeper.d.ts +12 -0
  25. package/dist/memory/ttlSweeper.d.ts.map +1 -0
  26. package/dist/memory/ttlSweeper.js +28 -0
  27. package/dist/memory/ttlSweeper.js.map +1 -0
  28. package/dist/memory/types.d.ts +142 -0
  29. package/dist/memory/types.d.ts.map +1 -0
  30. package/dist/memory/types.js +6 -0
  31. package/dist/memory/types.js.map +1 -0
  32. package/dist/storage/ScopedFactRepository.d.ts +156 -0
  33. package/dist/storage/ScopedFactRepository.d.ts.map +1 -0
  34. package/dist/storage/ScopedFactRepository.js +278 -0
  35. package/dist/storage/ScopedFactRepository.js.map +1 -0
  36. package/dist/storage/factImport.d.ts +21 -0
  37. package/dist/storage/factImport.d.ts.map +1 -0
  38. package/dist/storage/factImport.js +61 -0
  39. package/dist/storage/factImport.js.map +1 -0
  40. package/dist/storage/factMutations.d.ts +46 -0
  41. package/dist/storage/factMutations.d.ts.map +1 -0
  42. package/dist/storage/factMutations.js +73 -0
  43. package/dist/storage/factMutations.js.map +1 -0
  44. package/dist/storage/factQueries.d.ts +16 -0
  45. package/dist/storage/factQueries.d.ts.map +1 -0
  46. package/dist/storage/factQueries.js +36 -0
  47. package/dist/storage/factQueries.js.map +1 -0
  48. package/dist/storage/factRetention.d.ts +30 -0
  49. package/dist/storage/factRetention.d.ts.map +1 -0
  50. package/dist/storage/factRetention.js +65 -0
  51. package/dist/storage/factRetention.js.map +1 -0
  52. package/dist/storage/fileLock.d.ts +56 -0
  53. package/dist/storage/fileLock.d.ts.map +1 -0
  54. package/dist/storage/fileLock.js +138 -0
  55. package/dist/storage/fileLock.js.map +1 -0
  56. package/dist/storage/fileTransaction.d.ts +33 -0
  57. package/dist/storage/fileTransaction.d.ts.map +1 -0
  58. package/dist/storage/fileTransaction.js +108 -0
  59. package/dist/storage/fileTransaction.js.map +1 -0
  60. package/dist/storage/idbAdapter.d.ts +95 -0
  61. package/dist/storage/idbAdapter.d.ts.map +1 -0
  62. package/dist/storage/idbAdapter.js +311 -0
  63. package/dist/storage/idbAdapter.js.map +1 -0
  64. package/dist/storage/nodeFileAdapter.d.ts +127 -0
  65. package/dist/storage/nodeFileAdapter.d.ts.map +1 -0
  66. package/dist/storage/nodeFileAdapter.js +275 -0
  67. package/dist/storage/nodeFileAdapter.js.map +1 -0
  68. package/dist/storage/syncJournal.d.ts +19 -0
  69. package/dist/storage/syncJournal.d.ts.map +1 -0
  70. package/dist/storage/syncJournal.js +46 -0
  71. package/dist/storage/syncJournal.js.map +1 -0
  72. package/dist/storage/types.d.ts +207 -0
  73. package/dist/storage/types.d.ts.map +1 -0
  74. package/dist/storage/types.js +90 -0
  75. package/dist/storage/types.js.map +1 -0
  76. package/package.json +3 -3
  77. package/src/errors/SSMError.ts +1 -0
  78. package/src/handoff/handoff.test.ts +52 -2
  79. package/src/handoff/index.ts +5 -5
  80. package/src/handoff/note.ts +1 -1
  81. package/src/handoff/pack.ts +32 -9
  82. package/src/memory/MemoryStore.ts +245 -525
  83. package/src/memory/SemanticRecall.ts +204 -0
  84. package/src/memory/records.ts +79 -0
  85. package/src/memory/ttlSweeper.ts +29 -0
  86. package/src/memory/types.ts +149 -0
  87. package/src/storage/ScopedFactRepository.ts +373 -0
  88. package/src/storage/factImport.ts +73 -0
  89. package/src/storage/factMutations.ts +106 -0
  90. package/src/storage/factQueries.ts +41 -0
  91. package/src/storage/factRetention.ts +82 -0
  92. package/src/storage/fileLock.ts +162 -0
  93. package/src/storage/fileTransaction.ts +116 -0
  94. package/src/storage/idbAdapter.ts +373 -0
  95. package/src/storage/nodeFileAdapter.ts +331 -0
  96. package/src/storage/syncJournal.ts +47 -0
  97. package/src/storage/types.ts +262 -0
@@ -1,321 +1,216 @@
1
1
  /**
2
2
  * MemoryStore – persistent key-value fact store and weight checkpoint helper.
3
3
  *
4
- * Uses IndexedDB with a dedicated 'ssmjs' database containing two object stores:
5
- * - 'facts' : MemoryEntry records keyed by the fact key string
6
- * - 'weights' : a single ArrayBuffer keyed by `weightsKey`
4
+ * Persistence is delegated to a narrow {@link FactStoragePort} (M01) fronted by
5
+ * a {@link ScopedFactRepository} unit-of-work layer (M02–M06):
6
+ * - Browser default: IndexedDB via {@link IdbStorageAdapter}; every write
7
+ * resolves only after the transaction commits (M02).
8
+ * - Node.js: pass `storage: await NodeFileStorageAdapter.open(path)` for real
9
+ * durable storage. fake-indexeddb via `idbFactory` remains supported for
10
+ * TESTS, but is never durable and is not presented as such.
11
+ * - Facts live in an explicit {@link MemoryScope} partition (default: the
12
+ * unbound, never-connected scope) with durable revisions, operation
13
+ * identity, provenance and — when `sync: true` — a journaled tombstone
14
+ * trail that caps/TTL will not sacrifice (M03/M04/M06).
15
+ *
16
+ * Recall ranking and its derived embedding/index caches live in
17
+ * {@link SemanticRecall}; the public shapes in `./types.ts`.
7
18
  *
8
19
  * Weight save/load is delegated to the SSMRuntime passed to saveWeights/loadWeights.
9
20
  */
10
21
 
11
- import { SSMError } from '../errors/SSMError.js';
12
- import { tokenize, jaccardSimilarity } from '../similarity/index.js';
13
- import { hybridRetrieve, denseSearch, HnswIndex, type RetrievalCandidate, type HybridRetrieveOptions } from '../retrieval/index.js';
14
-
15
- export type FactType = 'text' | 'json' | 'number' | 'boolean';
16
-
17
- export interface MemoryEntry {
18
- key : string;
19
- content : string;
20
- timestamp : number;
21
- /**
22
- * Monotonic write sequence, used only to break `timestamp` ties so that
23
- * ordering stays deterministic when several entries are written within the
24
- * same millisecond (Date.now() resolution). Higher = written later.
25
- * Optional for backward compatibility with entries persisted/imported
26
- * before this field existed (those sort as seq 0).
27
- */
28
- seq? : number;
29
- /** Optional time-to-live in milliseconds. */
30
- ttlMs? : number;
31
- /** Semantic type of the stored value. Default: 'text'. */
32
- type? : FactType;
33
- /** Tags for grouping and filtering. */
34
- tags? : string[];
35
- /** Importance weight in the range 0–1. Default: 0.5. */
36
- importance?: number;
37
- }
38
-
39
- /**
40
- * Which ranker produced a recall ordering. Matches the vocabulary the gateway's
41
- * Evermind recall/validate surfaces already report, so a host can render one chip
42
- * ("Semantic recall" / "Lexical recall (fallback)") regardless of which layer
43
- * answered.
44
- */
45
- export type RecallMethod = 'embedding' | 'lexical';
46
-
47
- /** A recalled entry with its relevance score (higher = closer). */
48
- export interface ScoredMemory {
49
- entry: MemoryEntry;
50
- score: number;
51
- }
52
-
53
- /** A ranked recall: the hits plus the ranker that ordered them. */
54
- export interface RankedRecall {
55
- hits: ScoredMemory[];
56
- method: RecallMethod;
57
- }
58
-
59
- export interface RememberOptions {
60
- /** Override the store-level defaultTtlMs for this entry. */
61
- ttlMs? : number;
62
- type? : FactType;
63
- tags? : string[];
64
- importance?: number;
65
- }
66
-
67
- export interface MemoryStoreOptions {
68
- /** IndexedDB database name. Default: 'ssmjs'. */
69
- dbName? : string;
70
- /** Key used for weight storage within the 'weights' object store. Default: 'ssmjs-weights'. */
71
- weightsKey? : string;
72
- /**
73
- * IDBFactory to use instead of the global `indexedDB`.
74
- * Use this in Node.js environments with fake-indexeddb:
75
- * import { IDBFactory } from 'fake-indexeddb';
76
- * const idbFactory = new IDBFactory();
77
- */
78
- idbFactory? : IDBFactory;
79
- /**
80
- * Default TTL applied to new entries when no per-entry ttlMs is provided.
81
- * Entries with an expired TTL are filtered from recallAll() and related methods.
82
- */
83
- defaultTtlMs? : number;
84
- /**
85
- * Fact count at/above which semantic recall switches from an exact O(N)
86
- * cosine scan to an O(log N) HNSW ANN index. Default 256 — small stores stay
87
- * exact (simpler + faster), large stores stay fast. (EVM-1)
88
- */
89
- annThreshold? : number;
90
- /**
91
- * Hard cap on stored facts. When set, each write evicts the lowest-value
92
- * entries (expired first, then lowest importance, then oldest) so the store
93
- * stays bounded. Unset = unbounded (legacy). (EVM-7)
94
- */
95
- maxEntries? : number;
96
- /**
97
- * Max content→embedding vectors held in the LRU recall cache. Default 2000.
98
- * (EVM-8 — bounded LRU instead of clear-on-overflow.)
99
- */
100
- embedCacheMax?: number;
101
- }
102
-
103
- const FACTS_STORE = 'facts';
104
- const WEIGHTS_STORE = 'weights';
105
- const DB_VERSION = 1;
106
-
107
- /**
108
- * Process-wide monotonic counter stamped onto each remembered entry. Strictly
109
- * increasing per write regardless of store instance, so it reliably breaks
110
- * `timestamp` ties (same-millisecond writes) in newest-first ordering.
111
- */
112
- let _writeSeq = 0;
113
-
114
- // Minimal interface to avoid importing SSMRuntime (circular dep)
115
- interface SaveLoadRuntime {
116
- save(opts?: { storage: 'indexedDB'; key: string }): Promise<void>;
117
- load(opts?: { key: string }): Promise<boolean>;
118
- }
119
-
120
- // Forward-declared to avoid circular dependency at import time
121
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
- type SSMRuntimeRef = any;
22
+ import type { HybridRetrieveOptions } from '../retrieval/index.js';
23
+ import { IdbStorageAdapter } from '../storage/idbAdapter.js';
24
+ import { ScopedFactRepository } from '../storage/ScopedFactRepository.js';
25
+ import {
26
+ MemoryStorageError,
27
+ type ConsistencyNote,
28
+ type FactProvenance,
29
+ type FactStoragePort,
30
+ type MemoryScope,
31
+ type StoredFactRecord,
32
+ type TombstoneRecord,
33
+ type JournalEntry,
34
+ } from '../storage/types.js';
35
+ import { SemanticRecall } from './SemanticRecall.js';
36
+ import { isExpired, liveEntries, rememberInput, toEntry, toImportRecord } from './records.js';
37
+ import { TtlSweeper } from './ttlSweeper.js';
38
+ import type {
39
+ MemoryEntry,
40
+ MemoryStoreOptions,
41
+ RankedRecall,
42
+ RememberOptions,
43
+ SaveLoadRuntime,
44
+ ScoredMemory,
45
+ SSMRuntimeRef,
46
+ } from './types.js';
47
+
48
+ export type {
49
+ FactType,
50
+ MemoryEntry,
51
+ MemoryStoreOptions,
52
+ RankedRecall,
53
+ RecallMethod,
54
+ RememberOptions,
55
+ ScoredMemory,
56
+ } from './types.js';
123
57
 
124
58
  /** Max content→embedding vectors retained (LRU-evicted past this). (EVM-8) */
125
59
  const EMBED_CACHE_MAX = 2000;
126
60
 
127
61
  export class MemoryStore {
128
- private readonly _dbName : string;
129
62
  private readonly _weightsKey : string;
130
- private readonly _idb : IDBFactory | undefined;
131
63
  private readonly _defaultTtl : number | undefined;
132
- private readonly _annThreshold : number;
133
64
  private readonly _maxEntries : number | undefined;
134
- private readonly _embedCacheMax : number;
135
- private _db: IDBDatabase | null = null;
136
- private _sweepTimer: ReturnType<typeof setInterval> | null = null;
137
- /** Content → L2-normalised embedding cache, used by recallSimilar. */
138
- private readonly _embedCache = new Map<string, Float32Array>();
139
- /** Runtime whose embeddings populate `_embedCache`; a change invalidates it. */
140
- private _embedRuntime: unknown = null;
141
-
142
- // ── Persistent dense index (EVM-1b) ────────────────────────────────────────
143
- // Fact embeddings keyed by `${key}@${seq}` so an EDIT (seq bump) is a new key
144
- // and an unchanged fact is never re-embedded. The HNSW index is derived from
145
- // these vectors and reused across recalls until the fact set changes — so
146
- // steady-state recall is O(1) query-embed + O(log N) search, not O(N) embed.
147
- private readonly _denseVectors = new Map<string, Float32Array>();
148
- private _denseIndex: HnswIndex | null = null;
149
- /** Signature of the fact set the current index was built from. */
150
- private _denseSig = '';
151
- /** Identity of the runtime whose embeddings populated the vectors. */
152
- private _denseRuntime: unknown = null;
65
+ private readonly _scope: MemoryScope;
66
+ /** The unit-of-work layer every durable operation flows through. */
67
+ readonly repository: ScopedFactRepository;
68
+ private readonly _storage: FactStoragePort;
69
+ /** Cross-context subscription, opened on the first operation (see `_ready`). */
70
+ private _unsubscribe: (() => void) | undefined;
71
+ private _subscribed = false;
72
+ private readonly _sweeper = new TtlSweeper();
73
+ /** Ranking + its embedding / dense-index caches (EVM-1, EVM-1b, EVM-8). */
74
+ private readonly _recall: SemanticRecall;
75
+ /**
76
+ * Last store generation observed from a FOREIGN context (another tab,
77
+ * window or process). Monotonic, in-memory only; lets readers notice
78
+ * cross-context writes without a storage round-trip per recall (M05).
79
+ */
80
+ private _foreignRevision = 0;
153
81
 
154
82
  constructor(opts: MemoryStoreOptions = {}) {
155
- this._dbName = opts.dbName ?? 'ssmjs';
156
83
  this._weightsKey = opts.weightsKey ?? 'ssmjs-weights';
157
- this._idb = opts.idbFactory;
158
84
  this._defaultTtl = opts.defaultTtlMs;
159
- this._annThreshold = opts.annThreshold ?? 256;
160
85
  this._maxEntries = opts.maxEntries;
161
- this._embedCacheMax = opts.embedCacheMax ?? EMBED_CACHE_MAX;
86
+ this._recall = new SemanticRecall({
87
+ annThreshold : opts.annThreshold ?? 256,
88
+ embedCacheMax: opts.embedCacheMax ?? EMBED_CACHE_MAX,
89
+ // Opt-in floor: 0 keeps recall returning its best hits, as it always did.
90
+ minRelevance : opts.minRelevance ?? 0,
91
+ });
92
+ this._scope = opts.scope ? { ...opts.scope } : {};
93
+ this._storage = opts.storage ?? new IdbStorageAdapter(opts.dbName ?? 'ssmjs', {
94
+ idbFactory: opts.idbFactory,
95
+ contextId : opts.contextId,
96
+ });
97
+ this.repository = new ScopedFactRepository(this._storage, {
98
+ syncEnabled: opts.sync ?? false,
99
+ contextId : opts.contextId,
100
+ });
162
101
  }
163
102
 
164
- // ── Internal DB open ──────────────────────────────────────────────────────
165
-
166
- private _open(): Promise<IDBDatabase> {
167
- if (this._db) return Promise.resolve(this._db);
168
-
169
- return new Promise((resolve, reject) => {
170
- const factory = this._idb ?? (typeof indexedDB !== 'undefined' ? indexedDB : undefined);
171
- if (!factory) {
172
- reject(new SSMError(
173
- 'MEMORY_UNAVAILABLE',
174
- 'IndexedDB is not available in this environment. Pass an idbFactory option (e.g. from fake-indexeddb) for Node.js support.',
175
- ));
176
- return;
177
- }
178
-
179
- const req = factory.open(this._dbName, DB_VERSION);
180
-
181
- req.onupgradeneeded = (e) => {
182
- const db = (e.target as IDBOpenDBRequest).result;
183
- if (!db.objectStoreNames.contains(FACTS_STORE)) {
184
- db.createObjectStore(FACTS_STORE, { keyPath: 'key' });
185
- }
186
- if (!db.objectStoreNames.contains(WEIGHTS_STORE)) {
187
- db.createObjectStore(WEIGHTS_STORE);
188
- }
189
- };
190
-
191
- req.onsuccess = () => {
192
- this._db = req.result;
193
- resolve(req.result);
194
- };
195
- /* istanbul ignore next -- IDB open onerror fires only on storage faults; not reproducible with fake-indexeddb */
196
- req.onerror = () => reject(new SSMError(
197
- 'MEMORY_UNAVAILABLE',
198
- `Failed to open IndexedDB "${this._dbName}": ${req.error?.message ?? 'unknown'}`,
199
- req.error,
200
- ));
201
- });
103
+ /**
104
+ * Cross-context invalidation (M05): a sibling tab/window/process that
105
+ * commits against the same storage invalidates this instance's derived
106
+ * caches. Subscribed lazily on the first operation: before it there are no
107
+ * caches to invalidate, and an eager subscription opened a global
108
+ * BroadcastChannel in the constructor that kept Node processes alive.
109
+ */
110
+ private _ready(): void {
111
+ if (this._subscribed) return;
112
+ this._subscribed = true;
113
+ this._unsubscribe = this.repository.subscribe((note) => this._onForeignChange(note));
202
114
  }
203
115
 
204
- // ── TTL helpers ───────────────────────────────────────────────────────────
116
+ /** The scope partition this store is bound to (never widened implicitly). */
117
+ get scope(): MemoryScope {
118
+ return { ...this._scope };
119
+ }
120
+
121
+ /** The durable storage backend kind in use ('indexedDB' | 'node-file'). */
122
+ get storageKind(): FactStoragePort['kind'] {
123
+ return this._storage.kind;
124
+ }
125
+
126
+ /**
127
+ * Best-effort in-memory token that changes when another context writes to
128
+ * this store's database. Readers (e.g. EvermindCognition's recall cache)
129
+ * can fold it into their cache key so cross-context writes invalidate
130
+ * without a storage hit per recall.
131
+ */
132
+ get foreignRevision(): number {
133
+ return this._foreignRevision;
134
+ }
205
135
 
206
- private _isExpired(entry: MemoryEntry): boolean {
207
- if (entry.ttlMs == null) return false;
208
- return Date.now() > entry.timestamp + entry.ttlMs;
136
+ private _onForeignChange(note: ConsistencyNote): void {
137
+ if (note.revision <= this._foreignRevision) return;
138
+ this._foreignRevision = note.revision;
139
+ // Derived caches describe a fact set another context just changed.
140
+ this._recall.invalidate();
209
141
  }
210
142
 
211
143
  // ── Semantic facts ────────────────────────────────────────────────────────
212
144
 
213
- /** Stores or overwrites a fact. */
145
+ /**
146
+ * Stores or overwrites a fact. Resolves only after the underlying
147
+ * transaction commits durably (M02); the fact, its provenance and its sync
148
+ * journal entry commit atomically.
149
+ */
214
150
  async remember(key: string, content: string, opts?: RememberOptions): Promise<void> {
215
- const db = await this._open();
216
- const entry: MemoryEntry = {
217
- key,
218
- content,
219
- timestamp : Date.now(),
220
- seq : ++_writeSeq,
221
- ttlMs : opts?.ttlMs ?? this._defaultTtl,
222
- type : opts?.type,
223
- tags : opts?.tags,
224
- importance : opts?.importance,
225
- };
226
-
227
- const tx = db.transaction(FACTS_STORE, 'readwrite');
228
- await requestToPromise(tx.objectStore(FACTS_STORE).put(entry), `Failed to store fact "${key}"`, () => undefined);
229
- // EVM-7: keep the store bounded — evict lowest-value entries past the cap.
230
- if (this._maxEntries != null) await this._enforceCap();
151
+ this._ready();
152
+ await this.repository.remember(this._scope, key, rememberInput(content, opts, this._defaultTtl));
153
+ // EVM-7: keep the store bounded — evict lowest-value SYNCED entries past
154
+ // the cap; unsynced knowledge is never sacrificed to a cache cap (M06).
155
+ if (this._maxEntries != null) {
156
+ await this.repository.evictForCap(this._scope, this._maxEntries, isExpired);
157
+ }
231
158
  }
232
159
 
233
160
  /**
234
- * Evicts the lowest-value entries until at most `maxEntries` remain. Eviction
235
- * priority (removed first): expired → lowest importance → oldest. No-op when
236
- * no cap is set or the store is under it. (EVM-7)
161
+ * Compare-and-set write (M04/M05): commits only if the record's current
162
+ * revision equals `expectedRevision` (`null` asserts the key is absent).
163
+ * Throws a MemoryStorageError otherwise (`code` 'MEMORY_CONFLICT',
164
+ * `storageCode` 'STORAGE_CONFLICT'; see `isStorageConflict`) — callers reconcile
165
+ * (re-read, re-evaluate evidence) instead of blindly overwriting a
166
+ * concurrent writer. Returns the committed entry.
237
167
  */
238
- private async _enforceCap(): Promise<void> {
239
- if (this._maxEntries == null) return;
240
- const db = await this._open();
241
- const tx = db.transaction(FACTS_STORE, 'readonly');
242
- const all = await requestToPromise(
243
- tx.objectStore(FACTS_STORE).getAll() as IDBRequest<MemoryEntry[]>,
244
- 'Failed to scan facts for cap',
245
- (r) => r,
246
- );
247
- if (all.length <= this._maxEntries) return;
248
-
249
- // Ascending sort = worst first: expired (0) before live (1), then lower
250
- // importance, then older timestamp, then lower write sequence.
251
- const rank = (e: MemoryEntry): [number, number, number, number] =>
252
- [this._isExpired(e) ? 0 : 1, e.importance ?? 0.5, e.timestamp, e.seq ?? 0];
253
- const sorted = [...all].sort((a, b) => {
254
- const ra = rank(a), rb = rank(b);
255
- for (let i = 0; i < ra.length; i++) {
256
- if (ra[i] !== rb[i]) return ra[i]! - rb[i]!;
257
- }
258
- return 0;
168
+ async rememberCas(key: string, content: string, expectedRevision: number | null, opts?: RememberOptions): Promise<MemoryEntry> {
169
+ this._ready();
170
+ const { record } = await this.repository.remember(this._scope, key, {
171
+ ...rememberInput(content, opts, this._defaultTtl),
172
+ expectedRevision,
259
173
  });
260
- const victims = sorted.slice(0, all.length - this._maxEntries);
261
- await Promise.all(victims.map((e) => this.forget(e.key)));
174
+ return toEntry(record);
175
+ }
176
+
177
+ /** Fetches an entry WITH its durable revision, for CAS-based reconciliation. */
178
+ async recallVersioned(key: string): Promise<MemoryEntry | undefined> {
179
+ return this.recall(key);
262
180
  }
263
181
 
264
182
  /**
265
- * Starts a background timer that periodically hard-deletes expired entries —
183
+ * Starts a background timer that periodically deletes expired entries —
266
184
  * TTL is otherwise only evaluated lazily on read, so an idle store would keep
267
185
  * dead entries forever. Idempotent; pair with {@link stopTtlSweeper}. (EVM-7)
186
+ *
187
+ * With sync enabled each expiry is a scoped deletion (tombstone + journal),
188
+ * not a silent drop (M06).
268
189
  */
269
190
  startTtlSweeper(intervalMs: number): void {
270
- if (this._sweepTimer) return;
271
- const timer = setInterval(() => { void this.purgeExpired(); }, intervalMs);
272
- // Don't keep a Node process alive just for the sweeper.
273
- const maybeUnref = timer as unknown as { unref?: () => void };
274
- /* istanbul ignore else -- unref exists in Node; absent in the browser */
275
- if (typeof maybeUnref.unref === 'function') {
276
- maybeUnref.unref();
277
- }
278
- this._sweepTimer = timer;
191
+ this._sweeper.start(intervalMs, () => this.purgeExpired());
279
192
  }
280
193
 
281
194
  /** Stops the background TTL sweeper started by {@link startTtlSweeper}. */
282
195
  stopTtlSweeper(): void {
283
- if (this._sweepTimer) {
284
- clearInterval(this._sweepTimer);
285
- this._sweepTimer = null;
286
- }
196
+ this._sweeper.stop();
287
197
  }
288
198
 
289
199
  /**
290
- * Retrieves a fact by key.
200
+ * Retrieves a fact by key from this store's scope partition.
291
201
  * Returns `undefined` if the key does not exist or the entry has expired.
292
202
  */
293
203
  async recall(key: string): Promise<MemoryEntry | undefined> {
294
- const db = await this._open();
295
-
296
- const tx = db.transaction(FACTS_STORE, 'readonly');
297
- return requestToPromise(
298
- tx.objectStore(FACTS_STORE).get(key) as IDBRequest<MemoryEntry | undefined>,
299
- `Failed to recall fact "${key}"`,
300
- (entry) => (entry && this._isExpired(entry) ? undefined : entry),
301
- );
204
+ this._ready();
205
+ const record = await this.repository.get(this._scope, key);
206
+ if (!record || isExpired(record)) return undefined;
207
+ return toEntry(record);
302
208
  }
303
209
 
304
- /** Returns all non-expired stored facts, newest first. */
210
+ /** Returns all non-expired stored facts in this store's scope, newest first. */
305
211
  async recallAll(): Promise<MemoryEntry[]> {
306
- const db = await this._open();
307
-
308
- const tx = db.transaction(FACTS_STORE, 'readonly');
309
- return requestToPromise(
310
- tx.objectStore(FACTS_STORE).getAll() as IDBRequest<MemoryEntry[]>,
311
- 'Failed to recall all facts',
312
- (entries) => entries
313
- .filter(e => !this._isExpired(e))
314
- // Newest first; break same-millisecond ties by write sequence so
315
- // ordering is deterministic (IndexedDB getAll() returns key order,
316
- // which would otherwise surface same-ms writes oldest-first).
317
- .sort((a, b) => b.timestamp - a.timestamp || (b.seq ?? 0) - (a.seq ?? 0)),
318
- );
212
+ this._ready();
213
+ return liveEntries(await this.repository.getAll(this._scope));
319
214
  }
320
215
 
321
216
  /**
@@ -329,23 +224,19 @@ export class MemoryStore {
329
224
 
330
225
  /**
331
226
  * Returns all non-expired entries that contain the given tag.
227
+ * Looks the tag up on the facts `byTag` index when the storage adapter has
228
+ * one; a full scan is used only when that index is missing.
332
229
  */
333
230
  async recallByTag(tag: string): Promise<MemoryEntry[]> {
334
- const all = await this.recallAll();
335
- return all.filter(e => e.tags?.includes(tag) ?? false);
231
+ this._ready();
232
+ return liveEntries(await this.repository.getByTag(this._scope, tag));
336
233
  }
337
234
 
338
235
  /**
339
- * Finds the top-K semantically similar entries to `query`.
340
- *
341
- * When `runtime` exposes an `embed()` method (the SSMRuntime does), similarity
342
- * is computed as cosine distance between SSM hidden-state embeddings — i.e. the
343
- * memory layer uses the very model it is attached to, and recall quality
344
- * improves automatically as that model is adapted/distilled. Embeddings are
345
- * cached per content string to avoid recomputing across calls.
346
- *
347
- * If no embedding-capable runtime is provided, or embedding fails for any
348
- * reason, it transparently falls back to Jaccard word-overlap similarity.
236
+ * Finds the top-K semantically similar entries to `query`: cosine over the
237
+ * attached SSM runtime's embeddings (so recall improves as that model is
238
+ * adapted), or Jaccard word overlap when no runtime can embed. See
239
+ * {@link SemanticRecall}.
349
240
  */
350
241
  async recallSimilar(query: string, topK: number, runtime?: SSMRuntimeRef): Promise<MemoryEntry[]> {
351
242
  return (await this.recallSimilarScored(query, topK, runtime)).map(s => s.entry);
@@ -357,52 +248,15 @@ export class MemoryStore {
357
248
  * so callers can rank/threshold by TRUE similarity instead of list position.
358
249
  * {@link recallSimilar} is the score-dropping convenience wrapper over this.
359
250
  */
360
- async recallSimilarScored(query: string, topK: number, runtime?: SSMRuntimeRef): Promise<Array<{ entry: MemoryEntry; score: number }>> {
361
- const all = await this.recallAll();
362
- if (all.length === 0) return [];
363
-
364
- // ── Preferred path: SSM-embedding cosine similarity ───────────────────
365
- if (runtime != null && typeof runtime.embed === 'function') {
366
- const queryVec = await this._embedWithCache(runtime, query);
367
- if (queryVec) {
368
- // EVM-1b: sync the persistent vector store + index (embeds only the
369
- // delta), then query it — no per-call re-embed of every fact.
370
- const synced = await this._syncDense(runtime, all);
371
- if (synced) {
372
- const hits = synced.index
373
- // EVM-1: above the threshold, query the persistent HNSW (O(log N)).
374
- ? synced.index.search(queryVec, topK)
375
- // Below it, an exact scan over the (already-embedded) vectors.
376
- : denseSearch(queryVec, synced.items, topK, this._annThreshold);
377
- return hits
378
- .map(h => { const entry = synced.byKey.get(h.id); return entry ? { entry, score: h.score } : null; })
379
- .filter((e): e is { entry: MemoryEntry; score: number } => !!e);
380
- }
381
- }
382
- // Any failure falls through to the Jaccard path below.
383
- }
384
-
385
- // ── Fallback: Jaccard word-overlap similarity ─────────────────────────
386
- const queryTokens = new Set(tokenize(query));
387
- const scored = all.map(entry => {
388
- const entryTokens = new Set(tokenize(entry.content));
389
- const score = jaccardSimilarity(queryTokens, entryTokens);
390
- return { entry, score };
391
- });
392
-
393
- scored.sort((a, b) => b.score - a.score);
394
- return scored.slice(0, topK);
251
+ async recallSimilarScored(query: string, topK: number, runtime?: SSMRuntimeRef): Promise<ScoredMemory[]> {
252
+ return this._recall.similar(await this.recallAll(), query, topK, runtime);
395
253
  }
396
254
 
397
255
  /**
398
- * Hybrid recall: fuses dense (SSM-embedding cosine) and sparse (BM25 lexical)
399
- * rankings via Reciprocal Rank Fusion, then applies an MMR diversity rerank.
400
- *
401
- * This is the production RAG retrieval path — it catches both semantic matches
402
- * (embeddings) and exact-token matches (BM25 — identifiers, codes, rare names)
403
- * that cosine-only `recallSimilar` misses, and avoids returning near-duplicate
404
- * facts. Degrades to BM25-only when no embedding-capable runtime is available,
405
- * so it is always strictly at least as good as the lexical fallback.
256
+ * Hybrid recall: dense (SSM-embedding cosine) and sparse (BM25) rankings
257
+ * fused by Reciprocal Rank Fusion, then an MMR diversity rerank — catches
258
+ * exact-token matches cosine misses, and degrades to BM25 alone without a
259
+ * runtime. The production RAG path.
406
260
  */
407
261
  async recallHybrid(
408
262
  query: string,
@@ -414,19 +268,10 @@ export class MemoryStore {
414
268
  }
415
269
 
416
270
  /**
417
- * The hybrid retrieval path with its evidence attached: each hit's fused score,
418
- * plus WHICH ranker produced the ordering.
419
- *
420
- * {@link recallHybrid} is the convenience wrapper that throws both away, so
421
- * there is exactly one implementation of the blend — and it is the shared
422
- * `hybridRetrieve` → `reciprocalRankFusion` primitive, never a second hand-
423
- * rolled mix of cosine and word overlap.
424
- *
425
- * `method` is honest rather than aspirational: `'embedding'` means the query
426
- * really did embed and the dense ranking really did participate in the fusion;
427
- * `'lexical'` means no model was available (or embedding failed) and the
428
- * ordering is BM25 alone. Callers surface it so a user can tell semantic
429
- * recall from the fallback instead of guessing.
271
+ * {@link recallHybrid} with its evidence: each hit's fused score, and WHICH
272
+ * ranker produced the ordering — `'embedding'` only when the query and the
273
+ * candidates really embedded, `'lexical'` when the ordering is BM25 alone.
274
+ * One blend implementation: the shared `hybridRetrieve` primitive.
430
275
  */
431
276
  async recallRanked(
432
277
  query: string,
@@ -434,218 +279,102 @@ export class MemoryStore {
434
279
  runtime?: SSMRuntimeRef,
435
280
  opts?: HybridRetrieveOptions,
436
281
  ): Promise<RankedRecall> {
437
- const all = await this.recallAll();
438
- if (all.length === 0) return { hits: [], method: 'lexical' };
439
-
440
- // Embed candidates + query where a runtime is available; null vectors are
441
- // fine — hybridRetrieve degrades that candidate to BM25-only.
442
- const canEmbed = runtime != null && typeof runtime.embed === 'function';
443
- const queryVec = canEmbed ? (await this._embedWithCache(runtime, query)) ?? undefined : undefined;
444
-
445
- const candidates: RetrievalCandidate[] = [];
446
- for (const entry of all) {
447
- const vector = queryVec ? (await this._embedWithCache(runtime, entry.content)) ?? undefined : undefined;
448
- candidates.push({ id: entry.key, text: entry.content, vector });
449
- }
450
-
451
- // The dense ranking only exists when the query embedded AND at least one
452
- // candidate did — anything less is a lexical ordering wearing a label.
453
- const dense = !!queryVec && candidates.some(c => !!c.vector);
454
- const hits = hybridRetrieve({ text: query, vector: queryVec }, candidates, { topK, annThreshold: this._annThreshold, ...opts });
455
- const byKey = new Map(all.map(e => [e.key, e]));
456
- return {
457
- hits: hits
458
- .map(h => { const entry = byKey.get(h.id); return entry ? { entry, score: h.score } : null; })
459
- .filter((h): h is ScoredMemory => !!h),
460
- method: dense ? 'embedding' : 'lexical',
461
- };
282
+ return this._recall.ranked(await this.recallAll(), query, topK, runtime, opts);
462
283
  }
463
284
 
464
285
  /**
465
- * Returns a cached embedding for `text`, computing it via `runtime.embed()`
466
- * on a cache miss. Returns `null` (never throws) when embedding is
467
- * unavailable so callers can fall back to lexical similarity.
286
+ * Deletes all entries whose TTL has expired. With sync enabled each expiry
287
+ * commits a tombstone + journal entry in the same transaction, so expiries
288
+ * propagate instead of resurrecting from a replica (M06).
289
+ * @returns The number of entries deleted.
468
290
  */
469
- private async _embedWithCache(runtime: SSMRuntimeRef, text: string): Promise<Float32Array | null> {
470
- // Embeddings are model-specific: if the runtime instance changed, the cached
471
- // vectors are from a different model and must be dropped. (EVM-1b)
472
- if (runtime !== this._embedRuntime) {
473
- this._embedCache.clear();
474
- this._embedRuntime = runtime;
475
- }
476
- const cached = this._embedCache.get(text);
477
- if (cached) {
478
- // LRU touch: re-insert so this entry becomes most-recently-used. (EVM-8)
479
- this._embedCache.delete(text);
480
- this._embedCache.set(text, cached);
481
- return cached;
482
- }
483
- try {
484
- const vec = await runtime.embed(text);
485
- if (vec instanceof Float32Array && vec.length > 0) {
486
- // EVM-8: bound cache growth by evicting the LEAST-recently-used entry
487
- // (Map keeps insertion order; the first key is the LRU), instead of
488
- // dumping the whole cache on every overflow.
489
- if (this._embedCache.size >= this._embedCacheMax) {
490
- const lru = this._embedCache.keys().next().value;
491
- if (lru !== undefined) this._embedCache.delete(lru);
492
- }
493
- this._embedCache.set(text, vec);
494
- return vec;
495
- }
496
- } catch {
497
- // Embedding unavailable (no GPU, destroyed runtime, etc.) — signal fallback.
498
- }
499
- return null;
291
+ async purgeExpired(): Promise<number> {
292
+ this._ready();
293
+ return this.repository.purgeExpired(this._scope, isExpired);
500
294
  }
501
295
 
502
296
  /**
503
- * Synchronise the persistent dense vector store + HNSW index to `all` (EVM-1b).
504
- * Embeds ONLY new/changed facts (keyed by `key@seq`), prunes removed ones, and
505
- * rebuilds the index only when the fact set actually changed — so a read-heavy
506
- * workload reuses both the embeddings and the index across recalls. Returns
507
- * `null` (caller falls back to lexical) if any embedding is unavailable, or
508
- * resets everything when the runtime identity changes (embeddings are
509
- * model-specific).
297
+ * Deletes a single fact. No-op if key does not exist. With sync enabled the
298
+ * delete is a durable tombstone + journal entry committed atomically (M04).
510
299
  */
511
- private async _syncDense(
512
- runtime: SSMRuntimeRef,
513
- all: MemoryEntry[],
514
- ): Promise<{ byKey: Map<string, MemoryEntry>; items: Array<{ id: string; vector: Float32Array }>; index: HnswIndex | null } | null> {
515
- if (runtime !== this._denseRuntime) {
516
- this._denseVectors.clear();
517
- this._denseIndex = null;
518
- this._denseSig = '';
519
- this._denseRuntime = runtime;
520
- }
300
+ async forget(key: string): Promise<void> {
301
+ this._ready();
302
+ await this.repository.forget(this._scope, key);
303
+ }
521
304
 
522
- const vkeyOf = (e: MemoryEntry): string => `${e.key}@${e.seq ?? 0}`;
523
- const currentVkeys = new Set<string>();
524
- let sig = `${all.length}`;
525
- for (const e of all) { const k = vkeyOf(e); currentVkeys.add(k); sig += '|' + k; }
526
-
527
- const byKey = new Map<string, MemoryEntry>();
528
- const items: Array<{ id: string; vector: Float32Array }> = [];
529
- for (const entry of all) {
530
- const vk = vkeyOf(entry);
531
- let vec = this._denseVectors.get(vk);
532
- if (!vec) {
533
- const v = await this._embedWithCache(runtime, entry.content);
534
- if (!v) return null; // embedding failed → lexical fallback
535
- vec = v;
536
- this._denseVectors.set(vk, vec);
537
- }
538
- items.push({ id: entry.key, vector: vec });
539
- byKey.set(entry.key, entry);
540
- }
305
+ /** Deletes all facts in this store's scope. Does not affect saved weights. */
306
+ async clear(): Promise<void> {
307
+ // Drop the persistent dense index/vectors too (EVM-1b) — they describe a
308
+ // fact set that no longer exists.
309
+ this._ready();
310
+ this._recall.resetDense();
311
+ await this.repository.clear(this._scope);
312
+ }
541
313
 
542
- // Drop vectors for facts that are gone or were edited (stale vkeys).
543
- if (this._denseVectors.size > currentVkeys.size) {
544
- for (const k of this._denseVectors.keys()) {
545
- if (!currentVkeys.has(k)) this._denseVectors.delete(k);
546
- }
547
- }
314
+ // ── Sync protocol surface (M04) ───────────────────────────────────────────
548
315
 
549
- // Maintain the ANN index only above the threshold; (re)build it only when
550
- // the fact set changed (no embedding involved — purely in-memory).
551
- if (all.length >= this._annThreshold) {
552
- if (sig !== this._denseSig || this._denseIndex === null) {
553
- const index = new HnswIndex();
554
- for (const it of items) index.add(it.id, it.vector);
555
- this._denseIndex = index;
556
- }
557
- } else {
558
- this._denseIndex = null;
559
- }
560
- this._denseSig = sig;
561
- return { byKey, items, index: this._denseIndex };
316
+ /** Operations not yet acknowledged by sync — the pending knowledge set. */
317
+ async pendingOps(): Promise<JournalEntry[]> {
318
+ return this.repository.pendingOps();
319
+ }
320
+
321
+ /** Delete tombstones in this store's scope (or all scopes when omitted). */
322
+ async tombstones(scope?: MemoryScope): Promise<TombstoneRecord[]> {
323
+ return this.repository.tombstones(scope);
562
324
  }
563
325
 
564
326
  /**
565
- * Hard-deletes all entries whose TTL has expired.
566
- * @returns The number of entries deleted.
327
+ * Acknowledges that the given operations reached the sync target. Their
328
+ * facts/tombstones stop being `pendingSync`, so retention may touch them
329
+ * again. Retention NEVER touches unacknowledged knowledge (M06).
567
330
  */
568
- async purgeExpired(): Promise<number> {
569
- const db = await this._open();
570
-
571
- // Load all raw entries (including expired ones) to check each
572
- const tx = db.transaction(FACTS_STORE, 'readonly');
573
- const all = await requestToPromise(
574
- tx.objectStore(FACTS_STORE).getAll() as IDBRequest<MemoryEntry[]>,
575
- 'Failed to scan facts for purge',
576
- (r) => r,
577
- );
578
-
579
- const expired = all.filter(e => this._isExpired(e));
580
- if (expired.length === 0) return 0;
581
-
582
- await Promise.all(expired.map(e => this.forget(e.key)));
583
- return expired.length;
331
+ async markSynced(opIds: string[]): Promise<void> {
332
+ await this.repository.markSynced(opIds);
584
333
  }
585
334
 
586
- /** Deletes a single fact. No-op if key does not exist. */
587
- async forget(key: string): Promise<void> {
588
- const db = await this._open();
589
-
590
- const tx = db.transaction(FACTS_STORE, 'readwrite');
591
- return requestToPromise(tx.objectStore(FACTS_STORE).delete(key), `Failed to forget fact "${key}"`, () => undefined);
335
+ /** The current durable store generation. */
336
+ async revision(): Promise<number> {
337
+ return this.repository.revision();
592
338
  }
593
339
 
594
- /** Deletes all facts. Does not affect saved weights. */
595
- async clear(): Promise<void> {
596
- const db = await this._open();
597
- // Drop the persistent dense index/vectors too (EVM-1b) — they describe a
598
- // fact set that no longer exists.
599
- this._denseVectors.clear();
600
- this._denseIndex = null;
601
- this._denseSig = '';
602
-
603
- const tx = db.transaction(FACTS_STORE, 'readwrite');
604
- return requestToPromise(tx.objectStore(FACTS_STORE).clear(), 'Failed to clear facts', () => undefined);
340
+ /** Releases cross-context subscriptions and the underlying connection. */
341
+ close(): void {
342
+ this.stopTtlSweeper();
343
+ this._unsubscribe?.();
344
+ this._unsubscribe = undefined;
345
+ this._storage.close?.();
605
346
  }
606
347
 
607
348
  // ── Cross-session memory merge ────────────────────────────────────────────
608
349
 
609
350
  /**
610
351
  * Returns all non-expired facts as a plain array for export.
611
- * Suitable for serialisation and import into another MemoryStore instance.
352
+ * Scope, revision and provenance travel with each entry (M03).
612
353
  */
613
354
  async exportAll(): Promise<MemoryEntry[]> {
614
355
  return this.recallAll();
615
356
  }
616
357
 
617
358
  /**
618
- * Imports entries from an external array.
359
+ * Imports entries from an external array INTO this store's scope partition.
360
+ * Source timestamps, sequences, tags, importance, TTL and provenance are
361
+ * preserved — import never rewrites source time (M06).
619
362
  *
620
363
  * - `'merge'` : only writes an entry if no existing entry with the same
621
364
  * key exists, or if the incoming entry has a newer timestamp.
622
365
  * - `'overwrite'` : writes all entries unconditionally.
623
366
  */
624
- async importAll(entries: MemoryEntry[], strategy: 'merge' | 'overwrite'): Promise<void> {
625
- for (const entry of entries) {
626
- if (strategy === 'overwrite') {
627
- await this._putRaw(entry);
628
- } else {
629
- const existing = await this.recall(entry.key);
630
- if (existing == null || entry.timestamp > existing.timestamp) {
631
- await this._putRaw(entry);
632
- }
633
- }
634
- }
635
- }
636
-
637
- /** Writes a raw MemoryEntry directly (preserves original timestamp / metadata). */
638
- private async _putRaw(entry: MemoryEntry): Promise<void> {
639
- const db = await this._open();
640
- const tx = db.transaction(FACTS_STORE, 'readwrite');
641
- return requestToPromise(tx.objectStore(FACTS_STORE).put(entry), `Failed to import fact "${entry.key}"`, () => undefined);
367
+ async importAll(entries: MemoryEntry[], strategy: 'merge' | 'overwrite', importedFrom?: string): Promise<void> {
368
+ this._ready();
369
+ const records = entries.map((e) => toImportRecord(e, this._scope));
370
+ await this.repository.importRecords(this._scope, records, strategy, importedFrom);
642
371
  }
643
372
 
644
373
  // ── Weight persistence ────────────────────────────────────────────────────
645
374
 
646
375
  /**
647
376
  * Saves SSM weights via `runtime.save()`.
648
- * The weights are stored under `weightsKey` in this store's IndexedDB,
377
+ * The weights are stored under `weightsKey` in the session's IndexedDB,
649
378
  * separate from MambaSession's own key.
650
379
  */
651
380
  async saveWeights(runtime: SaveLoadRuntime): Promise<void> {
@@ -661,27 +390,18 @@ export class MemoryStore {
661
390
  }
662
391
  }
663
392
 
664
- // ── IndexedDB helper ──────────────────────────────────────────────────────────
665
-
666
- /**
667
- * Wires an IDBRequest to a promise: resolves with `map(result)` on success,
668
- * rejects with a normalised MEMORY_UNAVAILABLE error on failure. Extracted so
669
- * the success/error wiring lives in one place instead of being duplicated across
670
- * every store method.
671
- *
672
- * The onerror branch fires only on IndexedDB storage faults (quota exceeded,
673
- * disk failure, corruption), which the in-memory fake-indexeddb used in tests
674
- * cannot reproduce — hence the istanbul ignore on that single line.
675
- */
676
- function requestToPromise<R, T>(req: IDBRequest<R>, failMsg: string, map: (result: R) => T): Promise<T> {
677
- return new Promise<T>((resolve, reject) => {
678
- req.onsuccess = () => resolve(map(req.result));
679
- /* istanbul ignore next -- IDB storage-fault path; not reproducible with fake-indexeddb */
680
- req.onerror = () => reject(new SSMError(
681
- 'MEMORY_UNAVAILABLE',
682
- `${failMsg}: ${req.error?.message ?? 'unknown'}`,
683
- req.error ?? undefined,
684
- ));
685
- });
686
- }
687
-
393
+ // Re-export the storage seam so consumers reach it through the store's module.
394
+ export {
395
+ IdbStorageAdapter,
396
+ ScopedFactRepository,
397
+ MemoryStorageError,
398
+ };
399
+ export type {
400
+ ConsistencyNote,
401
+ FactProvenance,
402
+ FactStoragePort,
403
+ JournalEntry,
404
+ MemoryScope,
405
+ StoredFactRecord,
406
+ TombstoneRecord,
407
+ };