@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.
- package/CHANGELOG.md +43 -1
- package/dist/cli/messenger/context.js +1 -0
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/conversations-command.js +2 -2
- package/dist/cli/messenger/message-answers.d.ts +1 -0
- package/dist/cli/messenger/message-writes.d.ts +2 -0
- package/dist/cli/messenger/messages-search-command.js +6 -10
- package/dist/cli/messenger/models-command.js +34 -7
- package/dist/cli/messenger/search-answers.d.ts +3 -0
- package/dist/cli/messenger/search-answers.js +40 -8
- package/dist/cli/messenger/search-command.js +3 -1
- package/dist/cli/messenger/store-maintenance-command.js +11 -5
- package/dist/cli/model-catalogue-command.js +1 -1
- package/dist/cli/settings.d.ts +4 -0
- package/dist/cli/settings.js +5 -0
- package/dist/cli/shared-command-exports.d.ts +4 -0
- package/dist/embeddings/child.js +15 -5
- package/dist/embeddings/embed.d.ts +16 -2
- package/dist/embeddings/embed.js +57 -28
- package/dist/embeddings/models.d.ts +18 -0
- package/dist/embeddings/models.js +69 -0
- package/dist/embeddings/process.d.ts +6 -1
- package/dist/embeddings/process.js +23 -7
- package/dist/embeddings/rerank.d.ts +10 -0
- package/dist/embeddings/rerank.js +31 -0
- package/dist/entities/conversation-results.d.ts +2 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +37 -4
- package/dist/entities/listener.d.ts +4 -0
- package/dist/entities/message-context.d.ts +3 -0
- package/dist/entities/message-write.d.ts +4 -0
- package/dist/entities/message.d.ts +2 -0
- package/dist/entities/message.js +2 -0
- package/dist/entities/model.d.ts +5 -2
- package/dist/entities/model.js +5 -2
- package/dist/entities/store-maintenance.d.ts +11 -3
- package/dist/entities/store-maintenance.js +18 -2
- package/dist/entities/store-records.d.ts +1 -0
- package/dist/errors/reasons.js +7 -0
- package/dist/mcp/tool.d.ts +1 -1
- package/dist/mcp/tool.js +1 -0
- package/dist/services/deps.d.ts +3 -0
- package/dist/services/embeddings-maintenance.d.ts +10 -2
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/messages-archive-search.d.ts +2 -2
- package/dist/services/messages-archive-search.js +4 -4
- package/dist/services/messages-discovery.d.ts +33 -4
- package/dist/services/messages-discovery.js +88 -3
- package/dist/services/messages-meaning.d.ts +2 -1
- package/dist/services/messages-meaning.js +7 -3
- package/dist/services/messages-rerank.d.ts +7 -0
- package/dist/services/messages-rerank.js +42 -0
- package/dist/services/messages-search.d.ts +5 -0
- package/dist/services/messages.d.ts +6 -2
- package/dist/services/messages.js +4 -3
- package/dist/services/model-downloads.d.ts +3 -1
- package/dist/services/model-downloads.js +20 -1
- package/dist/services/notes-search.d.ts +7 -2
- package/dist/services/notes-search.js +7 -4
- package/dist/services/search-all-meetings.d.ts +3 -2
- package/dist/services/search-all-meetings.js +8 -8
- package/dist/services/search-all.d.ts +7 -1
- package/dist/services/search-all.js +109 -45
- package/dist/store/drivers/bun-sqlite.d.ts +6 -0
- package/dist/store/drivers/bun-sqlite.js +16 -1
- package/dist/store/drivers/node-sqlite.d.ts +5 -1
- package/dist/store/drivers/node-sqlite.js +15 -1
- package/dist/store/exports.d.ts +1 -0
- package/dist/store/exports.js +1 -0
- package/dist/store/sqlite/accounts.js +2 -2
- package/dist/store/sqlite/chats.d.ts +1 -1
- package/dist/store/sqlite/chats.js +11 -4
- package/dist/store/sqlite/document-pictures.js +5 -7
- package/dist/store/sqlite/email-index.d.ts +1 -3
- package/dist/store/sqlite/email-index.js +5 -21
- package/dist/store/sqlite/emails.js +4 -3
- package/dist/store/sqlite/image-chunks.d.ts +3 -3
- package/dist/store/sqlite/image-chunks.js +55 -25
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/meeting-vectors.js +11 -9
- package/dist/store/sqlite/message-chunks.d.ts +5 -2
- package/dist/store/sqlite/message-chunks.js +44 -20
- package/dist/store/sqlite/migrations.generated.js +22 -0
- package/dist/store/sqlite/note-index.js +2 -7
- package/dist/store/sqlite/open-store.js +4 -0
- package/dist/store/sqlite/open.js +4 -6
- package/dist/store/sqlite/schema.d.ts +118 -1
- package/dist/store/sqlite/schema.js +18 -1
- package/dist/store/sqlite/vector-index.d.ts +76 -0
- package/dist/store/sqlite/vector-index.js +205 -0
- package/dist/store/sqlite/vectors.d.ts +8 -5
- package/dist/store/sqlite/vectors.js +82 -39
- package/dist/store/vector-extension.d.ts +10 -0
- package/dist/store/vector-extension.js +53 -0
- 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
|
-
/**
|
|
40
|
-
|
|
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
|
|
51
|
-
* points at: the same text in two chats has one vector.
|
|
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: (
|
|
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
|
-
/**
|
|
117
|
-
|
|
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
|
|
134
|
-
* points at: the same text in two chats has one vector.
|
|
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 `
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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 = (
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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()]
|
|
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.
|
|
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
|
|
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",
|