@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
@@ -0,0 +1,331 @@
1
+ /**
2
+ * storage/nodeFileAdapter.ts – Durable Node.js implementation of the storage
3
+ * port, backed by a single atomically-replaced JSON document.
4
+ *
5
+ * This is the VSIX extension-host / MCP adapter the offline-memory scope (M01)
6
+ * requires: real durable storage on Node WITHOUT an in-memory IndexedDB shim.
7
+ * fake-indexeddb evaporates with the process; this adapter fsyncs a temp file
8
+ * and atomically renames it over the database file, so a crash mid-write leaves
9
+ * either the old or the new document — never a torn one.
10
+ *
11
+ * Transaction semantics (M02/M05):
12
+ * - All transactions are serialized through one promise queue, so concurrent
13
+ * writers in this process interleave at transaction granularity.
14
+ * - A read-write transaction holds an exclusive `<file>.lock` (see
15
+ * `fileLock.ts`: atomic create, stale-lock reclamation by PID liveness,
16
+ * bounded wait) across refresh + mutate + persist, so two processes can
17
+ * never both build on the same stale document and lose a write.
18
+ * - Under that lock, the file is re-read when another process changed it
19
+ * (mtime/size freshness), so cross-process writers build on each other's
20
+ * commits instead of blindly overwriting.
21
+ * - If the persist fails, the in-memory document is rolled back to the
22
+ * pre-transaction snapshot and the transaction rejects — no partial commits.
23
+ *
24
+ * Cross-process consistency: a polling watcher (default 1s, injectable)
25
+ * republishes foreign changes to subscribers, the Node analogue of the IDB
26
+ * adapter's BroadcastChannel.
27
+ *
28
+ * Node builtins are loaded indirectly (via a non-static import) so a browser
29
+ * bundle of this package never statically pulls in `node:fs`.
30
+ */
31
+
32
+ import { FileLock, type FileLockOptions } from './fileLock.js';
33
+ import { FileTransaction, emptyDocument, type NodeFileDocument } from './fileTransaction.js';
34
+ import {
35
+ MemoryStorageError,
36
+ type ConsistencyNote,
37
+ type FactStoragePort,
38
+ type StorageStoreName,
39
+ type StorageTransaction,
40
+ } from './types.js';
41
+
42
+ export type { NodeFileDocument } from './fileTransaction.js';
43
+
44
+ export interface NodeFileStorageAdapterOptions {
45
+ /** Identity of this context for cross-context notes. Default: random. */
46
+ contextId?: string;
47
+ /**
48
+ * Poll interval (ms) for foreign-change notification. 0 disables the
49
+ * watcher. Default: 1000. Freshness is also checked synchronously before
50
+ * every write transaction regardless of this interval.
51
+ */
52
+ watchIntervalMs?: number;
53
+ /** Injectable fs (tests, sandboxed hosts). Default: node:fs. */
54
+ fs?: NodeFs;
55
+ /** Injectable path helpers. Default: node:path. */
56
+ path?: NodePath;
57
+ /** Cross-process write lock tuning (timeout, liveness probe). */
58
+ lock?: FileLockOptions;
59
+ }
60
+
61
+ /** The subset of node:fs this adapter uses. */
62
+ export interface NodeFs {
63
+ existsSync(path: string): boolean;
64
+ readFileSync(path: string, encoding: 'utf8'): string;
65
+ statSync(path: string): { mtimeMs: number; size: number };
66
+ mkdirSync(path: string, opts: { recursive: boolean }): void;
67
+ openSync(path: string, flags: string): number;
68
+ writeFileSync(fd: number, data: string): void;
69
+ fsyncSync(fd: number): void;
70
+ closeSync(fd: number): void;
71
+ renameSync(from: string, to: string): void;
72
+ unlinkSync(path: string): void;
73
+ copyFileSync(from: string, to: string): void;
74
+ }
75
+
76
+ export interface NodePath {
77
+ dirname(p: string): string;
78
+ }
79
+
80
+ /** Load a module without a static specifier, so bundlers skip Node builtins. */
81
+ function dynamicImport(m: string): Promise<unknown> {
82
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
83
+ return new Function('m', 'return import(m)')(m) as Promise<unknown>;
84
+ }
85
+
86
+ export class NodeFileStorageAdapter implements FactStoragePort {
87
+ readonly kind = 'node-file' as const;
88
+
89
+ private readonly _fs: NodeFs;
90
+ private readonly _path: NodePath;
91
+ private readonly _contextId: string;
92
+ private _doc: NodeFileDocument;
93
+ private _stamp: { mtimeMs: number; size: number } | null = null;
94
+ /** Transaction queue tail — all transacts serialize through this. */
95
+ private _queue: Promise<unknown> = Promise.resolve();
96
+ private _watchTimer: ReturnType<typeof setInterval> | null = null;
97
+ private readonly _listeners = new Set<(note: ConsistencyNote) => void>();
98
+ private readonly _watchIntervalMs: number;
99
+ private readonly _lock: FileLock;
100
+
101
+ private constructor(
102
+ private readonly _filePath: string,
103
+ fs: NodeFs,
104
+ path: NodePath,
105
+ opts: NodeFileStorageAdapterOptions,
106
+ ) {
107
+ this._fs = fs;
108
+ this._path = path;
109
+ this._contextId = opts.contextId ?? `node-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
110
+ this._watchIntervalMs = opts.watchIntervalMs ?? 1000;
111
+ this._lock = new FileLock(fs, `${_filePath}.lock`, opts.lock);
112
+ this._doc = this._loadFromDisk();
113
+ }
114
+
115
+ /**
116
+ * Opens (creating if necessary) the durable file at `filePath`. Async only
117
+ * because Node builtins load indirectly; the document itself is read
118
+ * synchronously before this resolves.
119
+ */
120
+ static async open(filePath: string, opts: NodeFileStorageAdapterOptions = {}): Promise<NodeFileStorageAdapter> {
121
+ const fs = (opts.fs ?? (await dynamicImport('node:fs'))) as NodeFs;
122
+ const path = (opts.path ?? (await dynamicImport('node:path'))) as NodePath;
123
+ return new NodeFileStorageAdapter(filePath, fs, path, opts);
124
+ }
125
+
126
+ /** Identity of this context, stamped onto change notes and opIds. */
127
+ get contextId(): string {
128
+ return this._contextId;
129
+ }
130
+
131
+ /** The durable file backing this adapter. */
132
+ get filePath(): string {
133
+ return this._filePath;
134
+ }
135
+
136
+ // ── Boot / crash recovery ─────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Reads the document from disk. Recovery order on a corrupt main file
140
+ * (killed mid-rename is impossible, but a torn fsync on exotic filesystems
141
+ * is not): try `<file>.tmp` (the last fully-fsynced candidate), then give up
142
+ * with the corrupt file preserved at `<file>.corrupt` for forensics rather
143
+ * than silently starting empty.
144
+ */
145
+ private _loadFromDisk(): NodeFileDocument {
146
+ const fs = this._fs;
147
+ const dir = this._path.dirname(this._filePath);
148
+ if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
149
+
150
+ const parsed = this._readDocument(this._filePath);
151
+ if (parsed) return parsed;
152
+
153
+ if (fs.existsSync(this._filePath)) {
154
+ const tmp = this._readDocument(`${this._filePath}.tmp`);
155
+ if (tmp) return tmp;
156
+ try {
157
+ fs.copyFileSync(this._filePath, `${this._filePath}.corrupt`);
158
+ fs.unlinkSync(this._filePath);
159
+ } catch { /* best-effort forensics */ }
160
+ }
161
+ return emptyDocument();
162
+ }
163
+
164
+ private _readDocument(path: string): NodeFileDocument | null {
165
+ const fs = this._fs;
166
+ if (!fs.existsSync(path)) return null;
167
+ let raw: unknown;
168
+ try {
169
+ raw = JSON.parse(fs.readFileSync(path, 'utf8'));
170
+ } catch {
171
+ return null;
172
+ }
173
+ if (!raw || typeof raw !== 'object') return null;
174
+ const doc = raw as Partial<NodeFileDocument>;
175
+ if (doc.formatVersion !== 1 || !Array.isArray(doc.facts)) return null;
176
+ const full: NodeFileDocument = {
177
+ formatVersion: 1,
178
+ facts: doc.facts,
179
+ tombstones: Array.isArray(doc.tombstones) ? doc.tombstones : [],
180
+ journal: Array.isArray(doc.journal) ? doc.journal : [],
181
+ meta: doc.meta?.key === 'counters'
182
+ ? doc.meta
183
+ : { key: 'counters', seq: 0, revision: 0, opSeq: 0 },
184
+ };
185
+ try {
186
+ const st = fs.statSync(path);
187
+ this._stamp = { mtimeMs: st.mtimeMs, size: st.size };
188
+ } catch { /* stamp stays null; next write re-checks */ }
189
+ return full;
190
+ }
191
+
192
+ /** Re-reads the document when another process rewrote the file. */
193
+ private _refreshIfChanged(): void {
194
+ const fs = this._fs;
195
+ if (!fs.existsSync(this._filePath)) {
196
+ // Deleted underneath us: an empty document is the truth now.
197
+ if (this._stamp) {
198
+ this._stamp = null;
199
+ this._doc = emptyDocument();
200
+ }
201
+ return;
202
+ }
203
+ let st: { mtimeMs: number; size: number };
204
+ try {
205
+ st = fs.statSync(this._filePath);
206
+ } catch {
207
+ return;
208
+ }
209
+ if (this._stamp && st.mtimeMs === this._stamp.mtimeMs && st.size === this._stamp.size) return;
210
+ const doc = this._readDocument(this._filePath);
211
+ if (doc) this._doc = doc;
212
+ }
213
+
214
+ /**
215
+ * Atomic durable write: fsync the full document to `<file>.tmp`, then rename
216
+ * over the live file. A crash leaves the old file intact; the tmp file is
217
+ * the recovery source on next boot.
218
+ */
219
+ private _persist(): void {
220
+ const fs = this._fs;
221
+ const text = JSON.stringify(this._doc);
222
+ const tmp = `${this._filePath}.tmp`;
223
+ const fd = fs.openSync(tmp, 'w');
224
+ try {
225
+ fs.writeFileSync(fd, text);
226
+ fs.fsyncSync(fd);
227
+ } finally {
228
+ fs.closeSync(fd);
229
+ }
230
+ fs.renameSync(tmp, this._filePath);
231
+ try {
232
+ const st = fs.statSync(this._filePath);
233
+ this._stamp = { mtimeMs: st.mtimeMs, size: st.size };
234
+ } catch { /* re-stamped on next change check */ }
235
+ }
236
+
237
+ // ── Port ──────────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Serialized unit of work. Read-write transactions take the cross-process
241
+ * lock, refresh from disk (so we build on other processes' commits), run
242
+ * `fn` against the live document, then persist atomically before
243
+ * releasing. Any failure rolls the in-memory document back to the
244
+ * pre-transaction snapshot — no partial commits, in memory or on disk.
245
+ */
246
+ transact<T>(
247
+ _stores: StorageStoreName[],
248
+ mode: 'readonly' | 'readwrite',
249
+ fn: (tx: StorageTransaction) => Promise<T> | T,
250
+ ): Promise<T> {
251
+ const run = this._queue.then((): Promise<T> => mode === 'readwrite'
252
+ ? this._lock.run(() => this._readWrite(fn))
253
+ : Promise.resolve(fn(new FileTransaction(this._doc))));
254
+ // Keep the queue alive across rejections.
255
+ this._queue = run.catch(() => undefined);
256
+ return run;
257
+ }
258
+
259
+ private async _readWrite<T>(fn: (tx: StorageTransaction) => Promise<T> | T): Promise<T> {
260
+ this._refreshIfChanged();
261
+ const snapshot = JSON.stringify(this._doc);
262
+ let result: T;
263
+ try {
264
+ result = await fn(new FileTransaction(this._doc));
265
+ } catch (err) {
266
+ // A unit of work that threw leaves nothing behind, in memory either.
267
+ this._doc = JSON.parse(snapshot) as NodeFileDocument;
268
+ throw err;
269
+ }
270
+ try {
271
+ this._persist();
272
+ } catch (err) {
273
+ // Roll back: the caller must not observe a committed-looking state
274
+ // that never reached disk.
275
+ this._doc = JSON.parse(snapshot) as NodeFileDocument;
276
+ throw new MemoryStorageError(
277
+ 'STORAGE_ABORTED',
278
+ `Failed to durably persist "${this._filePath}": ${err instanceof Error ? err.message : String(err)}`,
279
+ err,
280
+ );
281
+ }
282
+ return result;
283
+ }
284
+
285
+ // ── Cross-process consistency seam (M05) ──────────────────────────────────
286
+
287
+ publish(note: ConsistencyNote): void {
288
+ // Co-process subscribers get the note synchronously (subscribers filter
289
+ // their own origin); foreign processes learn via the watcher.
290
+ for (const l of this._listeners) l(note);
291
+ }
292
+
293
+ subscribe(listener: (note: ConsistencyNote) => void): () => void {
294
+ this._listeners.add(listener);
295
+ if (this._watchIntervalMs > 0 && !this._watchTimer) {
296
+ let lastRevision = this._doc.meta.revision;
297
+ const timer = setInterval(() => {
298
+ const before = this._doc.meta.revision;
299
+ this._refreshIfChanged();
300
+ if (this._doc.meta.revision !== before && this._doc.meta.revision !== lastRevision) {
301
+ lastRevision = this._doc.meta.revision;
302
+ const note: ConsistencyNote = {
303
+ kind: 'facts-changed',
304
+ revision: this._doc.meta.revision,
305
+ origin: 'foreign',
306
+ };
307
+ for (const l of this._listeners) l(note);
308
+ } else {
309
+ lastRevision = this._doc.meta.revision;
310
+ }
311
+ }, this._watchIntervalMs);
312
+ const maybeUnref = timer as unknown as { unref?: () => void };
313
+ if (typeof maybeUnref.unref === 'function') maybeUnref.unref();
314
+ this._watchTimer = timer;
315
+ }
316
+ return () => {
317
+ this._listeners.delete(listener);
318
+ if (this._listeners.size === 0 && this._watchTimer) {
319
+ clearInterval(this._watchTimer);
320
+ this._watchTimer = null;
321
+ }
322
+ };
323
+ }
324
+
325
+ close(): void {
326
+ if (this._watchTimer) {
327
+ clearInterval(this._watchTimer);
328
+ this._watchTimer = null;
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * storage/syncJournal.ts – The sync-acknowledgement half of the journal (M04/M06).
3
+ *
4
+ * The journal holds only operations still owed to a sync target: entries are
5
+ * written unsynced (and only when sync is enabled — see `factMutations.journal`)
6
+ * and are DELETED once acknowledged, so the journal stays bounded by the
7
+ * outstanding work instead of growing with every write ever made.
8
+ */
9
+
10
+ import type { FactStoragePort, JournalEntry, StoredFactRecord, TombstoneRecord } from './types.js';
11
+
12
+ /** Journal entries not yet acknowledged by sync — the pending operation set. */
13
+ export async function pendingOps(storage: FactStoragePort): Promise<JournalEntry[]> {
14
+ return storage.transact(['journal'], 'readonly', async (tx) => {
15
+ const all = await tx.getAll<JournalEntry>('journal');
16
+ return all.filter((j) => !j.synced);
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Acknowledges synced operations: their journal entries are pruned, and the
22
+ * facts/tombstones they produced clear `pendingSync`, making cap eviction
23
+ * eligible again. Rows already marked synced (written by older versions,
24
+ * which journaled every write even without sync) are pruned too.
25
+ */
26
+ export async function markSynced(storage: FactStoragePort, opIds: string[]): Promise<void> {
27
+ if (opIds.length === 0) return;
28
+ const acked = new Set(opIds);
29
+ await storage.transact(['journal', 'facts', 'tombstones'], 'readwrite', async (tx) => {
30
+ const journal = await tx.getAll<JournalEntry>('journal');
31
+ for (const entry of journal) {
32
+ if (entry.synced || acked.has(entry.opId)) await tx.delete('journal', entry.opId);
33
+ }
34
+ const facts = await tx.getAll<StoredFactRecord>('facts');
35
+ for (const record of facts) {
36
+ if (acked.has(record.opId) && record.pendingSync) {
37
+ await tx.put('facts', { ...record, pendingSync: false });
38
+ }
39
+ }
40
+ const tombstones = await tx.getAll<TombstoneRecord>('tombstones');
41
+ for (const tomb of tombstones) {
42
+ if (acked.has(tomb.opId) && tomb.pendingSync) {
43
+ await tx.put('tombstones', { ...tomb, pendingSync: false });
44
+ }
45
+ }
46
+ });
47
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * storage/types.ts – Narrow storage/transaction ports for durable memory.
3
+ *
4
+ * This module is the seam the offline-memory scope (M01–M05) requires:
5
+ * - MemoryStore no longer owns IndexedDB directly; it speaks to a
6
+ * {@link FactStoragePort} with REAL transaction semantics (a write resolves
7
+ * only after the transaction commits, and rejects on abort — M02).
8
+ * - Facts carry explicit scope partitions (account/tenant/project/repository/
9
+ * chat — or the explicit unbound scope for personal, never-connected
10
+ * memory), durable revisions, operation identity and provenance (M03/M04).
11
+ * - Deletes write tombstones, and every mutation appends to a sync journal in
12
+ * the SAME transaction as the fact change, so a crash can never acknowledge
13
+ * a fact whose journal entry was lost (M02/M04).
14
+ *
15
+ * Two durable adapters implement the port:
16
+ * - `IdbStorageAdapter` (browser / VSIX webview — IndexedDB)
17
+ * - `NodeFileStorageAdapter` (VSIX extension host / MCP — atomic JSON file)
18
+ * An in-memory IndexedDB shim (fake-indexeddb) is a TEST double, never a
19
+ * durability claim.
20
+ */
21
+
22
+ import { SSMError, type SSMErrorCode } from '../errors/SSMError.js';
23
+
24
+ /** Explicit ownership partition for a fact. All fields optional; absent = unbound. */
25
+ export interface MemoryScope {
26
+ account?: string;
27
+ tenant?: string;
28
+ project?: string;
29
+ repository?: string;
30
+ chat?: string;
31
+ }
32
+
33
+ /** The explicit "no account, never connected" partition — NOT the absence of a scope. */
34
+ export const UNBOUND_SCOPE: MemoryScope = Object.freeze({});
35
+
36
+ const SCOPE_FIELDS = ['account', 'tenant', 'project', 'repository', 'chat'] as const;
37
+
38
+ /**
39
+ * Canonical partition string for a scope. The unbound scope partitions to the
40
+ * empty string, so legacy records (written before scopes existed) land in the
41
+ * unbound partition and stay readable. Two scopes that differ in any field are
42
+ * different partitions — callers can never widen a recall by omitting a field
43
+ * unless they genuinely target the unbound partition.
44
+ */
45
+ export function scopePartitionKey(scope: MemoryScope | undefined): string {
46
+ if (!scope) return '';
47
+ const parts: string[] = [];
48
+ for (const f of SCOPE_FIELDS) {
49
+ const v = scope[f];
50
+ if (v != null && v !== '') parts.push(`${f}=${encodeURIComponent(v)}`);
51
+ }
52
+ return parts.join('|');
53
+ }
54
+
55
+ /**
56
+ * Physical record id for a (scope, key) pair. Unbound-scope ids are the bare
57
+ * key — byte-identical to pre-scope records, so existing databases migrate
58
+ * without rewriting keys.
59
+ */
60
+ export function scopedRecordId(scope: MemoryScope | undefined, key: string): string {
61
+ const partition = scopePartitionKey(scope);
62
+ return partition === '' ? key : `${partition}::${key}`;
63
+ }
64
+
65
+ /** Where a fact came from. Survives export/import so source time is never rewritten. */
66
+ export interface FactProvenance {
67
+ /** Origin surface/agent, e.g. 'vscode', 'mcp', 'web', 'cognition'. */
68
+ source?: string;
69
+ /** Snapshot/store this record was imported from. */
70
+ importedFrom?: string;
71
+ /** The timestamp the source store recorded; preserved across import (M06). */
72
+ originalTimestamp?: number;
73
+ }
74
+
75
+ export type StorageStoreName = 'facts' | 'tombstones' | 'journal' | 'meta';
76
+
77
+ /**
78
+ * Secondary index on {@link StoredFactRecord.key}. Key lookup uses this (or the
79
+ * primary key) — never a full scan — when the adapter has built it.
80
+ */
81
+ export const FACT_KEY_INDEX = 'byKey';
82
+
83
+ /**
84
+ * Secondary multi-entry index on {@link StoredFactRecord.tags}. Tag lookup uses
85
+ * this when present and falls back to a scan only when the index is missing.
86
+ */
87
+ export const FACT_TAG_INDEX = 'byTag';
88
+
89
+ /**
90
+ * The durable fact record. Extends the legacy MemoryEntry shape with identity,
91
+ * revision and sync state. `pendingSync` is only ever true when the owning
92
+ * store was created with sync enabled — without a sync target there is nothing
93
+ * to be pending FOR, and eviction must behave exactly as before (M06).
94
+ */
95
+ export interface StoredFactRecord {
96
+ /** Physical primary key: {@link scopedRecordId}. */
97
+ id: string;
98
+ /** Caller-facing key within the scope. */
99
+ key: string;
100
+ /** Ownership partition. */
101
+ scope: MemoryScope;
102
+ content: string;
103
+ /** Wall-clock write time (source time; preserved by import). */
104
+ timestamp: number;
105
+ /** Durable monotonic tiebreaker for same-millisecond writes. */
106
+ seq: number;
107
+ /** Durable revision: the store generation that last wrote this record. */
108
+ revision: number;
109
+ /** Durable identity of the operation that produced this record. */
110
+ opId: string;
111
+ ttlMs?: number;
112
+ type?: 'text' | 'json' | 'number' | 'boolean';
113
+ tags?: string[];
114
+ importance?: number;
115
+ provenance?: FactProvenance;
116
+ /** True until a sync acknowledgement clears it; such facts are NEVER evicted by caps. */
117
+ pendingSync: boolean;
118
+ }
119
+
120
+ /** A durable deletion marker. Deletes propagate through sync; tombstones prevent resurrection. */
121
+ export interface TombstoneRecord {
122
+ id: string;
123
+ key: string;
124
+ scope: MemoryScope;
125
+ deletedAt: number;
126
+ /** Store generation of the delete; always greater than the fact's last revision. */
127
+ revision: number;
128
+ opId: string;
129
+ pendingSync: boolean;
130
+ }
131
+
132
+ export type JournalOpKind = 'remember' | 'forget';
133
+
134
+ /** One durable operation in the sync journal — written atomically with its fact change. */
135
+ export interface JournalEntry {
136
+ opId: string;
137
+ kind: JournalOpKind;
138
+ id: string;
139
+ key: string;
140
+ scope: MemoryScope;
141
+ /** Store generation after this op committed. */
142
+ revision: number;
143
+ /** The revision the writer based its write on (CAS); undefined = blind write. */
144
+ baseRevision?: number;
145
+ timestamp: number;
146
+ synced: boolean;
147
+ }
148
+
149
+ /** Counters kept durably in the meta store. */
150
+ export interface MetaCounters {
151
+ key: 'counters';
152
+ /** Durable write sequence (replaces the process-local `_writeSeq`). */
153
+ seq: number;
154
+ /** Durable store generation, bumped by every committed write transaction. */
155
+ revision: number;
156
+ /** Durable operation counter; opIds are `${contextId}:${opSeq}`. */
157
+ opSeq: number;
158
+ }
159
+
160
+ /**
161
+ * The minimal surface a transaction offers. Adapters MUST keep the underlying
162
+ * transaction alive across the awaited operations (request-chained awaits only)
163
+ * and MUST NOT resolve {@link FactStoragePort.transact} before commit.
164
+ */
165
+ export interface StorageTransaction {
166
+ get<T>(store: StorageStoreName, id: string): Promise<T | undefined>;
167
+ getAll<T>(store: StorageStoreName): Promise<T[]>;
168
+ put(store: StorageStoreName, value: unknown): Promise<void>;
169
+ delete(store: StorageStoreName, id: string): Promise<void>;
170
+ /**
171
+ * Lookup via a named index. `undefined` means this store has no such index
172
+ * (legacy schema, or an adapter that has not built it) — the caller MUST
173
+ * fall back to a primary-key get or a scan. An empty array means the index
174
+ * exists and matched nothing; do not scan in that case.
175
+ */
176
+ getByIndex?<T>(store: StorageStoreName, indexName: string, query: string): Promise<T[] | undefined>;
177
+ }
178
+
179
+ /** A change notice for cross-context invalidation (M05). */
180
+ export interface ConsistencyNote {
181
+ kind: 'facts-changed';
182
+ /** Scope partition that changed; undefined = whole database. */
183
+ scope?: MemoryScope;
184
+ /** Durable generation after the change. */
185
+ revision: number;
186
+ /** Identity of the committing context; a context ignores its own notes. */
187
+ origin: string;
188
+ }
189
+
190
+ /**
191
+ * The narrow durable-storage port. One transaction = one unit of work:
192
+ * fact mutation + provenance + tombstone/event + journal commit together or
193
+ * not at all.
194
+ */
195
+ export interface FactStoragePort {
196
+ /** Which durable backend this is (for honest capability reporting). */
197
+ readonly kind: 'indexedDB' | 'node-file';
198
+ /**
199
+ * Runs `fn` inside one real transaction over the named stores. Resolves with
200
+ * `fn`'s result ONLY after the transaction has committed durably; rejects if
201
+ * the transaction aborts, `fn` throws, or the persist fails. A rejected
202
+ * transact leaves NO partial writes behind.
203
+ */
204
+ transact<T>(
205
+ stores: StorageStoreName[],
206
+ mode: 'readonly' | 'readwrite',
207
+ fn: (tx: StorageTransaction) => Promise<T> | T,
208
+ ): Promise<T>;
209
+ /**
210
+ * Publishes a change notice to other contexts sharing this storage, when the
211
+ * adapter supports cross-context notification. Optional.
212
+ */
213
+ publish?(note: ConsistencyNote): void;
214
+ /**
215
+ * Subscribes to change notices from OTHER contexts (tabs, extension windows,
216
+ * MCP processes) sharing this storage. Returns an unsubscribe function.
217
+ * Optional: an adapter without it simply offers no cross-context seam.
218
+ */
219
+ subscribe?(listener: (note: ConsistencyNote) => void): () => void;
220
+ close?(): void | Promise<void>;
221
+ }
222
+
223
+ /** Detailed error codes surfaced by the storage layer (`MemoryStorageError.storageCode`). */
224
+ export type StorageErrorCode =
225
+ /** The durable backend is unavailable or faulted. */
226
+ | 'STORAGE_UNAVAILABLE'
227
+ /** A compare-and-set write found a newer revision than its base. */
228
+ | 'STORAGE_CONFLICT'
229
+ /** A transaction aborted; no partial writes were committed. */
230
+ | 'STORAGE_ABORTED';
231
+
232
+ /**
233
+ * The public {@link SSMError} code for each storage code. Callers that predate
234
+ * the storage layer branch on `code === 'MEMORY_UNAVAILABLE'` (what MemoryStore
235
+ * always rejected with when IndexedDB was missing or faulted), so unavailable
236
+ * and aborted storage keep that code; a CAS conflict is the new `MEMORY_CONFLICT`.
237
+ */
238
+ const PUBLIC_CODE: Record<StorageErrorCode, SSMErrorCode> = {
239
+ STORAGE_UNAVAILABLE: 'MEMORY_UNAVAILABLE',
240
+ STORAGE_ABORTED : 'MEMORY_UNAVAILABLE',
241
+ STORAGE_CONFLICT : 'MEMORY_CONFLICT',
242
+ };
243
+
244
+ /**
245
+ * A storage-layer failure. It IS an {@link SSMError} whose `code` is the public
246
+ * contract (`MEMORY_UNAVAILABLE` / `MEMORY_CONFLICT`); `storageCode` keeps the
247
+ * detailed storage code for callers that need it.
248
+ */
249
+ export class MemoryStorageError extends SSMError {
250
+ readonly storageCode: StorageErrorCode;
251
+
252
+ constructor(storageCode: StorageErrorCode, message: string, cause?: unknown) {
253
+ super(PUBLIC_CODE[storageCode], message, cause);
254
+ this.storageCode = storageCode;
255
+ this.name = 'MemoryStorageError';
256
+ }
257
+ }
258
+
259
+ /** Type guard for conflict errors so callers can reconcile instead of blindly retrying. */
260
+ export function isStorageConflict(err: unknown): err is MemoryStorageError {
261
+ return err instanceof MemoryStorageError && err.storageCode === 'STORAGE_CONFLICT';
262
+ }