onbuzz 5.6.1 → 5.6.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.
@@ -1,364 +1,407 @@
1
- /**
2
- * InMemoryJsonStore — concrete VectorStore backed by a single JSON file.
3
- *
4
- * Design choices:
5
- * - Vectors held in memory as Float32Array (~3 KB each at 768 dims).
6
- * At our scale (~10K vectors per agent) that's <30 MB resident.
7
- * - Cosine similarity reduces to a dot product because the base-class
8
- * normalizes every stored + queried vector. Tight loop in V8.
9
- * - Single-file persistence with atomic write (write to `.tmp`, then
10
- * fs.rename — atomic on the same filesystem). No partial reads.
11
- * - File format is human-inspectable JSON (verbose but debuggable).
12
- * The 4× size penalty vs binary is fine until we hit 100K+ vectors,
13
- * at which point sqlite-vec is the planned drop-in.
14
- * - Substring search is plain `String.includes()` on `metadata.text`
15
- * after lowercasing both sides. No regex, matches the codebase's
16
- * "native string ops" preference.
17
- * - Fingerprint/dimension drift on load: silently empties the store
18
- * (we treat the on-disk file as garbage). The orchestrator's
19
- * responsibility is to backfill from source.
20
- *
21
- * Concurrency: not safe for multiple writers on the same file from
22
- * different processes. Loxia is single-process per agent state, so
23
- * this is fine. If we ever multiprocess, switch to sqlite-vec.
24
- */
25
-
26
- import fsp from 'fs/promises';
27
- import path from 'path';
28
- import { reciprocalRankFusion } from './storeInterface.js';
29
-
30
- const FILE_VERSION = 1;
31
-
32
- export class InMemoryJsonStore {
33
- /**
34
- * @param {object} opts
35
- * @param {string} opts.filePath - Absolute path to the .vec.json file.
36
- * @param {number} opts.dimensions - Expected vector dimensionality.
37
- * @param {string} opts.modelFingerprint - From EmbeddingProvider.getModelFingerprint().
38
- * @param {object} [opts.logger]
39
- */
40
- constructor({ filePath, dimensions, modelFingerprint, logger = null }) {
41
- if (!filePath) throw new Error('InMemoryJsonStore requires filePath');
42
- if (!Number.isInteger(dimensions) || dimensions <= 0) {
43
- throw new Error('InMemoryJsonStore requires positive integer dimensions');
44
- }
45
- if (!modelFingerprint) throw new Error('InMemoryJsonStore requires modelFingerprint');
46
-
47
- this._filePath = filePath;
48
- this._dimensions = dimensions;
49
- this._modelFingerprint = modelFingerprint;
50
- this._logger = logger;
51
-
52
- /** @type {Array<{id: string, vector: Float32Array, metadata: object}>} */
53
- this._rows = [];
54
- /** @type {Map<string, number>} id → index into _rows */
55
- this._idIndex = new Map();
56
- this._loaded = false;
57
- }
58
-
59
- /**
60
- * Load from disk. Idempotent; safe to call repeatedly. If the file
61
- * doesn't exist, or its fingerprint / dimensions don't match the
62
- * constructor args, the store starts empty (caller re-embeds).
63
- */
64
- async load() {
65
- if (this._loaded) return;
66
- this._loaded = true;
67
-
68
- let raw;
69
- try {
70
- raw = await fsp.readFile(this._filePath, 'utf8');
71
- } catch (err) {
72
- if (err?.code === 'ENOENT') return; // first run, file doesn't exist
73
- this._logger?.warn?.('[vectorStore] failed to read store; starting empty', { path: this._filePath, err: err.message });
74
- return;
75
- }
76
-
77
- let parsed;
78
- try {
79
- parsed = JSON.parse(raw);
80
- } catch (err) {
81
- this._logger?.warn?.('[vectorStore] corrupt JSON; starting empty', { path: this._filePath, err: err.message });
82
- return;
83
- }
84
-
85
- if (parsed?.version !== FILE_VERSION) {
86
- this._logger?.info?.('[vectorStore] file version mismatch; starting empty', {
87
- path: this._filePath, found: parsed?.version, expected: FILE_VERSION,
88
- });
89
- return;
90
- }
91
- if (parsed.dimensions !== this._dimensions) {
92
- this._logger?.info?.('[vectorStore] dimension mismatch; starting empty (re-embed required)', {
93
- path: this._filePath, fileDims: parsed.dimensions, expectedDims: this._dimensions,
94
- });
95
- return;
96
- }
97
- if (parsed.modelFingerprint !== this._modelFingerprint) {
98
- this._logger?.info?.('[vectorStore] fingerprint mismatch; starting empty (re-embed required)', {
99
- path: this._filePath, fileFp: parsed.modelFingerprint, expectedFp: this._modelFingerprint,
100
- });
101
- return;
102
- }
103
- if (!Array.isArray(parsed.rows)) return;
104
-
105
- for (const r of parsed.rows) {
106
- if (!r || typeof r.id !== 'string' || !Array.isArray(r.vector)) continue;
107
- if (r.vector.length !== this._dimensions) continue;
108
- this._rows.push({
109
- id: r.id,
110
- vector: Float32Array.from(r.vector),
111
- metadata: r.metadata || {},
112
- });
113
- this._idIndex.set(r.id, this._rows.length - 1);
114
- }
115
- }
116
-
117
- async stats() {
118
- let newestTs = null;
119
- for (const r of this._rows) {
120
- const ts = r.metadata?.createdAt;
121
- if (typeof ts === 'string' && (!newestTs || ts > newestTs)) {
122
- newestTs = ts;
123
- }
124
- }
125
- return {
126
- count: this._rows.length,
127
- dimensions: this._dimensions,
128
- modelFingerprint: this._modelFingerprint,
129
- lastIndexedAt: newestTs,
130
- };
131
- }
132
-
133
- async upsert(id, vector, metadata = {}) {
134
- this._assertLoaded();
135
- this._validateVector(vector);
136
- if (typeof id !== 'string' || !id) throw new Error('upsert: id must be a non-empty string');
137
-
138
- const existing = this._idIndex.get(id);
139
- if (existing !== undefined) {
140
- this._rows[existing] = { id, vector, metadata };
141
- } else {
142
- this._rows.push({ id, vector, metadata });
143
- this._idIndex.set(id, this._rows.length - 1);
144
- }
145
- await this._flush();
146
- }
147
-
148
- async upsertBatch(rows) {
149
- this._assertLoaded();
150
- if (!Array.isArray(rows)) throw new Error('upsertBatch: rows must be an array');
151
- for (const r of rows) {
152
- this._validateVector(r?.vector);
153
- if (typeof r?.id !== 'string' || !r.id) throw new Error('upsertBatch: each row needs an id');
154
- const existing = this._idIndex.get(r.id);
155
- if (existing !== undefined) {
156
- this._rows[existing] = { id: r.id, vector: r.vector, metadata: r.metadata || {} };
157
- } else {
158
- this._rows.push({ id: r.id, vector: r.vector, metadata: r.metadata || {} });
159
- this._idIndex.set(r.id, this._rows.length - 1);
160
- }
161
- }
162
- await this._flush();
163
- }
164
-
165
- async delete(id) {
166
- this._assertLoaded();
167
- const idx = this._idIndex.get(id);
168
- if (idx === undefined) return;
169
- // Swap-remove for O(1). Update the moved row's index.
170
- const last = this._rows.length - 1;
171
- if (idx !== last) {
172
- this._rows[idx] = this._rows[last];
173
- this._idIndex.set(this._rows[idx].id, idx);
174
- }
175
- this._rows.pop();
176
- this._idIndex.delete(id);
177
- await this._flush();
178
- }
179
-
180
- /**
181
- * Delete every row whose metadata satisfies `predicate`. Used when a
182
- * single source object (e.g. a conversation message) produced multiple
183
- * chunks under the convention `${sourceId}#${chunkIndex}` — calling
184
- * `delete()` per chunk would require N round-trips and N flushes.
185
- *
186
- * Returns the number of rows removed. One atomic flush at the end.
187
- *
188
- * @param {(metadata: object) => boolean} predicate
189
- * @returns {Promise<number>}
190
- */
191
- async deleteWhere(predicate) {
192
- this._assertLoaded();
193
- if (typeof predicate !== 'function') {
194
- throw new Error('deleteWhere: predicate must be a function');
195
- }
196
- let removed = 0;
197
- // Walk from the tail so swap-remove indices stay valid as we splice.
198
- for (let i = this._rows.length - 1; i >= 0; i--) {
199
- const row = this._rows[i];
200
- let match;
201
- try { match = !!predicate(row.metadata); } catch { match = false; }
202
- if (!match) continue;
203
- const last = this._rows.length - 1;
204
- if (i !== last) {
205
- this._rows[i] = this._rows[last];
206
- this._idIndex.set(this._rows[i].id, i);
207
- }
208
- this._rows.pop();
209
- this._idIndex.delete(row.id);
210
- removed++;
211
- }
212
- if (removed > 0) await this._flush();
213
- return removed;
214
- }
215
-
216
- /**
217
- * Cosine query. Vectors are normalized, so cosine = dot product.
218
- * Linear scan — fine up to ~100K rows. For larger corpora we swap
219
- * to sqlite-vec (HNSW) without changing this interface.
220
- *
221
- * @param {Float32Array} queryVector
222
- * @param {object} [opts]
223
- * @param {number} [opts.topK=10]
224
- * @param {(metadata: object) => boolean} [opts.filter]
225
- * @param {(baseScore: number, metadata: object) => number} [opts.scoring]
226
- * Optional per-row score adjuster — used by the multi-signal
227
- * ranking layer (recency + access boosts). See
228
- * `vectorStore/scoring.js`. Identity when omitted.
229
- */
230
- async query(queryVector, { topK = 10, filter = null, scoring = null } = {}) {
231
- this._assertLoaded();
232
- this._validateVector(queryVector);
233
- const adjust = typeof scoring === 'function' ? scoring : null;
234
-
235
- const scored = [];
236
- for (const row of this._rows) {
237
- if (filter && !filter(row.metadata)) continue;
238
- const base = dot(queryVector, row.vector);
239
- const score = adjust ? adjust(base, row.metadata) : base;
240
- // `score` is the RANKING signal (recency/access-adjusted). `similarity`
241
- // is the RAW cosine — preserved so callers can GATE on relevance
242
- // independently of ranking (so a recency-damped old-but-relevant row
243
- // isn't filtered out see autoRecall's relevance gate / AR3).
244
- scored.push({ id: row.id, score, similarity: base, metadata: row.metadata });
245
- }
246
- scored.sort((a, b) => b.score - a.score);
247
- return scored.slice(0, topK);
248
- }
249
-
250
- /**
251
- * Hybrid query: rank-fuse semantic results with substring matches
252
- * over `metadata.text`. RRF is used because raw scores live on
253
- * different scales (cosine vs boolean) and RRF only needs ranks.
254
- *
255
- * @param {Float32Array} queryVector
256
- * @param {string} queryText - For substring matching.
257
- * @param {object} [opts]
258
- * @param {number} [opts.topK=10]
259
- * @param {Function} [opts.filter] - Same shape as query().
260
- * @returns {Promise<Array<{id, score, metadata}>>}
261
- */
262
- async hybridQuery(queryVector, queryText, { topK = 10, filter = null, scoring = null } = {}) {
263
- this._assertLoaded();
264
- // The semantic candidate set is the one that benefits from scoring
265
- // adjustments. The substring path is rank-only, so reweighting
266
- // wouldn't affect RRF anyway.
267
- const semantic = await this.query(queryVector, { topK: topK * 4, filter, scoring });
268
- const substring = await this._substringMatches(queryText, { topK: topK * 4, filter });
269
- const fused = reciprocalRankFusion([semantic, substring], { topK });
270
- // `score` here is the RRF rank-fusion value (NOT a cosine different
271
- // scale). Carry the raw cosine `similarity` from the semantic list so
272
- // callers can still gate on relevance; substring-only hits have no
273
- // semantic similarity (undefined).
274
- const simById = new Map(semantic.map((s) => [s.id, s.similarity]));
275
- return fused.map(({ id, score }) => {
276
- const idx = this._idIndex.get(id);
277
- const row = idx !== undefined ? this._rows[idx] : null;
278
- return { id, score, similarity: simById.get(id), metadata: row?.metadata || {} };
279
- });
280
- }
281
-
282
- /** @private */
283
- async _substringMatches(queryText, { topK, filter }) {
284
- if (typeof queryText !== 'string' || !queryText) return [];
285
- const needle = queryText.toLowerCase();
286
- const matches = [];
287
- for (const row of this._rows) {
288
- if (filter && !filter(row.metadata)) continue;
289
- const haystack = String(row.metadata?.text || '').toLowerCase();
290
- if (haystack.length === 0) continue;
291
- const idx = haystack.indexOf(needle);
292
- if (idx === -1) continue;
293
- // Score = inverse position (earlier matches rank higher). Used only
294
- // for ordering inside this list — final fusion ignores raw scores.
295
- matches.push({ id: row.id, score: 1 / (1 + idx) });
296
- }
297
- matches.sort((a, b) => b.score - a.score);
298
- return matches.slice(0, topK);
299
- }
300
-
301
- async drop() {
302
- this._assertLoaded();
303
- this._rows = [];
304
- this._idIndex.clear();
305
- try {
306
- await fsp.unlink(this._filePath);
307
- } catch (err) {
308
- if (err?.code !== 'ENOENT') {
309
- this._logger?.warn?.('[vectorStore] drop(): could not unlink file', { path: this._filePath, err: err.message });
310
- }
311
- }
312
- }
313
-
314
- /**
315
- * Force a flush. Public because callers occasionally want to ensure
316
- * persistence before a critical handoff (e.g. shutdown).
317
- */
318
- async flush() {
319
- await this._flush();
320
- }
321
-
322
- /** @private */
323
- _assertLoaded() {
324
- if (!this._loaded) {
325
- throw new Error('VectorStore not loaded — call await store.load() first');
326
- }
327
- }
328
-
329
- /** @private */
330
- _validateVector(v) {
331
- if (!(v instanceof Float32Array)) {
332
- throw new Error('vector must be a Float32Array');
333
- }
334
- if (v.length !== this._dimensions) {
335
- throw new Error(`vector has wrong dimensions (got ${v.length}, expected ${this._dimensions})`);
336
- }
337
- }
338
-
339
- /** @private */
340
- async _flush() {
341
- const payload = {
342
- version: FILE_VERSION,
343
- dimensions: this._dimensions,
344
- modelFingerprint: this._modelFingerprint,
345
- savedAt: new Date().toISOString(),
346
- rows: this._rows.map(r => ({
347
- id: r.id,
348
- vector: Array.from(r.vector), // JSON can't hold typed arrays
349
- metadata: r.metadata,
350
- })),
351
- };
352
- const tmpPath = `${this._filePath}.tmp`;
353
- await fsp.mkdir(path.dirname(this._filePath), { recursive: true });
354
- await fsp.writeFile(tmpPath, JSON.stringify(payload));
355
- await fsp.rename(tmpPath, this._filePath);
356
- }
357
- }
358
-
359
- /** Dot product over two equal-length Float32Arrays. Hot path no allocation. */
360
- function dot(a, b) {
361
- let s = 0;
362
- for (let i = 0; i < a.length; i++) s += a[i] * b[i];
363
- return s;
364
- }
1
+ /**
2
+ * InMemoryJsonStore — concrete VectorStore backed by a single JSON file.
3
+ *
4
+ * Design choices:
5
+ * - Vectors held in memory as Float32Array (~3 KB each at 768 dims).
6
+ * At our scale (~10K vectors per agent) that's <30 MB resident.
7
+ * - Cosine similarity reduces to a dot product because the base-class
8
+ * normalizes every stored + queried vector. Tight loop in V8.
9
+ * - Single-file persistence with atomic write (write to `.tmp`, then
10
+ * fs.rename — atomic on the same filesystem). No partial reads.
11
+ * - File format is human-inspectable JSON (verbose but debuggable).
12
+ * The 4× size penalty vs binary is fine until we hit 100K+ vectors,
13
+ * at which point sqlite-vec is the planned drop-in.
14
+ * - Substring search is plain `String.includes()` on `metadata.text`
15
+ * after lowercasing both sides. No regex, matches the codebase's
16
+ * "native string ops" preference.
17
+ * - Fingerprint/dimension drift on load: silently empties the store
18
+ * (we treat the on-disk file as garbage). The orchestrator's
19
+ * responsibility is to backfill from source.
20
+ *
21
+ * Concurrency: not safe for multiple writers on the same file from
22
+ * different processes. Loxia is single-process per agent state, so
23
+ * this is fine. If we ever multiprocess, switch to sqlite-vec.
24
+ */
25
+
26
+ import fsp from 'fs/promises';
27
+ import path from 'path';
28
+ import { reciprocalRankFusion } from './storeInterface.js';
29
+
30
+ const FILE_VERSION = 1;
31
+
32
+ export class InMemoryJsonStore {
33
+ /**
34
+ * @param {object} opts
35
+ * @param {string} opts.filePath - Absolute path to the .vec.json file.
36
+ * @param {number} opts.dimensions - Expected vector dimensionality.
37
+ * @param {string} opts.modelFingerprint - From EmbeddingProvider.getModelFingerprint().
38
+ * @param {object} [opts.logger]
39
+ */
40
+ constructor({ filePath, dimensions, modelFingerprint, logger = null }) {
41
+ if (!filePath) throw new Error('InMemoryJsonStore requires filePath');
42
+ if (!Number.isInteger(dimensions) || dimensions <= 0) {
43
+ throw new Error('InMemoryJsonStore requires positive integer dimensions');
44
+ }
45
+ if (!modelFingerprint) throw new Error('InMemoryJsonStore requires modelFingerprint');
46
+
47
+ this._filePath = filePath;
48
+ this._dimensions = dimensions;
49
+ this._modelFingerprint = modelFingerprint;
50
+ this._logger = logger;
51
+
52
+ /** @type {Array<{id: string, vector: Float32Array, metadata: object}>} */
53
+ this._rows = [];
54
+ /** @type {Map<string, number>} id → index into _rows */
55
+ this._idIndex = new Map();
56
+ this._loaded = false;
57
+ // Snapshot writes slower than this (stringify time) warn, rate-limited —
58
+ // stall attribution for the "event-loop p99 in the seconds" incident.
59
+ this._slowWriteMs = 250;
60
+ }
61
+
62
+ /**
63
+ * Load from disk. Idempotent; safe to call repeatedly. If the file
64
+ * doesn't exist, or its fingerprint / dimensions don't match the
65
+ * constructor args, the store starts empty (caller re-embeds).
66
+ */
67
+ async load() {
68
+ if (this._loaded) return;
69
+ this._loaded = true;
70
+
71
+ let raw;
72
+ try {
73
+ raw = await fsp.readFile(this._filePath, 'utf8');
74
+ } catch (err) {
75
+ if (err?.code === 'ENOENT') return; // first run, file doesn't exist
76
+ this._logger?.warn?.('[vectorStore] failed to read store; starting empty', { path: this._filePath, err: err.message });
77
+ return;
78
+ }
79
+
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(raw);
83
+ } catch (err) {
84
+ this._logger?.warn?.('[vectorStore] corrupt JSON; starting empty', { path: this._filePath, err: err.message });
85
+ return;
86
+ }
87
+
88
+ if (parsed?.version !== FILE_VERSION) {
89
+ this._logger?.info?.('[vectorStore] file version mismatch; starting empty', {
90
+ path: this._filePath, found: parsed?.version, expected: FILE_VERSION,
91
+ });
92
+ return;
93
+ }
94
+ if (parsed.dimensions !== this._dimensions) {
95
+ this._logger?.info?.('[vectorStore] dimension mismatch; starting empty (re-embed required)', {
96
+ path: this._filePath, fileDims: parsed.dimensions, expectedDims: this._dimensions,
97
+ });
98
+ return;
99
+ }
100
+ if (parsed.modelFingerprint !== this._modelFingerprint) {
101
+ this._logger?.info?.('[vectorStore] fingerprint mismatch; starting empty (re-embed required)', {
102
+ path: this._filePath, fileFp: parsed.modelFingerprint, expectedFp: this._modelFingerprint,
103
+ });
104
+ return;
105
+ }
106
+ if (!Array.isArray(parsed.rows)) return;
107
+
108
+ for (const r of parsed.rows) {
109
+ if (!r || typeof r.id !== 'string' || !Array.isArray(r.vector)) continue;
110
+ if (r.vector.length !== this._dimensions) continue;
111
+ this._rows.push({
112
+ id: r.id,
113
+ vector: Float32Array.from(r.vector),
114
+ metadata: r.metadata || {},
115
+ });
116
+ this._idIndex.set(r.id, this._rows.length - 1);
117
+ }
118
+ }
119
+
120
+ async stats() {
121
+ let newestTs = null;
122
+ for (const r of this._rows) {
123
+ const ts = r.metadata?.createdAt;
124
+ if (typeof ts === 'string' && (!newestTs || ts > newestTs)) {
125
+ newestTs = ts;
126
+ }
127
+ }
128
+ return {
129
+ count: this._rows.length,
130
+ dimensions: this._dimensions,
131
+ modelFingerprint: this._modelFingerprint,
132
+ lastIndexedAt: newestTs,
133
+ };
134
+ }
135
+
136
+ async upsert(id, vector, metadata = {}) {
137
+ this._assertLoaded();
138
+ this._validateVector(vector);
139
+ if (typeof id !== 'string' || !id) throw new Error('upsert: id must be a non-empty string');
140
+
141
+ const existing = this._idIndex.get(id);
142
+ if (existing !== undefined) {
143
+ this._rows[existing] = { id, vector, metadata };
144
+ } else {
145
+ this._rows.push({ id, vector, metadata });
146
+ this._idIndex.set(id, this._rows.length - 1);
147
+ }
148
+ await this._flush();
149
+ }
150
+
151
+ async upsertBatch(rows) {
152
+ this._assertLoaded();
153
+ if (!Array.isArray(rows)) throw new Error('upsertBatch: rows must be an array');
154
+ for (const r of rows) {
155
+ this._validateVector(r?.vector);
156
+ if (typeof r?.id !== 'string' || !r.id) throw new Error('upsertBatch: each row needs an id');
157
+ const existing = this._idIndex.get(r.id);
158
+ if (existing !== undefined) {
159
+ this._rows[existing] = { id: r.id, vector: r.vector, metadata: r.metadata || {} };
160
+ } else {
161
+ this._rows.push({ id: r.id, vector: r.vector, metadata: r.metadata || {} });
162
+ this._idIndex.set(r.id, this._rows.length - 1);
163
+ }
164
+ }
165
+ await this._flush();
166
+ }
167
+
168
+ async delete(id) {
169
+ this._assertLoaded();
170
+ const idx = this._idIndex.get(id);
171
+ if (idx === undefined) return;
172
+ // Swap-remove for O(1). Update the moved row's index.
173
+ const last = this._rows.length - 1;
174
+ if (idx !== last) {
175
+ this._rows[idx] = this._rows[last];
176
+ this._idIndex.set(this._rows[idx].id, idx);
177
+ }
178
+ this._rows.pop();
179
+ this._idIndex.delete(id);
180
+ await this._flush();
181
+ }
182
+
183
+ /**
184
+ * Delete every row whose metadata satisfies `predicate`. Used when a
185
+ * single source object (e.g. a conversation message) produced multiple
186
+ * chunks under the convention `${sourceId}#${chunkIndex}` calling
187
+ * `delete()` per chunk would require N round-trips and N flushes.
188
+ *
189
+ * Returns the number of rows removed. One atomic flush at the end.
190
+ *
191
+ * @param {(metadata: object) => boolean} predicate
192
+ * @returns {Promise<number>}
193
+ */
194
+ async deleteWhere(predicate) {
195
+ this._assertLoaded();
196
+ if (typeof predicate !== 'function') {
197
+ throw new Error('deleteWhere: predicate must be a function');
198
+ }
199
+ let removed = 0;
200
+ // Walk from the tail so swap-remove indices stay valid as we splice.
201
+ for (let i = this._rows.length - 1; i >= 0; i--) {
202
+ const row = this._rows[i];
203
+ let match;
204
+ try { match = !!predicate(row.metadata); } catch { match = false; }
205
+ if (!match) continue;
206
+ const last = this._rows.length - 1;
207
+ if (i !== last) {
208
+ this._rows[i] = this._rows[last];
209
+ this._idIndex.set(this._rows[i].id, i);
210
+ }
211
+ this._rows.pop();
212
+ this._idIndex.delete(row.id);
213
+ removed++;
214
+ }
215
+ if (removed > 0) await this._flush();
216
+ return removed;
217
+ }
218
+
219
+ /**
220
+ * Cosine query. Vectors are normalized, so cosine = dot product.
221
+ * Linear scan — fine up to ~100K rows. For larger corpora we swap
222
+ * to sqlite-vec (HNSW) without changing this interface.
223
+ *
224
+ * @param {Float32Array} queryVector
225
+ * @param {object} [opts]
226
+ * @param {number} [opts.topK=10]
227
+ * @param {(metadata: object) => boolean} [opts.filter]
228
+ * @param {(baseScore: number, metadata: object) => number} [opts.scoring]
229
+ * Optional per-row score adjuster — used by the multi-signal
230
+ * ranking layer (recency + access boosts). See
231
+ * `vectorStore/scoring.js`. Identity when omitted.
232
+ */
233
+ async query(queryVector, { topK = 10, filter = null, scoring = null } = {}) {
234
+ this._assertLoaded();
235
+ this._validateVector(queryVector);
236
+ const adjust = typeof scoring === 'function' ? scoring : null;
237
+
238
+ const scored = [];
239
+ for (const row of this._rows) {
240
+ if (filter && !filter(row.metadata)) continue;
241
+ const base = dot(queryVector, row.vector);
242
+ const score = adjust ? adjust(base, row.metadata) : base;
243
+ // `score` is the RANKING signal (recency/access-adjusted). `similarity`
244
+ // is the RAW cosine preserved so callers can GATE on relevance
245
+ // independently of ranking (so a recency-damped old-but-relevant row
246
+ // isn't filtered out see autoRecall's relevance gate / AR3).
247
+ scored.push({ id: row.id, score, similarity: base, metadata: row.metadata });
248
+ }
249
+ scored.sort((a, b) => b.score - a.score);
250
+ return scored.slice(0, topK);
251
+ }
252
+
253
+ /**
254
+ * Hybrid query: rank-fuse semantic results with substring matches
255
+ * over `metadata.text`. RRF is used because raw scores live on
256
+ * different scales (cosine vs boolean) and RRF only needs ranks.
257
+ *
258
+ * @param {Float32Array} queryVector
259
+ * @param {string} queryText - For substring matching.
260
+ * @param {object} [opts]
261
+ * @param {number} [opts.topK=10]
262
+ * @param {Function} [opts.filter] - Same shape as query().
263
+ * @returns {Promise<Array<{id, score, metadata}>>}
264
+ */
265
+ async hybridQuery(queryVector, queryText, { topK = 10, filter = null, scoring = null } = {}) {
266
+ this._assertLoaded();
267
+ // The semantic candidate set is the one that benefits from scoring
268
+ // adjustments. The substring path is rank-only, so reweighting
269
+ // wouldn't affect RRF anyway.
270
+ const semantic = await this.query(queryVector, { topK: topK * 4, filter, scoring });
271
+ const substring = await this._substringMatches(queryText, { topK: topK * 4, filter });
272
+ const fused = reciprocalRankFusion([semantic, substring], { topK });
273
+ // `score` here is the RRF rank-fusion value (NOT a cosine — different
274
+ // scale). Carry the raw cosine `similarity` from the semantic list so
275
+ // callers can still gate on relevance; substring-only hits have no
276
+ // semantic similarity (undefined).
277
+ const simById = new Map(semantic.map((s) => [s.id, s.similarity]));
278
+ return fused.map(({ id, score }) => {
279
+ const idx = this._idIndex.get(id);
280
+ const row = idx !== undefined ? this._rows[idx] : null;
281
+ return { id, score, similarity: simById.get(id), metadata: row?.metadata || {} };
282
+ });
283
+ }
284
+
285
+ /** @private */
286
+ async _substringMatches(queryText, { topK, filter }) {
287
+ if (typeof queryText !== 'string' || !queryText) return [];
288
+ const needle = queryText.toLowerCase();
289
+ const matches = [];
290
+ for (const row of this._rows) {
291
+ if (filter && !filter(row.metadata)) continue;
292
+ const haystack = String(row.metadata?.text || '').toLowerCase();
293
+ if (haystack.length === 0) continue;
294
+ const idx = haystack.indexOf(needle);
295
+ if (idx === -1) continue;
296
+ // Score = inverse position (earlier matches rank higher). Used only
297
+ // for ordering inside this list — final fusion ignores raw scores.
298
+ matches.push({ id: row.id, score: 1 / (1 + idx) });
299
+ }
300
+ matches.sort((a, b) => b.score - a.score);
301
+ return matches.slice(0, topK);
302
+ }
303
+
304
+ async drop() {
305
+ this._assertLoaded();
306
+ this._rows = [];
307
+ this._idIndex.clear();
308
+ try {
309
+ await fsp.unlink(this._filePath);
310
+ } catch (err) {
311
+ if (err?.code !== 'ENOENT') {
312
+ this._logger?.warn?.('[vectorStore] drop(): could not unlink file', { path: this._filePath, err: err.message });
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Force a flush. Public because callers occasionally want to ensure
319
+ * persistence before a critical handoff (e.g. shutdown).
320
+ */
321
+ async flush() {
322
+ await this._flush();
323
+ }
324
+
325
+ /** @private */
326
+ _assertLoaded() {
327
+ if (!this._loaded) {
328
+ throw new Error('VectorStore not loaded — call await store.load() first');
329
+ }
330
+ }
331
+
332
+ /** @private */
333
+ _validateVector(v) {
334
+ if (!(v instanceof Float32Array)) {
335
+ throw new Error('vector must be a Float32Array');
336
+ }
337
+ if (v.length !== this._dimensions) {
338
+ throw new Error(`vector has wrong dimensions (got ${v.length}, expected ${this._dimensions})`);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Serialized flush. Concurrent mutators used to race the SAME tmp path:
344
+ * both wrote `<file>.tmp`, the first rename consumed it, and the second
345
+ * rename failed ENOENT (real mac incident — reminisce.vec.json.tmp,
346
+ * repeating every indexer tick). Same class as the saveJSON corruption:
347
+ * the cure is chaining every flush behind the previous one. Each caller
348
+ * still awaits (and sees the failure of) ITS OWN flush; a failed link
349
+ * never poisons later flushes. The body reads `this._rows` when its turn
350
+ * comes, so a chained flush can only write NEWER state — harmless.
351
+ * @private
352
+ */
353
+ _flush() {
354
+ // Coalesce: a link that is QUEUED but not yet started will write the
355
+ // LATEST state anyway (the body snapshots at its turn), so piggyback on
356
+ // it instead of queueing another full-file serialization behind it. A
357
+ // burst of N mutations costs at most 2 writes (the running one + one
358
+ // queued), not N — each write is an O(file) synchronous stringify, and
359
+ // large reminisce stores made that an event-loop stall per mutation.
360
+ if (this._flushQueued) return this._flushQueued;
361
+ const write = () => {
362
+ this._flushQueued = null; // started later mutations need a new link
363
+ return this._writeSnapshot();
364
+ };
365
+ const p = (this._flushTail ?? Promise.resolve()).then(write, write);
366
+ this._flushQueued = p;
367
+ this._flushTail = p.catch(() => {});
368
+ return p;
369
+ }
370
+
371
+ /** @private the actual atomic tmp+rename write — only via _flush() */
372
+ async _writeSnapshot() {
373
+ const payload = {
374
+ version: FILE_VERSION,
375
+ dimensions: this._dimensions,
376
+ modelFingerprint: this._modelFingerprint,
377
+ savedAt: new Date().toISOString(),
378
+ rows: this._rows.map(r => ({
379
+ id: r.id,
380
+ vector: Array.from(r.vector), // JSON can't hold typed arrays
381
+ metadata: r.metadata,
382
+ })),
383
+ };
384
+ const t0 = Date.now();
385
+ const json = JSON.stringify(payload);
386
+ const stringifyMs = Date.now() - t0;
387
+ // Stall attribution (rate-limited): big stores stringify for hundreds of
388
+ // ms SYNCHRONOUSLY — production logs should name the culprit file.
389
+ if (stringifyMs >= this._slowWriteMs && Date.now() - (this._slowWarnAt || 0) >= 60_000) {
390
+ this._slowWarnAt = Date.now();
391
+ this._logger?.warn?.('[vectorStore] SLOW snapshot serialization — event-loop stall', {
392
+ path: this._filePath, bytes: json.length, rows: this._rows.length, stringifyMs,
393
+ });
394
+ }
395
+ const tmpPath = `${this._filePath}.tmp`;
396
+ await fsp.mkdir(path.dirname(this._filePath), { recursive: true });
397
+ await fsp.writeFile(tmpPath, json);
398
+ await fsp.rename(tmpPath, this._filePath);
399
+ }
400
+ }
401
+
402
+ /** Dot product over two equal-length Float32Arrays. Hot path — no allocation. */
403
+ function dot(a, b) {
404
+ let s = 0;
405
+ for (let i = 0; i < a.length; i++) s += a[i] * b[i];
406
+ return s;
407
+ }