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.
- package/config/default.json +105 -0
- package/package.json +3 -1
- package/scripts/swap-brand.js +30 -5
- package/src/core/__tests__/stateManager.atomicWrite.test.js +41 -0
- package/src/core/stateManager.js +35 -1
- package/src/interfaces/webServer.js +20 -6
- package/src/services/embeddings/vectorStore/__tests__/inMemoryJsonStore.flushRace.test.js +336 -0
- package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +407 -364
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
this.
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
if (idx
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* @param {
|
|
225
|
-
* @param {
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* @param {
|
|
259
|
-
* @param {
|
|
260
|
-
* @
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (v
|
|
335
|
-
throw new Error(
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
+
}
|