@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.
- package/CHANGELOG.md +25 -0
- package/dist/cli/messenger/store-maintenance-command.js +5 -2
- package/dist/cli/upgrade-command.js +1 -1
- package/dist/entities/index.d.ts +10 -3
- package/dist/entities/store-maintenance.d.ts +10 -3
- package/dist/entities/store-maintenance.js +10 -1
- package/dist/errors/reasons.js +7 -0
- package/dist/models/anthropic.js +2 -4
- 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 +16 -1
- package/dist/store/exports.d.ts +1 -0
- package/dist/store/exports.js +1 -0
- package/dist/store/sqlite/document-pictures.js +4 -1
- package/dist/store/sqlite/drizzle/bun.js +58 -14
- package/dist/store/sqlite/drizzle/bun.js.map +3 -3
- package/dist/store/sqlite/drizzle/core.js +173 -7
- package/dist/store/sqlite/drizzle/core.js.map +4 -4
- package/dist/store/sqlite/drizzle/node.js +57 -13
- package/dist/store/sqlite/drizzle/node.js.map +3 -3
- package/dist/store/sqlite/drizzle/shared-55XJMTPA.js +6514 -0
- package/dist/store/sqlite/drizzle/shared-55XJMTPA.js.map +7 -0
- package/dist/store/sqlite/image-chunks.d.ts +1 -1
- package/dist/store/sqlite/image-chunks.js +46 -13
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/message-chunks.d.ts +5 -2
- package/dist/store/sqlite/message-chunks.js +42 -13
- package/dist/store/sqlite/migrations.generated.js +22 -0
- package/dist/store/sqlite/open-store.js +4 -0
- package/dist/store/sqlite/open.js +5 -6
- package/dist/store/sqlite/schema/canonical-tags.d.ts +1 -0
- package/dist/store/sqlite/schema/document-index-pending.d.ts +1 -0
- package/dist/store/sqlite/schema/document-transfers.d.ts +1 -0
- package/dist/store/sqlite/schema/tag-consolidations.d.ts +2 -0
- package/dist/store/sqlite/schema.d.ts +214 -0
- package/dist/store/sqlite/schema.js +17 -0
- package/dist/store/sqlite/vector-index.d.ts +67 -0
- package/dist/store/sqlite/vector-index.js +180 -0
- package/dist/store/sqlite/vectors.d.ts +6 -3
- package/dist/store/sqlite/vectors.js +71 -31
- package/dist/store/vector-extension.d.ts +10 -0
- package/dist/store/vector-extension.js +53 -0
- package/package.json +24 -25
- package/dist/store/sqlite/drizzle/shared-HKXF6HKX.js +0 -4846
- 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
|
-
/**
|
|
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;
|
|
@@ -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`);
|
|
@@ -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 = (
|
|
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,
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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()]
|
|
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.
|
|
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.
|
|
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": "
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
191
|
+
"linkedom": "0.18.14",
|
|
192
192
|
"liquidjs": "10.30.0",
|
|
193
|
-
"lossless-json": "4.3.
|
|
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": "
|
|
201
|
+
"sherpa-onnx": "2.0.0",
|
|
202
202
|
"string-width": "8.3.0",
|
|
203
|
-
"unpdf": "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.
|
|
211
|
-
"@cspell/dict-en-gb": "5.0.
|
|
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.
|
|
214
|
-
"@modelcontextprotocol/client": "2.
|
|
215
|
-
"@napi-rs/canvas": "1.0.
|
|
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.
|
|
217
|
+
"@types/node": "^22.20.5",
|
|
218
218
|
"@types/pngjs": "6.0.5",
|
|
219
|
-
"@vitest/coverage-v8": "^5.0.
|
|
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.
|
|
224
|
-
"drizzle-kit": "1.0.0-rc.
|
|
225
|
-
"drizzle-orm": "1.0.0-rc.
|
|
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.
|
|
230
|
-
"libphonenumber-js": "1.13.
|
|
231
|
-
"markdownlint-cli2": "0.
|
|
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.
|
|
233
|
+
"vitest": "^5.0.5"
|
|
235
234
|
},
|
|
236
235
|
"publishConfig": {
|
|
237
236
|
"access": "public"
|