@fgv/ts-agent-memory-sqlite-vec 5.1.0-42

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 (71) hide show
  1. package/.rush/temp/689b960e7e1a3b63bd68fbb91474d5c28d567861.tar.log +60 -0
  2. package/.rush/temp/chunked-rush-logs/ts-agent-memory-sqlite-vec.build.chunks.jsonl +9 -0
  3. package/.rush/temp/operation/build/all.log +9 -0
  4. package/.rush/temp/operation/build/log-chunks.jsonl +9 -0
  5. package/.rush/temp/operation/build/state.json +3 -0
  6. package/.rush/temp/shrinkwrap-deps.json +720 -0
  7. package/README.md +109 -0
  8. package/config/api-extractor.json +38 -0
  9. package/config/jest.config.json +13 -0
  10. package/config/rig.json +6 -0
  11. package/dist/index.js +6 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/packlets/sqlite-vec-index/index.js +8 -0
  14. package/dist/packlets/sqlite-vec-index/index.js.map +1 -0
  15. package/dist/packlets/sqlite-vec-index/model.js +6 -0
  16. package/dist/packlets/sqlite-vec-index/model.js.map +1 -0
  17. package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +277 -0
  18. package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -0
  19. package/dist/packlets/sqlite-vec-index/sqliteVecVectorIndex.js +182 -0
  20. package/dist/packlets/sqlite-vec-index/sqliteVecVectorIndex.js.map +1 -0
  21. package/dist/test/unit/sqliteVecFragmentIndex.test.js +352 -0
  22. package/dist/test/unit/sqliteVecFragmentIndex.test.js.map +1 -0
  23. package/dist/test/unit/sqliteVecVectorIndex.test.js +199 -0
  24. package/dist/test/unit/sqliteVecVectorIndex.test.js.map +1 -0
  25. package/dist/ts-agent-memory-sqlite-vec.d.ts +225 -0
  26. package/dist/tsdoc-metadata.json +11 -0
  27. package/eslint.config.js +15 -0
  28. package/etc/ts-agent-memory-sqlite-vec.api.md +66 -0
  29. package/lib/index.d.ts +2 -0
  30. package/lib/index.d.ts.map +1 -0
  31. package/lib/index.js +22 -0
  32. package/lib/index.js.map +1 -0
  33. package/lib/packlets/sqlite-vec-index/index.d.ts +4 -0
  34. package/lib/packlets/sqlite-vec-index/index.d.ts.map +1 -0
  35. package/lib/packlets/sqlite-vec-index/index.js +24 -0
  36. package/lib/packlets/sqlite-vec-index/index.js.map +1 -0
  37. package/lib/packlets/sqlite-vec-index/model.d.ts +48 -0
  38. package/lib/packlets/sqlite-vec-index/model.d.ts.map +1 -0
  39. package/lib/packlets/sqlite-vec-index/model.js +7 -0
  40. package/lib/packlets/sqlite-vec-index/model.js.map +1 -0
  41. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts +94 -0
  42. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts.map +1 -0
  43. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +281 -0
  44. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -0
  45. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.d.ts +80 -0
  46. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.d.ts.map +1 -0
  47. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.js +186 -0
  48. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.js.map +1 -0
  49. package/lib/test/unit/sqliteVecFragmentIndex.test.d.ts +2 -0
  50. package/lib/test/unit/sqliteVecFragmentIndex.test.d.ts.map +1 -0
  51. package/lib/test/unit/sqliteVecFragmentIndex.test.js +390 -0
  52. package/lib/test/unit/sqliteVecFragmentIndex.test.js.map +1 -0
  53. package/lib/test/unit/sqliteVecVectorIndex.test.d.ts +2 -0
  54. package/lib/test/unit/sqliteVecVectorIndex.test.d.ts.map +1 -0
  55. package/lib/test/unit/sqliteVecVectorIndex.test.js +237 -0
  56. package/lib/test/unit/sqliteVecVectorIndex.test.js.map +1 -0
  57. package/package.json +87 -0
  58. package/rush-logs/ts-agent-memory-sqlite-vec.build.cache.log +3 -0
  59. package/rush-logs/ts-agent-memory-sqlite-vec.build.log +9 -0
  60. package/src/index.ts +6 -0
  61. package/src/packlets/sqlite-vec-index/index.ts +8 -0
  62. package/src/packlets/sqlite-vec-index/model.ts +56 -0
  63. package/src/packlets/sqlite-vec-index/sqliteVecFragmentIndex.ts +381 -0
  64. package/src/packlets/sqlite-vec-index/sqliteVecVectorIndex.ts +255 -0
  65. package/src/test/unit/sqliteVecFragmentIndex.test.ts +466 -0
  66. package/src/test/unit/sqliteVecVectorIndex.test.ts +253 -0
  67. package/temp/build/lint/_eslint-5eVG3S6w.json +34 -0
  68. package/temp/build/typescript/ts_8nwakTlr.json +1 -0
  69. package/temp/ts-agent-memory-sqlite-vec.api.json +1167 -0
  70. package/temp/ts-agent-memory-sqlite-vec.api.md +66 -0
  71. package/tsconfig.json +8 -0
