@wirecat/cli-core 0.433.0 → 0.435.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 (96) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/cli/messenger/context.js +1 -0
  3. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  4. package/dist/cli/messenger/conversations-command.js +2 -2
  5. package/dist/cli/messenger/message-answers.d.ts +1 -0
  6. package/dist/cli/messenger/message-writes.d.ts +2 -0
  7. package/dist/cli/messenger/messages-search-command.js +6 -10
  8. package/dist/cli/messenger/models-command.js +34 -7
  9. package/dist/cli/messenger/search-answers.d.ts +3 -0
  10. package/dist/cli/messenger/search-answers.js +40 -8
  11. package/dist/cli/messenger/search-command.js +3 -1
  12. package/dist/cli/messenger/store-maintenance-command.js +11 -5
  13. package/dist/cli/model-catalogue-command.js +1 -1
  14. package/dist/cli/settings.d.ts +4 -0
  15. package/dist/cli/settings.js +5 -0
  16. package/dist/cli/shared-command-exports.d.ts +4 -0
  17. package/dist/embeddings/child.js +15 -5
  18. package/dist/embeddings/embed.d.ts +16 -2
  19. package/dist/embeddings/embed.js +57 -28
  20. package/dist/embeddings/models.d.ts +18 -0
  21. package/dist/embeddings/models.js +69 -0
  22. package/dist/embeddings/process.d.ts +6 -1
  23. package/dist/embeddings/process.js +23 -7
  24. package/dist/embeddings/rerank.d.ts +10 -0
  25. package/dist/embeddings/rerank.js +31 -0
  26. package/dist/entities/conversation-results.d.ts +2 -0
  27. package/dist/entities/evidence.d.ts +8 -0
  28. package/dist/entities/index.d.ts +37 -4
  29. package/dist/entities/listener.d.ts +4 -0
  30. package/dist/entities/message-context.d.ts +3 -0
  31. package/dist/entities/message-write.d.ts +4 -0
  32. package/dist/entities/message.d.ts +2 -0
  33. package/dist/entities/message.js +2 -0
  34. package/dist/entities/model.d.ts +5 -2
  35. package/dist/entities/model.js +5 -2
  36. package/dist/entities/store-maintenance.d.ts +11 -3
  37. package/dist/entities/store-maintenance.js +18 -2
  38. package/dist/entities/store-records.d.ts +1 -0
  39. package/dist/errors/reasons.js +7 -0
  40. package/dist/mcp/tool.d.ts +1 -1
  41. package/dist/mcp/tool.js +1 -0
  42. package/dist/services/deps.d.ts +3 -0
  43. package/dist/services/embeddings-maintenance.d.ts +10 -2
  44. package/dist/services/index.d.ts +2 -0
  45. package/dist/services/index.js +1 -0
  46. package/dist/services/messages-archive-search.d.ts +2 -2
  47. package/dist/services/messages-archive-search.js +4 -4
  48. package/dist/services/messages-discovery.d.ts +33 -4
  49. package/dist/services/messages-discovery.js +88 -3
  50. package/dist/services/messages-meaning.d.ts +2 -1
  51. package/dist/services/messages-meaning.js +7 -3
  52. package/dist/services/messages-rerank.d.ts +7 -0
  53. package/dist/services/messages-rerank.js +42 -0
  54. package/dist/services/messages-search.d.ts +5 -0
  55. package/dist/services/messages.d.ts +6 -2
  56. package/dist/services/messages.js +4 -3
  57. package/dist/services/model-downloads.d.ts +3 -1
  58. package/dist/services/model-downloads.js +20 -1
  59. package/dist/services/notes-search.d.ts +7 -2
  60. package/dist/services/notes-search.js +7 -4
  61. package/dist/services/search-all-meetings.d.ts +3 -2
  62. package/dist/services/search-all-meetings.js +8 -8
  63. package/dist/services/search-all.d.ts +7 -1
  64. package/dist/services/search-all.js +109 -45
  65. package/dist/store/drivers/bun-sqlite.d.ts +6 -0
  66. package/dist/store/drivers/bun-sqlite.js +16 -1
  67. package/dist/store/drivers/node-sqlite.d.ts +5 -1
  68. package/dist/store/drivers/node-sqlite.js +15 -1
  69. package/dist/store/exports.d.ts +1 -0
  70. package/dist/store/exports.js +1 -0
  71. package/dist/store/sqlite/accounts.js +2 -2
  72. package/dist/store/sqlite/chats.d.ts +1 -1
  73. package/dist/store/sqlite/chats.js +11 -4
  74. package/dist/store/sqlite/document-pictures.js +5 -7
  75. package/dist/store/sqlite/email-index.d.ts +1 -3
  76. package/dist/store/sqlite/email-index.js +5 -21
  77. package/dist/store/sqlite/emails.js +4 -3
  78. package/dist/store/sqlite/image-chunks.d.ts +3 -3
  79. package/dist/store/sqlite/image-chunks.js +55 -25
  80. package/dist/store/sqlite/manifest.js +2 -0
  81. package/dist/store/sqlite/meeting-vectors.js +11 -9
  82. package/dist/store/sqlite/message-chunks.d.ts +5 -2
  83. package/dist/store/sqlite/message-chunks.js +44 -20
  84. package/dist/store/sqlite/migrations.generated.js +22 -0
  85. package/dist/store/sqlite/note-index.js +2 -7
  86. package/dist/store/sqlite/open-store.js +4 -0
  87. package/dist/store/sqlite/open.js +4 -6
  88. package/dist/store/sqlite/schema.d.ts +118 -1
  89. package/dist/store/sqlite/schema.js +18 -1
  90. package/dist/store/sqlite/vector-index.d.ts +76 -0
  91. package/dist/store/sqlite/vector-index.js +205 -0
  92. package/dist/store/sqlite/vectors.d.ts +8 -5
  93. package/dist/store/sqlite/vectors.js +82 -39
  94. package/dist/store/vector-extension.d.ts +10 -0
  95. package/dist/store/vector-extension.js +53 -0
  96. package/package.json +2 -2
