@wirecat/cli-core 0.433.0 → 0.434.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.434.0 — 25.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **Search by meaning ranks by a sqlite-vec index in the store (store version 34), and every store connection loads the extension.** Each embedding model gets a `vec0` table, `vector_index_<id>`, listed in the new `vector_indexes`; triggers on `chunks`, `embeddings` and `messages` keep it in step inside each write's own transaction. `nearestMessages`, `nearestImages` and conversation search (without a person, project, scope or conversation list) take candidates from it, check them with the scan's own SQL and score them as before, so they answer exactly what the scan answers; the scan stays for the filters the index cannot express. A store that held vectors is indexed on its first open by this version, in short batches. **Version 34 raises `minCompatible` to 34**: a build without the extension cannot write chunks to a store with these triggers, so older CLIs refuse the file and ask to be upgraded. The extension ships in `@wirecat/cli-core-sqlite` 1.1.0 (now a required runtime piece on every platform, Windows included); when it is missing or does not load, opening the store fails with `store.vector-extension-missing` or `store.vector-extension-failed` and says to reinstall. Under Node the store is opened with `allowExtension`, and SQL's `load_extension()` is switched off again right after.
15
+ - **`store check` reports `vector_indexes`** — per model, the rows its index holds and the rows it should, and how many differ either way (`missing`, `extra`), compared whole — and fails `checks.vector_indexes` when a ready index is off.
16
+ - **Vectors are stored at length one.** `saveVectors` and a document picture's vector are scaled to unit length before they are kept, so search by dot product and by the index rank alike; a vector with a non-finite value is refused (`store.vector-invalid`). Vectors stored before keep their length. The `not_applicable.extensions` field ("SQLite needs none") is gone.
17
+
10
18
  ## 0.433.0 — 25.10.2026
11
19
 
12
20
  ### Changed — may break callers
@@ -12,7 +12,7 @@ import { migrate } from "../../store/migrations.js";
12
12
  import { openCache } from "../../store/open.js";
13
13
  import { storePath } from "../../store/path.js";
14
14
  import { deleteCopy, repairStore } from "../../store/repair.js";
15
- import { drainNoteIndex, fillSearchIndex, fillStems, involvementStoreOver, noteIndexState, openSqlite, openStore, orphanPointers, pendingNormalization, resetAttachmentWords, resetNoteIndex, resetSearchIndex, resetStems, searchIndexState, stemmerCache, stemsState, } from "../../store/store.js";
15
+ import { drainNoteIndex, fillSearchIndex, fillStems, involvementStoreOver, noteIndexState, openSqlite, openStore, orphanPointers, pendingNormalization, resetAttachmentWords, resetNoteIndex, resetSearchIndex, resetStems, searchIndexState, stemmerCache, stemsState, vectorIndexCounts, } from "../../store/store.js";
16
16
  import { environmentOf, outputFor } from "../context.js";
17
17
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
18
18
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
@@ -241,11 +241,14 @@ const inspect = (path) => reading(path, (database) => {
241
241
  const { bavail, bsize } = statfsSync(dirname(path));
242
242
  const free = Number(bavail) * Number(bsize);
243
243
  const behind = schema.version > 0 ? chatsBehind(database) : [];
244
+ const vectorIndexes = schema.version >= 34 ? vectorIndexCounts(database) : [];
244
245
  const checks = {
245
246
  schema: schema.version >= SPEAKS && schema.writable,
246
247
  integrity: integrity.length === 1 && integrity[0] === "ok",
247
248
  foreignKeys: foreignKeys === 0,
248
249
  searchIndexes: Object.values(searchIndexes).every((state) => state === "ok"),
250
+ // One that is still filling is short by design; a ready one must hold exactly the rows its sources give.
251
+ vectorIndexes: vectorIndexes.every(({ ready, missing, extra }) => !ready || missing + extra === 0),
249
252
  // A backup or a VACUUM needs about as much again.
250
253
  disk: free >= size,
251
254
  };
@@ -268,7 +271,7 @@ const inspect = (path) => reading(path, (database) => {
268
271
  orphanPointers: schema.version > 0 ? orphanPointers(database) : [],
269
272
  conversations: conversationsBuilt(database),
270
273
  vectors: vectorsHeld(database),
271
- notApplicable: { extensions: "SQLite needs none" },
274
+ vectorIndexes,
272
275
  };
273
276
  });
274
277
  /** `rank = 1` compares the index with its table; without it a stale external-content index passes. */
@@ -9690,6 +9690,7 @@ export declare const ENTITIES: {
9690
9690
  readonly integrity: v.BooleanSchema<undefined>;
9691
9691
  readonly foreign_keys: v.BooleanSchema<undefined>;
9692
9692
  readonly search_indexes: v.BooleanSchema<undefined>;
9693
+ readonly vector_indexes: v.BooleanSchema<undefined>;
9693
9694
  readonly disk: v.BooleanSchema<undefined>;
9694
9695
  }, undefined>;
9695
9696
  readonly schema: v.StrictObjectSchema<{
@@ -9756,9 +9757,15 @@ export declare const ENTITIES: {
9756
9757
  readonly models: v.RecordSchema<v.StringSchema<undefined>, v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
9757
9758
  readonly unused: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9758
9759
  }, undefined>, undefined>;
9759
- readonly not_applicable: v.StrictObjectSchema<{
9760
- readonly extensions: v.StringSchema<undefined>;
9761
- }, undefined>;
9760
+ readonly vector_indexes: v.ArraySchema<v.StrictObjectSchema<{
9761
+ readonly model: v.StringSchema<undefined>;
9762
+ readonly dims: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9763
+ readonly ready: v.BooleanSchema<undefined>;
9764
+ readonly indexed: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9765
+ readonly expected: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9766
+ readonly missing: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9767
+ readonly extra: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9768
+ }, undefined>, undefined>;
9762
9769
  }, undefined>], undefined>;
9763
9770
  readonly store_migration: v.UnionSchema<[v.StrictObjectSchema<{
9764
9771
  readonly path: v.StringSchema<undefined>;
@@ -222,6 +222,7 @@ export declare const storeCheck: v.UnionSchema<[v.StrictObjectSchema<{
222
222
  readonly integrity: v.BooleanSchema<undefined>;
223
223
  readonly foreign_keys: v.BooleanSchema<undefined>;
224
224
  readonly search_indexes: v.BooleanSchema<undefined>;
225
+ readonly vector_indexes: v.BooleanSchema<undefined>;
225
226
  readonly disk: v.BooleanSchema<undefined>;
226
227
  }, undefined>;
227
228
  readonly schema: v.StrictObjectSchema<{
@@ -288,9 +289,15 @@ export declare const storeCheck: v.UnionSchema<[v.StrictObjectSchema<{
288
289
  readonly models: v.RecordSchema<v.StringSchema<undefined>, v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
289
290
  readonly unused: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
290
291
  }, undefined>, undefined>;
291
- readonly not_applicable: v.StrictObjectSchema<{
292
- readonly extensions: v.StringSchema<undefined>;
293
- }, undefined>;
292
+ readonly vector_indexes: v.ArraySchema<v.StrictObjectSchema<{
293
+ readonly model: v.StringSchema<undefined>;
294
+ readonly dims: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
295
+ readonly ready: v.BooleanSchema<undefined>;
296
+ readonly indexed: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
297
+ readonly expected: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
298
+ readonly missing: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
299
+ readonly extra: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
300
+ }, undefined>, undefined>;
294
301
  }, undefined>], undefined>;
295
302
  export declare const storeMigration: v.UnionSchema<[v.StrictObjectSchema<{
296
303
  readonly path: v.StringSchema<undefined>;
@@ -87,6 +87,7 @@ export const storeCheck = v.union([
87
87
  integrity: v.boolean(),
88
88
  foreign_keys: v.boolean(),
89
89
  search_indexes: v.boolean(),
90
+ vector_indexes: v.boolean(),
90
91
  disk: v.boolean(),
91
92
  }),
92
93
  schema,
@@ -118,7 +119,15 @@ export const storeCheck = v.union([
118
119
  stale_agent_links: count,
119
120
  })),
120
121
  vectors: v.nullable(v.strictObject({ models: v.record(text, count), unused: count })),
121
- not_applicable: v.strictObject({ extensions: text }),
122
+ vector_indexes: v.array(v.strictObject({
123
+ model: text,
124
+ dims: count,
125
+ ready: v.boolean(),
126
+ indexed: count,
127
+ expected: count,
128
+ missing: count,
129
+ extra: count,
130
+ })),
122
131
  }),
123
132
  ]);
124
133
  const indexed = { normalized: count, indexed: count, terms: count, stemmed: count, notes_indexed: count };
