@otto-code/brain 0.7.6 → 0.8.1

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 (87) hide show
  1. package/dist/bench/context-corpus.js +3 -3
  2. package/dist/bench/corpus.js +2 -2
  3. package/dist/bench/curated-repos.js +3 -3
  4. package/dist/bench/health.d.ts +1 -1
  5. package/dist/bench/health.js +2 -2
  6. package/dist/bench/tasks.js +2 -2
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/commands/bench.d.ts +1 -1
  10. package/dist/commands/bench.js +27 -4
  11. package/dist/commands/calibrate.d.ts +1 -1
  12. package/dist/commands/calibrate.js +5 -2
  13. package/dist/commands/catalog.d.ts +1 -1
  14. package/dist/commands/config.d.ts +1 -1
  15. package/dist/commands/lifecycle.js +1 -1
  16. package/dist/commands/pull.d.ts +1 -1
  17. package/dist/commands/pull.js +9 -4
  18. package/dist/commands/report.d.ts +1 -1
  19. package/dist/commands/rescore.d.ts +1 -1
  20. package/dist/commands/rescore.js +1 -1
  21. package/dist/commands/runtime.d.ts +2 -1
  22. package/dist/commands/runtime.js +32 -3
  23. package/dist/commands/scan.d.ts +1 -1
  24. package/dist/commands/scan.js +6 -1
  25. package/dist/commands/search.d.ts +1 -1
  26. package/dist/commands/share.js +2 -2
  27. package/dist/commands/sweep.d.ts +2 -2
  28. package/dist/commands/sweep.js +22 -8
  29. package/dist/commands/ui.d.ts +1 -1
  30. package/dist/config/index.d.ts +2 -1
  31. package/dist/config/index.js +2 -1
  32. package/dist/config/otto-home.js +1 -1
  33. package/dist/config/paths.d.ts +1 -0
  34. package/dist/config/paths.js +4 -0
  35. package/dist/config/profile-edit.d.ts +94 -0
  36. package/dist/config/profile-edit.js +269 -0
  37. package/dist/config/profiles.d.ts +2 -2
  38. package/dist/config/profiles.js +1 -1
  39. package/dist/config/schema.d.ts +4 -4
  40. package/dist/config/schema.js +6 -6
  41. package/dist/config/store.d.ts +1 -1
  42. package/dist/config/store.js +1 -1
  43. package/dist/models/download.js +1 -1
  44. package/dist/models/enrich.d.ts +2 -2
  45. package/dist/models/index.d.ts +1 -1
  46. package/dist/models/index.js +1 -1
  47. package/dist/models/pick.d.ts +9 -0
  48. package/dist/models/pick.js +24 -1
  49. package/dist/ops/report.js +28 -28
  50. package/dist/ops/results.d.ts +128 -9
  51. package/dist/ops/results.js +77 -5
  52. package/dist/output/render.js +1 -1
  53. package/dist/output/types.d.ts +1 -1
  54. package/dist/runtime/args.d.ts +17 -4
  55. package/dist/runtime/args.js +20 -14
  56. package/dist/runtime/index.d.ts +9 -3
  57. package/dist/runtime/index.js +21 -3
  58. package/dist/runtime/lmstudio.d.ts +2 -2
  59. package/dist/runtime/lmstudio.js +115 -28
  60. package/dist/runtime/managed.d.ts +72 -8
  61. package/dist/runtime/managed.js +404 -38
  62. package/dist/service/activity.d.ts +83 -0
  63. package/dist/service/activity.js +216 -0
  64. package/dist/service/host-api.d.ts +132 -0
  65. package/dist/service/host-api.js +397 -0
  66. package/dist/service/http-util.d.ts +27 -0
  67. package/dist/service/http-util.js +72 -0
  68. package/dist/service/model-selector.d.ts +2 -2
  69. package/dist/service/model-selector.js +6 -6
  70. package/dist/service/router.d.ts +28 -4
  71. package/dist/service/router.js +128 -94
  72. package/dist/service/scheduler.d.ts +2 -2
  73. package/dist/service/scheduler.js +1 -1
  74. package/dist/service/serve.d.ts +2 -2
  75. package/dist/service/serve.js +51 -8
  76. package/dist/service/supervisor.d.ts +6 -0
  77. package/dist/service/supervisor.js +2 -0
  78. package/dist/service/tailscale.js +1 -1
  79. package/dist/service/tls.d.ts +4 -4
  80. package/dist/service/tls.js +3 -3
  81. package/dist/sysmon.d.ts +20 -4
  82. package/dist/sysmon.js +42 -18
  83. package/dist/tui/app.d.ts +12 -2
  84. package/dist/tui/app.js +46 -22
  85. package/dist/vram.d.ts +8 -1
  86. package/dist/vram.js +6 -3
  87. package/package.json +1 -1
