@wirecat/cli-core 0.446.0 → 0.448.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,34 @@ 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.448.0 — 26.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Text models and rerankers run on native ONNX Runtime on Linux (glibc, x64 and arm64) and Apple-silicon Macs**,
15
+ 2–3× faster than WebAssembly: on one real archive `search messages --discover` went from 4.6 s to 3.2 s with the
16
+ same answers. `models download` of a text model or reranker now also fetches Microsoft's ONNX Runtime 1.30.0
17
+ library for the system (10–42 MB, checked by sha256) into `models/runtime/`; an install that already has its
18
+ models gets it on its next `models download`. Windows, Intel Macs, musl, picture models, and any library that
19
+ does not load keep running through WebAssembly. Needs `@wirecat/cli-core-onnx` 1.1.0.
20
+
21
+ ## 0.447.0 — 26.10.2026
22
+
23
+ ### Added
24
+
25
+ - **The parity manifest knows `session start --answer-stdin`**, in tg and max: the flag that lets WireCat Desktop
26
+ answer a sign-in's questions on stdin (`signInSteps`, 0.443.0). WhatsApp's QR sign-in asks nothing, so wa does not
27
+ take it yet.
28
+
29
+ ### Fixed
30
+
31
+ - **`--discover`'s reranker reads only the first 300 characters of the message a hit replies to.** The parent came
32
+ first in full, and a long one filled the reranker's 512 tokens, so the hit itself was cut off and the model scored
33
+ the parent instead (18 of 119 replies in one real chat). With the parent cut, the six known questions on that
34
+ chat kept every answer in the first five, and the reranker does about 40% less work.
35
+
8
36
  ## 0.446.0 — 26.10.2026
9
37
 
10
38
  ### Fixed
package/README.md CHANGED
@@ -130,8 +130,9 @@ code and its document must agree. A search plan that reads an index once per mes
130
130
  pattern can hang the search.
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
- - **Search by meaning, opt in.** Conversations are split into chunks and embedded locally by a WebAssembly ONNX
134
- runtime, the same on Node and Bun — e5-small by default, or any OpenAI-compatible API with your own key, after
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
135
136
  you see the cost. Results by meaning and by words are merged by reciprocal rank fusion.
136
137
  - **The archive and the server together.** `--backend both` also asks the messenger's own search; its hits are saved
137
138
  and filtered by the same query, so nothing is counted twice. `--thread` brings the reply thread around a hit.
@@ -2,6 +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
6
  export const textModelsDirectory = (env = process.env) => sharedModelsDirectory("text", env);
6
7
  export const placedText = (model, directory) => model.files.map((file) => [file, join(directory, model.id, file.name)]);
7
8
  export const isTextModelInstalled = (model, directory) => filesPresent(placedText(model, directory));
@@ -122,13 +123,29 @@ export const meanOf = (data, tokens, dims) => {
122
123
  };
123
124
  /** A model's tokenizer and ONNX session, from its folder. */
124
125
  export const loadModel = async (model, directory, threads) => {
125
- const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("@wirecat/cli-core-onnx")]);
126
+ const [{ Tokenizer }, runtime] = await Promise.all([import("@huggingface/tokenizers"), onnxRuntime()]);
126
127
  const path = (name) => join(directory, model.id, name);
127
128
  const tokenizer = new Tokenizer(JSON.parse(readFileSync(path("tokenizer.json"), "utf8")), JSON.parse(readFileSync(path("tokenizer_config.json"), "utf8")));
128
- ort.env.wasm.numThreads = threads;
129
- const session = await ort.InferenceSession.create(readFileSync(path(model.onnx)), model.externalData
129
+ const bytes = readFileSync(path(model.onnx));
130
+ const external = model.externalData
130
131
  ? { externalData: [{ path: basename(model.externalData), data: readFileSync(path(model.externalData)) }] }
131
- : {});
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);
132
149
  return { ort, tokenizer, session };
133
150
  };