@@ -55,6 +55,13 @@ const OTHERS = [
55
55
  ["state.invalid", "configuration_error", "the saved sync state is unreadable or invalid; it is kept, not reset"],
56
56
  validation("store.refused", "the local store refused the document text"),
57
57
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
58
+ [
59
+ "store.vector-extension-missing",
60
+ "configuration_error",
61
+ "the install has no sqlite-vec extension for this platform; reinstall the CLI",
62
+ ],
63
+ ["store.vector-extension-failed", "configuration_error", "the sqlite-vec extension did not load; reinstall the CLI"],
64
+ validation("store.vector-invalid", "a vector to store had a non-finite value"),
58
65
  validation("catalog.scope-limit", "the selected catalog exceeds its entry limit"),
59
66
  validation("evidence.row-too-large", "one evidence row is over its size limit"),
60
67
  validation("index.not-ready", "the search index is still being built"),
@@ -9,6 +9,7 @@ interface BunStatement {
9
9
  export interface BunDatabase {
10
10
  exec(sql: string): void;
11
11
  query(sql: string): BunStatement;
12
+ loadExtension(path: string): void;
12
13
  close(): void;
13
14
  }
14
15
  export interface BunDatabaseClass {
@@ -25,5 +26,10 @@ export interface BunDatabaseClass {
25
26
  */
26
27
  export declare const bunDatabase: () => Promise<BunDatabaseClass>;
27
28
  export declare const openWithBunSqlite: (path: string) => Promise<CacheDatabase>;
29
+ /** A connection with the vector extension loaded; it closes again when the extension does not load. */
30
+ export declare const bunClient: (path: string, options?: {
31
+ readonly?: boolean;
32
+ create?: boolean;
33
+ }) => Promise<BunDatabase>;
28
34
  export declare const cacheOverBunSqlite: (database: BunDatabase) => CacheDatabase;
29
35
  export {};
@@ -1,3 +1,4 @@
1
+ import { loadVectorExtension, vectorExtension } from "../vector-extension.js";
1
2
  let customised = false;
2
3
  /**
3
4
  * Bun on macOS takes the system's SQLite, which can be older than the store needs, so it always
@@ -29,7 +30,21 @@ export const bunDatabase = async () => {
29
30
  }
30
31
  return Database;
31
32
  };
32
- export const openWithBunSqlite = async (path) => cacheOverBunSqlite(new (await bunDatabase())(path));
33
+ export const openWithBunSqlite = async (path) => cacheOverBunSqlite(await bunClient(path));
34
+ /** A connection with the vector extension loaded; it closes again when the extension does not load. */
35
+ export const bunClient = async (path, options) => {
36
+ const Database = await bunDatabase();
37
+ const extension = await vectorExtension();
38
+ const client = options ? new Database(path, options) : new Database(path);
39
+ try {
40
+ loadVectorExtension(client, extension);
41
+ return client;
42
+ }
43
+ catch (error) {
44
+ client.close();
45
+ throw error;
46
+ }
47
+ };
33
48
  export const cacheOverBunSqlite = (database) => {
34
49
  return {
35
50
  exec: (sql) => database.exec(sql),
@@ -4,5 +4,9 @@ import type { CacheDatabase } from "../driver.js";
4
4
  * Import this module only through a dynamic import that follows `quietSqliteWarning()`: a static import loads
5
5
  * `node:sqlite`, and on Node 22 its ExperimentalWarning, before the filter can run.
6
6
  */
7
- export declare const openWithNodeSqlite: (path: string) => CacheDatabase;
7
+ export declare const openWithNodeSqlite: (path: string) => Promise<CacheDatabase>;
8
+ /** A connection with the vector extension loaded; it closes again when the extension does not load. */
9
+ export declare const nodeClient: (path: string, { readOnly }?: {
10
+ readOnly?: boolean;
11
+ }) => Promise<DatabaseSync>;
8
12
  export declare const cacheOverNodeSqlite: (database: DatabaseSync) => CacheDatabase;
@@ -1,10 +1,24 @@
1
1
  // biome-ignore-all lint/plugin/no-sqlite-driver: cli-core owns the store, so it opens the driver itself
2
2
  import { DatabaseSync } from "node:sqlite";
3
+ import { loadVectorExtension, vectorExtension } from "../vector-extension.js";
3
4
  /**
4
5
  * Import this module only through a dynamic import that follows `quietSqliteWarning()`: a static import loads
5
6
  * `node:sqlite`, and on Node 22 its ExperimentalWarning, before the filter can run.
6
7
  */
7
- export const openWithNodeSqlite = (path) => cacheOverNodeSqlite(new DatabaseSync(path));
8
+ export const openWithNodeSqlite = async (path) => cacheOverNodeSqlite(await nodeClient(path));
9
+ /** A connection with the vector extension loaded; it closes again when the extension does not load. */
10
+ export const nodeClient = async (path, { readOnly = false } = {}) => {
11
+ const extension = await vectorExtension();
12
+ const client = new DatabaseSync(path, { readOnly, allowExtension: true });
13
+ try {
14
+ loadVectorExtension(client, extension);
15
+ return client;
16
+ }
17
+ catch (error) {
18
+ client.close();
19
+ throw error;
20
+ }
21
+ };
8
22
  export const cacheOverNodeSqlite = (database) => {
9
23
  return {
10
24
  exec: (sql) => database.exec(sql),
@@ -54,6 +54,7 @@ export { savedTaskCategories } from "./sqlite/task-category-settings.js";
54
54
  export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
55
55
  export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
56
56
  export { inboxKey } from "./sqlite/tasks.js";
57
+ export { vectorIndexCounts } from "./sqlite/vector-index.js";
57
58
  export type { MatchPlace, ScoredHit } from "./sqlite/words.js";
58
59
  export type { TaskDetails } from "./sqlite/work-tasks.js";
59
60
  export type { SyncStore } from "./sync.js";
@@ -24,3 +24,4 @@ export { savedTaskCategories } from "./sqlite/task-category-settings.js";
24
24
  export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
25
25
  export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
26
26
  export { inboxKey } from "./sqlite/tasks.js";
27
+ export { vectorIndexCounts } from "./sqlite/vector-index.js";
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { write } from "./transaction.js";
3
+ import { ensureVectorIndex, unitVector } from "./vector-index.js";
3
4
  import { dot } from "./vectors.js";
4
5
  /** A document's picture is one chunk of this type at position 0; its vector is the picture's, not text's. */
5
6
  export const DOCUMENT_PICTURE = "document_picture";
@@ -44,10 +45,12 @@ export const documentPicturesOver = ({ database, now }) => ({
44
45
  throw new CliError("not_found", `no document ${ref} in the local store`);
45
46
  const at = now();
46
47
  dropDocumentPicture(database, id);
48
+ ensureVectorIndex(database, model, dims);
49
+ const unit = unitVector(vector);
47
50
  database
48
51
  .prepare(`INSERT INTO embeddings (model, content_hash, dims, vector, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
49
52
  ON CONFLICT DO NOTHING`)
50
- .run(model, hash, dims, Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength), at, at);
53
+ .run(model, hash, dims, Buffer.from(unit.buffer, unit.byteOffset, unit.byteLength), at, at);
51
54
  database
52
55
  .prepare(`INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope,
53
56
  account_id, occurred_at, created_at, updated_at) VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?)`)
@@ -46,7 +46,7 @@ export interface NearestImage {
46
46
  * The images of the account nearest to `query` by the vector of `model`, best first. Position 0 of an image is
47
47
  * its context text, position 1 its picture (`PICTURE_POSITION`): a model's vectors only ever sit at one.
48
48
  */
49
- export declare const nearestImages: ({ orm }: StoreContext, accountPk: number, { chatKey, model, since, limit, query, position, }: {
49
+ export declare const nearestImages: (context: StoreContext, accountPk: number, { chatKey, model, since, limit, query, position, }: {
50
50
  chatKey?: number;
51
51
  model: string;
52
52
  since?: number;
@@ -1,4 +1,5 @@
1
1
  import { sql } from "./drizzle/core.js";
2
+ import { searchIndex } from "./vector-index.js";
2
3
  import { dot } from "./vectors.js";
3
4
  const IS_IMAGE = sql `(a.kind = 'photo' OR a.mime LIKE 'image/%')`;
4
5
  const neighbours = ({ orm }, chatKey, messagePk, sentAt, { messages, withinMs }, side) => orm.all(sql `SELECT sender_name AS sender, text, sent_at AS sentAt FROM messages
@@ -62,32 +63,64 @@ export const embeddedHashes = ({ orm }, model, hashes) => new Set(hashes.length
62
63
  WHERE model = ${model} AND content_hash IN (SELECT value FROM json_each(${JSON.stringify(hashes)}))`)
63
64
  .map(({ hash }) => hash));
64
65
  const SCAN_PAGE = 5_000;
66
+ /** The scan's rows: one page after `after`, or the `ids` the index picked. */
67
+ const imageRows = ({ orm }, accountPk, { chatKey, model, since, position }, rows) => orm.all(sql `SELECT k.id, k.content_hash AS hash, ch.external_id AS chatId, m.external_id AS messageId, v.vector
68
+ FROM chunks k CROSS JOIN attachments a ON a.id = k.chunkable_id AND a.attachable_type = 'message'
69
+ JOIN messages m ON m.id = a.attachable_id AND m.deleted_at IS NULL
70
+ JOIN chats ch ON ch.id = m.chat_id
71
+ JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
72
+ WHERE k.chunkable_type = 'image' AND k.position = ${position} AND ch.account_id = ${accountPk}
73
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
74
+ ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
75
+ ${"ids" in rows
76
+ ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(rows.ids)})) ORDER BY k.id`
77
+ : sql `AND k.id > ${rows.after} ORDER BY k.id LIMIT ${SCAN_PAGE}`}`);
65
78
  /**
66
79
  * The images of the account nearest to `query` by the vector of `model`, best first. Position 0 of an image is
67
80
  * its context text, position 1 its picture (`PICTURE_POSITION`): a model's vectors only ever sit at one.
68
81
  */
69
- export const nearestImages = ({ orm }, accountPk, { chatKey, model, since, limit, query, position = 0, }) => {
82
+ export const nearestImages = (context, accountPk, { chatKey, model, since, limit, query, position = 0, }) => {
83
+ const filter = {
84
+ model,
85
+ position,
86
+ ...(chatKey === undefined ? {} : { chatKey }),
87
+ ...(since === undefined ? {} : { since }),
88
+ };
89
+ const score = (row) => ({
90
+ id: row.id,
91
+ chatId: row.chatId,
92
+ messageId: row.messageId,
93
+ score: dot(query, row.vector),
94
+ });
95
+ // Stable, so equal scores keep the scan's order, by chunk id.
96
+ const best = (found) => found.sort((a, b) => b.score - a.score).slice(0, limit);
97
+ const indexed = searchIndex(context.database, model, {
98
+ kind: "image",
99
+ account: accountPk,
100
+ ...(chatKey === undefined ? {} : { chat: chatKey }),
101
+ ...(since === undefined ? {} : { since }),
102
+ }, query, limit, (candidates) => {
103
+ const hashes = new Map(candidates.map(({ id, hash }) => [id, hash]));
104
+ const results = best(imageRows(context, accountPk, filter, { ids: [...hashes.keys()] })
105
+ .filter((row) => hashes.get(row.id) === row.hash)
106
+ .map(score));
107
+ return { results, best: results.map(({ id }) => id) };
108
+ });
109
+ const plain = (found) => found.map(({ chatId, messageId, score }) => ({ chatId, messageId, score }));
110
+ if (indexed)
111
+ return plain(indexed);
70
112
  const found = [];
71
113
  let after = 0;
72
114
  for (;;) {
73
- const rows = orm.all(sql `SELECT k.id, ch.external_id AS chatId, m.external_id AS messageId, v.vector
74
- FROM chunks k CROSS JOIN attachments a ON a.id = k.chunkable_id AND a.attachable_type = 'message'
75
- JOIN messages m ON m.id = a.attachable_id AND m.deleted_at IS NULL
76
- JOIN chats ch ON ch.id = m.chat_id
77
- JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
78
- WHERE k.chunkable_type = 'image' AND k.position = ${position} AND ch.account_id = ${accountPk}
79
- AND k.id > ${after}
80
- ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
81
- ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
82
- ORDER BY k.id LIMIT ${SCAN_PAGE}`);
115
+ const rows = imageRows(context, accountPk, filter, { after });
83
116
  for (const row of rows)
84
- found.push({ chatId: row.chatId, messageId: row.messageId, score: dot(query, row.vector) });
117
+ found.push(score(row));
85
118
  const last = rows.at(-1);
86
119
  if (!last || rows.length < SCAN_PAGE)
87
120
  break;
88
121
  after = last.id;
89
122
  }
90
- return found.sort((a, b) => b.score - a.score).slice(0, limit);
123
+ return plain(best(found));
91
124
  };
92
125
  /** An image's own vector is its chunk at this position; position 0 is the text around it. */
93
126
  export const PICTURE_POSITION = 1;
@@ -43,6 +43,8 @@ export const MANIFEST = [
43
43
  { name: "20261025071850_version-32-files-data", version: 32, minCompatible: 32 },
44
44
  { name: "20261025072643_version-33-calendars", version: 33, minCompatible: 32 },
45
45
  { name: "20261025072644_version-33-calendars-triggers", version: 33, minCompatible: 32 },
46
+ { name: "20261025182500_version-34-vector-index", version: 34, minCompatible: 34 },
47
+ { name: "20261025182501_version-34-vector-index-triggers", version: 34, minCompatible: 34 },
46
48
  ];
47
49
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
48
50
  const migrations = [];
@@ -33,12 +33,15 @@ export interface NearestMessage {
33
33
  messageId: string;
34
34
  score: number;
35
35
  }
36
- /** The account's live messages nearest to `query` by their vector of `model`, best first: a scan of every vector. */
37
- export declare const nearestMessages: ({ orm }: StoreContext, accountPk: number, { chatKey, model, since, before, limit, query, }: {
36
+ type MessageFilter = {
38
37
  chatKey?: number;
39
38
  model: string;
40
39
  since?: number;
41
40
  before?: number;
41
+ };
42
+ /** The account's live messages nearest to `query` by their vector of `model`, best first. */
43
+ export declare const nearestMessages: (context: StoreContext, accountPk: number, { limit, query, ...filter }: MessageFilter & {
42
44
  limit: number;
43
45
  query: Float32Array;
44
46
  }) => NearestMessage[];
47
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { sql } from "./drizzle/core.js";
2
+ import { searchIndex } from "./vector-index.js";
2
3
  import { dot } from "./vectors.js";
3
4
  /** Live messages with text of the account, by pk after `after`; the caller compares hashes. */
4
5
  export const messagesToEmbed = ({ orm }, accountPk, { chatKey, model, after, limit }) => orm
@@ -51,26 +52,54 @@ export const dropGoneMessageChunks = (context, accountPk) => {
51
52
  export const hasMessageVectors = ({ orm }, accountPk, model) => orm.get(sql `SELECT 1 FROM chunks k JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
52
53
  WHERE k.chunkable_type = 'message' AND k.account_id = ${accountPk} LIMIT 1`) !== undefined;
53
54
  const SCAN_PAGE = 5_000;
54
- /** The account's live messages nearest to `query` by their vector of `model`, best first: a scan of every vector. */
55
- export const nearestMessages = ({ orm }, accountPk, { chatKey, model, since, before, limit, query, }) => {
55
+ /** The scan's rows: one page after `after`, or the `ids` the index picked. */
56
+ const messageRows = ({ orm }, accountPk, { chatKey, model, since, before }, rows) => orm.all(sql `SELECT k.id, k.content_hash AS hash, ch.external_id AS chatId, m.external_id AS messageId, v.vector
57
+ FROM chunks k CROSS JOIN messages m ON m.id = k.chunkable_id AND m.deleted_at IS NULL
58
+ JOIN chats ch ON ch.id = m.chat_id
59
+ JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
60
+ WHERE k.chunkable_type = 'message' AND k.position = 0 AND k.account_id = ${accountPk}
61
+ ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
62
+ ${since === undefined ? sql `` : sql `AND m.sent_at >= ${since}`}
63
+ ${before === undefined ? sql `` : sql `AND m.sent_at < ${before}`}
64
+ ${"ids" in rows
65
+ ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(rows.ids)})) ORDER BY k.id`
66
+ : sql `AND k.id > ${rows.after} ORDER BY k.id LIMIT ${SCAN_PAGE}`}`);
67
+ /** The account's live messages nearest to `query` by their vector of `model`, best first. */
68
+ export const nearestMessages = (context, accountPk, { limit, query, ...filter }) => {
69
+ const score = (row) => ({
70
+ id: row.id,
71
+ chatId: row.chatId,
72
+ messageId: row.messageId,
73
+ score: dot(query, row.vector),
74
+ });
75
+ // Stable, so equal scores keep the scan's order, by chunk id.
76
+ const best = (found) => found.sort((a, b) => b.score - a.score).slice(0, limit);
77
+ const indexed = searchIndex(context.database, filter.model, {
78
+ kind: "message",
79
+ account: accountPk,
80
+ ...(filter.chatKey === undefined ? {} : { chat: filter.chatKey }),
81
+ ...(filter.since === undefined ? {} : { since: filter.since }),
82
+ ...(filter.before === undefined ? {} : { before: filter.before }),
83
+ }, query, limit, (candidates) => {
84
+ const hashes = new Map(candidates.map(({ id, hash }) => [id, hash]));
85
+ const results = best(messageRows(context, accountPk, filter, { ids: [...hashes.keys()] })
86
+ .filter((row) => hashes.get(row.id) === row.hash)
87
+ .map(score));
88
+ return { results, best: results.map(({ id }) => id) };
89
+ });
90
+ const plain = (found) => found.map(({ chatId, messageId, score }) => ({ chatId, messageId, score }));
91
+ if (indexed)
92
+ return plain(indexed);
56
93
  const found = [];
57
94
  let after = 0;
58
95
  for (;;) {
59
- const rows = orm.all(sql `SELECT k.id, ch.external_id AS chatId, m.external_id AS messageId, v.vector
60
- FROM chunks k CROSS JOIN messages m ON m.id = k.chunkable_id AND m.deleted_at IS NULL
61
- JOIN chats ch ON ch.id = m.chat_id
62
- JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
63
- WHERE k.chunkable_type = 'message' AND k.position = 0 AND k.account_id = ${accountPk} AND k.id > ${after}
64
- ${chatKey === undefined ? sql `AND ch.searchable = 1` : sql `AND m.chat_id = ${chatKey}`}
65
- ${since === undefined ? sql `` : sql `AND m.sent_at >= ${since}`}
66
- ${before === undefined ? sql `` : sql `AND m.sent_at < ${before}`}
67
- ORDER BY k.id LIMIT ${SCAN_PAGE}`);
96
+ const rows = messageRows(context, accountPk, filter, { after });
68
97
  for (const row of rows)
69
- found.push({ chatId: row.chatId, messageId: row.messageId, score: dot(query, row.vector) });
98
+ found.push(score(row));
70
99
  const last = rows.at(-1);
71
100
  if (!last || rows.length < SCAN_PAGE)
72
101
  break;
73
102
  after = last.id;
74
103
  }
75
- return found.sort((a, b) => b.score - a.score).slice(0, limit);
104
+ return plain(best(found));
76
105
  };
@@ -739,5 +739,27 @@ export const GENERATED = [
739
739
  "INSERT OR IGNORE INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version, built_at, analyzer)\n VALUES ('event_index', 0, 0, 0, 1, NULL, NULL);",
740
740
  "INSERT OR IGNORE INTO event_index_pending (indexable_type, id) SELECT 'event', id FROM events;"
741
741
  ]
742
+ },
743
+ {
744
+ "name": "20261025182500_version-34-vector-index",
745
+ "statements": [
746
+ "CREATE TABLE `vector_index_redo` (\n\t`chunk_id` integer NOT NULL\n);",
747
+ "CREATE TABLE `vector_indexes` (\n\t`id` integer PRIMARY KEY,\n\t`model` text NOT NULL UNIQUE,\n\t`dims` integer NOT NULL,\n\t`ready` integer NOT NULL,\n\t`filled_through` text NOT NULL\n);"
748
+ ]
749
+ },
750
+ {
751
+ "name": "20261025182501_version-34-vector-index-triggers",
752
+ "statements": [
753
+ "-- Every change that can add, drop or move a chunk's vector passes the chunk's id through vector_index_redo, where\n-- each model's own trigger (made with its vec0 table, `vector-index.ts`) re-derives that row. Writes in TypeScript\n-- and the schema's own delete triggers (document_bd, note_bd, …) all reach the index this way.\n-- A chunk usually comes before its vector; the vector's own trigger indexes it then.\n-- CROSS JOIN: led by the vectors, SQLite read every one of them per chunk (7 ms each at 100k).\nCREATE TRIGGER vector_index_chunk_ai AFTER INSERT ON chunks WHEN EXISTS (SELECT 1 FROM vector_indexes i\n CROSS JOIN embeddings v ON v.model = i.model AND v.content_hash = new.content_hash) BEGIN\n INSERT INTO vector_index_redo (chunk_id) VALUES (new.id);\n DELETE FROM vector_index_redo;\nEND;",
754
+ "CREATE TRIGGER vector_index_chunk_ad AFTER DELETE ON chunks BEGIN\n INSERT INTO vector_index_redo (chunk_id) VALUES (old.id);\n DELETE FROM vector_index_redo;\nEND;",
755
+ "CREATE TRIGGER vector_index_chunk_au AFTER UPDATE OF content_hash, chunkable_type, chunkable_id, account_id, occurred_at\n ON chunks WHEN old.content_hash IS NOT new.content_hash OR old.chunkable_type IS NOT new.chunkable_type\n OR old.chunkable_id IS NOT new.chunkable_id OR old.account_id IS NOT new.account_id\n OR old.occurred_at IS NOT new.occurred_at BEGIN\n INSERT INTO vector_index_redo (chunk_id) VALUES (new.id);\n DELETE FROM vector_index_redo;\nEND;",
756
+ "CREATE TRIGGER vector_index_embedding_ai AFTER INSERT ON embeddings\n WHEN EXISTS (SELECT 1 FROM vector_indexes WHERE model = new.model) BEGIN\n INSERT INTO vector_index_redo (chunk_id) SELECT id FROM chunks WHERE content_hash = new.content_hash;\n DELETE FROM vector_index_redo;\nEND;",
757
+ "CREATE TRIGGER vector_index_embedding_ad AFTER DELETE ON embeddings\n WHEN EXISTS (SELECT 1 FROM vector_indexes WHERE model = old.model) BEGIN\n INSERT INTO vector_index_redo (chunk_id) SELECT id FROM chunks WHERE content_hash = old.content_hash;\n DELETE FROM vector_index_redo;\nEND;",
758
+ "CREATE TRIGGER vector_index_embedding_au AFTER UPDATE OF model, content_hash, vector ON embeddings BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE content_hash = old.content_hash OR content_hash = new.content_hash;\n DELETE FROM vector_index_redo;\nEND;",
759
+ "-- A message's chunk and its images' chunks are indexed under its chat and send time; a chat merge moves messages.\nCREATE TRIGGER vector_index_message_au AFTER UPDATE OF chat_id, sent_at ON messages\n WHEN old.chat_id IS NOT new.chat_id OR old.sent_at IS NOT new.sent_at BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE chunkable_type = 'message' AND chunkable_id = new.id\n UNION ALL SELECT k.id FROM attachments a JOIN chunks k ON k.chunkable_type = 'image' AND k.chunkable_id = a.id\n WHERE a.attachable_type = 'message' AND a.attachable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
760
+ "-- An image's chat and account come from its message; a chat merge moves attachments to the kept copy.\nCREATE TRIGGER vector_index_attachment_au AFTER UPDATE OF attachable_type, attachable_id ON attachments\n WHEN old.attachable_type IS NOT new.attachable_type OR old.attachable_id IS NOT new.attachable_id BEGIN\n INSERT INTO vector_index_redo (chunk_id) SELECT id FROM chunks WHERE chunkable_type = 'image' AND chunkable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
761
+ "CREATE TRIGGER vector_index_conversation_au AFTER UPDATE OF chat_id ON conversations\n WHEN old.chat_id IS NOT new.chat_id BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE chunkable_type = 'conversation' AND chunkable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
762
+ "-- Images and conversations are indexed under their chat's account; messages carry their own.\nCREATE TRIGGER vector_index_chat_au AFTER UPDATE OF account_id ON chats WHEN old.account_id IS NOT new.account_id BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT k.id FROM messages m JOIN attachments a ON a.attachable_type = 'message' AND a.attachable_id = m.id\n JOIN chunks k ON k.chunkable_type = 'image' AND k.chunkable_id = a.id WHERE m.chat_id = new.id\n UNION ALL SELECT k.id FROM conversations c JOIN chunks k ON k.chunkable_type = 'conversation' AND k.chunkable_id = c.id\n WHERE c.chat_id = new.id;\n DELETE FROM vector_index_redo;\nEND;"
763
+ ]
742
764
  }
743
765
  ];
@@ -15,6 +15,7 @@ import { loadProviders } from "./providers.js";
15
15
  import { assertReadCompatible } from "./read-compatibility.js";
16
16
  import { stemsState } from "./stems.js";
17
17
  import { fillStems, followDefaultStemmers } from "./stems-fill.js";
18
+ import { fillVectorIndexes } from "./vector-index.js";
18
19
  export const BACKFILL_ON_OPEN = 5_000;
19
20
  const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
20
21
  const createWritable = (file) => {
@@ -62,6 +63,9 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
62
63
  stemming.watermark - stemming.filledThrough + stemming.pending <= BACKFILL_ON_OPEN)
63
64
  fillStems(database, { now });
64
65
  logPruner(database, now)();
66
+ fillVectorIndexes(database, {
67
+ notice: (model) => warn(`indexing the stored vectors of ${model} for search by meaning — once; a large archive takes minutes`),
68
+ });
65
69
  }
66
70
  loadProviders(database);
67
71
  return { ...opened, now, sharedConfig: sharedConfigPath(file, env ?? process.env) };
@@ -20,10 +20,9 @@ export const openSqlite = async (path, { readOnly = false } = {}) => {
20
20
  };
21
21
  const openUnderNode = async (path, readOnly) => {
22
22
  quietSqliteWarning();
23
- const { DatabaseSync } = await import("node:sqlite");
24
23
  const { drizzle } = await import("./drizzle/node.js");
25
- const { cacheOverNodeSqlite } = await import("../drivers/node-sqlite.js");
26
- const client = new DatabaseSync(path, { readOnly });
24
+ const { cacheOverNodeSqlite, nodeClient } = await import("../drivers/node-sqlite.js");
25
+ const client = await nodeClient(path, { readOnly });
27
26
  try {
28
27
  return { database: cacheOverNodeSqlite(client), orm: drizzle({ client }) };
29
28
  }
@@ -33,10 +32,9 @@ const openUnderNode = async (path, readOnly) => {
33
32
  }
34
33
  };
35
34
  const openUnderBun = async (path, readOnly) => {
36
- const { bunDatabase, cacheOverBunSqlite } = await import("../drivers/bun-sqlite.js");
37
- const Database = await bunDatabase();
35
+ const { bunClient, cacheOverBunSqlite } = await import("../drivers/bun-sqlite.js");
38
36
  const { drizzle } = (await import("./drizzle/bun.js"));
39
- const client = readOnly ? new Database(path, { readonly: true, create: false }) : new Database(path);
37
+ const client = await bunClient(path, readOnly ? { readonly: true, create: false } : undefined);
40
38
  try {
41
39
  return { database: cacheOverBunSqlite(client), orm: drizzle({ client }) };
42
40
  }
@@ -16637,6 +16637,123 @@ export declare const mentionPending: import("drizzle-orm/sqlite-core").SQLiteTab
16637
16637
  dialect: 'sqlite';
16638
16638
  isAlias: false;
16639
16639
  }>;
16640
+ /**
16641
+ * One sqlite-vec `vec0` table per embedding model, `vector_index_<id>`, created at run time with its trigger since
16642
+ * its width is the model's: a row per chunk with a vector of that model, rowid `chunks.id` (`vector-index.ts`).
16643
+ */
16644
+ export declare const vectorIndexes: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
16645
+ name: "vector_indexes";
16646
+ schema: undefined;
16647
+ columns: {
16648
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16649
+ name: string;
16650
+ tableName: "vector_indexes";
16651
+ dataType: "number int53";
16652
+ data: number;
16653
+ driverParam: number;
16654
+ notNull: true;
16655
+ hasDefault: true;
16656
+ isPrimaryKey: true;
16657
+ isAutoincrement: false;
16658
+ hasRuntimeDefault: false;
16659
+ enumValues: undefined;
16660
+ baseColumn: never;
16661
+ identity: undefined;
16662
+ generated: undefined;
16663
+ }, {}>;
16664
+ model: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16665
+ name: string;
16666
+ tableName: "vector_indexes";
16667
+ dataType: "string";
16668
+ data: string;
16669
+ driverParam: string;
16670
+ notNull: true;
16671
+ hasDefault: false;
16672
+ isPrimaryKey: false;
16673
+ isAutoincrement: false;
16674
+ hasRuntimeDefault: false;
16675
+ enumValues: [string, ...string[]];
16676
+ baseColumn: never;
16677
+ identity: undefined;
16678
+ generated: undefined;
16679
+ }, {}>;
16680
+ dims: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16681
+ name: string;
16682
+ tableName: "vector_indexes";
16683
+ dataType: "number int53";
16684
+ data: number;
16685
+ driverParam: number;
16686
+ notNull: true;
16687
+ hasDefault: false;
16688
+ isPrimaryKey: false;
16689
+ isAutoincrement: false;
16690
+ hasRuntimeDefault: false;
16691
+ enumValues: undefined;
16692
+ baseColumn: never;
16693
+ identity: undefined;
16694
+ generated: undefined;
16695
+ }, {}>;
16696
+ ready: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16697
+ name: string;
16698
+ tableName: "vector_indexes";
16699
+ dataType: "number int53";
16700
+ data: number;
16701
+ driverParam: number;
16702
+ notNull: true;
16703
+ hasDefault: false;
16704
+ isPrimaryKey: false;
16705
+ isAutoincrement: false;
16706
+ hasRuntimeDefault: false;
16707
+ enumValues: undefined;
16708
+ baseColumn: never;
16709
+ identity: undefined;
16710
+ generated: undefined;
16711
+ }, {}>;
16712
+ filledThrough: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16713
+ name: string;
16714
+ tableName: "vector_indexes";
16715
+ dataType: "string";
16716
+ data: string;
16717
+ driverParam: string;
16718
+ notNull: true;
16719
+ hasDefault: false;
16720
+ isPrimaryKey: false;
16721
+ isAutoincrement: false;
16722
+ hasRuntimeDefault: false;
16723
+ enumValues: [string, ...string[]];
16724
+ baseColumn: never;
16725
+ identity: undefined;
16726
+ generated: undefined;
16727
+ }, {}>;
16728
+ };
16729
+ dialect: 'sqlite';
16730
+ isAlias: false;
16731
+ }>;
16732
+ /** Always empty: each changed chunk's id passes through it so that every model's index re-derives its row. */
16733
+ export declare const vectorIndexRedo: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
16734
+ name: "vector_index_redo";
16735
+ schema: undefined;
16736
+ columns: {
16737
+ chunkId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
16738
+ name: string;
16739
+ tableName: "vector_index_redo";
16740
+ dataType: "number int53";
16741
+ data: number;
16742
+ driverParam: number;
16743
+ notNull: true;
16744
+ hasDefault: false;
16745
+ isPrimaryKey: false;
16746
+ isAutoincrement: false;
16747
+ hasRuntimeDefault: false;
16748
+ enumValues: undefined;
16749
+ baseColumn: never;
16750
+ identity: undefined;
16751
+ generated: undefined;
16752
+ }, {}>;
16753
+ };
16754
+ dialect: 'sqlite';
16755
+ isAlias: false;
16756
+ }>;
16640
16757
  /** Settings of the store file itself, shared by every profile, tg and MAX — unlike a profile's config file. */