@@ -1,12 +1,54 @@
1
1
  /**
2
2
  * The self-contained runtime source: otto-brain downloads a pinned llama.cpp
3
3
  * build into `$OTTO_HOME/otto-brain/runtimes/` and runs it directly, so the tool
4
- * needs no other software installed. Downloaded runtimes keep their DLLs in the
5
- * same directory as the exe (so `vendorDir` is null buildEnv already puts the
6
- * runtime dir on PATH, which is what the DLL-stub trap requires).
4
+ * needs no other software installed. Downloaded runtimes keep their shared
5
+ * libraries in the same directory as the binary (so `vendorDir` is null - the
6
+ * loader path built by `args.buildEnv` already covers that directory, which is
7
+ * what the DLL-stub trap requires).
7
8
  *
8
- * Extraction uses only OS built-ins PowerShell's Expand-Archive for .zip and
9
- * the bundled `tar` for tarballs to keep the "nothing else to install" promise.
9
+ * Extraction uses only OS built-ins - PowerShell's Expand-Archive for .zip on
10
+ * Windows, the bundled `tar` for tarballs everywhere else - to keep the
11
+ * "nothing else to install" promise.
12
+ *
13
+ * ## The upstream asset matrix (verified against release b10265)
14
+ *
15
+ * Every fact below was read off the release, not inferred from the naming
16
+ * scheme, because the scheme has changed at least once: tag b4600 shipped
17
+ * `llama-b4600-bin-win-cuda-cu12.4-x64.zip` while b10265 ships
18
+ * `llama-b10265-bin-win-cuda-12.4-x64.zip` (no `cu`). A pin bump must re-verify
19
+ * the names against that tag's asset list; they are not stable across tags.
20
+ *
21
+ * - Windows assets are `.zip`; macOS and Linux assets are `.tar.gz`.
22
+ * - **Linux has no CUDA *release asset*, and we deliberately do not source one
23
+ * elsewhere.** The Linux GPU assets are Vulkan, ROCm and SYCL only, so the
24
+ * Linux GPU default is Vulkan - the one accelerator covering NVIDIA, AMD and
25
+ * Intel from a single asset. There is a real Linux CUDA build upstream, in
26
+ * the `ghcr.io/ggml-org/llama.cpp:server-cuda-b<n>` container images (484 of
27
+ * them, and 376 build numbers carry both an image and a release, so a pinned
28
+ * version-aligned import is genuinely possible). It was extracted, run and
29
+ * benchmarked on 2026-08-04 and then rejected on the numbers:
30
+ *
31
+ * CUDA beats Vulkan by 1.00x-1.04x on an RTX 5090 at every prefill depth
32
+ * from 512 to 8192 and at token generation, inside the run-to-run error at
33
+ * three of five points, because NVIDIA's Vulkan driver exposes
34
+ * NV_coopmat2 and llama.cpp's Vulkan backend uses those tensor cores.
35
+ *
36
+ * Paying for that would mean a 40x download (1.3 GB against 32 MB) and a 14x
37
+ * on-disk footprint, assembled from three origins, because `libggml-cuda.so`
38
+ * also needs `libcublas`, `libcudart` and `libnccl.so.2` - and NCCL ships in
39
+ * neither NVIDIA redistributable. Do not reopen this without a measurement on
40
+ * hardware that does *not* report NV_coopmat2, which is the one case where
41
+ * the gap could still be real. Full evidence:
42
+ * findings/linux-gpu-acceleration/2026-08-04-cuda-vs-vulkan-and-cuda-asset-origins.md
43
+ * - **No Linux asset ships `libgomp.so.1`**, which `llama-server` hard-links,
44
+ * so a host without `libgomp1` installs a runtime that then exits 127 on
45
+ * spawn. Windows bundles its OpenMP runtime (`libomp140.x86_64.dll`); Linux
46
+ * bundles nothing. `buildEnv` cannot paper over it - the library is not in
47
+ * the runtime dir that `LD_LIBRARY_PATH` already points at.
48
+ * - Windows CUDA needs a *second* archive (`cudart-llama-bin-win-cuda-*.zip`)
49
+ * extracted over the first, and that asset's name carries no build tag.
50
+ * - macOS arm64 is Metal-accelerated in the stock `macos-arm64` asset; there is
51
+ * no separate Metal asset to select. macOS x64 is CPU-only in practice.
10
52
  */
11
53
  import { spawn } from "node:child_process";
12
54
  import fs from "node:fs";
@@ -14,24 +56,125 @@ import { createWriteStream } from "node:fs";
14
56
  import path from "node:path";