@@ -0,0 +1,76 @@
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
+ }[];
68
+ /**
69
+ * The one rule for a vector whose text may be gone: it goes once no chunk of any kind holds its hash, for every model.
70
+ * Call it with the hashes a write released, after that write's new chunks are in, so a text that comes back keeps its
71
+ * vector. The key is (model, content_hash): naming each model reaches the row by the key; the hash alone scans every
72
+ * vector.
73
+ */
74
+ export declare const dropUnusedVectors: (database: CacheDatabase, hashes: Iterable<string>) => void;
75
+ /** Every vector no chunk holds, left by a build before `dropUnusedVectors` covered every write; how many. */
76
+ export declare const dropAllUnusedVectors: (database: CacheDatabase) => number;
@@ -0,0 +1,205 @@
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
+ });
181
+ /**
182
+ * The one rule for a vector whose text may be gone: it goes once no chunk of any kind holds its hash, for every model.
183
+ * Call it with the hashes a write released, after that write's new chunks are in, so a text that comes back keeps its
184
+ * vector. The key is (model, content_hash): naming each model reaches the row by the key; the hash alone scans every
185
+ * vector.
186
+ */
187
+ export const dropUnusedVectors = (database, hashes) => {
188
+ const unique = new Set(hashes);
189
+ if (unique.size === 0)
190
+ return;
191
+ const models = database
192
+ .prepare(`WITH RECURSIVE m(model) AS (SELECT min(model) FROM embeddings
193
+ UNION ALL SELECT (SELECT min(model) FROM embeddings WHERE model > m.model) FROM m WHERE m.model IS NOT NULL)
194
+ SELECT model FROM m WHERE model IS NOT NULL`)
195
+ .all()
196
+ .map(({ model }) => String(model));
197
+ const drop = database.prepare("DELETE FROM embeddings WHERE model = ? AND content_hash = ? AND NOT EXISTS (SELECT 1 FROM chunks WHERE content_hash = ?)");
198
+ for (const hash of unique)
199
+ for (const model of models)
200
+ drop.run(model, hash, hash);
201
+ };
202
+ /** Every vector no chunk holds, left by a build before `dropUnusedVectors` covered every write; how many. */
203
+ export const dropAllUnusedVectors = (database) => database
204
+ .prepare("DELETE FROM embeddings WHERE NOT EXISTS (SELECT 1 FROM chunks k WHERE k.content_hash = embeddings.content_hash) RETURNING 1")
205
+ .all().length;
@@ -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;
@@ -47,8 +50,8 @@ export declare const vectorStatus: ({ orm }: StoreContext, chatKey: number, mode
47
50
  embedded: number;
48
51
  };
