@wirecat/cli-core 0.432.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli/messenger/store-maintenance-command.js +5 -2
  3. package/dist/cli/upgrade-command.js +1 -1
  4. package/dist/entities/index.d.ts +10 -3
  5. package/dist/entities/store-maintenance.d.ts +10 -3
  6. package/dist/entities/store-maintenance.js +10 -1
  7. package/dist/errors/reasons.js +7 -0
  8. package/dist/models/anthropic.js +2 -4
  9. package/dist/store/drivers/bun-sqlite.d.ts +6 -0
  10. package/dist/store/drivers/bun-sqlite.js +16 -1
  11. package/dist/store/drivers/node-sqlite.d.ts +5 -1
  12. package/dist/store/drivers/node-sqlite.js +16 -1
  13. package/dist/store/exports.d.ts +1 -0
  14. package/dist/store/exports.js +1 -0
  15. package/dist/store/sqlite/document-pictures.js +4 -1
  16. package/dist/store/sqlite/drizzle/bun.js +58 -14
  17. package/dist/store/sqlite/drizzle/bun.js.map +3 -3
  18. package/dist/store/sqlite/drizzle/core.js +173 -7
  19. package/dist/store/sqlite/drizzle/core.js.map +4 -4
  20. package/dist/store/sqlite/drizzle/node.js +57 -13
  21. package/dist/store/sqlite/drizzle/node.js.map +3 -3
  22. package/dist/store/sqlite/drizzle/shared-55XJMTPA.js +6514 -0
  23. package/dist/store/sqlite/drizzle/shared-55XJMTPA.js.map +7 -0
  24. package/dist/store/sqlite/image-chunks.d.ts +1 -1
  25. package/dist/store/sqlite/image-chunks.js +46 -13
  26. package/dist/store/sqlite/manifest.js +2 -0
  27. package/dist/store/sqlite/message-chunks.d.ts +5 -2
  28. package/dist/store/sqlite/message-chunks.js +42 -13
  29. package/dist/store/sqlite/migrations.generated.js +22 -0
  30. package/dist/store/sqlite/open-store.js +4 -0
  31. package/dist/store/sqlite/open.js +5 -6
  32. package/dist/store/sqlite/schema/canonical-tags.d.ts +1 -0
  33. package/dist/store/sqlite/schema/document-index-pending.d.ts +1 -0
  34. package/dist/store/sqlite/schema/document-transfers.d.ts +1 -0
  35. package/dist/store/sqlite/schema/tag-consolidations.d.ts +2 -0
  36. package/dist/store/sqlite/schema.d.ts +214 -0
  37. package/dist/store/sqlite/schema.js +17 -0
  38. package/dist/store/sqlite/vector-index.d.ts +67 -0
  39. package/dist/store/sqlite/vector-index.js +180 -0
  40. package/dist/store/sqlite/vectors.d.ts +6 -3
  41. package/dist/store/sqlite/vectors.js +71 -31
  42. package/dist/store/vector-extension.d.ts +10 -0
  43. package/dist/store/vector-extension.js +53 -0
  44. package/package.json +24 -25
  45. package/dist/store/sqlite/drizzle/shared-HKXF6HKX.js +0 -4846
  46. package/dist/store/sqlite/drizzle/shared-HKXF6HKX.js.map +0 -7
@@ -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.432.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",
@@ -16,7 +16,7 @@
16
16
  "engines": {
17
17
  "node": "^22.16.0 || >=24"
18
18
  },
19
- "packageManager": "pnpm@11.20.0",
19
+ "packageManager": "pnpm@11.28.8",
20
20
  "files": [
21
21
  "dist",
22
22
  "parity.json",
@@ -170,17 +170,17 @@
170
170
  "help:examples": "HELP_EXAMPLES_REPORT=1 vitest run scripts/help-examples.test.ts --silent=false"
171
171
  },
