@wirecat/cli-core 0.450.0 → 0.451.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.451.0 — 27.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Picture models run on the native ONNX Runtime too**, where text models do: on one machine CLIP embedded 12
15
+ pictures in 0.5 s against 1.1 s through WebAssembly, and text queries in 0.15 s against 0.26 s, with vectors at
16
+ cosine 0.99 or more to WebAssembly's, so pictures embedded before still match. `openSession` in
17
+ `src/embeddings/onnx-runtime.ts` is now the one place that picks the runtime and falls back per model.
18
+ - **Text models and rerankers run natively on Windows too** (x64 and arm64): `models download` fetches
19
+ `onnxruntime.dll` out of `onnxruntime-node`'s npm tarball (113.5 MB once, checked by sha256), because the Windows
20
+ binding needs its DirectML build, which Microsoft's release archive lacks. Needs `@wirecat/cli-core-onnx` 1.2.0.
21
+ A new native-runtime workflow downloads and runs the tiny test models natively on Linux x64 and arm64, macOS,
22
+ and Windows x64 and arm64, and checks that Alpine stays on WebAssembly.
23
+
24
+ ### Fixed
25
+
26
+ - **Meaning search over documents and notes uses the vector index**, as message search already did: it scanned every
27
+ chunk of the store in pages (on one archive 600,000 chunks, mostly mail, for 23,000 document chunks), 7–18 s a
28
+ query; now about 0.05 s, with the same results, ties included. It still scans when the index is not ready or cannot
29
+ give an exact answer. `memo search all` gets most of the gain.
30
+
8
31
  ## 0.450.0 — 27.10.2026
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -131,8 +131,8 @@ code and its document must agree. A search plan that reads an index once per mes
131
131
  - **Presets for what people actually look for.** `preset:password`, `api-key`, `card`, `phone`, `email`, `url` and
132
132
  seven more find candidates, labelled as candidates.
133
133
  - **Search by meaning, opt in.** Conversations are split into chunks and embedded locally by ONNX Runtime — native
134
- on Linux (glibc) and Apple-silicon Macs once `models download` has fetched it, WebAssembly elsewhere, on Node and
135
- Bun alike — e5-small by default, or any OpenAI-compatible API with your own key, after
134
+ on Linux (glibc), Apple-silicon Macs and Windows once `models download` has fetched it, WebAssembly elsewhere, on
135
+ Node and Bun alike — e5-small by default, or any OpenAI-compatible API with your own key, after
136
136
  you see the cost. Results by meaning and by words are merged by reciprocal rank fusion.
137
137
  - **The archive and the server together.** `--backend both` also asks the messenger's own search; its hits are saved
138
138
  and filtered by the same query, so nothing is counted twice. `--thread` brings the reply thread around a hit.
@@ -226,9 +226,11 @@ they deliberately differ. A rule changes in its own pull request, before the cod
226
226
  over an in-memory fake or a real client.
227
227
  - **Checked across tools and runtimes.** CI runs on Linux: the test suite on Node 24, smoke checks on Bun and on
228
228
  Node 22.16, a parity workflow that builds the Telegram, MAX and WhatsApp CLIs against this repository, and
229
- gitleaks over the full history. macOS and Windows run only three workflows: the SQLite library build (macOS) and
230
- the ONNX runtime probe (macOS and Windows) run monthly and when started by hand; the scheduler test (macOS and
231
- Windows) runs only when started by hand. The rest of the suite does not run on macOS or Windows.
229
+ gitleaks over the full history. macOS and Windows run only four workflows: the SQLite library build (macOS) and
230
+ the ONNX runtime probe (macOS and Windows) run monthly and when started by hand; the native-runtime check (Linux,
231
+ macOS and Windows, x64 and arm64, and Alpine) runs monthly and on every pull request that changes how models
232
+ load; the scheduler test (macOS and Windows) runs only when started by hand. The rest of the suite does not run on
233
+ macOS or Windows.
232
234
  - **One shared toolchain.** Biome, markdownlint, CSpell and Lefthook configs come from the