49
52
  /**
50
- * Drops the vectors of the chat's chunks, or only one model's — but not one another chat's chunk still
51
- * points at: the same text in two chats has one vector. Messages are never touched.
53
+ * Drops the vectors of the chat's conversation and message chunks, or only one model's — but not one a chunk outside
54
+ * the chat still points at: the same text in two chats has one vector. The chunks themselves stay, to be embedded again.
52
55
  */
53
56
  export declare const clearVectors: ({ orm }: StoreContext, chatKey: number, model: string | undefined) => number;
54
57
  export interface NearestChunk {
@@ -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`);
@@ -130,16 +138,19 @@ export const vectorStatus = ({ orm }, chatKey, model) => {
130
138
  return { chunks: Number(row?.chunks ?? 0), embedded: Number(row?.embedded ?? 0) };
131
139
  };
132
140
  /**
133
- * Drops the vectors of the chat's chunks, or only one model's — but not one another chat's chunk still
134
- * points at: the same text in two chats has one vector. Messages are never touched.
141
+ * Drops the vectors of the chat's conversation and message chunks, or only one model's — but not one a chunk outside
142
+ * the chat still points at: the same text in two chats has one vector. The chunks themselves stay, to be embedded again.
135
143
  */
136
- export const clearVectors = ({ orm }, chatKey, model) => orm.all(sql `DELETE FROM embeddings
144
+ export const clearVectors = ({ orm }, chatKey, model) => orm.all(sql `WITH mine(id) AS (
145
+ SELECT k.id FROM chunks k JOIN conversations c ON k.chunkable_type = 'conversation' AND c.id = k.chunkable_id
146
+ WHERE c.chat_id = ${chatKey}
147
+ UNION ALL SELECT k.id FROM chunks k JOIN messages m ON k.chunkable_type = 'message' AND m.id = k.chunkable_id
148
+ WHERE m.chat_id = ${chatKey})
149
+ DELETE FROM embeddings
137
150
  WHERE ${model === undefined ? sql `1` : sql `model = ${model}`}
138
- AND content_hash IN (SELECT k.content_hash FROM chunks k
139
- JOIN conversations c ON k.chunkable_type = 'conversation' AND c.id = k.chunkable_id WHERE c.chat_id = ${chatKey})
140
- AND content_hash NOT IN (SELECT k.content_hash FROM chunks k
141
- LEFT JOIN conversations c ON k.chunkable_type = 'conversation' AND c.id = k.chunkable_id
142
- WHERE c.id IS NULL OR c.chat_id <> ${chatKey})
151
+ AND content_hash IN (SELECT content_hash FROM chunks WHERE id IN (SELECT id FROM mine))
152
+ AND NOT EXISTS (SELECT 1 FROM chunks o WHERE o.content_hash = embeddings.content_hash
153
+ AND o.id NOT IN (SELECT id FROM mine))
143
154
  RETURNING 1 AS n`).length;
144
155
  /** Rows read per step of a search, so a chat of any size is scanned in bounded memory. */
145
156
  const SCAN_PAGE = 5_000;
@@ -157,14 +168,13 @@ export const dot = (query, blob) => {
157
168
  * The conversations of the current builds in scope nearest to `query`, best first: each scored by its
158
169
  * best chunk. Vectors are unit length, so the dot product is the cosine (phase 5 E3).
159
170
  */
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,
171
+ export const nearestChunks = (context, accountPk, filter) => {
172
+ const { orm } = context;
173
+ const { chatKey, model, since, limit, query, exclude, conversations, scope, projectId, before, personId } = filter;
174
+ const rows = (page) => orm.all(
175
+ // CROSS JOIN keeps the chunks first, so each page walks their key; led by the vectors, SQLite re-read and
176
+ // sorted every one of them per page — 1.3 s against 140 ms at 42k chunks (bench/embeddings/README.md).
177
+ 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
178
  r.text_start AS start, r.text_end AS end, k.content_hash AS hash, v.vector FROM chunks k
169
179
  CROSS JOIN conversations c ON c.id = k.chunkable_id
170
180
  JOIN chunk_messages r ON r.chunk_id = k.id
@@ -180,29 +190,62 @@ export const nearestChunks = ({ orm }, accountPk, { chatKey, model, since, limit
180
190
  ${since === undefined ? sql `` : sql `AND k.occurred_at >= ${since}`}
181
191
  ${exclude === undefined ? sql `` : sql `AND c.id <> ${exclude}`}
182
192
  ${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)
193
+ ${"ids" in page
194
+ ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(page.ids)}))
195
+ ORDER BY k.chunkable_id, k.position`
196
+ : sql `AND (k.chunkable_id, k.position) > (${page.after.conversation}, ${page.after.ordinal})
197
+ ORDER BY k.chunkable_id, k.position LIMIT ${SCAN_PAGE}`}`);
198
+ const keepBest = (best, row) => {
199
+ const score = dot(query, row.vector);
200
+ const held = best.get(row.conversation);
201
+ if (held && score <= held.score)
202
+ return;
203
+ const range = rangeOf(row.start, row.end);
204
+ best.set(row.conversation, {
205
+ id: row.id,
206
+ conversationPk: row.conversation,
207
+ firstMessagePk: row.first,
208
+ lastMessagePk: row.last,
209
+ ...(range ? { range } : {}),
210
+ hash: row.hash,
211
+ score,
212
+ });
213
+ };
214
+ // Stable, so equal scores keep the scan's order, by conversation.
215
+ const ranked = (found) => found.sort((a, b) => b.score - a.score).slice(0, limit);
216
+ const plain = (found) => found.map(({ id: _, ...chunk }) => chunk);
217
+ // The index has no person, project, scope or conversation list; with one of those the scan stays.
218
+ const indexable = personId === undefined && projectId === undefined && scope === undefined && conversations === undefined;
219
+ const indexed = indexable &&
220
+ searchIndex(context.database, model, {
221
+ kind: "conversation",
222
+ account: accountPk,
223
+ ...(chatKey === undefined ? {} : { chat: chatKey }),
224
+ ...(since === undefined ? {} : { since }),
225
+ ...(before === undefined ? {} : { before }),
226
+ }, query, limit, (candidates) => {
227
+ const hashes = new Map(candidates.map(({ id, hash }) => [id, hash]));
228
+ const best = new Map();
229
+ for (const row of rows({ ids: [...hashes.keys()] }))
230
+ if (hashes.get(row.id) === row.hash)
231
+ keepBest(best, row);
232
+ const results = ranked([...best.values()]);
233
+ return { results, best: results.map(({ id }) => id) };
234
+ });
235
+ if (indexed)
236
+ return plain(indexed);
237
+ const best = new Map();
238
+ let after = { conversation: 0, ordinal: -1 };
239
+ for (;;) {
240
+ const page = rows({ after });
241
+ for (const row of page)
242
+ keepBest(best, row);
243
+ const last = page.at(-1);
244
+ if (!last || page.length < SCAN_PAGE)
202
245
  break;
203
246
  after = { conversation: last.conversation, ordinal: last.ordinal };
204
247
  }
205
- return [...best.values()].sort((a, b) => b.score - a.score).slice(0, limit);
248
+ return plain(ranked([...best.values()]));
206
249
  };
207
250
  const hasVectorOf = (models) => sql `EXISTS (SELECT 1 FROM chunks k
208
251
  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.435.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",