nomarmy 0.1.0-alpha.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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
@@ -0,0 +1,476 @@
1
+ // nomArmy hardware detection (v1.3).
2
+ //
3
+ // `detectHardware()` returns normalized *facts* about the machine so that
4
+ // `lib/sizing.mjs` can turn them into a recommendation for the three coupled
5
+ // knobs `NOMARMY_LLAMA_CONTEXT`, `NOMARMY_LLAMA_PARALLEL` and
6
+ // `NOMARMY_MAX_WORKERS`.
7
+ //
8
+ // Contract:
9
+ // * It never throws. Anything undetermined is `null`.
10
+ // * Every probe is individually fault tolerant. A missing `nvidia-smi` is a
11
+ // normal outcome on a Mac, not an error.
12
+ // * Everything attempted is recorded in `probes`, so a human can see what was
13
+ // measured versus what was left unknown. A recommendation built on nulls
14
+ // must be presentable as a guess, and that is only possible if the caller
15
+ // can tell the difference.
16
+ //
17
+ // External commands are deliberately limited to:
18
+ // * nvidia-smi --query-gpu=memory.total,memory.free --format=csv,noheader,nounits
19
+ // * sysctl -n <key> (macOS only: physical core count, memory size)
20
+ // * podman info --format {{.Host.MemTotal}}
21
+ // Everything else comes from `node:os` or from reading `/proc`.
22
+
23
+ import os from "node:os";
24
+ import fs from "node:fs";
25
+ import { spawnSync } from "node:child_process";
26
+
27
+ export const MIB = 1024 * 1024;
28
+ export const GIB = 1024 * 1024 * 1024;
29
+
30
+ /** Wall-clock budget for any external command. Detection must stay cheap. */
31
+ const COMMAND_TIMEOUT_MS = 5000;
32
+ /** `podman info` can hang while a macOS Podman machine is still starting up. */
33
+ const PODMAN_TIMEOUT_MS = 6000;
34
+
35
+ /**
36
+ * Run an external command without ever throwing.
37
+ *
38
+ * @param {string} file
39
+ * @param {string[]} args
40
+ * @param {number} [timeoutMs]
41
+ * @returns {{ ok: boolean, stdout: string, reason: string|null }}
42
+ */
43
+ function runCommand(file, args, timeoutMs = COMMAND_TIMEOUT_MS) {
44
+ try {
45
+ const result = spawnSync(file, args, {
46
+ encoding: "utf8",
47
+ timeout: timeoutMs,
48
+ windowsHide: true,
49
+ shell: false,
50
+ maxBuffer: MIB,
51
+ });
52
+ if (result.error) {
53
+ return { ok: false, stdout: "", reason: result.error.message };
54
+ }
55
+ if (result.signal) {
56
+ return { ok: false, stdout: "", reason: `terminated by ${result.signal}` };
57
+ }
58
+ if (result.status !== 0) {
59
+ const stderr = (result.stderr || "").trim().split("\n")[0] || "";
60
+ return {
61
+ ok: false,
62
+ stdout: "",
63
+ reason: `exit ${result.status}${stderr ? `: ${stderr}` : ""}`,
64
+ };
65
+ }
66
+ return { ok: true, stdout: result.stdout || "", reason: null };
67
+ } catch (err) {
68
+ return { ok: false, stdout: "", reason: String(err && err.message ? err.message : err) };
69
+ }
70
+ }
71
+
72
+ /** Read a small text file, or null. Never throws. */
73
+ function readTextFile(file) {
74
+ try {
75
+ return fs.readFileSync(file, "utf8");
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Record one attempted measurement. `fn` may return a value or null; any throw
83
+ * is captured on the probe rather than propagated.
84
+ *
85
+ * @template T
86
+ * @param {Array<object>} probes
87
+ * @param {string} name
88
+ * @param {string} description
89
+ * @param {(entry: object) => T} fn
90
+ * @returns {T|null}
91
+ */
92
+ function probe(probes, name, description, fn) {
93
+ const entry = { name, description, ok: false, source: null, detail: null, error: null };
94
+ probes.push(entry);
95
+ try {
96
+ const value = fn(entry);
97
+ entry.ok = value !== null && value !== undefined;
98
+ return entry.ok ? value : null;
99
+ } catch (err) {
100
+ entry.ok = false;
101
+ entry.error = String(err && err.message ? err.message : err);
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Pure parsers, exported so they can be tested without the hardware present.
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Parse the CSV emitted by
112
+ * `nvidia-smi --query-gpu=memory.total,memory.free --format=csv,noheader,nounits`.
113
+ * Values are MiB. Returns one entry per GPU; `[]` when nothing parses.
114
+ *
115
+ * @param {string} stdout
116
+ * @returns {Array<object>}
117
+ */
118
+ export function parseNvidiaSmiMemoryCsv(stdout) {
119
+ if (typeof stdout !== "string") return [];
120
+ const gpus = [];
121
+ for (const rawLine of stdout.split("\n")) {
122
+ const line = rawLine.trim();
123
+ if (!line) continue;
124
+ const fields = line.split(",").map((f) => f.trim());
125
+ if (fields.length < 2) continue;
126
+ const totalMiB = Number.parseInt(fields[0], 10);
127
+ const freeMiB = Number.parseInt(fields[1], 10);
128
+ if (!Number.isFinite(totalMiB) && !Number.isFinite(freeMiB)) continue;
129
+ gpus.push({
130
+ index: gpus.length,
131
+ vendor: "nvidia",
132
+ totalVramBytes: Number.isFinite(totalMiB) ? totalMiB * MIB : null,
133
+ freeVramBytes: Number.isFinite(freeMiB) ? freeMiB * MIB : null,
134
+ });
135
+ }
136
+ return gpus;
137
+ }
138
+
139
+ /**
140
+ * Count physical cores from `/proc/cpuinfo` by counting distinct
141
+ * (physical id, core id) pairs. Returns null when the file does not carry
142
+ * those fields, which is normal on ARM SoCs and inside containers.
143
+ *
144
+ * @param {string} text
145
+ * @returns {number|null}
146
+ */
147
+ export function parseProcCpuinfoPhysicalCores(text) {
148
+ if (typeof text !== "string" || !text) return null;
149
+ const pairs = new Set();
150
+ let physicalId = null;
151
+ let coreId = null;
152
+ for (const rawLine of text.split("\n")) {
153
+ const line = rawLine.trim();
154
+ if (!line) {
155
+ if (physicalId !== null && coreId !== null) pairs.add(`${physicalId}/${coreId}`);
156
+ physicalId = null;
157
+ coreId = null;
158
+ continue;
159
+ }
160
+ const sep = line.indexOf(":");
161
+ if (sep < 0) continue;
162
+ const key = line.slice(0, sep).trim();
163
+ const value = line.slice(sep + 1).trim();
164
+ if (key === "physical id") physicalId = value;
165
+ else if (key === "core id") coreId = value;
166
+ }
167
+ if (physicalId !== null && coreId !== null) pairs.add(`${physicalId}/${coreId}`);
168
+ return pairs.size > 0 ? pairs.size : null;
169
+ }
170
+
171
+ /**
172
+ * Read a `/proc/meminfo` field (kB) and return bytes.
173
+ *
174
+ * `MemAvailable` is the kernel's own estimate of what a new allocation can get
175
+ * without swapping, which is the number that matters here. `MemFree` is not.
176
+ *
177
+ * @param {string} text
178
+ * @param {string} [field]
179
+ * @returns {number|null}
180
+ */
181
+ export function parseProcMeminfoBytes(text, field = "MemAvailable") {
182
+ if (typeof text !== "string" || !text) return null;
183
+ const match = new RegExp(`^${field}:\\s+(\\d+)\\s*kB`, "m").exec(text);
184
+ if (!match) return null;
185
+ const kB = Number.parseInt(match[1], 10);
186
+ return Number.isFinite(kB) ? kB * 1024 : null;
187
+ }
188
+
189
+ /**
190
+ * Parse `vm_stat` into memory available without swapping: free + inactive +
191
+ * speculative + purgeable pages, scaled by the page size vm_stat itself
192
+ * reports. `os.freemem()` on Darwin counts only free + speculative, which
193
+ * ignores tens of GiB of reclaimable file-backed cache the kernel evicts
194
+ * instantly under pressure -- the same machine can report single-digit GiB
195
+ * "free" while `memory_pressure` says it is nowhere near constrained. This
196
+ * mirrors macOS's own `MemAvailable`-equivalent notion, the same way
197
+ * `parseProcMeminfoBytes` does for Linux.
198
+ *
199
+ * Some inactive pages are dirty and need a writeback/compression pass before
200
+ * they are truly free, so this can slightly overstate available memory. That
201
+ * is still far closer to reality than freemem's undercount.
202
+ *
203
+ * Returns null if the page size or any of the four fields cannot be found,
204
+ * so the caller can fall back rather than silently mis-measuring.
205
+ *
206
+ * @param {string} stdout
207
+ * @returns {number|null}
208
+ */
209
+ export function parseVmStatBytes(stdout) {
210
+ if (typeof stdout !== "string" || !stdout) return null;
211
+ const pageSizeMatch = /page size of (\d+) bytes/.exec(stdout);
212
+ if (!pageSizeMatch) return null;
213
+ const pageSize = Number.parseInt(pageSizeMatch[1], 10);
214
+ if (!Number.isFinite(pageSize) || pageSize <= 0) return null;
215
+
216
+ const field = (name) => {
217
+ const m = new RegExp(`^${name}:\\s+(\\d+)\\.?`, "m").exec(stdout);
218
+ return m ? Number.parseInt(m[1], 10) : null;
219
+ };
220
+ const free = field("Pages free");
221
+ const inactive = field("Pages inactive");
222
+ const speculative = field("Pages speculative");
223
+ const purgeable = field("Pages purgeable");
224
+ if (![free, inactive, speculative, purgeable].every((n) => Number.isFinite(n))) return null;
225
+
226
+ return (free + inactive + speculative + purgeable) * pageSize;
227
+ }
228
+
229
+ /**
230
+ * WSL2 identifies itself in the kernel release string and in `/proc/version`.
231
+ * It matters because a WSL2 VM sees only the memory WSL was granted (often
232
+ * about half the host), so "total RAM" there is not the machine's RAM.
233
+ *
234
+ * @param {string} release
235
+ * @param {string|null} [procVersion]
236
+ * @returns {boolean}
237
+ */
238
+ export function looksLikeWSL(release, procVersion = null) {
239
+ const haystack = `${release || ""}\n${procVersion || ""}`;
240
+ return /microsoft|wsl/i.test(haystack);
241
+ }
242
+
243
+ /**
244
+ * Parse `podman info --format {{.Host.MemTotal}}` output (bytes). On macOS
245
+ * this is the Podman machine VM's total memory (its configured ceiling, seen
246
+ * from inside); on native Linux it is just the real host total. Same bare-
247
+ * integer output shape as Docker's equivalent `{{.MemTotal}}` field, so the
248
+ * parsing logic is unchanged from before, only the field path differs.
249
+ *
250
+ * @param {string} stdout
251
+ * @returns {number|null}
252
+ */
253
+ export function parsePodmanMemTotal(stdout) {
254
+ if (typeof stdout !== "string") return null;
255
+ const value = Number.parseInt(stdout.trim(), 10);
256
+ return Number.isFinite(value) && value > 0 ? value : null;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // detectHardware
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * Detect the machine's capacity-relevant facts.
265
+ *
266
+ * @param {{ skipPodman?: boolean, skipGpu?: boolean }} [options]
267
+ * @returns {object} facts; never throws, `null` where undetermined
268
+ */
269
+ export function detectHardware(options = {}) {
270
+ const probes = [];
271
+ const platform = process.platform;
272
+ const arch = process.arch;
273
+
274
+ const release = probe(probes, "os.release", "kernel/OS release string", () => os.release() || null);
275
+
276
+ const procVersion =
277
+ platform === "linux"
278
+ ? probe(probes, "proc.version", "read /proc/version (WSL marker)", (entry) => {
279
+ const text = readTextFile("/proc/version");
280
+ if (text) entry.source = "/proc/version";
281
+ return text;
282
+ })
283
+ : null;
284
+
285
+ const isWSL = platform === "linux" ? looksLikeWSL(release || "", procVersion) : false;
286
+
287
+ const logicalCores = probe(probes, "cpu.logical", "logical core count", (entry) => {
288
+ entry.source = "node:os";
289
+ if (typeof os.availableParallelism === "function") {
290
+ const n = os.availableParallelism();
291
+ if (Number.isFinite(n) && n > 0) return n;
292
+ }
293
+ const cpus = os.cpus();
294
+ return Array.isArray(cpus) && cpus.length > 0 ? cpus.length : null;
295
+ });
296
+
297
+ const cpuModel = probe(probes, "cpu.model", "CPU model string", (entry) => {
298
+ entry.source = "node:os";
299
+ const cpus = os.cpus();
300
+ const model = Array.isArray(cpus) && cpus[0] ? String(cpus[0].model || "").trim() : "";
301
+ return model || null;
302
+ });
303
+
304
+ const physicalCores = probe(probes, "cpu.physical", "physical core count", (entry) => {
305
+ if (platform === "linux") {
306
+ entry.source = "/proc/cpuinfo";
307
+ const parsed = parseProcCpuinfoPhysicalCores(readTextFile("/proc/cpuinfo") || "");
308
+ if (parsed) return parsed;
309
+ entry.detail = "no physical id/core id fields (normal on ARM SoCs and in containers)";
310
+ return null;
311
+ }
312
+ if (platform === "darwin") {
313
+ entry.source = "sysctl -n hw.physicalcpu";
314
+ const res = runCommand("sysctl", ["-n", "hw.physicalcpu"]);
315
+ if (!res.ok) {
316
+ entry.detail = res.reason;
317
+ return null;
318
+ }
319
+ const n = Number.parseInt(res.stdout.trim(), 10);
320
+ return Number.isFinite(n) && n > 0 ? n : null;
321
+ }
322
+ entry.detail = "physical core count is not obtainable on this platform without extra tooling";
323
+ return null;
324
+ });
325
+
326
+ const totalMemoryBytes = probe(probes, "memory.total", "total system RAM", (entry) => {
327
+ entry.source = "node:os totalmem";
328
+ const total = os.totalmem();
329
+ return Number.isFinite(total) && total > 0 ? total : null;
330
+ });
331
+
332
+ const availableMemoryBytes = probe(
333
+ probes,
334
+ "memory.available",
335
+ "memory available without swapping",
336
+ (entry) => {
337
+ if (platform === "linux") {
338
+ const parsed = parseProcMeminfoBytes(readTextFile("/proc/meminfo") || "", "MemAvailable");
339
+ if (parsed) {
340
+ entry.source = "/proc/meminfo MemAvailable";
341
+ return parsed;
342
+ }
343
+ }
344
+ if (platform === "darwin") {
345
+ const res = runCommand("vm_stat", []);
346
+ if (res.ok) {
347
+ const parsed = parseVmStatBytes(res.stdout);
348
+ if (parsed != null) {
349
+ entry.source = "vm_stat (free+inactive+speculative+purgeable pages)";
350
+ entry.detail =
351
+ "Reclaimable-inclusive, closer to macOS's own notion of available memory than node:os freemem, " +
352
+ "which counts only free+speculative and ignores tens of GiB of evictable file-backed cache. " +
353
+ "Some inactive pages are dirty and need a writeback/compression pass first, so this can slightly " +
354
+ "overstate what is instantly free.";
355
+ return parsed;
356
+ }
357
+ }
358
+ }
359
+ entry.source = "node:os freemem";
360
+ if (platform === "darwin") {
361
+ entry.detail =
362
+ "macOS freemem excludes reclaimable cache and compressed pages, so it understates what is really " +
363
+ "available; vm_stat was unavailable or unparseable, so this is a conservative fallback";
364
+ }
365
+ const free = os.freemem();
366
+ return Number.isFinite(free) && free > 0 ? free : null;
367
+ },
368
+ );
369
+
370
+ // Apple Silicon: one physical memory pool is shared by CPU and GPU. There is
371
+ // no separate VRAM figure to read, and the GPU cannot address all of it --
372
+ // macOS caps the GPU wired working set below total RAM. Sizing must not treat
373
+ // unified memory as if it were discrete VRAM *plus* system RAM.
374
+ const appleSilicon = platform === "darwin" && arch === "arm64";
375
+ const unifiedMemoryBytes = probe(
376
+ probes,
377
+ "memory.unified",
378
+ "Apple Silicon unified memory (CPU and GPU share one pool)",
379
+ (entry) => {
380
+ if (!appleSilicon) {
381
+ entry.detail = "not Apple Silicon";
382
+ return null;
383
+ }
384
+ entry.source = "sysctl -n hw.memsize";
385
+ const res = runCommand("sysctl", ["-n", "hw.memsize"]);
386
+ if (res.ok) {
387
+ const n = Number.parseInt(res.stdout.trim(), 10);
388
+ if (Number.isFinite(n) && n > 0) return n;
389
+ } else {
390
+ entry.detail = res.reason;
391
+ }
392
+ entry.source = "node:os totalmem (fallback)";
393
+ return totalMemoryBytes;
394
+ },
395
+ );
396
+
397
+ const gpus = probe(probes, "gpu.nvidia", "NVIDIA VRAM via nvidia-smi", (entry) => {
398
+ if (options.skipGpu) {
399
+ entry.detail = "skipped by caller";
400
+ return null;
401
+ }
402
+ entry.source =
403
+ "nvidia-smi --query-gpu=memory.total,memory.free --format=csv,noheader,nounits";
404
+ const res = runCommand("nvidia-smi", [
405
+ "--query-gpu=memory.total,memory.free",
406
+ "--format=csv,noheader,nounits",
407
+ ]);
408
+ if (!res.ok) {
409
+ // Absence of nvidia-smi is the expected outcome on Apple Silicon and on
410
+ // CPU-only Linux. It is information, not a failure.
411
+ entry.detail = `nvidia-smi unavailable (${res.reason}); treating as "no NVIDIA GPU"`;
412
+ return null;
413
+ }
414
+ const parsed = parseNvidiaSmiMemoryCsv(res.stdout);
415
+ if (parsed.length === 0) {
416
+ entry.detail = "nvidia-smi ran but returned no parseable rows";
417
+ return null;
418
+ }
419
+ return parsed;
420
+ });
421
+
422
+ const podman = probe(probes, "podman.info", "Podman availability and reported memory", (entry) => {
423
+ if (options.skipPodman) {
424
+ entry.detail = "skipped by caller";
425
+ return null;
426
+ }
427
+ entry.source = "podman info --format {{.Host.MemTotal}}";
428
+ const res = runCommand("podman", ["info", "--format", "{{.Host.MemTotal}}"], PODMAN_TIMEOUT_MS);
429
+ if (!res.ok) {
430
+ entry.detail = `podman unavailable or not responding (${res.reason})`;
431
+ return { available: false, totalMemoryBytes: null };
432
+ }
433
+ return { available: true, totalMemoryBytes: parsePodmanMemTotal(res.stdout) };
434
+ });
435
+
436
+ const gpuList = Array.isArray(gpus) ? gpus : [];
437
+ const nvidiaTotal = gpuList.reduce((sum, g) => sum + (g.totalVramBytes || 0), 0);
438
+ const nvidiaFree = gpuList.reduce(
439
+ (sum, g) => sum + (g.freeVramBytes != null ? g.freeVramBytes : g.totalVramBytes || 0),
440
+ 0,
441
+ );
442
+
443
+ return {
444
+ detectedAt: new Date().toISOString(),
445
+ platform,
446
+ arch,
447
+ release: release || null,
448
+ isWSL,
449
+ appleSilicon,
450
+ cpu: {
451
+ model: cpuModel || null,
452
+ physicalCores: physicalCores || null,
453
+ logicalCores: logicalCores || null,
454
+ },
455
+ memory: {
456
+ totalBytes: totalMemoryBytes || null,
457
+ availableBytes: availableMemoryBytes || null,
458
+ // True when the GPU has no private memory of its own. On such machines
459
+ // model weights, KV cache, the OS, the coordinator and every Podman
460
+ // sandbox all come out of the same pool.
461
+ unified: appleSilicon,
462
+ unifiedBytes: appleSilicon ? unifiedMemoryBytes || totalMemoryBytes || null : null,
463
+ },
464
+ gpu: {
465
+ vendor: gpuList.length > 0 ? "nvidia" : appleSilicon ? "apple" : null,
466
+ count: gpuList.length,
467
+ devices: gpuList,
468
+ totalVramBytes: gpuList.length > 0 ? nvidiaTotal : null,
469
+ freeVramBytes: gpuList.length > 0 ? nvidiaFree : null,
470
+ },
471
+ podman: podman || { available: null, totalMemoryBytes: null },
472
+ probes,
473
+ };
474
+ }
475
+
476
+ export default { detectHardware };