@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.
- package/dist/errors/SSMError.d.ts +1 -1
- package/dist/errors/SSMError.d.ts.map +1 -1
- package/dist/errors/SSMError.js.map +1 -1
- package/dist/handoff/index.d.ts +5 -5
- package/dist/handoff/index.js +5 -5
- package/dist/handoff/note.d.ts +1 -1
- package/dist/handoff/note.js +1 -1
- package/dist/handoff/pack.d.ts +13 -4
- package/dist/handoff/pack.d.ts.map +1 -1
- package/dist/handoff/pack.js +31 -10
- package/dist/handoff/pack.js.map +1 -1
- package/dist/memory/MemoryStore.d.ts +120 -181
- package/dist/memory/MemoryStore.d.ts.map +1 -1
- package/dist/memory/MemoryStore.js +201 -389
- package/dist/memory/MemoryStore.js.map +1 -1
- package/dist/memory/SemanticRecall.d.ts +64 -0
- package/dist/memory/SemanticRecall.d.ts.map +1 -0
- package/dist/memory/SemanticRecall.js +188 -0
- package/dist/memory/SemanticRecall.js.map +1 -0
- package/dist/memory/records.d.ts +26 -0
- package/dist/memory/records.d.ts.map +1 -0
- package/dist/memory/records.js +71 -0
- package/dist/memory/records.js.map +1 -0
- package/dist/memory/ttlSweeper.d.ts +12 -0
- package/dist/memory/ttlSweeper.d.ts.map +1 -0
- package/dist/memory/ttlSweeper.js +28 -0
- package/dist/memory/ttlSweeper.js.map +1 -0
- package/dist/memory/types.d.ts +142 -0
- package/dist/memory/types.d.ts.map +1 -0
- package/dist/memory/types.js +6 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/storage/ScopedFactRepository.d.ts +156 -0
- package/dist/storage/ScopedFactRepository.d.ts.map +1 -0
- package/dist/storage/ScopedFactRepository.js +278 -0
- package/dist/storage/ScopedFactRepository.js.map +1 -0
- package/dist/storage/factImport.d.ts +21 -0
- package/dist/storage/factImport.d.ts.map +1 -0
- package/dist/storage/factImport.js +61 -0
- package/dist/storage/factImport.js.map +1 -0
- package/dist/storage/factMutations.d.ts +46 -0
- package/dist/storage/factMutations.d.ts.map +1 -0
- package/dist/storage/factMutations.js +73 -0
- package/dist/storage/factMutations.js.map +1 -0
- package/dist/storage/factQueries.d.ts +16 -0
- package/dist/storage/factQueries.d.ts.map +1 -0
- package/dist/storage/factQueries.js +36 -0
- package/dist/storage/factQueries.js.map +1 -0
- package/dist/storage/factRetention.d.ts +30 -0
- package/dist/storage/factRetention.d.ts.map +1 -0
- package/dist/storage/factRetention.js +65 -0
- package/dist/storage/factRetention.js.map +1 -0
- package/dist/storage/fileLock.d.ts +56 -0
- package/dist/storage/fileLock.d.ts.map +1 -0
- package/dist/storage/fileLock.js +138 -0
- package/dist/storage/fileLock.js.map +1 -0
- package/dist/storage/fileTransaction.d.ts +33 -0
- package/dist/storage/fileTransaction.d.ts.map +1 -0
- package/dist/storage/fileTransaction.js +108 -0
- package/dist/storage/fileTransaction.js.map +1 -0
- package/dist/storage/idbAdapter.d.ts +95 -0
- package/dist/storage/idbAdapter.d.ts.map +1 -0
- package/dist/storage/idbAdapter.js +311 -0
- package/dist/storage/idbAdapter.js.map +1 -0
- package/dist/storage/nodeFileAdapter.d.ts +127 -0
- package/dist/storage/nodeFileAdapter.d.ts.map +1 -0
- package/dist/storage/nodeFileAdapter.js +275 -0
- package/dist/storage/nodeFileAdapter.js.map +1 -0
- package/dist/storage/syncJournal.d.ts +19 -0
- package/dist/storage/syncJournal.d.ts.map +1 -0
- package/dist/storage/syncJournal.js +46 -0
- package/dist/storage/syncJournal.js.map +1 -0
- package/dist/storage/types.d.ts +207 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +90 -0
- package/dist/storage/types.js.map +1 -0
- package/package.json +3 -3
- package/src/errors/SSMError.ts +1 -0
- package/src/handoff/handoff.test.ts +52 -2
- package/src/handoff/index.ts +5 -5
- package/src/handoff/note.ts +1 -1
- package/src/handoff/pack.ts +32 -9
- package/src/memory/MemoryStore.ts +245 -525
- package/src/memory/SemanticRecall.ts +204 -0
- package/src/memory/records.ts +79 -0
- package/src/memory/ttlSweeper.ts +29 -0
- package/src/memory/types.ts +149 -0
- package/src/storage/ScopedFactRepository.ts +373 -0
- package/src/storage/factImport.ts +73 -0
- package/src/storage/factMutations.ts +106 -0
- package/src/storage/factQueries.ts +41 -0
- package/src/storage/factRetention.ts +82 -0
- package/src/storage/fileLock.ts +162 -0
- package/src/storage/fileTransaction.ts +116 -0
- package/src/storage/idbAdapter.ts +373 -0
- package/src/storage/nodeFileAdapter.ts +331 -0
- package/src/storage/syncJournal.ts +47 -0
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
private
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
if (
|
|
208
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
return
|
|
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
|
-
*
|
|
342
|
-
*
|
|
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<
|
|
361
|
-
|
|
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:
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
*
|
|
504
|
-
*
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
*
|
|
566
|
-
*
|
|
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
|
|
569
|
-
|
|
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
|
-
/**
|
|
587
|
-
async
|
|
588
|
-
|
|
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
|
-
/**
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
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
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
+
};
|