233
235
  [WireCat standards](https://github.com/WireCatLabs/community/blob/main/standards/README.md), and CI fails when a
234
236
  copy drifts.
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { availableParallelism } from "node:os";
3
3
  import { basename, join } from "node:path";
4
4
  import { filesPresent, sharedModelsDirectory } from "../speech/install.js";
5
- import { onnxRuntime } from "./onnx-runtime.js";
5
+ import { openSession } from "./onnx-runtime.js";
6
6
  export const textModelsDirectory = (env = process.env) => sharedModelsDirectory("text", env);
7
7
  export const placedText = (model, directory) => model.files.map((file) => [file, join(directory, model.id, file.name)]);
8
8
  export const isTextModelInstalled = (model, directory) => filesPresent(placedText(model, directory));
@@ -123,29 +123,12 @@ export const meanOf = (data, tokens, dims) => {
123
123
  };
124
124
  /** A model's tokenizer and ONNX session, from its folder. */
125
125
  export const loadModel = async (model, directory, threads) => {
126
- const [{ Tokenizer }, runtime] = await Promise.all([import("@huggingface/tokenizers"), onnxRuntime()]);
126
+ const { Tokenizer } = await import("@huggingface/tokenizers");
127
127
  const path = (name) => join(directory, model.id, name);
128
128
  const tokenizer = new Tokenizer(JSON.parse(readFileSync(path("tokenizer.json"), "utf8")), JSON.parse(readFileSync(path("tokenizer_config.json"), "utf8")));
129
- const bytes = readFileSync(path(model.onnx));
130
- const external = model.externalData
129
+ const { ort, session } = await openSession(readFileSync(path(model.onnx)), threads, model.externalData
131
130
  ? { externalData: [{ path: basename(model.externalData), data: readFileSync(path(model.externalData)) }] }
132
- : {};
133
- if (runtime.native) {
134
- try {
135
- const session = await runtime.ort.InferenceSession.create(bytes, {
136
- ...external,
137
- intraOpNumThreads: threads,
138
- interOpNumThreads: 1,
139
- });
140
- return { ort: runtime.ort, tokenizer, session };
141
- }
142
- catch {
143
- // A model the native runtime will not open still runs through WebAssembly.
144
- }
145
- }
146
- const ort = await import("@wirecat/cli-core-onnx");
147
- ort.env.wasm.numThreads = threads;
148
- const session = await ort.InferenceSession.create(bytes, external);
131
+ : {});
149
132
  return { ort, tokenizer, session };
150
133
  };
151
134
  export const openEmbedder = async (model, directory, { threads = defaultThreads() } = {}) => {
@@ -1,7 +1,7 @@
1
1
  import { type Fetch } from "../speech/install.js";
2
2
  import type { ModelFile } from "../speech/models.js";
3
3
  export declare const ONNX_RUNTIME_VERSION = "1.30.0";
4
- /** The library of Microsoft's release, in its archive, and the name the binding looks for beside itself. */
4
+ /** The library in the archive it comes in, and the name the binding looks for beside itself. */
5
5
  export interface NativeRuntime {
6
6
  readonly target: string;
7
7
  readonly archive: ModelFile;
@@ -40,3 +40,16 @@ export interface OnnxRuntime {
40
40
  * process: a binding, once loaded, stays.
41
41
  */
42
42
  export declare const onnxRuntime: (env?: NodeJS.ProcessEnv) => Promise<OnnxRuntime>;
43
+ /**
44
+ * A session on the native runtime when it is installed and opens this model, else on WebAssembly, with the `ort`
45
+ * whose tensors that session takes.
46
+ */
47
+ export declare const openSession: (bytes: Uint8Array, threads: number, options?: {
48
+ externalData?: {
49
+ path: string;
50
+ data: Uint8Array;
51
+ }[];
52
+ }) => Promise<{
53
+ ort: typeof import("@wirecat/cli-core-onnx");
54
+ session: import("@wirecat/cli-core-onnx").InferenceSession;
55
+ }>;
@@ -12,9 +12,16 @@ const release = (name, sha256, bytes) => ({
12
12
  bytes,
13
13
  });
14
14
  /**
15
- * The systems the ONNX workflow runs natively; everywhere else, and on musl, the models run through WebAssembly.
16
- * Windows too: its binding needs the DirectML build of onnxruntime.dll, which Microsoft's release does not carry.
15
+ * onnxruntime-node's own npm tarball, for Windows: its binding imports the DirectML build of onnxruntime.dll by
16
+ * ordinal, which Microsoft's CPU release lacks; DirectML.dll is loaded only when used, so the DLL alone runs on CPU.
17
17
  */
18
+ const npmTarball = {
19
+ name: `onnxruntime-node-${ONNX_RUNTIME_VERSION}.tgz`,
20
+ url: `https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-${ONNX_RUNTIME_VERSION}.tgz`,
21
+ sha256: "6e3390d6b783e7be946fad629292799da28d0b42f84856e50d2c1b0383291e75",
22
+ bytes: 113_507_888,
23
+ };
24
+ /** The systems the native-runtime workflow runs natively; elsewhere, and on musl, the models run through WebAssembly. */
18
25
  const NATIVE = {
19
26
  "linux-x64": {
20
27
  archive: release("onnxruntime-linux-x64-1.30.0.tgz", "a5ed5a3cac51fbb2e90da632ae43d19212faaa20e76484e62bcb7c23ddb3b3fd", 11_306_877),
@@ -43,6 +50,24 @@ const NATIVE = {
43
50
  bytes: 43_879_424,
44
51
  },
45
52
  },
53
+ "win32-x64": {
54
+ archive: npmTarball,
55
+ member: "package/bin/napi-v6/win32/x64/onnxruntime.dll",
56
+ library: {
57
+ name: "onnxruntime.dll",
58
+ sha256: "508c362f5673483dd3a086379c392795b2e42d10d5e6f3f90ebd7ac21c97af67",
59
+ bytes: 28_754_232,
60
+ },
61
+ },
62
+ "win32-arm64": {
63
+ archive: npmTarball,
64
+ member: "package/bin/napi-v6/win32/arm64/onnxruntime.dll",
65
+ library: {
66
+ name: "onnxruntime.dll",
67
+ sha256: "6c3a2abf5c6aca11c48a0d338a91af309f2266f25ce2e4084e6a87bbcb0fbfd0",
68
+ bytes: 29_815_136,
69
+ },
70
+ },
46
71
  };
47
72
  const onMusl = () => {
48
73
  try {
@@ -159,3 +184,26 @@ export const onnxRuntime = (env = process.env) => {
159
184
  }
160
185
  return found;
161
186
  };
187
+ /**
188
+ * A session on the native runtime when it is installed and opens this model, else on WebAssembly, with the `ort`
189
+ * whose tensors that session takes.
190
+ */
191
+ export const openSession = async (bytes, threads, options = {}) => {
192
+ const runtime = await onnxRuntime();
193
+ if (runtime.native) {
194
+ try {
195
+ const session = await runtime.ort.InferenceSession.create(bytes, {
196
+ ...options,
197
+ intraOpNumThreads: threads,
198
+ interOpNumThreads: 1,
199
+ });
200
+ return { ort: runtime.ort, session };
201
+ }
202
+ catch {
203
+ // A model the native runtime will not open still runs through WebAssembly.
204
+ }
205
+ }
206
+ const ort = await import("@wirecat/cli-core-onnx");
207
+ ort.env.wasm.numThreads = threads;
208
+ return { ort, session: await ort.InferenceSession.create(bytes, options) };
209
+ };
@@ -1,22 +1,22 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { truncated, unit } from "../../embeddings/embed.js";
4
+ import { openSession } from "../../embeddings/onnx-runtime.js";
4
5
  import { preprocess, SIZE } from "./preprocess.js";
5
6
  export const openPictureEmbedder = async (model, directory, { threads = 4, canvas } = {}) => {
6
- const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("@wirecat/cli-core-onnx")]);
7
+ const { Tokenizer } = await import("@huggingface/tokenizers");
7
8
  const path = (name) => join(directory, model.id, name);
8
9
  const tokenizer = new Tokenizer(JSON.parse(readFileSync(path("tokenizer.json"), "utf8")), JSON.parse(readFileSync(path("tokenizer_config.json"), "utf8")));
9
- ort.env.wasm.numThreads = threads;
10
10
  const sessions = [];
11
11
  // A search needs the text side alone and `images embed` the vision side alone: each loads on first use.
12
12
  const lazy = (name) => {
13
- let session;
13
+ let opened;
14
14
  return () => {
15
- session ??= ort.InferenceSession.create(readFileSync(path(name))).then((created) => {
16
- sessions.push(created);
15
+ opened ??= openSession(readFileSync(path(name)), threads).then((created) => {
16
+ sessions.push(created.session);
17
17
  return created;
18
18
  });
19
- return session;
19
+ return opened;
20
20
  };
21
21
  };
22
22
  const vision = lazy(model.vision);
@@ -25,13 +25,14 @@ export const openPictureEmbedder = async (model, directory, { threads = 4, canva
25
25
  model,
26
26
  embedImage: async (bytes) => {
27
27
  const pixels = await preprocess(bytes, canvas);
28
- const output = await (await vision()).run({ pixel_values: new ort.Tensor("float32", pixels, [1, 3, SIZE, SIZE]) });
28
+ const { ort, session } = await vision();
29
+ const output = await session.run({ pixel_values: new ort.Tensor("float32", pixels, [1, 3, SIZE, SIZE]) });
29
30
  return unit(Float32Array.from(output.image_embeds?.data));
30
31
  },
31
32
  embedQuery: async (query) => {
32
33
  const ids = truncated(tokenizer.encode(query.toLowerCase()).ids, model.maxTokens);
33
34
  const shape = [1, ids.length];
34
- const session = await text();
35
+ const { ort, session } = await text();
35
36
  const feeds = {
36
37
  input_ids: new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), shape),
37
38
  };
@@ -12,6 +12,7 @@ import { documentOf, noteOf } from "./notes.js";
12
12
  import { stemmerCache } from "./stems.js";
13
13
  import { canonicalTagNamed } from "./tags.js";
14
14
  import { read } from "./transaction.js";
15
+ import { searchIndex } from "./vector-index.js";
15
16
  import { dot } from "./vectors.js";
16
17
  /** The fields a note has. A message-only field (`from:`, `chat:`, …) is refused rather than matching nothing. */
17
18
  export const NOTE_FIELDS = [
@@ -375,29 +376,72 @@ export const noteSearchOver = (context) => {
375
376
  nearest: async (model, query, { limit, folderIds, withInternal, source }) => {
376
377
  drain();
377
378
  const scope = live(folderIds, source, withInternal);
378
- const page = database.prepare(`SELECT k.id, k.chunkable_type AS type, k.chunkable_id AS owner, k.start_offset AS start, k.end_offset AS end, v.vector
379
- FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash
380
- WHERE k.id > ?${scope.sql}
381
- ORDER BY k.id LIMIT ?`);
382
- const best = new Map();
383
- let after = 0;
384
- for (;;) {
385
- const rows = page.all(model, after, ...scope.params, SCAN_PAGE);
386
- for (const row of rows) {
387
- const score = dot(query, row.vector);
388
- const key = `${row.type}:${row.owner}`;
389
- if (score > (best.get(key)?.score ?? Number.NEGATIVE_INFINITY))
390
- best.set(key, { score, start: Number(row.start), end: Number(row.end) });
379
+ const columns = `k.id, k.chunkable_type AS type, k.chunkable_id AS owner, k.start_offset AS start,
380
+ k.end_offset AS end, k.content_hash AS hash, v.vector
381
+ FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash`;
382
+ const keep = (best, row) => {
383
+ const score = dot(query, row.vector);
384
+ const key = `${row.type}:${row.owner}`;
385
+ const held = best.get(key);
386
+ if (!held || score > held.score)
387
+ best.set(key, {
388
+ score,
389
+ first: held?.first ?? Number(row.id),
390
+ chunk: Number(row.id),
391
+ start: Number(row.start),
392
+ end: Number(row.end),
393
+ });
394
+ };
395
+ // The scan meets each note first at its lowest chunk, and keeps that order among equal scores.
396
+ const ranked = (best) => [...best.entries()].sort(([, a], [, b]) => b.score - a.score || a.first - b.first).slice(0, limit);
397
+ const indexed = () => {
398
+ const types = source === undefined ? ["document", "note"] : source === "file" ? ["document"] : ["note"];
399
+ const accounts = database.prepare("SELECT DISTINCT coalesce(account_id, 0) AS account FROM chunks WHERE chunkable_type = ?");
400
+ const wanted = folderIds?.length ? new Set(folderIds.map(Number)) : undefined;
401
+ const candidates = database.prepare(`SELECT ${columns} WHERE k.id IN (SELECT value FROM json_each(?))${scope.sql} ORDER BY k.id`);
402
+ const first = database.prepare(`SELECT min(k.id) AS first FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash
403
+ WHERE k.chunkable_type = ? AND k.chunkable_id = ?${scope.sql}`);
404
+ const merged = new Map();
405
+ for (const type of types)
406
+ for (const { account } of accounts.all(type)) {
407
+ if (type === "document" && wanted && !wanted.has(Number(account)))
408
+ continue;
409
+ const found = searchIndex(database, model, { kind: type, account: Number(account) }, query, limit, (near) => {
410
+ const hashes = new Map(near.map(({ id, hash }) => [id, hash]));
411
+ const best = new Map();
412
+ for (const row of candidates.all(model, JSON.stringify([...hashes.keys()]), ...scope.params))
413
+ if (hashes.get(Number(row.id)) === row.hash)
414
+ keep(best, row);
415
+ for (const [key, held] of best) {
416
+ const [kind, owner] = key.split(":");
417
+ held.first = Number(first.get(model, String(kind), Number(owner), ...scope.params)?.first);
418
+ }
419
+ const results = ranked(best);
420
+ return { results, best: results.map(([, { chunk }]) => chunk) };
421
+ });
422
+ if (!found)
423
+ return undefined;
424
+ for (const [key, held] of found)
425
+ merged.set(key, held);
426
+ }
427
+ return ranked(merged);
428
+ };
429
+ const scanned = () => {
430
+ const page = database.prepare(`SELECT ${columns} WHERE k.id > ?${scope.sql} ORDER BY k.id LIMIT ?`);
431
+ const best = new Map();
432
+ let after = 0;
433
+ for (;;) {
434
+ const rows = page.all(model, after, ...scope.params, SCAN_PAGE);
435
+ for (const row of rows)
436
+ keep(best, row);
437
+ const last = rows.at(-1);
438
+ if (!last || rows.length < SCAN_PAGE)
439
+ break;
440
+ after = Number(last.id);
391
441
  }
392
- const last = rows.at(-1);
393
- if (!last || rows.length < SCAN_PAGE)
394
- break;
395
- after = Number(last.id);
396
- }
397
- return [...best.entries()]
398
- .sort(([, a], [, b]) => b.score - a.score)
399
- .slice(0, limit)
400
- .flatMap(([key, { score, start, end }]) => {
442
+ return ranked(best);
443
+ };
444
+ return (indexed() ?? scanned()).flatMap(([key, { score, start, end }]) => {
401
445
  const [type, id] = key.split(":");
402
446
  const note = read(String(type), Number(id));
403
447
  return note ? [{ ref: note.ref, note, score, range: { start, end } }] : [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.450.0",
3
+ "version": "0.451.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",
@@ -176,7 +176,7 @@
176
176
  "@modelcontextprotocol/server": "^2.4.0",
177
177
  "@resvg/resvg-js": "2.6.2",
178
178
  "@valibot/to-json-schema": "1.8.0",
179
- "@wirecat/cli-core-onnx": "1.1.0",
179
+ "@wirecat/cli-core-onnx": "1.2.0",
180
180
  "@wirecat/cli-core-sqlite": "1.1.0",
181
181
  "@xmldom/xmldom": "0.9.12",
182
182
  "chardet": "2.2.0",