134
151
  export const openEmbedder = async (model, directory, { threads = defaultThreads() } = {}) => {
@@ -94,7 +94,7 @@ export const RERANK_MODELS = [
94
94
  languages: "about 100 languages, Russian and English among them",
95
95
  licence: "Apache-2.0",
96
96
  maxTokens: 512,
97
- // The x86 name is the quantizer's target; Core runs every model as WebAssembly, on any processor.
97
+ // The x86 name is the quantizer's target; WebAssembly and native ONNX Runtime both run it on any processor.
98
98
  onnx: "onnx/model_quint8_avx2.onnx",
99
99
  files: [
100
100
  {
@@ -0,0 +1,42 @@
1
+ import { type Fetch } from "../speech/install.js";
2
+ import type { ModelFile } from "../speech/models.js";
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. */
5
+ export interface NativeRuntime {
6
+ readonly target: string;
7
+ readonly archive: ModelFile;
8
+ readonly member: string;
9
+ readonly library: {
10
+ name: string;
11
+ sha256: string;
12
+ bytes: number;
13
+ };
14
+ }
15
+ export declare const nativeRuntimeFor: ({ platform, arch, musl, }?: {
16
+ platform?: string;
17
+ arch?: string;
18
+ musl?: boolean;
19
+ }) => NativeRuntime | undefined;
20
+ export declare const runtimeFolder: (runtime: NativeRuntime, env?: NodeJS.ProcessEnv) => string;
21
+ /**
22
+ * The library this system's binding links to, out of Microsoft's release archive, checked against its own sha256
23
+ * and placed beside a copy of the binding, which loads it from its own folder. Only from `models download`: a search
24
+ * never downloads. `undefined` when this system runs models through WebAssembly only.
25
+ */
26
+ export declare const installNativeRuntime: ({ env, progress, fetch, runtime, binding, }?: {
27
+ env?: NodeJS.ProcessEnv;
28
+ progress?: (line: string) => void;
29
+ fetch?: Fetch;
30
+ runtime?: NativeRuntime;
31
+ binding?: string;
32
+ }) => Promise<string | undefined>;
33
+ export type Ort = typeof import("@wirecat/cli-core-onnx");
34
+ export interface OnnxRuntime {
35
+ readonly ort: Ort;
36
+ readonly native: boolean;
37
+ }
38
+ /**
39
+ * The native runtime when `models download` placed it for this system and it loads, else WebAssembly. One per
40
+ * process: a binding, once loaded, stays.
41
+ */
42
+ export declare const onnxRuntime: (env?: NodeJS.ProcessEnv) => Promise<OnnxRuntime>;
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { CliError } from "@wirecat/cli-base";
5
+ import { gunzipSync } from "fflate";
6
+ import { installFiles, megabytes, sharedModelsDirectory } from "../speech/install.js";
7
+ export const ONNX_RUNTIME_VERSION = "1.30.0";
8
+ const release = (name, sha256, bytes) => ({
9
+ name,
10
+ url: `https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_RUNTIME_VERSION}/${name}`,
11
+ sha256,
12
+ bytes,
13
+ });
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.
17
+ */
18
+ const NATIVE = {
19
+ "linux-x64": {
20
+ archive: release("onnxruntime-linux-x64-1.30.0.tgz", "a5ed5a3cac51fbb2e90da632ae43d19212faaa20e76484e62bcb7c23ddb3b3fd", 11_306_877),
21
+ member: "onnxruntime-linux-x64-1.30.0/lib/libonnxruntime.so.1.30.0",
22
+ library: {
23
+ name: "libonnxruntime.so.1",
24
+ sha256: "245a6f8c38127551057a1cd1ffd59f0a186a227ade4f3492dea2494eb565542e",
25
+ bytes: 28_985_152,
26
+ },
27
+ },
28
+ "linux-arm64": {
29
+ archive: release("onnxruntime-linux-aarch64-1.30.0.tgz", "e16a27a8ed330bbc698df7330b0cf56e722f354e3bcc92118682c74ef3c3e3da", 10_269_495),
30
+ member: "onnxruntime-linux-aarch64-1.30.0/lib/libonnxruntime.so.1.30.0",
31
+ library: {
32
+ name: "libonnxruntime.so.1",
33
+ sha256: "64e903a43a041240fd6bcffe0ac6d4fea47ef87bf24b9d097801bd00a9612a4b",
34
+ bytes: 25_135_496,
35
+ },
36
+ },
37
+ "darwin-arm64": {
38
+ archive: release("onnxruntime-osx-arm64-1.30.0.tgz", "6ebb5062a934537c352937821f9fe9718e7de1a2db1122a93dd363ffd53a7012", 42_373_116),
39
+ member: "onnxruntime-osx-arm64-1.30.0/lib/libonnxruntime.1.30.0.dylib",
40
+ library: {
41
+ name: "libonnxruntime.1.dylib",
42
+ sha256: "bcc9110f9d638a119de2db7afb3ba9a1da8085f0cb3401e1c48ae1caf450b6fa",
43
+ bytes: 43_879_424,
44
+ },
45
+ },
46
+ };
47
+ const onMusl = () => {
48
+ try {
49
+ return readFileSync("/proc/self/maps", "utf8").includes("ld-musl");
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ };
55
+ export const nativeRuntimeFor = ({ platform = process.platform, arch = process.arch, musl = platform === "linux" && onMusl(), } = {}) => {
56
+ const target = `${platform}-${arch}`;
57
+ const runtime = NATIVE[target];
58
+ return runtime && !musl ? { target, ...runtime } : undefined;
59
+ };
60
+ export const runtimeFolder = (runtime, env = process.env) => join(sharedModelsDirectory("runtime", env), `onnxruntime-${ONNX_RUNTIME_VERSION}-${runtime.target}`);
61
+ const BINDING = "onnxruntime_binding.node";
62
+ const sizeOf = (path) => {
63
+ try {
64
+ return statSync(path).size;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ };
70
+ const isInstalled = (runtime, folder) => sizeOf(join(folder, runtime.library.name)) === runtime.library.bytes && sizeOf(join(folder, BINDING)) !== undefined;
71
+ /**
72
+ * The library this system's binding links to, out of Microsoft's release archive, checked against its own sha256
73
+ * and placed beside a copy of the binding, which loads it from its own folder. Only from `models download`: a search
74
+ * never downloads. `undefined` when this system runs models through WebAssembly only.
75
+ */
76
+ export const installNativeRuntime = async ({ env = process.env, progress = () => { }, fetch, runtime = nativeRuntimeFor(), binding, } = {}) => {
77
+ if (!runtime)
78
+ return undefined;
79
+ const source = binding ?? (await import("@wirecat/cli-core-onnx")).nativeBindingFor();
80
+ if (!source)
81
+ return undefined;
82
+ const folder = runtimeFolder(runtime, env);
83
+ if (isInstalled(runtime, folder))
84
+ return folder;
85
+ progress(`ONNX Runtime ${ONNX_RUNTIME_VERSION} for ${runtime.target}: ${megabytes(runtime.archive.bytes)} from Microsoft`);
86
+ const parent = sharedModelsDirectory("runtime", env);
87
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
88
+ const staging = mkdtempSync(join(parent, ".onnxruntime-"));
89
+ try {
90
+ const archive = join(staging, runtime.archive.name);
91
+ await installFiles([[runtime.archive, archive]], { ...(fetch ? { fetch } : {}), progress });
92
+ const library = tarMember(gunzipSync(readFileSync(archive)), runtime.member);
93
+ rmSync(archive);
94
+ const sha256 = library && createHash("sha256").update(library).digest("hex");
95
+ if (!library || library.length !== runtime.library.bytes || sha256 !== runtime.library.sha256)
96
+ throw new CliError("invalid_response", `${runtime.archive.name} does not hold the ${runtime.library.name} this version expects — nothing was installed`);
97
+ writeFileSync(join(staging, runtime.library.name), library, { mode: 0o755 });
98
+ copyFileSync(source, join(staging, BINDING));
99
+ rmSync(folder, { recursive: true, force: true });
100
+ renameSync(staging, folder);
101
+ return folder;
102
+ }
103
+ finally {
104
+ rmSync(staging, { recursive: true, force: true });
105
+ }
106
+ };
107
+ /** One regular file of a ustar archive, by its path: PAX and GNU long names, and a leading `./`, are understood. */
108
+ const tarMember = (tar, member) => {
109
+ const text = (from, length) => new TextDecoder().decode(tar.subarray(from, from + length)).replace(/\0.*$/s, "");
110
+ const plain = (path) => path.replace(/^\.\//, "");
111
+ let longName;
112
+ for (let offset = 0; offset + 512 <= tar.length;) {
113
+ const name = text(offset, 100);
114
+ if (!name)
115
+ return undefined;
116
+ const size = Number.parseInt(text(offset + 124, 12).trim() || "0", 8);
117
+ const type = text(offset + 156, 1);
118
+ const prefix = text(offset + 345, 155);
119
+ const data = tar.subarray(offset + 512, offset + 512 + size);
120
+ offset += 512 + Math.ceil(size / 512) * 512;
121
+ if (type === "x") {
122
+ longName = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(new TextDecoder().decode(data))?.[1] ?? longName;
123
+ continue;
124
+ }
125
+ if (type === "L") {
126
+ longName = new TextDecoder().decode(data).replace(/\0.*$/s, "");
127
+ continue;
128
+ }
129
+ const path = plain(longName ?? (prefix ? `${prefix}/${name}` : name));
130
+ longName = undefined;
131
+ if (path === plain(member) && (type === "0" || type === ""))
132
+ return data;
133
+ }
134
+ return undefined;
135
+ };
136
+ const opened = new Map();
137
+ /**
138
+ * The native runtime when `models download` placed it for this system and it loads, else WebAssembly. One per
139
+ * process: a binding, once loaded, stays.
140
+ */
141
+ export const onnxRuntime = (env = process.env) => {
142
+ const runtime = nativeRuntimeFor();
143
+ const folder = runtime ? runtimeFolder(runtime, env) : "";
144
+ let found = opened.get(folder);
145
+ if (!found) {
146
+ found = (async () => {
147
+ const wasm = await import("@wirecat/cli-core-onnx");
148
+ if (runtime && isInstalled(runtime, folder)) {
149
+ try {
150
+ return { ort: wasm.openNative(join(folder, BINDING)), native: true };
151
+ }
152
+ catch {
153
+ // A library that does not load on this system: WebAssembly runs the same models, slower.
154
+ }
155
+ }
156
+ return { ort: wasm, native: false };
157
+ })();
158
+ opened.set(folder, found);
159
+ }
160
+ return found;
161
+ };
@@ -54,3 +54,11 @@ export declare const searchDiscovery: (store: MessageStore, account: AccountKey,
54
54
  export declare const nearEligible: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>, ranked: MeaningRanked, scope: string) => Promise<(FoundMessage & {
55
55
  cosine: number;
56
56
  })[]>;
57
+ /** What the reranker reads of a hit, as the benchmark gave it: its reply parent first, each line headed by where, who and when. */
58
+ type Said = {
59
+ senderName: string | null;
60
+ timestamp: string | null;
61
+ text: string;
62
+ };
63
+ export declare const pairInput: (hit: FoundMessage, parent: Said | null | undefined) => string;
64
+ export {};
@@ -289,9 +289,14 @@ const fusedWithMeaning = (words, near, described) => {
289
289
  });
290
290
  return [...merged.values()].sort((a, b) => b.rrf - a.rrf).map(({ hit, rrf }) => ({ ...hit, score: rrf }));
291
291
  };
292
- const pairInput = (hit, parent) => {
292
+ /**
293
+ * Only the start of the parent: whole, a long one filled the model's 512 tokens and cut off the hit it was scoring
294
+ * (18 of 119 replies in one real chat). Cut to 300 characters, every answer on the six known questions stayed.
295
+ */
296
+ const PARENT_CHARACTERS = 300;
297
+ export const pairInput = (hit, parent) => {
293
298
  const line = (m) => `[${hit.chatTitle ?? hit.chatId} · ${m.senderName ?? "unknown"} · ${m.timestamp?.slice(0, 10) ?? "undated"}] ${m.text}`;
294
- return (parent ? [parent, hit] : [hit]).map(line).join("\n");
299
+ return (parent ? [line(parent).slice(0, PARENT_CHARACTERS), line(hit)] : [line(hit)]).join("\n");
295
300
  };
296
301
  const accountOfHit = (hit) => {
297
302
  const { provider, account } = parseLocator(hit.locator);
@@ -1,9 +1,22 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { isTextModelInstalled, placedText, textModelsDirectory } from "../embeddings/embed.js";
3
3
  import { rerankModel, textModel } from "../embeddings/models.js";
4
+ import { installNativeRuntime } from "../embeddings/onnx-runtime.js";
4
5
  import { imageModel, imageModelsDirectory, isImageModelInstalled, placedImage, } from "../images/picture/models.js";
5
6
  import { installFiles, megabytes } from "../speech/install.js";
6
7
  const bytesOf = (model) => model.files.reduce((sum, file) => sum + file.bytes, 0);
8
+ /**
9
+ * The native ONNX Runtime beside the text models, where this system has one. It only makes them faster, so a
10
+ * failed download says why and the model is still installed, to run through WebAssembly.
11
+ */
12
+ const nativeRuntime = async ({ env, progress = () => { } }) => {
13
+ try {
14
+ await installNativeRuntime({ env, progress });
15
+ }
16
+ catch (error) {
17
+ progress(`ONNX Runtime not installed (${error instanceof Error ? error.message : String(error)}); models run through WebAssembly`);
18
+ }
19
+ };
7
20
  /**
8
21
  * The text embedding model into the shared models folder, checked against the sha256 this version expects and
9
22
  * loaded once on a word. Nothing is downloaded when it is already there; it is still loaded.
@@ -18,6 +31,7 @@ export const downloadTextModel = async (id, { env, progress = () => { }, acceptT
18
31
  progress(`${model.id}: ${megabytes(bytesOf(model))} from Hugging Face`);
19
32
  await installFiles(placedText(model, directory), { progress });
20
33
  }
34
+ await nativeRuntime({ env, progress });
21
35
  progress(`${model.id}: loading it once to check it works`);
22
36
  const { openEmbedder } = await import("../embeddings/embed.js");
23
37
  const embedder = await openEmbedder(model, directory);
@@ -39,6 +53,7 @@ export const downloadRerankModel = async (id, { env, progress = () => { } }) =>
39
53
  progress(`${model.id}: ${megabytes(bytesOf(model))} from Hugging Face`);
40
54
  await installFiles(placedText(model, directory), { progress });
41
55
  }
56
+ await nativeRuntime({ env, progress });
42
57
  progress(`${model.id}: loading it once to check it works`);
43
58
  const { openReranker } = await import("../embeddings/rerank.js");
44
59
  const reranker = await openReranker(model, directory);
@@ -1,6 +1,6 @@
1
1
  import { type ModelFile, type SpeechModel } from "./models.js";
2
2
  /** Shared by every CLI of the family, whatever it talks to: a model is hundreds of MB and is downloaded once. */
3
- export declare const sharedModelsDirectory: (kind: "audio" | "text" | "image", env?: NodeJS.ProcessEnv) => string;
3
+ export declare const sharedModelsDirectory: (kind: "audio" | "text" | "image" | "runtime", env?: NodeJS.ProcessEnv) => string;
4
4
  export declare const modelsDirectory: (env?: NodeJS.ProcessEnv) => string;
5
5
  export declare const modelPath: (directory: string, model: SpeechModel) => (name: string) => string;
6
6
  export declare const vadPath: (directory: string) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.446.0",
3
+ "version": "0.448.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.0.1",
179
+ "@wirecat/cli-core-onnx": "1.1.0",
180
180
  "@wirecat/cli-core-sqlite": "1.1.0",
181
181
  "@xmldom/xmldom": "0.9.12",
182
182
  "chardet": "2.2.0",
package/parity.json CHANGED
@@ -68,6 +68,9 @@
68
68
  "value": "<model>",
69
69
  "meaning": "only the stored answers this model gave"
70
70
  },
71
+ "--answer-stdin": {
72
+ "meaning": "with --json and no terminal: print each question as a `needs` line on stderr and read its answer, one line, from stdin"
73
+ },
71
74
  "--answerer": {
72
75
  "value": "<person>",
73
76
  "meaning": "stored name, alias, @username, ID or scoped person locator; ambiguous names require a choice; repeat it for more"
@@ -4193,6 +4196,10 @@
4193
4196
  "session start": {
4194
4197
  "in": "all",
4195
4198
  "options": {
4199
+ "--answer-stdin": {
4200
+ "in": ["tg", "max"],
4201
+ "reason": "only tg and max ask questions during sign-in (app keys, codes, a 2FA or cloud password); WhatsApp's QR sign-in asks nothing, and its phone login is not ported to signInSteps yet"
4202
+ },
4196
4203
  "--app": {
4197
4204
  "in": ["tg"],
4198
4205
  "reason": "Telegram asks each client for its own app from my.telegram.org; MAX has no such step; WhatsApp has no application registration"