172
172
  "dependencies": {
173
- "@anthropic-ai/sdk": "0.131.0",
173
+ "@anthropic-ai/sdk": "1.3.0",
174
174
  "@bomb.sh/tab": "0.0.22",
175
175
  "@huggingface/tokenizers": "0.2.0",
176
- "@modelcontextprotocol/server": "2.3.0",
176
+ "@modelcontextprotocol/server": "^2.4.0",
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
- "defuddle": "0.19.5",
183
+ "defuddle": "0.19.6",
184
184
  "dictionary-en": "4.0.0",
185
185
  "dictionary-es": "4.0.0",
186
186
  "dictionary-ru": "3.0.0",
@@ -188,9 +188,9 @@
188
188
  "fflate": "0.8.3",
189
189
  "iconv-lite": "0.7.3",
190
190
  "image-size": "2.0.4",
191
- "linkedom": "0.18.13",
191
+ "linkedom": "0.18.14",
192
192
  "liquidjs": "10.30.0",
193
- "lossless-json": "4.3.0",
193
+ "lossless-json": "4.3.3",
194
194
  "mammoth": "1.13.0",
195
195
  "mdast-util-from-markdown": "2.1.0",
196
196
  "mdast-util-gfm": "3.1.0",
@@ -198,40 +198,39 @@
198
198
  "ogg-opus-decoder": "1.7.5",
199
199
  "psl": "1.15.0",
200
200
  "qrcode-generator": "2.0.4",
201
- "sherpa-onnx": "1.13.8",
201
+ "sherpa-onnx": "2.0.0",
202
202
  "string-width": "8.3.0",
203
- "unpdf": "1.8.1",
203
+ "unpdf": "1.9.1",
204
204
  "valibot": "1.5.0",
205
205
  "word-extractor": "1.0.4",
206
206
  "wrap-ansi": "10.0.2",
207
207
  "yaml": "2.9.1"
208
208
  },
209
209
  "devDependencies": {
210
- "@biomejs/biome": "2.5.15",
211
- "@cspell/dict-en-gb": "5.0.34",
210
+ "@biomejs/biome": "2.6.3",
211
+ "@cspell/dict-en-gb": "5.0.38",
212
212
  "@cspell/dict-es-es": "3.0.9",
213
- "@cspell/dict-ru_ru": "2.3.3",
214
- "@modelcontextprotocol/client": "2.3.0",
215
- "@napi-rs/canvas": "1.0.10",
213
+ "@cspell/dict-ru_ru": "2.3.4",
214
+ "@modelcontextprotocol/client": "2.4.0",
215
+ "@napi-rs/canvas": "1.0.13",
216
216
  "@types/mdast": "4.0.4",
217
- "@types/node": "^22.20.4",
217
+ "@types/node": "^22.20.5",
218
218
  "@types/pngjs": "6.0.5",
219
- "@vitest/coverage-v8": "^5.0.2",
219
+ "@vitest/coverage-v8": "^5.0.5",
220
220
  "@wirecat/cli-base": "0.57.0",
221
221
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
222
222
  "commander": "15.0.0",
223
- "cspell": "10.3.6",
224
- "drizzle-kit": "1.0.0-rc.4",
225
- "drizzle-orm": "1.0.0-rc.4",
223
+ "cspell": "10.4.0",
224
+ "drizzle-kit": "1.0.0-rc.5",
225
+ "drizzle-orm": "1.0.0-rc.5",
226
226
  "esbuild": "0.28.2",
227
- "exceljs": "4.4.0",
228
227
  "jsqr": "1.4.0",
229
- "lefthook": "2.1.16",
230
- "libphonenumber-js": "1.13.14",
231
- "markdownlint-cli2": "0.23.3",
228
+ "lefthook": "2.2.2",
229
+ "libphonenumber-js": "1.13.15",
230
+ "markdownlint-cli2": "0.24.0",
232
231
  "pngjs": "7.0.0",
233
232
  "typescript": "^7.0.2",
234
- "vitest": "^5.0.1"
233
+ "vitest": "^5.0.5"
235
234
  },
236
235
  "publishConfig": {
237
236
  "access": "public"