@fgv/ts-agent-memory-sqlite-vec 5.1.0-47 → 5.1.0-49

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/dist/packlets/sqlite-vec-index/sqliteVecVectorIndex.js +107 -1
  3. package/dist/packlets/sqlite-vec-index/sqliteVecVectorIndex.js.map +1 -1
  4. package/dist/ts-agent-memory-sqlite-vec.d.ts +26 -0
  5. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.d.ts +23 -1
  6. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.d.ts.map +1 -1
  7. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.js +106 -0
  8. package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.js.map +1 -1
  9. package/package.json +17 -7
  10. package/.rush/temp/2b1ffec4a34b11a2a7317510ba4142ae814069d2.tar.log +0 -60
  11. package/.rush/temp/chunked-rush-logs/ts-agent-memory-sqlite-vec.build.chunks.jsonl +0 -9
  12. package/.rush/temp/operation/build/all.log +0 -9
  13. package/.rush/temp/operation/build/log-chunks.jsonl +0 -9
  14. package/.rush/temp/operation/build/state.json +0 -3
  15. package/.rush/temp/shrinkwrap-deps.json +0 -720
  16. package/config/api-extractor.json +0 -38
  17. package/config/jest.config.json +0 -13
  18. package/config/rig.json +0 -6
  19. package/dist/test/unit/sqliteVecFragmentIndex.test.js +0 -513
  20. package/dist/test/unit/sqliteVecFragmentIndex.test.js.map +0 -1
  21. package/dist/test/unit/sqliteVecVectorIndex.test.js +0 -199
  22. package/dist/test/unit/sqliteVecVectorIndex.test.js.map +0 -1
  23. package/eslint.config.js +0 -15
  24. package/etc/ts-agent-memory-sqlite-vec.api.md +0 -66
  25. package/lib/test/unit/sqliteVecFragmentIndex.test.d.ts +0 -2
  26. package/lib/test/unit/sqliteVecFragmentIndex.test.d.ts.map +0 -1
  27. package/lib/test/unit/sqliteVecFragmentIndex.test.js +0 -551
  28. package/lib/test/unit/sqliteVecFragmentIndex.test.js.map +0 -1
  29. package/lib/test/unit/sqliteVecVectorIndex.test.d.ts +0 -2
  30. package/lib/test/unit/sqliteVecVectorIndex.test.d.ts.map +0 -1
  31. package/lib/test/unit/sqliteVecVectorIndex.test.js +0 -237
  32. package/lib/test/unit/sqliteVecVectorIndex.test.js.map +0 -1
  33. package/rush-logs/ts-agent-memory-sqlite-vec.build.cache.log +0 -3
  34. package/rush-logs/ts-agent-memory-sqlite-vec.build.log +0 -9
  35. package/src/index.ts +0 -6
  36. package/src/packlets/sqlite-vec-index/index.ts +0 -8
  37. package/src/packlets/sqlite-vec-index/model.ts +0 -56
  38. package/src/packlets/sqlite-vec-index/sqliteVecFragmentIndex.ts +0 -540
  39. package/src/packlets/sqlite-vec-index/sqliteVecVectorIndex.ts +0 -255
  40. package/src/test/unit/sqliteVecFragmentIndex.test.ts +0 -691
  41. package/src/test/unit/sqliteVecVectorIndex.test.ts +0 -253
  42. package/temp/build/lint/_eslint-5eVG3S6w.json +0 -34
  43. package/temp/build/typescript/ts_8nwakTlr.json +0 -1
  44. package/temp/ts-agent-memory-sqlite-vec.api.json +0 -1167
  45. package/temp/ts-agent-memory-sqlite-vec.api.md +0 -66
  46. package/tsconfig.json +0 -8