16641
16758
  export declare const storeSettings: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
16642
16759
  name: "store_settings";
@@ -1684,6 +1684,23 @@ export const mentionPending = sqliteTable("mention_pending", {
1684
1684
  sourceType: text("source_type").notNull(),
1685
1685
  sourceId: integer("source_id").notNull(),
1686
1686
  }, (table) => [primaryKey({ columns: [table.sourceType, table.sourceId] })]);
1687
+ /**
1688
+ * One sqlite-vec `vec0` table per embedding model, `vector_index_<id>`, created at run time with its trigger since
1689
+ * its width is the model's: a row per chunk with a vector of that model, rowid `chunks.id` (`vector-index.ts`).
1690
+ */
1691
+ export const vectorIndexes = sqliteTable("vector_indexes", {
1692
+ id: integer("id").primaryKey(),
1693
+ model: text("model").notNull().unique(),
1694
+ dims: integer("dims").notNull(),
1695
+ /** 1 once it holds every vector; until then searches scan. */
1696
+ ready: integer("ready").notNull(),
1697
+ /** The last `content_hash` the fill of older vectors reached. */
1698
+ filledThrough: text("filled_through").notNull(),
1699
+ });
1700
+ /** Always empty: each changed chunk's id passes through it so that every model's index re-derives its row. */
1701
+ export const vectorIndexRedo = sqliteTable("vector_index_redo", {
1702
+ chunkId: integer("chunk_id").notNull(),
1703
+ });
1687
1704
  /** Settings of the store file itself, shared by every profile, tg and MAX — unlike a profile's config file. */