15
57
  import { Readable } from "node:stream";
16
58
  import { pipeline } from "node:stream/promises";
59
+ import { buildEnv } from "./args.js";
17
60
  /**
18
- * The default runtime build. The llama.cpp release tag and CUDA asset names must
19
- * be pinned per platform; verify the URLs against the current release before
20
- * shipping. Overridable via config (`runtime.path`) or an explicit spec.
61
+ * The default runtime build. Bumping this requires re-reading the tag's asset
62
+ * names (see the module header) - the naming scheme is not stable across tags.
21
63
  */
22
- export const DEFAULT_LLAMA_BUILD = "b4600";
64
+ export const DEFAULT_LLAMA_BUILD = "b10265";
23
65
  const LLAMA_RELEASE_BASE = "https://github.com/ggml-org/llama.cpp/releases/download";
24
- /** Build the default Windows CUDA 12 spec for a given llama.cpp build tag. */
25
- export function defaultRuntimeSpec(build = DEFAULT_LLAMA_BUILD) {
66
+ /** The CUDA toolkit version whose Windows assets we pin. */
67
+ const WINDOWS_CUDA = "12.4";
68
+ /** The binary name llama.cpp ships for a platform. */
69
+ export function serverExeName(platform = process.platform) {
70
+ return platform === "win32" ? "llama-server.exe" : "llama-server";
71
+ }
72
+ /**
73
+ * Pick the accelerator for a target. Deliberately conservative: it never
74
+ * chooses an accelerator whose asset does not exist for that platform/arch.
75
+ */
76
+ export function resolveRuntimeVariant(target = {}) {
77
+ const platform = target.platform ?? process.platform;
78
+ const arch = target.arch ?? process.arch;
79
+ const requested = target.variant ?? "auto";
80
+ if (requested !== "auto")
81
+ return requested;
82
+ if (platform === "darwin")
83
+ return arch === "arm64" ? "metal" : "cpu";
84
+ // Windows is the only platform with an upstream CUDA build. Everywhere else a
85
+ // GPU means Vulkan.
86
+ if (platform === "win32") {
87
+ if (arch === "arm64")
88
+ return "cpu";
89
+ return target.hasNvidiaGpu ? "cuda" : "vulkan";
90
+ }
91
+ if (platform === "linux")
92
+ return target.hasNvidiaGpu === false ? "cpu" : "vulkan";
93
+ return "cpu";
94
+ }
95
+ const VARIANT_LABELS = {
96
+ cuda: `CUDA ${WINDOWS_CUDA}`,
97
+ metal: "Metal",
98
+ vulkan: "Vulkan",
99
+ cpu: "CPU",
100
+ };
101
+ /**
102
+ * The archive names for a platform/arch/variant, or null when upstream ships no
103
+ * such asset. Returning null (rather than guessing a URL) is what keeps a bad
104
+ * combination from failing later as an opaque 404 mid-download.
105
+ */
106
+ function assetNames(build, platform, arch, variant) {
107
+ const bin = (suffix, ext) => `llama-${build}-bin-${suffix}.${ext}`;
108
+ if (platform === "win32") {
109
+ if (arch === "arm64")
110
+ return variant === "cpu" ? [bin("win-cpu-arm64", "zip")] : null;
111
+ if (arch !== "x64")
112
+ return null;
113
+ if (variant === "cuda") {
114
+ return [
115
+ bin(`win-cuda-${WINDOWS_CUDA}-x64`, "zip"),
116
+ // The CUDA runtime DLLs ride in a companion archive whose name carries
117
+ // no build tag; it is published under each release tag all the same.
118
+ `cudart-llama-bin-win-cuda-${WINDOWS_CUDA}-x64.zip`,
119
+ ];
120
+ }
121
+ if (variant === "vulkan")
122
+ return [bin("win-vulkan-x64", "zip")];
123
+ if (variant === "cpu")
124
+ return [bin("win-cpu-x64", "zip")];
125
+ return null;
126
+ }
127
+ if (platform === "darwin") {
128
+ if (arch === "arm64") {
129
+ // One asset serves both: the stock macos-arm64 build is Metal-enabled.
130
+ return variant === "metal" || variant === "cpu" ? [bin("macos-arm64", "tar.gz")] : null;
131
+ }
132
+ if (arch === "x64")
133
+ return variant === "cpu" ? [bin("macos-x64", "tar.gz")] : null;
134
+ return null;
135
+ }
136
+ if (platform === "linux") {
137
+ const slice = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : null;
138
+ if (!slice)
139
+ return null;
140
+ if (variant === "vulkan")
141
+ return [bin(`ubuntu-vulkan-${slice}`, "tar.gz")];
142
+ if (variant === "cpu")
143
+ return [bin(`ubuntu-${slice}`, "tar.gz")];
144
+ // No Linux CUDA/Metal *release* asset. A CUDA container image exists and was
145
+ // measured at parity with Vulkan; see the header before reopening this.
146
+ return null;
147
+ }
148
+ return null;
149
+ }
150
+ /**
151
+ * Build the runtime spec for a target. Throws when upstream publishes nothing
152
+ * for the combination, naming what it tried so the message is actionable.
153
+ */
154
+ export function defaultRuntimeSpec(build = DEFAULT_LLAMA_BUILD, target = {}) {
155
+ const tag = build || DEFAULT_LLAMA_BUILD;
156
+ const platform = target.platform ?? process.platform;
157
+ const arch = target.arch ?? process.arch;
158
+ const variant = resolveRuntimeVariant({ ...target, platform, arch });
159
+ const names = assetNames(tag, platform, arch, variant);
160
+ if (!names) {
161
+ throw new Error(`llama.cpp publishes no ${variant} build for ${platform}/${arch} - ` +
162
+ `pass --variant with one of ${supportedVariants(platform, arch).join(", ") || "(none)"}, ` +
163
+ `or point runtime.path at a runtime you built yourself`);
164
+ }
26
165
  return {
27
- label: "CUDA 12 (managed)",
28
- version: build,
29
- assets: [
30
- `${LLAMA_RELEASE_BASE}/${build}/llama-${build}-bin-win-cuda-12.4-x64.zip`,
31
- `${LLAMA_RELEASE_BASE}/${build}/cudart-llama-bin-win-cuda-12.4-x64.zip`,
32
- ],
166
+ label: `${VARIANT_LABELS[variant]} (managed)`,
167
+ version: tag,
168
+ assets: names.map((name) => `${LLAMA_RELEASE_BASE}/${tag}/${name}`),
169
+ platform,
170
+ variant,
33
171
  };
34
172
  }
173
+ /** Variants with a published asset for a platform/arch, best first. */
174
+ export function supportedVariants(platform = process.platform, arch = process.arch) {
175
+ const order = ["cuda", "metal", "vulkan", "cpu"];
176
+ return order.filter((v) => assetNames(DEFAULT_LLAMA_BUILD, platform, arch, v) !== null);
177
+ }
35
178
  function slug(spec) {
36
179
  return `${spec.label}-${spec.version}`
37
180
  .toLowerCase()
@@ -54,16 +197,37 @@ function findFile(dir, name) {
54
197
  }
55
198
  return null;
56
199
  }
57
- /** A managed runtime is any dir under runtimesDir that contains llama-server.exe. */
58
- export function listManagedRuntimes(runtimesDir) {
200
+ /**
201
+ * Rank a managed runtime directory by the accelerator its slug encodes, best
202
+ * first. Once more than one variant can be installed side by side, readdir
203
+ * order is not a defensible preference: a machine that installed CPU for a
204
+ * one-off test would otherwise have it silently outrank its CUDA runtime.
205
+ */
206
+ function managedVariantRank(dirName) {
207
+ const order = ["cuda", "metal", "vulkan", "cpu"];
208
+ const lower = dirName.toLowerCase();
209
+ const index = order.findIndex((variant) => lower.startsWith(`${variant}-`));
210
+ return index === -1 ? order.length : index;
211
+ }
212
+ /** Numeric part of a `bNNNNN` llama.cpp build tag, for ordering. */
213
+ function buildNumber(version) {
214
+ const match = /(\d+)/.exec(version);
215
+ return match ? Number(match[1]) : 0;
216
+ }
217
+ /**
218
+ * A managed runtime is any dir under runtimesDir that contains llama-server,
219
+ * best accelerator first and newest build first within an accelerator.
220
+ */
221
+ export function listManagedRuntimes(runtimesDir, platform = process.platform) {
59
222
  if (!fs.existsSync(runtimesDir))
60
223
  return [];
224
+ const exeName = serverExeName(platform);
61
225
  const found = [];
62
226
  for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) {
63
227
  if (!entry.isDirectory())
64
228
  continue;
65
229
  const root = path.join(runtimesDir, entry.name);
66
- const exe = findFile(root, "llama-server.exe");
230
+ const exe = findFile(root, exeName);
67
231
  if (!exe)
68
232
  continue;
69
233
  found.push({
@@ -75,7 +239,8 @@ export function listManagedRuntimes(runtimesDir) {
75
239
  source: "managed",
76
240
  });
77
241
  }
78
- return found;
242
+ return found.sort((a, b) => managedVariantRank(a.label) - managedVariantRank(b.label) ||
243
+ buildNumber(b.version) - buildNumber(a.version));
79
244
  }
80
245
  async function downloadFile(url, dest, onProgress) {
81
246
  const response = await fetch(url);
@@ -100,22 +265,212 @@ function run(command, args) {
100
265
  child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)));
101
266
  });