@@ -1,540 +0,0 @@
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
- IFragmentLocator,
13
- IFragmentVectorIndex,
14
- IVectorQueryHit,
15
- MemoryId,
16
- MemoryScopeKey,
17
- edgeTargetKey
18
- } from '@fgv/ts-agent-memory';
19
- import { ISqliteVecFragmentIndexCreateParams } from './model';
20
-
21
- /** Default name for the fragment `vec0` virtual table. */
22
- const DEFAULT_TABLE_NAME: string = 'memory_fragments';
23
-
24
- /** A simple SQL identifier — the only shape allowed for the table name (it is interpolated into DDL). */
25
- const IDENTIFIER_RE: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/;
26
-
27
- /**
28
- * The auxiliary (`+`-prefixed) columns this version of the index writes. A table
29
- * created by an earlier version carries a different set; see
30
- * {@link SqliteVecFragmentIndex._readExistingDimension} for why that has to be
31
- * detected explicitly rather than migrated.
32
- */
33
- const AUXILIARY_COLUMNS: ReadonlyArray<string> = ['start_off', 'end_off', 'fragment_id'];
34
-
35
- /**
36
- * Matches one `+name` auxiliary-column declaration in a `vec0` `CREATE VIRTUAL TABLE`
37
- * statement. Only ever consumed via `String.matchAll`, which iterates a clone rather
38
- * than advancing this instance's `lastIndex`, so the shared `/g` regex is reusable.
39
- */
40
- const AUXILIARY_COLUMN_RE: RegExp = /\+\s*([A-Za-z_][A-Za-z0-9_]*)/g;
41
-
42
- /**
43
- * One KNN row as returned by the fragment `vec0` MATCH query. The offset columns are
44
- * typed `number | bigint` because `better-sqlite3` returns integer columns as
45
- * `bigint` when a consumer enables its safe-integer mode (`defaultSafeIntegers`);
46
- * {@link SqliteVecFragmentIndex._toOffset} coerces them to a plain `number` (and
47
- * fails loudly on an out-of-safe-range value) before they reach the public locator.
48
- * All three identity columns are nullable: a fragment stored without a locator has
49
- * `NULL` offsets, and one stored without a `fragmentId` has a `NULL` `fragment_id`.
50
- */
51
- interface IKnnRow {
52
- readonly target_key: string;
53
- // eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent locator offset
54
- readonly start_off: number | bigint | null;
55
- // eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent locator offset
56
- readonly end_off: number | bigint | null;
57
- // eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent fragment id
58
- readonly fragment_id: string | null;
59
- readonly distance: number;
60
- }
61
-
62
- /**
63
- * The identity fields of a fragment hit, in `IVectorQueryHit` shape: a field the
64
- * stored fragment did not carry is *absent*, never present-but-`undefined`, so a hit
65
- * for a fragment stored without a `fragmentId` is structurally identical to one this
66
- * index produced before `fragment_id` existed.
67
- */
68
- type FragmentIdentity = Pick<IVectorQueryHit, 'locator' | 'fragmentId'>;
69
-
70
- /**
71
- * A persistent, `sqlite-vec`-backed `IFragmentVectorIndex` (from
72
- * `@fgv/ts-agent-memory`) — the fragment-granular sibling of
73
- * {@link SqliteVecVectorIndex}, and the **durable** counterpart to the in-memory
74
- * `InMemoryFragmentCosineIndex`.
75
- *
76
- * @remarks
77
- * Where {@link SqliteVecVectorIndex} keys one vector per record on a
78
- * `target_key` primary key, this index holds **many** vectors per record — one per
79
- * fragment — so it keys the `vec0` table on `target_key` as a **`PARTITION KEY`**
80
- * (many rows may share it) and stores each fragment's identity in three auxiliary
81
- * columns (`+start_off`, `+end_off`, `+fragment_id`) that ride alongside the vector
82
- * and are returned on query but never filtered — in particular `fragment_id` is
83
- * stored and returned verbatim, never parsed and never part of the query path. A
84
- * query is a brute-force `vec0` KNN scan across all partitions returning per-fragment
85
- * hits, each carrying its record `target` plus whichever identity fields the stored
86
- * fragment was added with (a fragment must carry at least one).
87
- *
88
- * **`vec0` schema changes require a drop-and-re-index.** A
89
- * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table (SQLite
90
- * does not compare schemas) and `vec0` has no `ALTER TABLE ADD COLUMN`, so a database written by an
91
- * earlier version of this package keeps its old auxiliary columns. `create` detects
92
- * that by parsing the stored `CREATE VIRTUAL TABLE` SQL and fails with an actionable
93
- * message naming the expected and found columns, rather than letting a widened
94
- * `INSERT` surface an opaque `no such column` at statement-prepare time. There are no
95
- * in-place migrations: drop the table (or use a fresh `tableName`) and re-index.
96
- * Fragment vectors are re-derivable from the records, so this costs embedding time,
97
- * never data.
98
- *
99
- * Semantics match `InMemoryFragmentCosineIndex` exactly: `addFragments` is
100
- * whole-record-replace (a single transaction deletes every prior fragment of the
101
- * target, then inserts the new set), `remove` drops every fragment of a target,
102
- * and `query` applies the optional `maxPerRecord` cap **during selection, before
103
- * the topK cut** — so one long document cannot crowd others out. The dimension is
104
- * established by the first `addFragments` (the `vec0` column is fixed-width) and
105
- * recovered from the table schema when a persistent file is reopened; similarity is
106
- * cosine (`score = 1 - cosineDistance`), byte-identical to the in-memory index.
107
- * Large-N ANN indexing is explicitly out of scope, same regime as the record index.
108
- *
109
- * The `better-sqlite3` `Database` is consumer-owned (bring-your-own): this index
110
- * loads the `sqlite-vec` extension onto it and reads/writes the table, but never
111
- * opens or closes the connection.
112
- * @public
113
- */
114
- export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
115
- private readonly _db: BetterSqlite3.Database;
116
- private readonly _table: string;
117
- /** The dimension of every stored fragment vector; `undefined` until the table exists. */
118
- private _dimension: number | undefined;
119
- /** Prepared statements; created once the table exists (established or recovered). */
120
- private _stmts: IFragmentStatements | undefined;
121
-
122
- private constructor(db: BetterSqlite3.Database, table: string, dimension: number | undefined) {
123
- this._db = db;
124
- this._table = table;
125
- this._dimension = dimension;
126
- this._stmts = dimension === undefined ? undefined : this._prepare();
127
- }
128
-
129
- /** The number of records that currently have at least one stored fragment. Zero before the first add. */
130
- public get recordCount(): number {
131
- if (this._stmts === undefined) {
132
- return 0;
133
- }
134
- // `Number(...)` narrows the count in case the consumer enabled better-sqlite3
135
- // safe-integer mode (which returns `count(*)` as a `bigint`).
136
- return Number((this._stmts.recordCount.get() as { c: number | bigint }).c);
137
- }
138
-
139
- /** The total number of fragments currently held across all records. Zero before the first add. */
140
- public get fragmentCount(): number {
141
- if (this._stmts === undefined) {
142
- return 0;
143
- }
144
- return Number((this._stmts.fragmentCount.get() as { c: number | bigint }).c);
145
- }
146
-
147
- /**
148
- * Family-convention factory. Loads the `sqlite-vec` extension onto the supplied
149
- * `better-sqlite3` connection and, if the fragment table already exists (a
150
- * reopened persistent file), verifies its auxiliary-column set matches this
151
- * version's and recovers its established dimension so no re-embedding is needed on
152
- * open.
153
- *
154
- * @param params - See {@link ISqliteVecFragmentIndexCreateParams}.
155
- * @returns `Success` with the index, or `Failure` if the table name is not a
156
- * simple identifier, the extension fails to load, or the existing table was
157
- * written by a version with a different auxiliary-column set (which requires a
158
- * drop-and-re-index — `vec0` cannot be altered in place).
159
- */
160
- public static create(params: ISqliteVecFragmentIndexCreateParams): Promise<Result<SqliteVecFragmentIndex>> {
161
- const table: string = params.tableName ?? DEFAULT_TABLE_NAME;
162
- if (!IDENTIFIER_RE.test(table)) {
163
- return Promise.resolve(
164
- fail(`sqlite-vec fragment index: table name '${table}' is not a simple SQL identifier`)
165
- );
166
- }
167
- return Promise.resolve(
168
- captureResult(() => {
169
- loadSqliteVec(params.database);
170
- const dimension: number | undefined = SqliteVecFragmentIndex._readExistingDimension(
171
- params.database,
172
- table
173
- );
174
- return new SqliteVecFragmentIndex(params.database, table, dimension);
175
- }).withErrorFormat((e) => `sqlite-vec fragment index: failed to initialize: ${e}`)
176
- );
177
- }
178
-
179
- /** {@inheritDoc IFragmentVectorIndex.addFragments} */
180
- public addFragments(
181
- target: IEdgeTarget,
182
- fragments: ReadonlyArray<IEmbeddedFragment>
183
- ): Promise<Result<number>> {
184
- const key: string = edgeTargetKey(target);
185
- // Validate every fragment before touching the database, so a bad fragment never
186
- // leaves the record half-replaced or the dimension half-established (whole-record
187
- // replace is all-or-nothing). The effective dimension is the established one, or —
188
- // on a still-dimensionless index — the first fragment's length; it is committed
189
- // (via table creation) only once the whole batch validates.
190
- let dimension: number | undefined = this._dimension;
191
- for (const fragment of fragments) {
192
- if (fragment.vector.length === 0) {
193
- return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
194
- }
195
- // A fragment carrying neither identity cannot be resolved back to anything by a
196
- // consumer holding the hit — the same invariant `embeddedFragmentConverter`
197
- // enforces at the untyped boundary, re-checked here at the index seam.
198
- if (fragment.locator === undefined && fragment.fragmentId === undefined) {
199
- return Promise.resolve(
200
- fail(
201
- `fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`
202
- )
203
- );
204
- }
205
- // Locator offsets are persisted as SQLite integers (bound via BigInt). Reject a
206
- // non-safe-integer offset up front with a clear message, rather than letting
207
- // `BigInt(nonInteger)` throw cryptically inside the write transaction OR storing
208
- // a value the read-side `_toOffset` guard would later reject on every query.
209
- // An absent locator persists as a NULL offset pair and skips the check.
210
- if (
211
- fragment.locator !== undefined &&
212
- (!Number.isSafeInteger(fragment.locator.start) || !Number.isSafeInteger(fragment.locator.end))
213
- ) {
214
- return Promise.resolve(
215
- fail(
216
- `fragment index: cannot add '${key}': locator [${fragment.locator.start}, ${fragment.locator.end}) offsets must be safe integers`
217
- )
218
- );
219
- }
220
- if (dimension === undefined) {
221
- dimension = fragment.vector.length;
222
- } else if (fragment.vector.length !== dimension) {
223
- return Promise.resolve(
224
- fail(
225
- `fragment index: cannot add '${key}': fragment dimension ${fragment.vector.length} does not match index dimension ${dimension}`
226
- )
227
- );
228
- }
229
- }
230
- return Promise.resolve(
231
- captureResult(() => {
232
- // A same-target re-author (or an empty batch) still needs the table to exist
233
- // to delete prior fragments; create it lazily on the first non-empty add.
234
- if (this._stmts === undefined) {
235
- if (fragments.length === 0) {
236
- // Nothing stored yet and nothing to store: no table, no work.
237
- return 0;
238
- }
239
- // `fragments` is non-empty here (the empty case returned above), so the
240
- // validation loop proved every fragment shares `fragments[0]`'s length —
241
- // which IS the dimension to establish. Read it straight from the first
242
- // fragment: no cast, no invariant-dependent narrowing.
243
- const established: number = fragments[0].vector.length;
244
- this._createTable(established);
245
- this._dimension = established;
246
- this._stmts = this._prepare();
247
- }
248
- this._stmts.replace(key, fragments);
249
- return fragments.length;
250
- }).withErrorFormat((e) => `fragment index: cannot add '${key}': ${e}`)
251
- );
252
- }
253
-
254
- /** {@inheritDoc IFragmentVectorIndex.remove} */
255
- public remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {
256
- return Promise.resolve(
257
- captureResult(() => {
258
- // Idempotent: removing a target with no fragments (or before any add created
259
- // the table) still succeeds.
260
- if (this._stmts !== undefined) {
261
- this._stmts.deleteByTarget.run(edgeTargetKey(target));
262
- }
263
- return target;
264
- }).withErrorFormat((e) => `fragment index: cannot remove '${edgeTargetKey(target)}': ${e}`)
265
- );
266
- }
267
-
268
- /** {@inheritDoc IFragmentVectorIndex.query} */
269
- public query(
270
- vector: Float32Array,
271
- topK: number,
272
- maxPerRecord?: number
273
- ): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
274
- if (topK <= 0 || this._stmts === undefined) {
275
- return Promise.resolve(succeed([]));
276
- }
277
- if (vector.length !== this._dimension) {
278
- return Promise.resolve(
279
- fail(
280
- `fragment index: query dimension ${vector.length} does not match index dimension ${this._dimension}`
281
- )
282
- );
283
- }
284
- const stmts: IFragmentStatements = this._stmts;
285
- return Promise.resolve(
286
- captureResult<ReadonlyArray<IVectorQueryHit>>(() => {
287
- // With a per-record cap the topK winners may lie past the first topK rows (a
288
- // capped record's later fragments are skipped), so fetch the full ranked set
289
- // and apply the cap + topK cut here — exactly as the in-memory index does.
290
- // Uncapped, KNN's own `k = topK` is already the answer.
291
- const fetchK: number =
292
- maxPerRecord === undefined ? topK : Number((stmts.fragmentCount.get() as { c: number | bigint }).c);
293
- if (fetchK <= 0) {
294
- return [];
295
- }
296
- const rows: ReadonlyArray<IKnnRow> = stmts.query.all(
297
- SqliteVecFragmentIndex._toBlob(vector),
298
- fetchK
299
- ) as ReadonlyArray<IKnnRow>;
300
- // sqlite-vec returns rows ascending by distance (nearest first); score is
301
- // `1 - cosineDistance`, so this order is already descending score.
302
- const hits: IVectorQueryHit[] = [];
303
- const perRecord: Map<string, number> = new Map<string, number>();
304
- for (const row of rows) {
305
- if (hits.length >= topK) {
306
- break;
307
- }
308
- if (maxPerRecord !== undefined) {
309
- const used: number = perRecord.get(row.target_key) ?? 0;
310
- if (used >= maxPerRecord) {
311
- continue;
312
- }
313
- perRecord.set(row.target_key, used + 1);
314
- }
315
- const key: string = row.target_key;
316
- hits.push({
317
- target: SqliteVecFragmentIndex._parseKey(key),
318
- score: 1 - row.distance,
319
- ...SqliteVecFragmentIndex._toIdentity(row, key)
320
- });
321
- }
322
- return hits;
323
- }).withErrorFormat((e) => `fragment index: query failed: ${e}`)
324
- );
325
- }
326
-
327
- /**
328
- * Create the fragment `vec0` virtual table with the established dimension. The
329
- * auxiliary columns must stay in sync with `AUXILIARY_COLUMNS`, which
330
- * `create` compares against an existing table's stored DDL.
331
- */
332
- private _createTable(dimension: number): void {
333
- this._db.exec(
334
- `CREATE VIRTUAL TABLE IF NOT EXISTS "${this._table}" USING vec0(` +
335
- `target_key TEXT PARTITION KEY, embedding float[${dimension}] distance_metric=cosine, ` +
336
- `+start_off integer, +end_off integer, +fragment_id text)`
337
- );
338
- }
339
-
340
- /** Prepare the statements the index reuses. Requires the table to exist. */
341
- private _prepare(): IFragmentStatements {
342
- const del: BetterSqlite3.Statement = this._db.prepare(
343
- `DELETE FROM "${this._table}" WHERE target_key = ?`
344
- );
345
- const ins: BetterSqlite3.Statement = this._db.prepare(
346
- `INSERT INTO "${this._table}"(target_key, embedding, start_off, end_off, fragment_id) ` +
347
- `VALUES (?, ?, ?, ?, ?)`
348
- );
349
- // Whole-record replace: drop every prior fragment of the target, then insert the
350
- // new set, atomically. An empty set collapses to a pure delete.
351
- const replaceTxn: BetterSqlite3.Transaction<
352
- (key: string, fragments: ReadonlyArray<IEmbeddedFragment>) => void
353
- > = this._db.transaction((key: string, fragments: ReadonlyArray<IEmbeddedFragment>) => {
354
- del.run(key);
355
- for (const fragment of fragments) {
356
- ins.run(
357
- key,
358
- SqliteVecFragmentIndex._toBlob(fragment.vector),
359
- // vec0 typed columns reject a JS float; bind the offsets as integers. An
360
- // absent locator binds the pair as NULL — never a partial pair, so the read
361
- // side can treat a half-NULL pair as corruption rather than a legal shape.
362
- fragment.locator === undefined ? null : BigInt(fragment.locator.start),
363
- fragment.locator === undefined ? null : BigInt(fragment.locator.end),
364
- // Stored verbatim and never parsed; absent binds as NULL.
365
- fragment.fragmentId ?? null
366
- );
367
- }
368
- });
369
- return {
370
- deleteByTarget: del,
371
- replace: (key: string, fragments: ReadonlyArray<IEmbeddedFragment>): void => {
372
- replaceTxn(key, fragments);
373
- },
374
- query: this._db.prepare(
375
- `SELECT target_key, start_off, end_off, fragment_id, distance FROM "${this._table}" ` +
376
- `WHERE embedding MATCH ? AND k = ?`
377
- ),
378
- fragmentCount: this._db.prepare(`SELECT count(*) AS c FROM "${this._table}"`),
379
- recordCount: this._db.prepare(`SELECT count(DISTINCT target_key) AS c FROM "${this._table}"`)
380
- };
381
- }
382
-
383
- /**
384
- * Recover the established dimension of an existing fragment `vec0` table from its
385
- * stored `CREATE VIRTUAL TABLE` SQL (`float[<n>]`), after checking that the table's
386
- * auxiliary columns match `AUXILIARY_COLUMNS`. Returns `undefined` when the
387
- * table does not exist yet (a fresh database — dimension is set by the first add).
388
- *
389
- * Throws when a table of that name exists but is not a usable fragment index (a
390
- * mismatched auxiliary-column set, or no `vec0` embedding column); the caller runs
391
- * this inside `captureResult`, so it surfaces as a loud `Failure` from `create`.
392
- * The same stored DDL answers every one of those questions, so the checks cost
393
- * nothing extra.
394
- */
395
- private static _readExistingDimension(db: BetterSqlite3.Database, table: string): number | undefined {
396
- const row: { sql: string } | undefined = db
397
- .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
398
- .get(table) as { sql: string } | undefined;
399
- if (row === undefined) {
400
- return undefined;
401
- }
402
- SqliteVecFragmentIndex._verifyAuxiliaryColumns(row.sql, table);
403
- const match: RegExpMatchArray | null = row.sql.match(/float\[(\d+)\]/);
404
- if (match === null) {
405
- // The auxiliary columns matched but there is no `float[<n>]` embedding column,
406
- // so this is not a usable fragment index table. Same remedy as a column
407
- // mismatch — and failing here beats handing back a dimensionless index whose
408
- // first add would `CREATE VIRTUAL TABLE IF NOT EXISTS` into a no-op.
409
- throw new Error(
410
- `existing table '${table}' has no vec0 embedding column, so it is not a usable fragment ` +
411
- `index table. Drop it (or pass a fresh tableName) and re-add every fragment.`
412
- );
413
- }
414
- return Number(match[1]);
415
- }
416
-
417
- /**
418
- * Compare an existing table's auxiliary columns against `AUXILIARY_COLUMNS`.
419
- *
420
- * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table (SQLite
421
- * never compares schemas) and `vec0` has no `ALTER TABLE ADD COLUMN`, so a table
422
- * written by an earlier version of this package silently keeps its old columns and
423
- * only fails later — as an opaque `no such column` when the widened `INSERT` is
424
- * prepared. Detect it here instead and say what to do about it. Order is not
425
- * compared: every statement names its columns explicitly, so only the set matters.
426
- */
427
- private static _verifyAuxiliaryColumns(sql: string, table: string): void {
428
- const found: string[] = Array.from(sql.matchAll(AUXILIARY_COLUMN_RE), (m) => m[1]);
429
- const expected: ReadonlyArray<string> = AUXILIARY_COLUMNS;
430
- const matches: boolean =
431
- found.length === expected.length && expected.every((column) => found.includes(column));
432
- if (!matches) {
433
- throw new Error(
434
- `existing table '${table}' has auxiliary columns [${found.join(', ')}] but this index ` +
435
- `requires [${expected.join(', ')}] — it was written by a different version of ` +
436
- `@fgv/ts-agent-memory-sqlite-vec, or it is not a fragment index table at all. vec0 virtual ` +
437
- `tables cannot be altered in place, so this requires a drop-and-re-index: DROP TABLE ` +
438
- `"${table}" (or pass a fresh tableName) and re-add every fragment. Fragment vectors are ` +
439
- `re-derivable from the records, so this costs embedding time, never data.`
440
- );
441
- }
442
- }
443
-
444
- /**
445
- * Rebuild the identity fields of a hit from a persisted row, omitting each field
446
- * the stored fragment did not carry (so a hit is structurally identical to one this
447
- * index produced before `fragment_id` existed).
448
- *
449
- * A row carrying neither identity violates the write-side invariant and could not
450
- * be resolved by the caller, so it fails loudly instead of yielding an anonymous
451
- * hit.
452
- */
453
- private static _toIdentity(row: IKnnRow, key: string): FragmentIdentity {
454
- const locator: IFragmentLocator | undefined = SqliteVecFragmentIndex._toLocator(row, key);
455
- if (locator === undefined && row.fragment_id === null) {
456
- throw new Error(
457
- `fragment '${key}': row carries neither a locator nor a fragment id (corrupt persisted data)`
458
- );
459
- }
460
- return {
461
- ...(locator !== undefined ? { locator } : {}),
462
- ...(row.fragment_id !== null ? { fragmentId: row.fragment_id } : {})
463
- };
464
- }
465
-
466
- /**
467
- * Rebuild a fragment's locator from its persisted offsets, or `undefined` when the
468
- * fragment was stored without one (both offsets `NULL`).
469
- *
470
- * The pair is written all-or-nothing, so a half-`NULL` pair can only come from
471
- * corrupt / externally-edited data. Throw rather than coerce — `Number(null)` is
472
- * `0`, which would silently fabricate a span starting at the top of the body.
473
- */
474
- private static _toLocator(row: IKnnRow, key: string): IFragmentLocator | undefined {
475
- const start: number | bigint | null = row.start_off;
476
- const end: number | bigint | null = row.end_off;
477
- if (start === null && end === null) {
478
- return undefined;
479
- }
480
- if (start === null || end === null) {
481
- throw new Error(
482
- `fragment '${key}': locator has only one of its start/end offsets (corrupt persisted data)`
483
- );
484
- }
485
- return {
486
- start: SqliteVecFragmentIndex._toOffset(start, key),
487
- end: SqliteVecFragmentIndex._toOffset(end, key)
488
- };
489
- }
490
-
491
- /** Pack a `Float32Array` as the little-endian byte blob `vec0` stores. Copies, so the caller may reuse its buffer. */
492
- private static _toBlob(vector: Float32Array): Uint8Array {
493
- return new Uint8Array(Float32Array.from(vector).buffer);
494
- }
495
-
496
- /**
497
- * Reverse `edgeTargetKey` — the canonical key is `scope\0id` with NUL excluded
498
- * from both components, so the first NUL splits it unambiguously. A key with no
499
- * NUL cannot have been written by `edgeTargetKey`; rather than fabricate a wrong
500
- * `(scope, id)` from corrupt / externally-edited table data, throw so the query
501
- * surfaces it as a loud `Failure`.
502
- */
503
- private static _parseKey(key: string): IEdgeTarget {
504
- const nul: number = key.indexOf('\0');
505
- if (nul < 0) {
506
- throw new Error(`malformed target key '${key}': missing scope/id separator (corrupt persisted data)`);
507
- }
508
- return {
509
- scope: key.slice(0, nul) as unknown as MemoryScopeKey,
510
- id: key.slice(nul + 1) as unknown as MemoryId
511
- };
512
- }
513
-
514
- /**
515
- * Coerce a persisted locator offset to a plain `number`. `better-sqlite3` returns
516
- * integer columns as `bigint` under safe-integer mode, so an offset can arrive as
517
- * either; both narrow to `number` here. A value outside the safe-integer range
518
- * (only reachable via corrupt / externally-edited data — the index only ever
519
- * writes in-document offsets) throws rather than silently losing precision, so the
520
- * query surfaces it as a loud `Failure`.
521
- */
522
- private static _toOffset(value: number | bigint, key: string): number {
523
- const n: number = Number(value);
524
- if (!Number.isSafeInteger(n)) {
525
- throw new Error(
526
- `fragment '${key}': locator offset ${String(value)} is not a safe integer (corrupt persisted data)`
527
- );
528
- }
529
- return n;
530
- }
531
- }
532
-
533
- /** The prepared statements / helpers the fragment index reuses once its table exists. */
534
- interface IFragmentStatements {
535
- readonly deleteByTarget: BetterSqlite3.Statement;
536
- readonly replace: (key: string, fragments: ReadonlyArray<IEmbeddedFragment>) => void;
537
- readonly query: BetterSqlite3.Statement;
538
- readonly fragmentCount: BetterSqlite3.Statement;
539
- readonly recordCount: BetterSqlite3.Statement;
540
- }