@@ -0,0 +1,381 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import type BetterSqlite3 from 'better-sqlite3';
7
+ import { load as loadSqliteVec } from 'sqlite-vec';
8
+ import { Result, captureResult, fail, succeed } from '@fgv/ts-utils';
9
+ import {
10
+ IEdgeTarget,
11
+ IEmbeddedFragment,
12
+ IFragmentVectorIndex,
13
+ IVectorQueryHit,
14
+ MemoryId,
15
+ MemoryScopeKey,
16
+ edgeTargetKey
17
+ } from '@fgv/ts-agent-memory';
18
+ import { ISqliteVecFragmentIndexCreateParams } from './model';
19
+
20
+ /** Default name for the fragment `vec0` virtual table. */
21
+ const DEFAULT_TABLE_NAME: string = 'memory_fragments';
22
+
23
+ /** A simple SQL identifier — the only shape allowed for the table name (it is interpolated into DDL). */
24
+ const IDENTIFIER_RE: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/;
25
+
26
+ /**
27
+ * One KNN row as returned by the fragment `vec0` MATCH query. The offset columns are
28
+ * typed `number | bigint` because `better-sqlite3` returns integer columns as
29
+ * `bigint` when a consumer enables its safe-integer mode (`defaultSafeIntegers`);
30
+ * {@link SqliteVecFragmentIndex._toOffset} coerces them to a plain `number` (and
31
+ * fails loudly on an out-of-safe-range value) before they reach the public locator.
32
+ */
33
+ interface IKnnRow {
34
+ readonly target_key: string;
35
+ readonly start_off: number | bigint;
36
+ readonly end_off: number | bigint;
37
+ readonly distance: number;
38
+ }
39
+
40
+ /**
41
+ * A persistent, `sqlite-vec`-backed `IFragmentVectorIndex` (from
42
+ * `@fgv/ts-agent-memory`) — the fragment-granular sibling of
43
+ * {@link SqliteVecVectorIndex}, and the **durable** counterpart to the in-memory
44
+ * `InMemoryFragmentCosineIndex`.
45
+ *
46
+ * @remarks
47
+ * Where {@link SqliteVecVectorIndex} keys one vector per record on a
48
+ * `target_key` primary key, this index holds **many** vectors per record — one per
49
+ * in-record `[start, end)` span — so it keys the `vec0` table on `target_key` as a
50
+ * **`PARTITION KEY`** (many rows may share it) and stores each fragment's locator
51
+ * offsets in two auxiliary columns (`+start_off`, `+end_off`) that ride alongside
52
+ * the vector and are returned on query but never filtered. A query is a brute-force
53
+ * `vec0` KNN scan across all partitions returning per-fragment hits, each carrying
54
+ * its record `target` and the matched `locator`.
55
+ *
56
+ * Semantics match `InMemoryFragmentCosineIndex` exactly: `addFragments` is
57
+ * whole-record-replace (a single transaction deletes every prior fragment of the
58
+ * target, then inserts the new set), `remove` drops every fragment of a target,
59
+ * and `query` applies the optional `maxPerRecord` cap **during selection, before
60
+ * the topK cut** — so one long document cannot crowd others out. The dimension is
61
+ * established by the first `addFragments` (the `vec0` column is fixed-width) and
62
+ * recovered from the table schema when a persistent file is reopened; similarity is
63
+ * cosine (`score = 1 - cosineDistance`), byte-identical to the in-memory index.
64
+ * Large-N ANN indexing is explicitly out of scope, same regime as the record index.
65
+ *
66
+ * The `better-sqlite3` `Database` is consumer-owned (bring-your-own): this index
67
+ * loads the `sqlite-vec` extension onto it and reads/writes the table, but never
68
+ * opens or closes the connection.
69
+ * @public
70
+ */
71
+ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
72
+ private readonly _db: BetterSqlite3.Database;
73
+ private readonly _table: string;
74
+ /** The dimension of every stored fragment vector; `undefined` until the table exists. */
75
+ private _dimension: number | undefined;
76
+ /** Prepared statements; created once the table exists (established or recovered). */
77
+ private _stmts: IFragmentStatements | undefined;
78
+
79
+ private constructor(db: BetterSqlite3.Database, table: string, dimension: number | undefined) {
80
+ this._db = db;
81
+ this._table = table;
82
+ this._dimension = dimension;
83
+ this._stmts = dimension === undefined ? undefined : this._prepare();
84
+ }
85
+
86
+ /** The number of records that currently have at least one stored fragment. Zero before the first add. */
87
+ public get recordCount(): number {
88
+ if (this._stmts === undefined) {
89
+ return 0;
90
+ }
91
+ // `Number(...)` narrows the count in case the consumer enabled better-sqlite3
92
+ // safe-integer mode (which returns `count(*)` as a `bigint`).
93
+ return Number((this._stmts.recordCount.get() as { c: number | bigint }).c);
94
+ }
95
+
96
+ /** The total number of fragments currently held across all records. Zero before the first add. */
97
+ public get fragmentCount(): number {
98
+ if (this._stmts === undefined) {
99
+ return 0;
100
+ }
101
+ return Number((this._stmts.fragmentCount.get() as { c: number | bigint }).c);
102
+ }
103
+
104
+ /**
105
+ * Family-convention factory. Loads the `sqlite-vec` extension onto the supplied
106
+ * `better-sqlite3` connection and, if the fragment table already exists (a
107
+ * reopened persistent file), recovers its established dimension so no
108
+ * re-embedding is needed on open.
109
+ *
110
+ * @param params - See {@link ISqliteVecFragmentIndexCreateParams}.
111
+ * @returns `Success` with the index, or `Failure` if the table name is not a
112
+ * simple identifier or the extension fails to load.
113
+ */
114
+ public static create(params: ISqliteVecFragmentIndexCreateParams): Promise<Result<SqliteVecFragmentIndex>> {
115
+ const table: string = params.tableName ?? DEFAULT_TABLE_NAME;
116
+ if (!IDENTIFIER_RE.test(table)) {
117
+ return Promise.resolve(
118
+ fail(`sqlite-vec fragment index: table name '${table}' is not a simple SQL identifier`)
119
+ );
120
+ }
121
+ return Promise.resolve(
122
+ captureResult(() => {
123
+ loadSqliteVec(params.database);
124
+ const dimension: number | undefined = SqliteVecFragmentIndex._readExistingDimension(
125
+ params.database,
126
+ table
127
+ );
128
+ return new SqliteVecFragmentIndex(params.database, table, dimension);
129
+ }).withErrorFormat((e) => `sqlite-vec fragment index: failed to initialize: ${e}`)
130
+ );
131
+ }
132
+
133
+ /** {@inheritDoc IFragmentVectorIndex.addFragments} */
134
+ public addFragments(
135
+ target: IEdgeTarget,
136
+ fragments: ReadonlyArray<IEmbeddedFragment>
137
+ ): Promise<Result<number>> {
138
+ const key: string = edgeTargetKey(target);
139
+ // Validate every fragment before touching the database, so a bad fragment never
140
+ // leaves the record half-replaced or the dimension half-established (whole-record
141
+ // replace is all-or-nothing). The effective dimension is the established one, or —
142
+ // on a still-dimensionless index — the first fragment's length; it is committed
143
+ // (via table creation) only once the whole batch validates.
144
+ let dimension: number | undefined = this._dimension;
145
+ for (const fragment of fragments) {
146
+ if (fragment.vector.length === 0) {
147
+ return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
148
+ }
149
+ // Locator offsets are persisted as SQLite integers (bound via BigInt). Reject a
150
+ // non-safe-integer offset up front with a clear message, rather than letting
151
+ // `BigInt(nonInteger)` throw cryptically inside the write transaction OR storing
152
+ // a value the read-side `_toOffset` guard would later reject on every query.
153
+ if (!Number.isSafeInteger(fragment.locator.start) || !Number.isSafeInteger(fragment.locator.end)) {
154
+ return Promise.resolve(
155
+ fail(
156
+ `fragment index: cannot add '${key}': locator [${fragment.locator.start}, ${fragment.locator.end}) offsets must be safe integers`
157
+ )
158
+ );
159
+ }
160
+ if (dimension === undefined) {
161
+ dimension = fragment.vector.length;
162
+ } else if (fragment.vector.length !== dimension) {
163
+ return Promise.resolve(
164
+ fail(
165
+ `fragment index: cannot add '${key}': fragment dimension ${fragment.vector.length} does not match index dimension ${dimension}`
166
+ )
167
+ );
168
+ }
169
+ }
170
+ return Promise.resolve(
171
+ captureResult(() => {
172
+ // A same-target re-author (or an empty batch) still needs the table to exist
173
+ // to delete prior fragments; create it lazily on the first non-empty add.
174
+ if (this._stmts === undefined) {
175
+ if (fragments.length === 0) {
176
+ // Nothing stored yet and nothing to store: no table, no work.
177
+ return 0;
178
+ }
179
+ // `fragments` is non-empty here (the empty case returned above), so the
180
+ // validation loop proved every fragment shares `fragments[0]`'s length —
181
+ // which IS the dimension to establish. Read it straight from the first
182
+ // fragment: no cast, no invariant-dependent narrowing.
183
+ const established: number = fragments[0].vector.length;
184
+ this._createTable(established);
185
+ this._dimension = established;
186
+ this._stmts = this._prepare();
187
+ }
188
+ this._stmts.replace(key, fragments);
189
+ return fragments.length;
190
+ }).withErrorFormat((e) => `fragment index: cannot add '${key}': ${e}`)
191
+ );
192
+ }
193
+
194
+ /** {@inheritDoc IFragmentVectorIndex.remove} */
195
+ public remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {
196
+ return Promise.resolve(
197
+ captureResult(() => {
198
+ // Idempotent: removing a target with no fragments (or before any add created
199
+ // the table) still succeeds.
200
+ if (this._stmts !== undefined) {
201
+ this._stmts.deleteByTarget.run(edgeTargetKey(target));
202
+ }
203
+ return target;
204
+ }).withErrorFormat((e) => `fragment index: cannot remove '${edgeTargetKey(target)}': ${e}`)
205
+ );
206
+ }
207
+
208
+ /** {@inheritDoc IFragmentVectorIndex.query} */
209
+ public query(
210
+ vector: Float32Array,
211
+ topK: number,
212
+ maxPerRecord?: number
213
+ ): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
214
+ if (topK <= 0 || this._stmts === undefined) {
215
+ return Promise.resolve(succeed([]));
216
+ }
217
+ if (vector.length !== this._dimension) {
218
+ return Promise.resolve(
219
+ fail(
220
+ `fragment index: query dimension ${vector.length} does not match index dimension ${this._dimension}`
221
+ )
222
+ );
223
+ }
224
+ const stmts: IFragmentStatements = this._stmts;
225
+ return Promise.resolve(
226
+ captureResult<ReadonlyArray<IVectorQueryHit>>(() => {
227
+ // With a per-record cap the topK winners may lie past the first topK rows (a
228
+ // capped record's later fragments are skipped), so fetch the full ranked set
229
+ // and apply the cap + topK cut here — exactly as the in-memory index does.
230
+ // Uncapped, KNN's own `k = topK` is already the answer.
231
+ const fetchK: number =
232
+ maxPerRecord === undefined ? topK : Number((stmts.fragmentCount.get() as { c: number | bigint }).c);
233
+ if (fetchK <= 0) {
234
+ return [];
235
+ }
236
+ const rows: ReadonlyArray<IKnnRow> = stmts.query.all(
237
+ SqliteVecFragmentIndex._toBlob(vector),
238
+ fetchK
239
+ ) as ReadonlyArray<IKnnRow>;
240
+ // sqlite-vec returns rows ascending by distance (nearest first); score is
241
+ // `1 - cosineDistance`, so this order is already descending score.
242
+ const hits: IVectorQueryHit[] = [];
243
+ const perRecord: Map<string, number> = new Map<string, number>();
244
+ for (const row of rows) {
245
+ if (hits.length >= topK) {
246
+ break;
247
+ }
248
+ if (maxPerRecord !== undefined) {
249
+ const used: number = perRecord.get(row.target_key) ?? 0;
250
+ if (used >= maxPerRecord) {
251
+ continue;
252
+ }
253
+ perRecord.set(row.target_key, used + 1);
254
+ }
255
+ const key: string = row.target_key;
256
+ hits.push({
257
+ target: SqliteVecFragmentIndex._parseKey(key),
258
+ score: 1 - row.distance,
259
+ locator: {
260
+ start: SqliteVecFragmentIndex._toOffset(row.start_off, key),
261
+ end: SqliteVecFragmentIndex._toOffset(row.end_off, key)
262
+ }
263
+ });
264
+ }
265
+ return hits;
266
+ }).withErrorFormat((e) => `fragment index: query failed: ${e}`)
267
+ );
268
+ }
269
+
270
+ /** Create the fragment `vec0` virtual table with the established dimension. */
271
+ private _createTable(dimension: number): void {
272
+ this._db.exec(
273
+ `CREATE VIRTUAL TABLE IF NOT EXISTS "${this._table}" USING vec0(` +
274
+ `target_key TEXT PARTITION KEY, embedding float[${dimension}] distance_metric=cosine, ` +
275
+ `+start_off integer, +end_off integer)`
276
+ );
277
+ }
278
+
279
+ /** Prepare the statements the index reuses. Requires the table to exist. */
280
+ private _prepare(): IFragmentStatements {
281
+ const del: BetterSqlite3.Statement = this._db.prepare(
282
+ `DELETE FROM "${this._table}" WHERE target_key = ?`
283
+ );
284
+ const ins: BetterSqlite3.Statement = this._db.prepare(
285
+ `INSERT INTO "${this._table}"(target_key, embedding, start_off, end_off) VALUES (?, ?, ?, ?)`
286
+ );
287
+ // Whole-record replace: drop every prior fragment of the target, then insert the
288
+ // new set, atomically. An empty set collapses to a pure delete.
289
+ const replaceTxn: BetterSqlite3.Transaction<
290
+ (key: string, fragments: ReadonlyArray<IEmbeddedFragment>) => void
291
+ > = this._db.transaction((key: string, fragments: ReadonlyArray<IEmbeddedFragment>) => {
292
+ del.run(key);
293
+ for (const fragment of fragments) {
294
+ ins.run(
295
+ key,
296
+ SqliteVecFragmentIndex._toBlob(fragment.vector),
297
+ // vec0 typed columns reject a JS float; bind the offsets as integers.
298
+ BigInt(fragment.locator.start),
299
+ BigInt(fragment.locator.end)
300
+ );
301
+ }
302
+ });
303
+ return {
304
+ deleteByTarget: del,
305
+ replace: (key: string, fragments: ReadonlyArray<IEmbeddedFragment>): void => {
306
+ replaceTxn(key, fragments);
307
+ },
308
+ query: this._db.prepare(
309
+ `SELECT target_key, start_off, end_off, distance FROM "${this._table}" WHERE embedding MATCH ? AND k = ?`
310
+ ),
311
+ fragmentCount: this._db.prepare(`SELECT count(*) AS c FROM "${this._table}"`),
312
+ recordCount: this._db.prepare(`SELECT count(DISTINCT target_key) AS c FROM "${this._table}"`)
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Recover the established dimension of an existing fragment `vec0` table from its
318
+ * stored `CREATE VIRTUAL TABLE` SQL (`float[<n>]`). Returns `undefined` when the
319
+ * table does not exist yet (a fresh database — dimension is set by the first add).
320
+ */
321
+ private static _readExistingDimension(db: BetterSqlite3.Database, table: string): number | undefined {
322
+ const row: { sql: string } | undefined = db
323
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
324
+ .get(table) as { sql: string } | undefined;
325
+ if (row === undefined) {
326
+ return undefined;
327
+ }
328
+ const match: RegExpMatchArray | null = row.sql.match(/float\[(\d+)\]/);
329
+ return match === null ? undefined : Number(match[1]);
330
+ }
331
+
332
+ /** Pack a `Float32Array` as the little-endian byte blob `vec0` stores. Copies, so the caller may reuse its buffer. */
333
+ private static _toBlob(vector: Float32Array): Uint8Array {
334
+ return new Uint8Array(Float32Array.from(vector).buffer);
335
+ }
336
+
337
+ /**
338
+ * Reverse `edgeTargetKey` — the canonical key is `scope\0id` with NUL excluded
339
+ * from both components, so the first NUL splits it unambiguously. A key with no
340
+ * NUL cannot have been written by `edgeTargetKey`; rather than fabricate a wrong
341
+ * `(scope, id)` from corrupt / externally-edited table data, throw so the query
342
+ * surfaces it as a loud `Failure`.
343
+ */
344
+ private static _parseKey(key: string): IEdgeTarget {
345
+ const nul: number = key.indexOf('\0');
346
+ if (nul < 0) {
347
+ throw new Error(`malformed target key '${key}': missing scope/id separator (corrupt persisted data)`);
348
+ }
349
+ return {
350
+ scope: key.slice(0, nul) as unknown as MemoryScopeKey,
351
+ id: key.slice(nul + 1) as unknown as MemoryId
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Coerce a persisted locator offset to a plain `number`. `better-sqlite3` returns
357
+ * integer columns as `bigint` under safe-integer mode, so an offset can arrive as
358
+ * either; both narrow to `number` here. A value outside the safe-integer range
359
+ * (only reachable via corrupt / externally-edited data — the index only ever
360
+ * writes in-document offsets) throws rather than silently losing precision, so the
361
+ * query surfaces it as a loud `Failure`.
362
+ */
363
+ private static _toOffset(value: number | bigint, key: string): number {
364
+ const n: number = Number(value);
365
+ if (!Number.isSafeInteger(n)) {
366
+ throw new Error(
367
+ `fragment '${key}': locator offset ${String(value)} is not a safe integer (corrupt persisted data)`
368
+ );
369
+ }
370
+ return n;
371
+ }
372
+ }
373
+
374
+ /** The prepared statements / helpers the fragment index reuses once its table exists. */
375
+ interface IFragmentStatements {
376
+ readonly deleteByTarget: BetterSqlite3.Statement;
377
+ readonly replace: (key: string, fragments: ReadonlyArray<IEmbeddedFragment>) => void;
378
+ readonly query: BetterSqlite3.Statement;
379
+ readonly fragmentCount: BetterSqlite3.Statement;
380
+ readonly recordCount: BetterSqlite3.Statement;
381
+ }
@@ -0,0 +1,255 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import type BetterSqlite3 from 'better-sqlite3';
7
+ import { load as loadSqliteVec } from 'sqlite-vec';
8
+ import { Result, captureResult, fail, succeed } from '@fgv/ts-utils';
9
+ import {
10
+ IEdgeTarget,
11
+ IVectorIndex,
12
+ IVectorQueryHit,
13
+ MemoryId,
14
+ MemoryScopeKey,
15
+ edgeTargetKey
16
+ } from '@fgv/ts-agent-memory';
17
+ import { ISqliteVecVectorIndexCreateParams } from './model';
18
+
19
+ /** Default name for the `vec0` virtual table. */
20
+ const DEFAULT_TABLE_NAME: string = 'memory_vectors';
21
+
22
+ /** A simple SQL identifier — the only shape allowed for the table name (it is interpolated into DDL). */
23
+ const IDENTIFIER_RE: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+
25
+ /** One KNN row as returned by the `vec0` MATCH query. */
26
+ interface IKnnRow {
27
+ readonly target_key: string;
28
+ readonly distance: number;
29
+ }
30
+
31
+ /**
32
+ * A persistent, `sqlite-vec`-backed `IVectorIndex` for `@fgv/ts-agent-memory`.
33
+ *
34
+ * @remarks
35
+ * This is the **durable** counterpart to the in-memory `InMemoryCosineIndex`:
36
+ * embeddings live in a `sqlite-vec` `vec0` virtual table inside a `better-sqlite3`
37
+ * database, so they survive a process restart. A consumer that wires this index
38
+ * into `FileTreeMemoryStore` (instead of the in-memory index) opens an existing
39
+ * vault **without re-embedding it** — the vectors are already on disk. New writes
40
+ * still flow through the store's incremental embed-on-write path; there is no core
41
+ * store change.
42
+ *
43
+ * The index is keyed by the canonical `edgeTargetKey` of each record's
44
+ * scope-qualified `(scope, id)` address (a `TEXT PRIMARY KEY` on the `vec0` table),
45
+ * so two records that share a filename stem across scopes never collide. The
46
+ * dimension is established by the first `add` (the `vec0` column is fixed-width) and
47
+ * recovered from the table schema when a persistent file is reopened; every later
48
+ * `add`/`query` must match it or fail loudly, exactly as the in-memory index does.
49
+ * Similarity is cosine (`distance_metric=cosine`): the returned `score` is
50
+ * `1 - cosineDistance`, i.e. cosine similarity in `[-1, 1]`, higher = more similar —
51
+ * byte-for-byte the same scoring contract as `InMemoryCosineIndex`.
52
+ *
53
+ * Query is a brute-force `vec0` KNN scan (not an ANN structure): correct and
54
+ * durable, appropriate for the same "thousands of records" regime the in-memory
55
+ * index targets. Large-N ANN indexing is explicitly out of scope — see the README.
56
+ *
57
+ * The `better-sqlite3` `Database` is consumer-owned (bring-your-own): this index
58
+ * loads the `sqlite-vec` extension onto it and reads/writes the table, but never
59
+ * opens or closes the connection.
60
+ * @public
61
+ */
62
+ export class SqliteVecVectorIndex implements IVectorIndex {
63
+ private readonly _db: BetterSqlite3.Database;
64
+ private readonly _table: string;
65
+ /** The dimension of every stored vector; `undefined` until the table exists (first `add` or a reopened non-empty file). */
66
+ private _dimension: number | undefined;
67
+ /** Prepared statements; created once the table exists (established or recovered). */
68
+ private _stmts: ISqliteVecStatements | undefined;
69
+
70
+ private constructor(db: BetterSqlite3.Database, table: string, dimension: number | undefined) {
71
+ this._db = db;
72
+ this._table = table;
73
+ this._dimension = dimension;
74
+ this._stmts = dimension === undefined ? undefined : this._prepare();
75
+ }
76
+
77
+ /** The number of vectors currently held. Zero before the first `add`. */
78
+ public get size(): number {
79
+ if (this._stmts === undefined) {
80
+ return 0;
81
+ }
82
+ return (this._stmts.count.get() as { c: number }).c;
83
+ }
84
+
85
+ /**
86
+ * Family-convention factory. Loads the `sqlite-vec` extension onto the supplied
87
+ * `better-sqlite3` connection and, if the vector table already exists (a reopened
88
+ * persistent file), recovers its established dimension so no re-embedding is
89
+ * needed on open.
90
+ *
91
+ * @param params - See {@link ISqliteVecVectorIndexCreateParams}.
92
+ * @returns `Success` with the index, or `Failure` if the table name is not a
93
+ * simple identifier or the extension fails to load.
94
+ */
95
+ public static create(params: ISqliteVecVectorIndexCreateParams): Promise<Result<SqliteVecVectorIndex>> {
96
+ const table: string = params.tableName ?? DEFAULT_TABLE_NAME;
97
+ if (!IDENTIFIER_RE.test(table)) {
98
+ return Promise.resolve(fail(`sqlite-vec index: table name '${table}' is not a simple SQL identifier`));
99
+ }
100
+ return Promise.resolve(
101
+ captureResult(() => {
102
+ loadSqliteVec(params.database);
103
+ const dimension: number | undefined = SqliteVecVectorIndex._readExistingDimension(
104
+ params.database,
105
+ table
106
+ );
107
+ return new SqliteVecVectorIndex(params.database, table, dimension);
108
+ }).withErrorFormat((e) => `sqlite-vec index: failed to initialize: ${e}`)
109
+ );
110
+ }
111
+
112
+ /** {@inheritDoc IVectorIndex.add} */
113
+ public add(target: IEdgeTarget, vector: Float32Array): Promise<Result<string>> {
114
+ const key: string = edgeTargetKey(target);
115
+ if (vector.length === 0) {
116
+ return Promise.resolve(fail(`vector index: cannot add '${key}': empty vector`));
117
+ }
118
+ if (this._dimension !== undefined && vector.length !== this._dimension) {
119
+ return Promise.resolve(
120
+ fail(
121
+ `vector index: cannot add '${key}': dimension ${vector.length} does not match index dimension ${this._dimension}`
122
+ )
123
+ );
124
+ }
125
+ return Promise.resolve(
126
+ captureResult(() => {
127
+ if (this._stmts === undefined) {
128
+ this._createTable(vector.length);
129
+ this._dimension = vector.length;
130
+ this._stmts = this._prepare();
131
+ }
132
+ this._stmts.replace(key, SqliteVecVectorIndex._toBlob(vector));
133
+ return key;
134
+ }).withErrorFormat((e) => `vector index: cannot add '${key}': ${e}`)
135
+ );
136
+ }
137
+
138
+ /** {@inheritDoc IVectorIndex.remove} */
139
+ public remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {
140
+ return Promise.resolve(
141
+ captureResult(() => {
142
+ // Idempotent: removing a target with no embedding (or before any `add`
143
+ // created the table) still succeeds.
144
+ if (this._stmts !== undefined) {
145
+ this._stmts.delete.run(edgeTargetKey(target));
146
+ }
147
+ return target;
148
+ }).withErrorFormat((e) => `vector index: cannot remove '${edgeTargetKey(target)}': ${e}`)
149
+ );
150
+ }
151
+
152
+ /** {@inheritDoc IVectorIndex.query} */
153
+ public query(vector: Float32Array, topK: number): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
154
+ if (topK <= 0 || this._stmts === undefined) {
155
+ return Promise.resolve(succeed([]));
156
+ }
157
+ if (vector.length !== this._dimension) {
158
+ return Promise.resolve(
159
+ fail(
160
+ `vector index: query dimension ${vector.length} does not match index dimension ${this._dimension}`
161
+ )
162
+ );
163
+ }
164
+ return Promise.resolve(
165
+ captureResult<ReadonlyArray<IVectorQueryHit>>(() => {
166
+ const rows: ReadonlyArray<IKnnRow> = this._stmts!.query.all(
167
+ SqliteVecVectorIndex._toBlob(vector),
168
+ topK
169
+ ) as ReadonlyArray<IKnnRow>;
170
+ // sqlite-vec returns rows in ascending distance (nearest first); score is
171
+ // `1 - cosineDistance` = cosine similarity, so descending score is preserved.
172
+ return rows.map((row) => ({
173
+ target: SqliteVecVectorIndex._parseKey(row.target_key),
174
+ score: 1 - row.distance
175
+ }));
176
+ }).withErrorFormat((e) => `vector index: query failed: ${e}`)
177
+ );
178
+ }
179
+
180
+ /** Create the `vec0` virtual table with the established dimension. */
181
+ private _createTable(dimension: number): void {
182
+ this._db.exec(
183
+ `CREATE VIRTUAL TABLE IF NOT EXISTS "${this._table}" USING vec0(` +
184
+ `target_key TEXT PRIMARY KEY, embedding float[${dimension}] distance_metric=cosine)`
185
+ );
186
+ }
187
+
188
+ /** Prepare the statements the index reuses. Requires the table to exist. */
189
+ private _prepare(): ISqliteVecStatements {
190
+ const del: BetterSqlite3.Statement = this._db.prepare(
191
+ `DELETE FROM "${this._table}" WHERE target_key = ?`
192
+ );
193
+ const ins: BetterSqlite3.Statement = this._db.prepare(
194
+ `INSERT INTO "${this._table}"(target_key, embedding) VALUES (?, ?)`
195
+ );
196
+ // vec0 rejects INSERT OR REPLACE on a TEXT primary key, so replace is a
197
+ // delete-then-insert inside a single transaction.
198
+ const replaceTxn: BetterSqlite3.Transaction<(key: string, blob: Uint8Array) => void> =
199
+ this._db.transaction((key: string, blob: Uint8Array) => {
200
+ del.run(key);
201
+ ins.run(key, blob);
202
+ });
203
+ return {
204
+ delete: del,
205
+ replace: (key: string, blob: Uint8Array): void => {
206
+ replaceTxn(key, blob);
207
+ },
208
+ query: this._db.prepare(
209
+ `SELECT target_key, distance FROM "${this._table}" WHERE embedding MATCH ? AND k = ?`
210
+ ),
211
+ count: this._db.prepare(`SELECT count(*) AS c FROM "${this._table}"`)
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Recover the established dimension of an existing `vec0` table from its stored
217
+ * `CREATE VIRTUAL TABLE` SQL (`float[<n>]`). Returns `undefined` when the table
218
+ * does not exist yet (a fresh database — dimension is set by the first `add`).
219
+ */
220
+ private static _readExistingDimension(db: BetterSqlite3.Database, table: string): number | undefined {
221
+ const row: { sql: string } | undefined = db
222
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
223
+ .get(table) as { sql: string } | undefined;
224
+ if (row === undefined) {
225
+ return undefined;
226
+ }
227
+ const match: RegExpMatchArray | null = row.sql.match(/float\[(\d+)\]/);
228
+ return match === null ? undefined : Number(match[1]);
229
+ }
230
+
231
+ /** Pack a `Float32Array` as the little-endian byte blob `vec0` stores. Copies, so the caller may reuse its buffer. */
232
+ private static _toBlob(vector: Float32Array): Uint8Array {
233
+ return new Uint8Array(Float32Array.from(vector).buffer);
234
+ }
235
+
236
+ /**
237
+ * Reverse `edgeTargetKey` — the canonical key is `scope\0id` with NUL
238
+ * excluded from both components, so the first NUL splits it unambiguously.
239
+ */
240
+ private static _parseKey(key: string): IEdgeTarget {
241
+ const nul: number = key.indexOf('\0');
242
+ return {
243
+ scope: key.slice(0, nul) as unknown as MemoryScopeKey,
244
+ id: key.slice(nul + 1) as unknown as MemoryId
245
+ };
246
+ }
247
+ }
248
+
249
+ /** The prepared statements / helpers the index reuses once its table exists. */
250
+ interface ISqliteVecStatements {
251
+ readonly delete: BetterSqlite3.Statement;
252
+ readonly replace: (key: string, blob: Uint8Array) => void;
253
+ readonly query: BetterSqlite3.Statement;
254
+ readonly count: BetterSqlite3.Statement;
255
+ }