1688
1705
  export const storeSettings = sqliteTable("store_settings", {
1689
1706
  key: text("key").primaryKey(),
@@ -0,0 +1,67 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /**
3
+ * Every stored vector has length one, so the scan's dot product and the index's cosine rank alike. A model that
4
+ * already gives unit vectors (e5, the API models) changes by float rounding only. A zero vector (a text the model
5
+ * knows nothing of) stays zero: vec0 puts it first with no distance, so it is always among the candidates and scores
6
+ * 0 both ways.
7
+ */
8
+ export declare const unitVector: (vector: Float32Array) => Float32Array;
9
+ export interface VectorIndex {
10
+ table: string;
11
+ dims: number;
12
+ ready: boolean;
13
+ }
14
+ export declare const vectorIndexOf: (database: CacheDatabase, model: string) => VectorIndex | undefined;
15
+ /**
16
+ * The model's index, made on its first vector. A model that already has vectors (an older store) is filled by
17
+ * `fillVectorIndexes` in short batches, and searched by scan until it is ready.
18
+ */
19
+ export declare const ensureVectorIndex: (database: CacheDatabase, model: string, dims: number) => void;
20
+ /**
21
+ * Indexes the vectors a store held before its index existed, in hash order from where the last batch stopped. Rows
22
+ * the triggers wrote meanwhile are skipped. Returns the models it filled.
23
+ */
24
+ export declare const fillVectorIndexes: (database: CacheDatabase, { batch, notice }?: {
25
+ batch?: number;
26
+ notice?: (model: string) => void;
27
+ }) => string[];
28
+ export interface IndexFilter {
29
+ kind: string;
30
+ account: number;
31
+ chat?: number;
32
+ since?: number;
33
+ before?: number;
34
+ }
35
+ export interface Candidate {
36
+ id: number;
37
+ hash: string;
38
+ /** None for a zero vector. */
39
+ distance: number | null;
40
+ }
41
+ /**
42
+ * The best `limit` results by the index, or `undefined` when the caller must scan: no ready index for the model, or
43
+ * no exact answer within the most candidates vec0 returns. `survivors` applies the scan's own SQL to the candidates,
44
+ * in the scan's order (and checks each one's hash, so a reused chunk id is never taken for the old chunk), scores
45
+ * them, and names the chunks behind its best `limit` results. Those are exact once each is nearer than the last
46
+ * candidate by more than `NEAR_TIE`: every row nearer than that is among the candidates, and the rest cannot outrank
47
+ * them even where float32 and float64 disagree. A text many
48
+ * chunks share has one vector, so a tie that crosses the last candidate asks for more, as the scan's order decides it.
49
+ */
50
+ export declare const searchIndex: <T>(database: CacheDatabase, model: string, filter: IndexFilter, query: Float32Array, limit: number, survivors: (candidates: Candidate[]) => {
51
+ results: T[];
52
+ best: number[];
53
+ }) => T[] | undefined;
54
+ /**
55
+ * Per model, `store check`'s safety net: the rows its index holds against the rows `source` derives now, compared
56
+ * whole (chunk, kind, account, chat, time, hash, vector) both ways. `missing` are rows the index lacks or holds
57
+ * wrong; `extra` rows it should not hold.
58
+ */
59
+ export declare const vectorIndexCounts: (database: CacheDatabase) => {
60
+ model: string;
61
+ dims: number;
62
+ ready: boolean;
63
+ indexed: number;
64
+ expected: number;
65
+ missing: number;
66
+ extra: number;
67
+ }[];
@@ -0,0 +1,180 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { writeBatch } from "./transaction.js";
3
+ /**
4
+ * One sqlite-vec `vec0` table per embedding model, a row per chunk that has a vector of that model: rowid is
5
+ * `chunks.id`. The static triggers of store version 34 push every changed chunk id through `vector_index_redo`, and
6
+ * each model's own trigger re-derives that chunk's row, so every writer — the store's functions, the schema's own
7
+ * delete triggers, a raw `DELETE` — keeps the index in step inside its own transaction.
8
+ *
9
+ * `kind` and `account` are partition keys, `occurred_at` and `chat` metadata: the filters a KNN query applies before
10
+ * it ranks. `account`, `chat` and `occurred_at` are the columns each kind's scan filters on, so the index never
11
+ * leaves out a row the scan would keep; filters it cannot express are checked on the candidates after.
12
+ */
13
+ const source = (model, dims) => `SELECT k.id, k.chunkable_type,
14
+ coalesce(CASE k.chunkable_type
15
+ WHEN 'image' THEN (SELECT ch.account_id FROM attachments a JOIN messages m ON m.id = a.attachable_id
16
+ JOIN chats ch ON ch.id = m.chat_id WHERE a.id = k.chunkable_id AND a.attachable_type = 'message')
17
+ WHEN 'conversation' THEN (SELECT ch.account_id FROM conversations c JOIN chats ch ON ch.id = c.chat_id
18
+ WHERE c.id = k.chunkable_id)
19
+ ELSE k.account_id END, 0),
20
+ coalesce(CASE k.chunkable_type
21
+ WHEN 'message' THEN (SELECT m.chat_id FROM messages m WHERE m.id = k.chunkable_id)
22
+ WHEN 'image' THEN (SELECT m.chat_id FROM attachments a JOIN messages m ON m.id = a.attachable_id
23
+ WHERE a.id = k.chunkable_id AND a.attachable_type = 'message')
24
+ WHEN 'conversation' THEN (SELECT c.chat_id FROM conversations c WHERE c.id = k.chunkable_id) END, 0),
25
+ coalesce(CASE k.chunkable_type
26
+ WHEN 'message' THEN (SELECT m.sent_at FROM messages m WHERE m.id = k.chunkable_id)
27
+ ELSE k.occurred_at END, 0),
28
+ v.vector, k.content_hash
29
+ FROM chunks k JOIN embeddings v ON v.model = ${literal(model)} AND v.content_hash = k.content_hash
30
+ AND length(v.vector) = ${dims * 4}`;
31
+ /**
32
+ * Every stored vector has length one, so the scan's dot product and the index's cosine rank alike. A model that
33
+ * already gives unit vectors (e5, the API models) changes by float rounding only. A zero vector (a text the model
34
+ * knows nothing of) stays zero: vec0 puts it first with no distance, so it is always among the candidates and scores
35
+ * 0 both ways.
36
+ */
37
+ export const unitVector = (vector) => {
38
+ let sum = 0;
39
+ for (const value of vector)
40
+ sum += value * value;
41
+ const length = Math.sqrt(sum);
42
+ if (!Number.isFinite(length))
43
+ throw new CliError("validation_error", "a vector must have finite values", { reason: "store.vector-invalid" });
44
+ return length === 0 ? vector : vector.map((value) => value / length);
45
+ };
46
+ const literal = (text) => `'${text.replaceAll("'", "''")}'`;
47
+ const COLUMNS = "rowid, kind, account, chat, occurred_at, embedding, hash";
48
+ const tableOf = (id) => `vector_index_${id}`;
49
+ export const vectorIndexOf = (database, model) => {
50
+ const row = database.prepare("SELECT id, dims, ready FROM vector_indexes WHERE model = ?").get(model);
51
+ return row && { table: tableOf(Number(row.id)), dims: Number(row.dims), ready: row.ready === 1 };
52
+ };
53
+ /**
54
+ * The model's index, made on its first vector. A model that already has vectors (an older store) is filled by
55
+ * `fillVectorIndexes` in short batches, and searched by scan until it is ready.
56
+ */
57
+ export const ensureVectorIndex = (database, model, dims) => {
58
+ if (vectorIndexOf(database, model))
59
+ return;
60
+ const held = database.prepare("SELECT 1 FROM embeddings WHERE model = ? LIMIT 1").get(model) !== undefined;
61
+ const id = Number(database
62
+ .prepare("INSERT INTO vector_indexes (model, dims, ready, filled_through) VALUES (?, ?, ?, '') RETURNING id")
63
+ .get(model, dims, held ? 0 : 1)?.id);
64
+ const table = tableOf(id);
65
+ database.exec(`CREATE VIRTUAL TABLE ${table} USING vec0(kind text partition key, account integer partition key,
66
+ chat integer, occurred_at integer, embedding float[${dims}] distance_metric=cosine, +hash text)`);
67
+ database.exec(`CREATE TRIGGER ${table}_redo AFTER INSERT ON vector_index_redo BEGIN
68
+ DELETE FROM ${table} WHERE rowid = new.chunk_id;
69
+ INSERT INTO ${table} (${COLUMNS}) ${source(model, dims)} WHERE k.id = new.chunk_id;
70
+ END`);
71
+ };
72
+ /** The models with vectors and no index, by a walk of the primary key rather than a scan of every vector. */
73
+ const modelsWithoutIndex = (database) => database
74
+ .prepare(`WITH RECURSIVE m(model) AS (SELECT min(model) FROM embeddings
75
+ UNION ALL SELECT (SELECT min(model) FROM embeddings WHERE model > m.model) FROM m WHERE m.model IS NOT NULL)
76
+ SELECT m.model, (SELECT dims FROM embeddings WHERE model = m.model LIMIT 1) AS dims FROM m
77
+ WHERE m.model IS NOT NULL AND NOT EXISTS (SELECT 1 FROM vector_indexes i WHERE i.model = m.model)`)
78
+ .all();
79
+ /** Hashes per batch: a batch is one short write transaction, so other processes wait less than their timeout. */
80
+ const FILL_HASHES = 1_000;
81
+ /**
82
+ * Indexes the vectors a store held before its index existed, in hash order from where the last batch stopped. Rows
83
+ * the triggers wrote meanwhile are skipped. Returns the models it filled.
84
+ */
85
+ export const fillVectorIndexes = (database, { batch = FILL_HASHES, notice = () => { } } = {}) => {
86
+ for (const { model, dims } of modelsWithoutIndex(database))
87
+ writeBatch(database, () => ensureVectorIndex(database, model, Number(dims)));
88
+ const pending = database
89
+ .prepare("SELECT id, model, dims, filled_through FROM vector_indexes WHERE ready = 0 ORDER BY id")
90
+ .all();
91
+ for (const { id, model, dims, filled_through } of pending) {
92
+ notice(model);
93
+ const table = tableOf(id);
94
+ const step = database.prepare(`INSERT INTO ${table} (${COLUMNS}) ${source(model, dims)}
95
+ WHERE k.content_hash IN (SELECT value FROM json_each(?)) AND NOT EXISTS (SELECT 1 FROM ${table} WHERE rowid = k.id)`);
96
+ const hashes = database.prepare("SELECT content_hash AS hash FROM embeddings WHERE model = ? AND content_hash > ? ORDER BY content_hash LIMIT ?");
97
+ let after = filled_through;
98
+ for (;;) {
99
+ const done = writeBatch(database, () => {
100
+ const page = hashes.all(model, after, batch).map(({ hash }) => hash);
101
+ if (page.length > 0)
102
+ step.run(JSON.stringify(page));
103
+ after = page.at(-1) ?? after;
104
+ const finished = page.length < batch;
105
+ // Another process may be filling too: neither moves the other back, nor un-readies a finished index.
106
+ database
107
+ .prepare("UPDATE vector_indexes SET filled_through = ?, ready = ? WHERE id = ? AND ready = 0 AND filled_through <= ?")
108
+ .run(after, finished ? 1 : 0, id, after);
109
+ return finished;
110
+ });
111
+ if (done)
112
+ break;
113
+ }
114
+ }
115
+ return pending.map(({ model }) => model);
116
+ };
117
+ /**
118
+ * vec0 measures in float32 and the scan in float64, so two results closer than this may swap. Far above float32's
119
+ * error at these lengths, far below any distance that matters for ranking.
120
+ */
121
+ const NEAR_TIE = 1e-5;
122
+ /** vec0 refuses a k above this. */
123
+ const MAX_K = 4_096;
124
+ const knn = (database, index, filter, query, k) => database
125
+ .prepare(`SELECT rowid AS id, hash, distance FROM ${index.table} WHERE embedding MATCH ? AND k = ? AND kind = ? AND account = ?
126
+ ${filter.chat === undefined ? "" : "AND chat = ?"}
127
+ ${filter.since === undefined ? "" : "AND occurred_at >= ?"}
128
+ ${filter.before === undefined ? "" : "AND occurred_at < ?"}`)
129
+ .all(new Uint8Array(query.buffer, query.byteOffset, query.byteLength), BigInt(k), filter.kind,
130
+ // node:sqlite binds a JS number as REAL, which vec0's integer columns refuse.
131
+ BigInt(filter.account), ...[filter.chat, filter.since, filter.before].flatMap((value) => (value === undefined ? [] : [BigInt(value)])));
132
+ /**
133
+ * The best `limit` results by the index, or `undefined` when the caller must scan: no ready index for the model, or
134
+ * no exact answer within the most candidates vec0 returns. `survivors` applies the scan's own SQL to the candidates,
135
+ * in the scan's order (and checks each one's hash, so a reused chunk id is never taken for the old chunk), scores
136
+ * them, and names the chunks behind its best `limit` results. Those are exact once each is nearer than the last
137
+ * candidate by more than `NEAR_TIE`: every row nearer than that is among the candidates, and the rest cannot outrank
138
+ * them even where float32 and float64 disagree. A text many
139
+ * chunks share has one vector, so a tie that crosses the last candidate asks for more, as the scan's order decides it.
140
+ */
141
+ export const searchIndex = (database, model, filter, query, limit, survivors) => {
142
+ const index = vectorIndexOf(database, model);
143
+ if (!index?.ready || index.dims !== query.length)
144
+ return undefined;
145
+ let k = Math.min(MAX_K, Math.max(100, limit * 4));
146
+ for (;;) {
147
+ const candidates = knn(database, index, filter, query, k);
148
+ const { results, best } = survivors(candidates);
149
+ if (candidates.length < k)
150
+ return results;
151
+ const edge = candidates.at(-1)?.distance ?? null;
152
+ const distance = new Map(candidates.map((candidate) => [candidate.id, candidate.distance]));
153
+ // A last candidate with no distance is a zero vector: nothing is known to be nearer yet.
154
+ const nearer = (id) => edge !== null && (distance.get(id) ?? edge) < edge - NEAR_TIE;
155
+ if (best.length >= limit && best.every(nearer))
156
+ return results;
157
+ if (k === MAX_K)
158
+ return undefined;
159
+ k = Math.min(MAX_K, k * 4);
160
+ }
161
+ };
162
+ /**
163
+ * Per model, `store check`'s safety net: the rows its index holds against the rows `source` derives now, compared
164
+ * whole (chunk, kind, account, chat, time, hash, vector) both ways. `missing` are rows the index lacks or holds
165
+ * wrong; `extra` rows it should not hold.
166
+ */
167
+ export const vectorIndexCounts = (database) => database.prepare("SELECT id, model, dims, ready FROM vector_indexes ORDER BY id").all().map(({ id, model, dims, ready }) => {
168
+ const held = `SELECT ${COLUMNS} FROM ${tableOf(id)}`;
169
+ const wanted = source(model, Number(dims));
170
+ const count = (sql) => Number(database.prepare(`SELECT count(*) AS n FROM (${sql})`).get()?.n ?? 0);
171
+ return {
172
+ model,
173
+ dims: Number(dims),
174
+ ready: ready === 1,
175
+ indexed: count(held),
176
+ expected: count(wanted),
177
+ missing: count(`${wanted} EXCEPT ${held}`),
178
+ extra: count(`${held} EXCEPT ${wanted}`),
179
+ };
180
+ });
@@ -36,8 +36,11 @@ export declare const conversationVectors: (context: StoreContext, conversationPk
36
36
  chunks: number;
37
37
  vectors: Float32Array[];
38
38
  };
39
- /** A vector already there for the same model and text is kept: the same text gives the same vector. */
40
- export declare const saveVectors: ({ orm, now }: StoreContext, model: string, dims: number, vectors: {
39
+ /**
40
+ * A vector already there for the same model and text is kept: the same text gives the same vector. Each is stored at
41
+ * length one; a zero or non-finite vector is refused.
42
+ */
43
+ export declare const saveVectors: ({ orm, now, database }: StoreContext, model: string, dims: number, vectors: {
41
44
  hash: string;
42
45
  vector: Float32Array;
43
46
  }[]) => void;
@@ -65,7 +68,7 @@ export declare const dot: (query: Float32Array, blob: Uint8Array) => number;
65
68
  * The conversations of the current builds in scope nearest to `query`, best first: each scored by its
66
69
  * best chunk. Vectors are unit length, so the dot product is the cosine (phase 5 E3).
67
70
  */
68
- export declare const nearestChunks: ({ orm }: StoreContext, accountPk: number, { chatKey, model, since, limit, query, exclude, conversations, scope, projectId, before, personId, }: {
71
+ export declare const nearestChunks: (context: StoreContext, accountPk: number, filter: {
69
72
  chatKey?: number;
70
73
  model: string;
71
74
  since?: number;
@@ -1,5 +1,6 @@
1
1
  import { chunkHash, chunkTextOf } from "../../conversations/chunks.js";
2
2
  import { sql } from "./drizzle/core.js";
3
+ import { ensureVectorIndex, searchIndex, unitVector } from "./vector-index.js";
3
4
  const currentChunks = (chatKey) => sql `SELECT k.content_hash, k.chunkable_id AS conversation_id, r.first_message_id,
4
5
  r.last_message_id, r.text_start, r.text_end FROM chunks k JOIN chunk_messages r ON r.chunk_id = k.id
5
6
  JOIN conversations c ON k.chunkable_type = 'conversation' AND c.id = k.chunkable_id
@@ -113,10 +114,17 @@ export const conversationVectors = (context, conversationPk, model) => {
113
114
  : []);
114
115
  return { chunks: rows.length, vectors };
115
116
  };
116
- /** A vector already there for the same model and text is kept: the same text gives the same vector. */
117
- export const saveVectors = ({ orm, now }, model, dims, vectors) => {
117
+ /**
118
+ * A vector already there for the same model and text is kept: the same text gives the same vector. Each is stored at
119
+ * length one; a zero or non-finite vector is refused.
120
+ */
121
+ export const saveVectors = ({ orm, now, database }, model, dims, vectors) => {
122
+ if (vectors.length === 0)
123
+ return;
124
+ ensureVectorIndex(database, model, dims);
118
125
  const at = now();
119
- for (const { hash, vector } of vectors) {
126
+ for (const { hash, vector: given } of vectors) {
127
+ const vector = unitVector(given);
120
128
  orm.run(sql `INSERT INTO embeddings (model, content_hash, dims, vector, created_at, updated_at)
121
129
  VALUES (${model}, ${hash}, ${dims}, ${Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength)}, ${at}, ${at})
122
130
  ON CONFLICT DO NOTHING`);
@@ -157,14 +165,13 @@ export const dot = (query, blob) => {
157
165
  * The conversations of the current builds in scope nearest to `query`, best first: each scored by its
158
166
  * best chunk. Vectors are unit length, so the dot product is the cosine (phase 5 E3).
159
167
  */
160
- export const nearestChunks = ({ orm }, accountPk, { chatKey, model, since, limit, query, exclude, conversations, scope, projectId, before, personId, }) => {
161
- const best = new Map();
162
- let after = { conversation: 0, ordinal: -1 };
163
- for (;;) {
164
- const rows = orm.all(
165
- // CROSS JOIN keeps the chunks first, so each page walks their key; led by the vectors, SQLite re-read and
166
- // sorted every one of them per page — 1.3 s against 140 ms at 42k chunks (bench/embeddings/README.md).
167
- sql `SELECT k.chunkable_id AS conversation, k.position AS ordinal, r.first_message_id AS first, r.last_message_id AS last,
168
+ export const nearestChunks = (context, accountPk, filter) => {
169
+ const { orm } = context;
170
+ const { chatKey, model, since, limit, query, exclude, conversations, scope, projectId, before, personId } = filter;
171
+ const rows = (page) => orm.all(
172
+ // CROSS JOIN keeps the chunks first, so each page walks their key; led by the vectors, SQLite re-read and
173
+ // sorted every one of them per page — 1.3 s against 140 ms at 42k chunks (bench/embeddings/README.md).
174
+ sql `SELECT k.id, k.chunkable_id AS conversation, k.position AS ordinal, r.first_message_id AS first, r.last_message_id AS last,
168
175
  r.text_start AS start, r.text_end AS end, k.content_hash AS hash, v.vector FROM chunks k
169
176
  CROSS JOIN conversations c ON c.id = k.chunkable_id
170
177
  JOIN chunk_messages r ON r.chunk_id = k.id
@@ -180,29 +187,62 @@ export const nearestChunks = ({ orm }, accountPk, { chatKey, model, since, limit
180
187
  ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
181
188
  ${exclude === undefined ? sql `` : sql `AND c.id <> ${exclude}`}
182
189
  ${conversations === undefined ? sql `` : sql `AND c.id IN (SELECT value FROM json_each(${JSON.stringify(conversations)}))`}
183
- AND (k.chunkable_id, k.position) > (${after.conversation}, ${after.ordinal})
184
- ORDER BY k.chunkable_id, k.position LIMIT ${SCAN_PAGE}`);
185
- for (const row of rows) {
186
- const score = dot(query, row.vector);
187
- const held = best.get(row.conversation);
188
- if (!held || score > held.score) {
189
- const range = rangeOf(row.start, row.end);
190
- best.set(row.conversation, {
191
- conversationPk: row.conversation,
192
- firstMessagePk: row.first,
193
- lastMessagePk: row.last,
194
- ...(range ? { range } : {}),
195
- hash: row.hash,
196
- score,
197
- });
198
- }
199
- }
200
- const last = rows.at(-1);
201
- if (!last || rows.length < SCAN_PAGE)
190
+ ${"ids" in page
191
+ ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(page.ids)}))
192
+ ORDER BY k.chunkable_id, k.position`
193
+ : sql `AND (k.chunkable_id, k.position) > (${page.after.conversation}, ${page.after.ordinal})
194
+ ORDER BY k.chunkable_id, k.position LIMIT ${SCAN_PAGE}`}`);
195
+ const keepBest = (best, row) => {
196
+ const score = dot(query, row.vector);
197
+ const held = best.get(row.conversation);
198
+ if (held && score <= held.score)
199
+ return;
200
+ const range = rangeOf(row.start, row.end);
201
+ best.set(row.conversation, {
202
+ id: row.id,
203
+ conversationPk: row.conversation,
204
+ firstMessagePk: row.first,
205
+ lastMessagePk: row.last,
206
+ ...(range ? { range } : {}),
207
+ hash: row.hash,
208
+ score,
209
+ });
210
+ };
211
+ // Stable, so equal scores keep the scan's order, by conversation.
212
+ const ranked = (found) => found.sort((a, b) => b.score - a.score).slice(0, limit);
213
+ const plain = (found) => found.map(({ id: _, ...chunk }) => chunk);
214
+ // The index has no person, project, scope or conversation list; with one of those the scan stays.
215
+ const indexable = personId === undefined && projectId === undefined && scope === undefined && conversations === undefined;
216
+ const indexed = indexable &&
217
+ searchIndex(context.database, model, {
218
+ kind: "conversation",
219
+ account: accountPk,
220
+ ...(chatKey === undefined ? {} : { chat: chatKey }),
221
+ ...(since === undefined ? {} : { since }),
222
+ ...(before === undefined ? {} : { before }),
223
+ }, query, limit, (candidates) => {
224
+ const hashes = new Map(candidates.map(({ id, hash }) => [id, hash]));
225
+ const best = new Map();
226
+ for (const row of rows({ ids: [...hashes.keys()] }))
227
+ if (hashes.get(row.id) === row.hash)
228
+ keepBest(best, row);
229
+ const results = ranked([...best.values()]);
230
+ return { results, best: results.map(({ id }) => id) };
231
+ });
232
+ if (indexed)
233
+ return plain(indexed);
234
+ const best = new Map();
235
+ let after = { conversation: 0, ordinal: -1 };
236
+ for (;;) {
237
+ const page = rows({ after });
238
+ for (const row of page)
239
+ keepBest(best, row);
240
+ const last = page.at(-1);
241
+ if (!last || page.length < SCAN_PAGE)
202
242
  break;
203
243
  after = { conversation: last.conversation, ordinal: last.ordinal };
204
244
  }
205
- return [...best.values()].sort((a, b) => b.score - a.score).slice(0, limit);
245
+ return plain(ranked([...best.values()]));
206
246
  };
207
247
  const hasVectorOf = (models) => sql `EXISTS (SELECT 1 FROM chunks k
208
248
  JOIN conversations c ON k.chunkable_type = 'conversation' AND c.id = k.chunkable_id
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The sqlite-vec extension every store connection loads: the vector index's tables and triggers cannot be
3
+ * read or written without it. It ships in `@wirecat/cli-core-sqlite`, so a missing file is a broken install.
4
+ */
5
+ export declare const vectorExtension: () => Promise<string>;
6
+ /** Loads the extension into a connection that allows it, then closes the door for SQL's `load_extension()`. */
7
+ export declare const loadVectorExtension: (database: {
8
+ loadExtension(path: string): void;
9
+ enableLoadExtension?(allow: boolean): void;
10
+ }, path: string) => void;
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ const musl = () => {
4
+ if (process.platform !== "linux")
5
+ return false;
6
+ try {
7
+ return readFileSync("/proc/self/maps", "utf8").includes("ld-musl");
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ };
13
+ let found;
14
+ /**
15
+ * The sqlite-vec extension every store connection loads: the vector index's tables and triggers cannot be
16
+ * read or written without it. It ships in `@wirecat/cli-core-sqlite`, so a missing file is a broken install.
17
+ */
18
+ export const vectorExtension = () => {
19
+ found ??= locate();
20
+ return found;
21
+ };
22
+ const locate = async () => {
23
+ const onMusl = musl();
24
+ const target = `${process.platform}-${process.arch}${onMusl ? "-musl" : ""}`;
25
+ let path;
26
+ try {
27
+ const { extensionFor } = await import("@wirecat/cli-core-sqlite");
28
+ path = extensionFor({ musl: onMusl });
29
+ }
30
+ catch {
31
+ path = undefined;
32
+ }
33
+ if (path === undefined) {
34
+ found = undefined;
35
+ throw new CliError("configuration_error", `the message store needs the sqlite-vec extension, and @wirecat/cli-core-sqlite has none for ${target} — ` +
36
+ "reinstall the CLI so its dependencies install with it (an install that skips optional or native " +
37
+ "packages leaves it out)", { reason: "store.vector-extension-missing", target });
38
+ }
39
+ return path;
40
+ };
41
+ /** Loads the extension into a connection that allows it, then closes the door for SQL's `load_extension()`. */
42
+ export const loadVectorExtension = (database, path) => {
43
+ try {
44
+ database.loadExtension(path);
45
+ }
46
+ catch (error) {
47
+ throw new CliError("configuration_error", `the sqlite-vec extension did not load on ${process.platform}-${process.arch} — reinstall the CLI; on macOS ` +
48
+ "under Bun, @wirecat/cli-core-sqlite's SQLite must be installed, since the system's cannot load extensions", { reason: "store.vector-extension-failed", cause: error instanceof Error ? error.message : String(error) });
49
+ }
50
+ finally {
51
+ database.enableLoadExtension?.(false);
52
+ }
53
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.433.0",
3
+ "version": "0.434.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -177,7 +177,7 @@
177
177
  "@resvg/resvg-js": "2.6.2",
178
178
  "@valibot/to-json-schema": "1.8.0",
179
179
  "@wirecat/cli-core-onnx": "1.0.1",
180
- "@wirecat/cli-core-sqlite": "1.0.1",
180
+ "@wirecat/cli-core-sqlite": "1.1.0",
181
181
  "@xmldom/xmldom": "0.9.12",
182
182
  "chardet": "2.2.0",
183
183
  "defuddle": "0.19.6",