tryharness 0.7.22 → 0.7.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
@@ -0,0 +1,571 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import { ensureDir } from "./files.mjs";
6
+
7
+ // The local models the relay may run, each pinned to one repository commit and the SHA-256 of every file, with its
8
+ // licence and tier. This list is the only way a model reaches the machine: the downloader takes nothing else, refuses a
9
+ // file whose hash differs, and refuses any model whose licence is not on the allowlist. The same list drives what the
10
+ // person can see under "Local models". Core models come down with setup; extended ones the first time a feature needs them.
11
+
12
+ export const LICENCES = new Set([
13
+ "apache-2.0",
14
+ "mit",
15
+ "bsd-3-clause",
16
+ "cc-by-4.0",
17
+ ]);
18
+
19
+ // llama.cpp's own release build: the server that hosts every GGUF model (search, decision models) on the GPU when
20
+ // there is one. Release archives as published, pinned by the digest GitHub reports for each asset.
21
+ export const LLAMA = {
22
+ build: "b11609",
23
+ licence: "mit",
24
+ assets: {
25
+ "darwin-arm64": {
26
+ name: "llama-b11609-bin-macos-arm64.tar.gz",
27
+ sha256:
28
+ "d03f28b5a5baa9cdfbb01db5ed22c695126c11c193326f14cdc51d001be10f4e",
29
+ size: 12117158,
30
+ },
31
+ "darwin-x64": {
32
+ name: "llama-b11609-bin-macos-x64.tar.gz",
33
+ sha256:
34
+ "f95aa88329eb017213114c5f09841e9ee02477dacca2652f2451d322464b4acb",
35
+ size: 11614045,
36
+ },
37
+ "linux-x64-vulkan": {
38
+ name: "llama-b11609-bin-ubuntu-vulkan-x64.tar.gz",
39
+ sha256:
40
+ "8aff188828de76492343e19905f72408c10f5b870a03592155b0e7dd19b76ed0",
41
+ size: 30359149,
42
+ },
43
+ "linux-x64": {
44
+ name: "llama-b11609-bin-ubuntu-x64.tar.gz",
45
+ sha256:
46
+ "f95cbb27dd6e377f2ce30f5cfd7ec987af3259c025fc7c98fff62927a16ff528",
47
+ size: 17732148,
48
+ },
49
+ "linux-arm64-vulkan": {
50
+ name: "llama-b11609-bin-ubuntu-vulkan-arm64.tar.gz",
51
+ sha256:
52
+ "27e37fc8cd9cfa00f99f72c5d4753a81ecbbce30f00266aa9338e5d7ac4dd9fb",
53
+ size: 24120662,
54
+ },
55
+ "linux-arm64": {
56
+ name: "llama-b11609-bin-ubuntu-arm64.tar.gz",
57
+ sha256:
58
+ "af83fe40920dc788bb96b267aa579e76c20ee90f7e777b4a7a3031a4fcb9d942",
59
+ size: 13842753,
60
+ },
61
+ "win32-x64-vulkan": {
62
+ name: "llama-b11609-bin-win-vulkan-x64.zip",
63
+ sha256:
64
+ "7b50bdb2a27b5fe17759104b1d86e082a05583ff1bca8b3202bcfb2a7232a005",
65
+ size: 32134342,
66
+ },
67
+ "win32-x64": {
68
+ name: "llama-b11609-bin-win-cpu-x64.zip",
69
+ sha256:
70
+ "8708a7b81842333927f6377ac3f17ff8bba4dbe1ee0cf28dfd5e7a32e3a46b38",
71
+ size: 19518618,
72
+ },
73
+ },
74
+ };
75
+
76
+ export const MODELS = [
77
+ {
78
+ id: "search",
79
+ name: "EmbeddingGemma 2 (text)",
80
+ purpose: "One search space over code, agent sessions and board posts",
81
+ tier: "core",
82
+ runtime: "llama",
83
+ licence: "apache-2.0",
84
+ repo: "ggml-org/embeddinggemma-2-GGUF",
85
+ revision: "bfcd298762cc34d0357ece5ebdd31791a3a374d8",
86
+ files: [
87
+ {
88
+ path: "embeddinggemma-2-Q8_0.gguf",
89
+ sha256:
90
+ "2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135",
91
+ size: 309855456,
92
+ },
93
+ ],
94
+ llama: { embedding: true, c: 2048, ub: 2048, b: 2048 },
95
+ },
96
+ {
97
+ id: "guard",
98
+ name: "Wolf Defender small",
99
+ purpose: "Spots instructions hidden in what one agent hands another",
100
+ tier: "core",
101
+ runtime: "onnx",
102
+ licence: "apache-2.0",
103
+ repo: "patronus-studio/wolf-defender-prompt-injection-small",
104
+ revision: "bcab2eff97bcabd7227849639e2d0d7a61b46c92",
105
+ files: [
106
+ {
107
+ path: "onnx/int8_int4_embeddings/model.onnx",
108
+ sha256:
109
+ "a6c77496152e458c072e4787f872192af0449a427359d1afbfa1d4d6b116a305",
110
+ size: 96296126,
111
+ },
112
+ {
113
+ path: "onnx/onnx_fp16/tokenizer.json",
114
+ sha256:
115
+ "7e426c3929b44e6ab4c931770b5f22b913280633f5a1c67c81e9ad64decef55c",
116
+ size: 34363287,
117
+ },
118
+ {
119
+ path: "onnx/onnx_fp16/tokenizer_config.json",
120
+ sha256:
121
+ "14b147f2a4f939d9b12ab36e9633917040dd948fa78ce283b03402e4cf2c9cba",
122
+ size: 574,
123
+ },
124
+ {
125
+ path: "config.json",
126
+ sha256:
127
+ "b5bfba7b100b4b1aa361e8160e5593695164d81ca09b47f3b26561332b520218",
128
+ size: 2084,
129
+ },
130
+ ],
131
+ },
132
+ // The second guard reads short windows: in a long page or post, one planted sentence moves a 300-character window
133
+ // more than an 1,800-character one, and this model separates those better than the first (handoff-firewall eval). It
134
+ // is built on the first guard's vocabulary, so it uses that tokenizer (about 450 MB in memory) after its own extra step.
135
+ {
136
+ id: "guard-short",
137
+ name: "Horizon PI guard small",
138
+ purpose: "Finds one planted instruction in a long page or post",
139
+ tier: "core",
140
+ runtime: "onnx",
141
+ licence: "apache-2.0",
142
+ repo: "Horizon-Labs/prompt-injection-guard-small",
143
+ revision: "e0e88c4e940ee7e1b52bdf943d501ec696b83b9b",
144
+ files: [
145
+ {
146
+ path: "onnx/model_quantized.onnx",
147
+ sha256:
148
+ "61cd0741cdbfcc229e32e1de1392f352736d87d1e8b3c2b67ad9836bb7910d5d",
149
+ size: 268409234,
150
+ },
151
+ {
152
+ path: "config.json",
153
+ sha256:
154
+ "d6264037c9aa4b3d821c1c9a0329572033adbdb622788ecca91c3636066d9622",
155
+ size: 2031,
156
+ },
157
+ ],
158
+ tokenizer: { from: "guard", prepare: (text) => unhide(text) },
159
+ },
160
+ {
161
+ id: "credentials",
162
+ name: "credential-line 32M",
163
+ purpose: "Spots keys and passwords before a brief leaves the computer",
164
+ tier: "core",
165
+ runtime: "onnx",
166
+ licence: "mit",
167
+ repo: "zaindanaharper/credential-line-32m",
168
+ revision: "65422b823e9fb91d528b98229ebef6895d81de3b",
169
+ files: [
170
+ {
171
+ path: "onnx/model.onnx",
172
+ sha256:
173
+ "bd08c443d1472304624ea0b16ef8e08685a9e8fbe34ddfb9a65cfd00b6b4c47e",
174
+ size: 128379418,
175
+ },
176
+ {
177
+ path: "tokenizer.json",
178
+ sha256:
179
+ "218484396a9d08293b108b0e5ea31e0a1b7c801dcbed35a821deb392d2fe9bb4",
180
+ size: 3583485,
181
+ },
182
+ {
183
+ path: "tokenizer_config.json",
184
+ sha256:
185
+ "5bed9c3a3a8d5192d9e304ef695d265446452ba1b9a49e35183b74f982e21a53",
186
+ size: 396,
187
+ },
188
+ {
189
+ path: "config.json",
190
+ sha256:
191
+ "1fb928ec87f399096fdd444914c15a6643c9a0c4a7a7fc17c1e20f8bd61f7ea6",
192
+ size: 1918,
193
+ },
194
+ {
195
+ path: "policy.json",
196
+ sha256:
197
+ "db74709c4c08dc5d20732ec34aea723194bfa347553e97e81240f896a8de9770",
198
+ size: 754,
199
+ },
200
+ ],
201
+ },
202
+ // The search model's image and audio part: the same weights as search with its projector beside them, so a recording
203
+ // or a screenshot lands in the same space as a typed question. Downloaded when the person first adds a folder of them.
204
+ {
205
+ id: "media",
206
+ name: "EmbeddingGemma 2 (images and audio)",
207
+ purpose:
208
+ "Finds the minute of a recording or the screenshot a request is about, in folders the person adds",
209
+ tier: "extended",
210
+ runtime: "llama",
211
+ licence: "apache-2.0",
212
+ repo: "ggml-org/embeddinggemma-2-GGUF",
213
+ revision: "bfcd298762cc34d0357ece5ebdd31791a3a374d8",
214
+ files: [
215
+ {
216
+ path: "mmproj-embeddinggemma-2-Q8_0.gguf",
217
+ sha256:
218
+ "c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f",
219
+ size: 554821024,
220
+ },
221
+ ],
222
+ llama: { weights: "search", embedding: true, c: 2048, ub: 2048, b: 2048 },
223
+ },
224
+ // The browser's OCR pair (src/shared/localRuntime/gpuWorker/ocr): PaddlePaddle's own ONNX exports, the detector byte
225
+ // for byte the one the browser serves. The recogniser's character list is read from its inference.yml.
226
+ {
227
+ id: "ocr",
228
+ name: "PP-OCR (v6 small detector, v5 English recogniser)",
229
+ purpose:
230
+ "Reads screenshots so keys and personal details are hidden before an image leaves the computer",
231
+ tier: "core",
232
+ runtime: "onnx",
233
+ licence: "apache-2.0",
234
+ repo: "PaddlePaddle/en_PP-OCRv5_mobile_rec_onnx",
235
+ revision: "3fafbc3b5dcf93dd72add9f48368be8a3a2cd33b",
236
+ files: [
237
+ {
238
+ path: "det/inference.onnx",
239
+ repo: "PaddlePaddle/PP-OCRv6_small_det_onnx",
240
+ revision: "28fe5895c24fd108c19eb3e8479f4ab385fbfc62",
241
+ from: "inference.onnx",
242
+ sha256:
243
+ "d73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e",
244
+ size: 9880512,
245
+ },
246
+ {
247
+ path: "rec/inference.onnx",
248
+ from: "inference.onnx",
249
+ sha256:
250
+ "b5f833dfc5d0eb71da397b4efa06ebeee9b431b690a47d6af40d77d8eabc557f",
251
+ size: 7848423,
252
+ },
253
+ {
254
+ path: "rec/inference.yml",
255
+ from: "inference.yml",
256
+ sha256:
257
+ "27e91d0582f40168aa218303c76e184bc78fa7a5d105aad0cfbad8458b441067",
258
+ size: 3964,
259
+ },
260
+ ],
261
+ },
262
+ // Talk in the buddy (speech.mjs), downloaded the first time the person holds it: the VAD always, the speech model only
263
+ // where Apple's transcriber is not used.
264
+ {
265
+ id: "vad",
266
+ name: "Silero VAD 6.2",
267
+ purpose: "Hears whether anyone spoke while the talk key was held",
268
+ tier: "extended",
269
+ runtime: "onnx",
270
+ licence: "mit",
271
+ repo: "istupakov/silero-vad-onnx",
272
+ revision: "b3e3ee3cce4c11ceb63b1a0b229d916069c1ddf6",
273
+ files: [
274
+ {
275
+ path: "silero_vad_16k_op15.onnx",
276
+ sha256:
277
+ "7ed98ddbad84ccac4cd0aeb3099049280713df825c610a8ed34543318f1b2c49",
278
+ size: 1289603,
279
+ },
280
+ ],
281
+ },
282
+ // Speech where Apple's own transcriber is not on the computer (Linux, Windows, Macs before macOS 26): NVIDIA's
283
+ // Parakeet TDT 0.6B v3 (CC-BY-4.0; credited in the CLI's README), the 8-bit ONNX export by istupakov. Measured in the
284
+ // dictation eval against the Moonshine base it replaced: 2.8% of words wrong on real people reading against 4.9%. Two
285
+ // threads: a 5 s recording in 130 ms here against 205 ms on one (four: 99 ms).
286
+ {
287
+ id: "speech",
288
+ name: "Parakeet TDT 0.6B v3 (8-bit)",
289
+ purpose: "Turns what the person says into text for the buddy's composer",
290
+ tier: "extended",
291
+ runtime: "onnx",
292
+ licence: "cc-by-4.0",
293
+ repo: "istupakov/parakeet-tdt-0.6b-v3-onnx",
294
+ revision: "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
295
+ engine: "parakeet",
296
+ threads: 2,
297
+ files: [
298
+ {
299
+ path: "encoder-model.int8.onnx",
300
+ sha256:
301
+ "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09",
302
+ size: 652183999,
303
+ },
304
+ {
305
+ path: "decoder_joint-model.int8.onnx",
306
+ sha256:
307
+ "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70",
308
+ size: 18202004,
309
+ },
310
+ {
311
+ path: "nemo128.onnx",
312
+ sha256:
313
+ "a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f",
314
+ size: 139764,
315
+ },
316
+ {
317
+ path: "vocab.txt",
318
+ sha256:
319
+ "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d",
320
+ size: 93939,
321
+ },
322
+ {
323
+ path: "config.json",
324
+ sha256:
325
+ "666903c76b9798caf2c210afd4f6cd60b08a8dbf9800ec8d7a3bc0d2148ac466",
326
+ size: 97,
327
+ },
328
+ ],
329
+ },
330
+ ];
331
+
332
+ export const modelById = (id, list = MODELS) =>
333
+ list.find((entry) => entry.id === id) ?? null;
334
+ /**
335
+ * The extra step in Horizon PI guard small's tokenizer before the one it shares with Wolf Defender (same token ids on
336
+ * every text tried, hidden ones included): letters spelled in Unicode tag characters or variation selectors become
337
+ * plain letters, NFKC, then invisible and overlay characters go.
338
+ */
339
+ export const unhide = (text) =>
340
+ String(text)
341
+ .replace(/[\u{E0020}-\u{E007E}]/gu, (c) =>
342
+ String.fromCodePoint(c.codePointAt(0) - 0xe0000)
343
+ )
344
+ .replace(/[\u{E0110}-\u{E016E}]/gu, (c) =>
345
+ String.fromCodePoint(c.codePointAt(0) - 0xe0110 + 0x20)
346
+ )
347
+ .normalize("NFKC")
348
+ .replace(
349
+ /[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF\uFE00-\uFE0F\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}\u0305\u0331-\u0338\u20D0-\u20FF]/gu,
350
+ ""
351
+ );
352
+
353
+ export const modelDir = (root, entry) => path.join(root, entry.id);
354
+ export const modelFile = (root, entry, index = 0) =>
355
+ path.join(modelDir(root, entry), ...entry.files[index].path.split("/"));
356
+
357
+ /**
358
+ * Refuses an entry that could not be verified or used: unpinned, unhashed, or under a licence off the allowlist. A file
359
+ * may come from another repository (its own repo and revision, under the entry's licence) and from another path there
360
+ * (from), so models made of parts published apart stay one entry.
361
+ */
362
+ export function checkEntry(entry) {
363
+ if (!LICENCES.has(entry.licence))
364
+ throw new Error(
365
+ `${entry.name}: licence ${entry.licence} is not on the allowlist`
366
+ );
367
+ if (!/^[0-9a-f]{40}$/.test(entry.revision || ""))
368
+ throw new Error(`${entry.name}: not pinned to a repository commit`);
369
+ for (const file of entry.files) {
370
+ if (!/^[0-9a-f]{64}$/.test(file.sha256 || "") || !(file.size > 0))
371
+ throw new Error(
372
+ `${entry.name}: ${file.path} has no pinned hash and size`
373
+ );
374
+ if (
375
+ (file.repo !== undefined || file.revision !== undefined) &&
376
+ !(
377
+ /^[\w.-]+\/[\w.-]+$/.test(file.repo || "") &&
378
+ /^[0-9a-f]{40}$/.test(file.revision || "")
379
+ )
380
+ )
381
+ throw new Error(
382
+ `${entry.name}: ${file.path} names another repository without pinning it to a commit`
383
+ );
384
+ }
385
+ return entry;
386
+ }
387
+
388
+ export async function sha256File(file) {
389
+ const hash = createHash("sha256");
390
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
391
+ return hash.digest("hex");
392
+ }
393
+
394
+ const present = (file, size) => {
395
+ try {
396
+ return fs.statSync(file).size === size;
397
+ } catch {
398
+ return false;
399
+ }
400
+ };
401
+
402
+ async function download(url, target, expected, { fetchImpl, log }) {
403
+ const partial = `${target}.partial`;
404
+ ensureDir(path.dirname(target));
405
+ let have = 0;
406
+ try {
407
+ have = fs.statSync(partial).size;
408
+ } catch {}
409
+ const response = await fetchImpl(url, {
410
+ headers: have > 0 ? { range: `bytes=${have}-` } : {},
411
+ redirect: "follow",
412
+ });
413
+ if (response.status !== 416) {
414
+ if (!response.ok)
415
+ throw new Error(
416
+ `download of ${path.basename(target)} failed: HTTP ${response.status}`
417
+ );
418
+ const out = fs.createWriteStream(partial, {
419
+ flags: response.status === 206 ? "a" : "w",
420
+ });
421
+ for await (const chunk of response.body)
422
+ if (!out.write(chunk))
423
+ await new Promise((resolve) => out.once("drain", resolve));
424
+ await new Promise((resolve, reject) => {
425
+ out.end();
426
+ out.on("finish", resolve);
427
+ out.on("error", reject);
428
+ });
429
+ }
430
+ const actual = await sha256File(partial);
431
+ if (
432
+ actual !== expected.sha256 ||
433
+ fs.statSync(partial).size !== expected.size
434
+ ) {
435
+ fs.rmSync(partial, { force: true });
436
+ throw new Error(
437
+ `${path.basename(target)} did not match its pinned hash; it was deleted and nothing was installed`
438
+ );
439
+ }
440
+ fs.renameSync(partial, target);
441
+ log?.(`local models: ${path.basename(target)} verified`);
442
+ }
443
+
444
+ /** Downloads and verifies the given models' missing files into root. Returns the ids now present. */
445
+ export async function ensureModels({
446
+ root,
447
+ ids,
448
+ list = MODELS,
449
+ fetchImpl = fetch,
450
+ log = () => {},
451
+ }) {
452
+ const done = [];
453
+ for (const id of ids) {
454
+ const entry = checkEntry(
455
+ modelById(id, list) ??
456
+ (() => {
457
+ throw new Error(`unknown local model ${id}`);
458
+ })()
459
+ );
460
+ for (const [index, file] of entry.files.entries()) {
461
+ const target = modelFile(root, entry, index);
462
+ if (present(target, file.size)) continue;
463
+ await download(
464
+ `https://huggingface.co/${file.repo ?? entry.repo}/resolve/${file.revision ?? entry.revision}/${file.from ?? file.path}`,
465
+ target,
466
+ file,
467
+ { fetchImpl, log }
468
+ );
469
+ }
470
+ done.push(id);
471
+ }
472
+ return done;
473
+ }
474
+
475
+ export const modelsPresent = (root, ids, list = MODELS) =>
476
+ ids.every((id) => {
477
+ const entry = modelById(id, list);
478
+ return (
479
+ entry &&
480
+ entry.files.every((file, index) =>
481
+ present(modelFile(root, entry, index), file.size)
482
+ )
483
+ );
484
+ });
485
+
486
+ /** The llama.cpp build for this machine: the GPU build where the OS has one (Metal is built in on macOS). */
487
+ export function llamaAsset({
488
+ platform = process.platform,
489
+ arch = process.arch,
490
+ gpu = true,
491
+ } = {}) {
492
+ const base = `${platform}-${arch}`;
493
+ return (
494
+ LLAMA.assets[gpu && platform !== "darwin" ? `${base}-vulkan` : base] ??
495
+ LLAMA.assets[base] ??
496
+ null
497
+ );
498
+ }
499
+
500
+ export const llamaDir = (root) => path.join(root, `llama-${LLAMA.build}`);
501
+ export const llamaServer = (root) => {
502
+ const dir = llamaDir(root);
503
+ const name =
504
+ process.platform === "win32" ? "llama-server.exe" : "llama-server";
505
+ for (const candidate of [
506
+ path.join(dir, name),
507
+ path.join(dir, "build", "bin", name),
508
+ path.join(dir, `llama-${LLAMA.build}`, name),
509
+ ])
510
+ if (fs.existsSync(candidate)) return candidate;
511
+ return null;
512
+ };
513
+
514
+ /** Downloads, verifies and unpacks the llama.cpp server. Idempotent. */
515
+ export async function ensureLlama({
516
+ root,
517
+ gpu = true,
518
+ fetchImpl = fetch,
519
+ log = () => {},
520
+ }) {
521
+ if (llamaServer(root)) return runs(llamaServer(root));
522
+ const asset = llamaAsset({ gpu });
523
+ if (!asset)
524
+ throw new Error(
525
+ `no llama.cpp build is pinned for ${process.platform}-${process.arch}`
526
+ );
527
+ const archive = path.join(root, asset.name);
528
+ if (!present(archive, asset.size))
529
+ await download(
530
+ `https://github.com/ggml-org/llama.cpp/releases/download/${LLAMA.build}/${asset.name}`,
531
+ archive,
532
+ asset,
533
+ { fetchImpl, log }
534
+ );
535
+ else if ((await sha256File(archive)) !== asset.sha256) {
536
+ fs.rmSync(archive, { force: true });
537
+ throw new Error(
538
+ `${asset.name} did not match its pinned hash; it was deleted`
539
+ );
540
+ }
541
+ const dir = llamaDir(root);
542
+ ensureDir(dir);
543
+ const unpacked = asset.name.endsWith(".zip")
544
+ ? spawnSync("tar", ["-xf", archive, "-C", dir], { encoding: "utf8" })
545
+ : spawnSync(
546
+ "tar",
547
+ ["-xzf", archive, "-C", dir, "--strip-components", "1"],
548
+ { encoding: "utf8" }
549
+ );
550
+ if (unpacked.status !== 0)
551
+ throw new Error(
552
+ `unpacking ${asset.name} failed: ${(unpacked.stderr || unpacked.error?.message || "").trim()}`
553
+ );
554
+ fs.rmSync(archive, { force: true });
555
+ const server = llamaServer(root);
556
+ if (!server) throw new Error(`${asset.name} has no llama-server in it`);
557
+ return runs(server);
558
+ }
559
+
560
+ // A build that cannot start here says what is missing: on Linux, llama.cpp needs the system's OpenMP library, which
561
+ // minimal installs lack.
562
+ function runs(server) {
563
+ const missing = (
564
+ spawnSync(server, ["--version"], { encoding: "utf8" }).stderr || ""
565
+ ).match(/error while loading shared libraries: ([^:\s]+)/)?.[1];
566
+ if (missing)
567
+ throw new Error(
568
+ `the local model server needs ${missing}, which this computer does not have${missing.startsWith("libgomp") ? " (on Ubuntu or Debian: sudo apt install libgomp1)" : ""}`
569
+ );
570
+ return server;
571
+ }