102
267
  }
103
- async function extractArchive(archivePath, destDir) {
268
+ /** Spawn and collect the outcome instead of throwing, so callers can classify it. */
269
+ function runCapture(command, args, env) {
270
+ return new Promise((resolve) => {
271
+ const child = spawn(command, args, { windowsHide: true, env });
272
+ let stdout = "";
273
+ let stderr = "";
274
+ child.stdout.on("data", (d) => (stdout += d.toString()));
275
+ child.stderr.on("data", (d) => (stderr += d.toString()));
276
+ child.on("error", (err) => resolve({ code: null, stdout, stderr: `${stderr}${err}` }));
277
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
278
+ });
279
+ }
280
+ /**
281
+ * Distro packages providing a library llama.cpp links but ships on no asset.
282
+ * Keyed by soname because that is the string the dynamic loader prints.
283
+ */
284
+ const SYSTEM_LIBRARY_PACKAGES = {
285
+ "libgomp.so.1": "libgomp1 (Debian/Ubuntu), libgomp (Fedora/RHEL) or gcc-libs (Arch)",
286
+ };
287
+ /**
288
+ * Where to fetch the one library upstream leaves unmet on Linux.
289
+ *
290
+ * Debian's pool rather than Ubuntu's for two measured reasons: bookworm's
291
+ * `libgomp1` carries a `GLIBC_2.34` floor, which is *exactly* llama-server's own
292
+ * floor, so bundling it cannot narrow the set of systems the runtime already ran
293
+ * on; and its payload is `data.tar.xz`, which `tar` reads with the near-universal
294
+ * `xz`, where Ubuntu 24.04+ moved to zstd, which a minimal image does not have.
295
+ */
296
+ const LIBGOMP_PACKAGE = {
297
+ x64: "https://deb.debian.org/debian/pool/main/g/gcc-12/libgomp1_12.2.0-14+deb12u1_amd64.deb",
298
+ arm64: "https://deb.debian.org/debian/pool/main/g/gcc-12/libgomp1_12.2.0-14+deb12u1_arm64.deb",
299
+ };
300
+ /**
301
+ * Read one member out of a Unix `ar` archive, which is the container format of a
302
+ * `.deb`. Parsed here rather than shelled out to `ar`, which is binutils and not
303
+ * present on a minimal image - the exact kind of host that needs this repair.
304
+ *
305
+ * Layout: an 8-byte magic, then per member a fixed 60-byte ASCII header whose
306
+ * name is bytes 0-15 and size bytes 48-57, followed by the payload padded to an
307
+ * even offset.
308
+ */
309
+ function readArMember(archive, member) {
310
+ if (archive.subarray(0, 8).toString("ascii") !== "!<arch>\n")
311
+ return null;
312
+ let offset = 8;
313
+ while (offset + 60 <= archive.length) {
314
+ const header = archive.subarray(offset, offset + 60);
315
+ const name = header.subarray(0, 16).toString("ascii").trim().replace(/\/$/, "");
316
+ const size = Number.parseInt(header.subarray(48, 58).toString("ascii").trim(), 10);
317
+ if (!Number.isInteger(size) || size < 0)
318
+ return null;
319
+ const start = offset + 60;
320
+ if (name === member)
321
+ return archive.subarray(start, start + size);
322
+ offset = start + size + (size % 2);
323
+ }
324
+ return null;
325
+ }
326
+ /**
327
+ * Try to satisfy a missing system library by placing it beside the binary, where
328
+ * `buildEnv`'s loader path already looks.
329
+ *
330
+ * Only ever runs *after* the runtime has already failed to start, so it cannot
331
+ * regress a host that works: a system with its own `libgomp` never reaches here.
332
+ * Returns false on any failure, which leaves the caller reporting the actionable
333
+ * error it would have reported anyway. Best effort, never fatal in itself.
334
+ */
335
+ async function repairMissingLibrary(soname, destDir, arch) {
336
+ if (soname !== "libgomp.so.1")
337
+ return false;
338
+ const url = LIBGOMP_PACKAGE[arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : ""];
339
+ if (!url)
340
+ return false;
341
+ const scratch = path.join(destDir, ".libgomp-repair");
342
+ try {
343
+ fs.mkdirSync(scratch, { recursive: true });
344
+ const debPath = path.join(scratch, "lib.deb");
345
+ await downloadFile(url, debPath);
346
+ const payload = readArMember(fs.readFileSync(debPath), "data.tar.xz");
347
+ if (!payload)
348
+ return false;
349
+ const tarPath = path.join(scratch, "data.tar.xz");
350
+ fs.writeFileSync(tarPath, payload);
351
+ await extractArchive(tarPath, scratch, "linux");
352
+ // The package ships `libgomp.so.1` as a symlink to the real `libgomp.so.1.0.0`;
353
+ // copy the target under the soname so no symlink support is needed.
354
+ const real = findFile(scratch, "libgomp.so.1.0.0");
355
+ if (!real)
356
+ return false;
357
+ fs.copyFileSync(real, path.join(destDir, soname));
358
+ return true;
359
+ }
360
+ catch {
361
+ return false;
362
+ }
363
+ finally {
364
+ fs.rmSync(scratch, { recursive: true, force: true });
365
+ }
366
+ }
367
+ /**
368
+ * The actionable message for a dynamic-loader failure, or null when the output
369
+ * is not one. Pure, so the classification is testable without spawning.
370
+ *
371
+ * Returning null for an unrecognised failure is deliberate: a future llama.cpp
372
+ * that exits non-zero from `--version` must not turn a perfectly usable install
373
+ * into a hard failure. Only a positively identified missing library throws.
374
+ */
375
+ export function missingLibraryFrom(stderr) {
376
+ const missing =
377
+ // glibc's loader, and macOS dyld.
378
+ /error while loading shared libraries:\s*([^\s:]+)/.exec(stderr) ??
379
+ /Library not loaded:\s*(\S+)/.exec(stderr);
380
+ return missing ? missing[1] : null;
381
+ }
382
+ export function describeLoaderFailure(stderr, runtimeDir, platform = process.platform) {
383
+ const lib = missingLibraryFrom(stderr);
384
+ if (!lib)
385
+ return null;
386
+ const hint = SYSTEM_LIBRARY_PACKAGES[lib.replace(/^.*[/\\]/, "")];
387
+ return (`the runtime installed but cannot start: ${lib} is missing from this system. ` +
388
+ `llama.cpp links it and ships it on no ${platform} asset, so it has to come from the OS` +
389
+ (hint ? ` - install ${hint}` : "") +
390
+ `. The runtime is at ${runtimeDir}; re-run once the library is present.`);
391
+ }
392
+ /**
393
+ * Run the freshly installed binary once, so a missing *system* library surfaces
394
+ * at install time naming the library, instead of hours later as an opaque
395
+ * supervisor crash.
396
+ *
397
+ * This is not hypothetical: no upstream Linux asset ships `libgomp.so.1`, which
398
+ * `llama-server` hard-links, so on a host without `libgomp1` the download and
399
+ * extract both succeed and the binary then dies with exit 127. `buildEnv` cannot
400
+ * fix that - the library is not in the runtime dir LD_LIBRARY_PATH points at.
401
+ */
402
+ export async function verifyRuntimeExecutable(runtime, platform = process.platform, arch = process.arch) {
403
+ const env = buildEnv(runtime, process.env, platform);
404
+ const first = await runCapture(runtime.exe, ["--version"], env);
405
+ if (first.code === 0)
406
+ return;
407
+ const problem = describeLoaderFailure(first.stderr, runtime.dir, platform);
408
+ if (!problem)
409
+ return;
410
+ // Repair before giving up. `libgomp.so.1` is missing from every upstream Linux
411
+ // asset, so on a minimal image this is the *expected* first outcome, not an
412
+ // exceptional one, and telling the user to go install a package is a worse
413
+ // answer than placing the library where the loader already looks.
414
+ const soname = missingLibraryFrom(first.stderr);
415
+ if (platform === "linux" && soname && (await repairMissingLibrary(soname, runtime.dir, arch))) {
416
+ const retry = await runCapture(runtime.exe, ["--version"], env);
417
+ if (retry.code === 0)
418
+ return;
419
+ throw new Error(describeLoaderFailure(retry.stderr, runtime.dir, platform) ?? problem);
420
+ }
421
+ throw new Error(problem);
422
+ }
423
+ /**
424
+ * Device lines out of `--list-devices` stdout, which is a header followed by one
425
+ * indented line per device, or the literal `(none)`. Pure half of
426
+ * `listRuntimeDevices`.
427
+ */
428
+ export function parseDeviceList(stdout) {
429
+ return stdout
430
+ .split(/\r?\n/)
431
+ .map((line) => line.trim())
432
+ .filter((line) => line && !/^available devices:/i.test(line) && line !== "(none)");
433
+ }
434
+ /**
435
+ * Devices the runtime's backends actually found, one line each. Empty means the
436
+ * accelerator resolved to nothing and inference would silently fall back to CPU,
437
+ * which is a real configuration: on WSL2 there is no NVIDIA Vulkan ICD, so a
438
+ * Vulkan runtime on an NVIDIA machine reports no device and runs ~41x slower at
439
+ * prefill without saying so. Never throws; a probe failure reads as "unknown".
440
+ */
441
+ export async function listRuntimeDevices(runtime, platform = process.platform) {
442
+ const env = buildEnv(runtime, process.env, platform);
443
+ const { code, stdout } = await runCapture(runtime.exe, ["--list-devices"], env);
444
+ if (code !== 0)
445
+ return [];
446
+ return parseDeviceList(stdout);
447
+ }
448
+ export async function extractArchive(archivePath, destDir, platform = process.platform) {
104
449
  fs.mkdirSync(destDir, { recursive: true });
105
450
  if (/\.zip$/i.test(archivePath)) {
106
- // PowerShell ships with Windows; -Force overwrites an interrupted extract.
107
- // Paths are rooted at $OTTO_HOME (under the user profile), so a username with
108
- // an apostrophe would break or inject into a raw single-quoted string.
109
- // Escape single quotes for PowerShell (a literal ' is written as '').
110
- const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
111
- await run("powershell", [
112
- "-NoProfile",
113
- "-Command",
114
- `Expand-Archive -LiteralPath ${psQuote(archivePath)} -DestinationPath ${psQuote(destDir)} -Force`,
115
- ]);
451
+ if (platform === "win32") {
452
+ // PowerShell ships with Windows; -Force overwrites an interrupted extract.
453
+ // Paths are rooted at $OTTO_HOME (under the user profile), so a username with
454
+ // an apostrophe would break - or inject into - a raw single-quoted string.
455
+ // Escape single quotes for PowerShell (a literal ' is written as '').
456
+ const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
457
+ await run("powershell", [
458
+ "-NoProfile",
459
+ "-Command",
460
+ `Expand-Archive -LiteralPath ${psQuote(archivePath)} -DestinationPath ${psQuote(destDir)} -Force`,
461
+ ]);
462
+ return;
463
+ }
464
+ // macOS and Linux both ship a tar that reads zip (bsdtar on macOS, and on
465
+ // Linux the current assets are tarballs anyway - this branch only matters
466
+ // for a hand-passed zip). `unzip` is not assumed: it is not installed by
467
+ // default on every distro.
468
+ await run("tar", ["-xf", archivePath, "-C", destDir]);
116
469
  return;
117
470
  }
118
- if (/\.(tar\.gz|tgz|tar)$/i.test(archivePath)) {
471
+ // `.tar.xz` is here for the .deb payload the Linux libgomp repair unpacks; tar
472
+ // picks the decompressor itself, so this stays within the OS built-ins rule.
473
+ if (/\.(tar\.gz|tgz|tar\.xz|tar)$/i.test(archivePath)) {
119
474
  await run("tar", ["-xf", archivePath, "-C", destDir]);
120
475
  return;
121
476
  }
@@ -123,20 +478,26 @@ async function extractArchive(archivePath, destDir) {
123
478
  }
124
479
  /** Download + extract a runtime spec into runtimesDir and return the Runtime. */
125
480
  export async function installManagedRuntime(spec, runtimesDir, onProgress) {
481
+ const platform = spec.platform ?? process.platform;
126
482
  const targetDir = path.join(runtimesDir, slug(spec));
127
483
  fs.mkdirSync(targetDir, { recursive: true });
128
484
  for (const url of spec.assets) {
129
485
  const archivePath = path.join(targetDir, path.basename(new URL(url).pathname));
130
486
  await downloadFile(url, archivePath, onProgress);
131
487
  onProgress?.({ phase: "extracting", asset: url });
132
- await extractArchive(archivePath, targetDir);
488
+ await extractArchive(archivePath, targetDir, platform);
133
489
  fs.rmSync(archivePath, { force: true });
134
490
  }
135
- const exe = findFile(targetDir, "llama-server.exe");
491
+ const exeName = serverExeName(platform);
492
+ const exe = findFile(targetDir, exeName);
136
493
  if (!exe)
137
- throw new Error(`installed runtime has no llama-server.exe under ${targetDir}`);
138
- onProgress?.({ phase: "done" });
139
- return {
494
+ throw new Error(`installed runtime has no ${exeName} under ${targetDir}`);
495
+ if (platform !== "win32") {
496
+ // Archive mode bits survive `tar -x`, but not every extractor preserves
497
+ // them; without +x the supervisor's spawn fails with a bare EACCES.
498
+ fs.chmodSync(exe, 0o755);
499
+ }
500
+ const runtime = {
140
501
  label: spec.label,
141
502
  version: spec.version,
142
503
  dir: path.dirname(exe),
@@ -144,5 +505,10 @@ export async function installManagedRuntime(spec, runtimesDir, onProgress) {
144
505
  vendorDir: null,
145
506
  source: "managed",
146
507
  };
508
+ // Before reporting success. An install that cannot exec is not an install, and
509
+ // the loader error names the cause far better than the later spawn failure does.
510
+ await verifyRuntimeExecutable(runtime, platform);
511
+ onProgress?.({ phase: "done" });
512
+ return runtime;
147
513
  }
148
514
  //# sourceMappingURL=managed.js.map
@@ -0,0 +1,83 @@
1
+ /** The long-running ops worth reporting. Anything else is not worth a state. */
2
+ export type BrainActivityKind = "calibrate" | "sweep" | "benchmark" | "download" | "scan";
3
+ export interface BrainActivityRecord {
4
+ kind: BrainActivityKind;
5
+ /** The process running the op, so a crashed op does not pin the status. */
6
+ pid: number;
7
+ /** What it is working on - a model id, usually. Shown in the tooltip. */
8
+ target: string | null;
9
+ /** Completion in [0,1], for ops that can measure it. Null when they cannot. */
10
+ progress: number | null;
11
+ startedAt: string;
12
+ }
13
+ export interface BrainActivityHandle {
14
+ /** Publish progress. Throttled: this is a disk write on a hot loop otherwise. */
15
+ update(progress: number | null): void;
16
+ /** Clear the record. Safe to call more than once. */
17
+ end(): void;
18
+ }
19
+ /**
20
+ * Announce that this process has started a long-running op.
21
+ *
22
+ * Deliberately last-writer-wins rather than refusing when a record already
23
+ * exists: two ops at once is a real (if unwise) thing to do, the newer one is
24
+ * the more interesting answer, and a failed announce must never be a reason for
25
+ * the op itself to fail.
26
+ */
27
+ export declare function beginActivity(kind: BrainActivityKind, options?: {
28
+ target?: string | null;
29
+ env?: NodeJS.ProcessEnv;
30
+ }): BrainActivityHandle;
31
+ /**
32
+ * The op currently owning the host, or null.
33
+ *
34
+ * Clears the record as a side effect when the process behind it is gone, so a
35
+ * killed op self-heals on the next status poll rather than needing a restart.
36
+ */
37
+ export declare function readActivity(env?: NodeJS.ProcessEnv): BrainActivityRecord | null;
38
+ export declare function clearActivity(env?: NodeJS.ProcessEnv): void;
39
+ /**
40
+ * Run `fn` with the op announced, clearing the record however it finishes.
41
+ *
42
+ * The announce is never allowed to break the op: this is status reporting, and
43
+ * a read-only home directory is not a reason for a benchmark to fail.
44
+ */
45
+ export declare function withActivity<T>(kind: BrainActivityKind, options: {
46
+ target?: string | null;
47
+ env?: NodeJS.ProcessEnv;
48
+ }, fn: (handle: BrainActivityHandle) => Promise<T>): Promise<T>;
49
+ /**
50
+ * Whether a streamed chunk carries reasoning, and whether it carries content.
51
+ *
52
+ * These are the router's own predicates, lifted out so the live flag and the
53
+ * end-of-stream `reasoning-only` verdict cannot drift apart. They are
54
+ * deliberately crude substring tests over the raw SSE bytes: the brain proxies
55
+ * both the Anthropic and the OpenAI shapes, chunk boundaries fall wherever the
56
+ * network puts them, and parsing partial JSON per chunk on the hot proxy path
57
+ * would cost more than the signal is worth.
58
+ */
59
+ export declare function chunkHasReasoning(text: string): boolean;
60
+ export declare function chunkHasContent(text: string): boolean;
61
+ /**
62
+ * Which in-flight completions are currently mid-thought.
63
+ *
64
+ * A request counts as thinking once reasoning has gone past and before any
65
+ * content has: that is the window where the model is working and the user has
66
+ * nothing to read yet, which is exactly what the rail's "thinking" state is
67
+ * claiming. The moment content starts the request is generating instead, even
68
+ * if more reasoning follows - a stream that is producing readable output should
69
+ * not report as though it were still silent.
70
+ *
71
+ * A set rather than a boolean because llama-server runs several slots at once,
72
+ * and one request finishing its thought must not clear the flag for another.
73
+ */
74
+ export declare class ReasoningTracker {
75
+ #private;
76
+ /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
77
+ observe(requestId: string, text: string): void;
78
+ /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
79
+ end(requestId: string): void;
80
+ get active(): boolean;
81
+ get count(): number;
82
+ }
83
+ //# sourceMappingURL=activity.d.ts.map