diff --git a/packages/user-intent-kit/bin/uik-daemon.js b/packages/user-intent-kit/bin/uik-daemon.js index 163c33e..038f2ad 100755 --- a/packages/user-intent-kit/bin/uik-daemon.js +++ b/packages/user-intent-kit/bin/uik-daemon.js @@ -18,6 +18,12 @@ * devices view (mac-mini, car-pi, linux-server). * Without it a row renders with a blank type, * which is how the M5 first appeared (2026-08-29). + * INTENT_DEVICE_MODEL default: unset - what this box serves, shown on + * its device card next to the Pi's GGUF name. Leave + * unset on Linux to read it from the running + * llama-server's command line on every poll (a + * model swap shows within one heartbeat); set it + * where nothing on the box can be asked. * INTENT_DEVICE_PUBLISH default: 1 - set to 0 on a SECONDARY daemon (a * second agent's presence beat on the same machine) * so exactly one daemon owns the device row; two @@ -36,6 +42,7 @@ const userId = process.env.INTENT_USER_ID; const agentHandle = process.env.INTENT_AGENT_HANDLE || '@agent'; const deviceId = process.env.INTENT_DEVICE_ID || hostname(); const deviceKind = process.env.INTENT_DEVICE_KIND || undefined; +const deviceModel = process.env.INTENT_DEVICE_MODEL || undefined; const publishDevice = process.env.INTENT_DEVICE_PUBLISH !== '0'; const pollIntervalMs = Number(process.env.POLL_INTERVAL_MS || 30000); @@ -60,7 +67,7 @@ if (userId.toLowerCase() === agentHandle.replace(/^@/, '').toLowerCase()) { const client = new IntentClient({ baseUrl, apiKey, userId, deviceId }); const iak = new IAKAdapter(client, { agentHandle, machine: deviceId }); -const desktop = new DesktopAdapter(client, { pollIntervalMs, machine: deviceId, kind: deviceKind }); +const desktop = new DesktopAdapter(client, { pollIntervalMs, machine: deviceId, kind: deviceKind, model: deviceModel }); if (publishDevice) desktop.start(); diff --git a/packages/user-intent-kit/src/adapters/desktop.js b/packages/user-intent-kit/src/adapters/desktop.js index 7f44b36..26372b9 100644 --- a/packages/user-intent-kit/src/adapters/desktop.js +++ b/packages/user-intent-kit/src/adapters/desktop.js @@ -19,6 +19,7 @@ export class DesktopAdapter { #pollTimer; #machine; #kind; + #model; #pollIntervalMs; /** @@ -26,10 +27,11 @@ export class DesktopAdapter { * @param {object} [opts] * @param {number} [opts.pollIntervalMs=30000] - How often to publish state */ - constructor(client, { pollIntervalMs = 30000, machine, kind } = {}) { + constructor(client, { pollIntervalMs = 30000, machine, kind, model } = {}) { this.#client = client; this.#machine = machine ?? client?.deviceId ?? undefined; this.#kind = kind; + this.#model = model; this.#pollIntervalMs = pollIntervalMs; this.#pollTimer = null; } @@ -100,7 +102,7 @@ export class DesktopAdapter { screen_active: active, context: active ? 'active' : 'idle', ...(idleSec === undefined ? {} : { idle_sec: idleSec }), - ...collectHostTelemetry({ machine: this.#machine, kind: this.#kind }), + ...collectHostTelemetry({ machine: this.#machine, kind: this.#kind, model: this.#model }), }; try { diff --git a/packages/user-intent-kit/src/host-telemetry.js b/packages/user-intent-kit/src/host-telemetry.js index 9d28ad9..5757944 100644 --- a/packages/user-intent-kit/src/host-telemetry.js +++ b/packages/user-intent-kit/src/host-telemetry.js @@ -18,6 +18,7 @@ import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; +import { basename } from 'node:path'; import { cpus, freemem, loadavg, platform, totalmem } from 'node:os'; /** Plausible CPU die temperatures. Outside this, assume the sensor lied. */ @@ -71,9 +72,97 @@ export const defaultSources = { }, run, listThermalZones: () => readdirSync(THERMAL_ROOT).filter((z) => z.startsWith('thermal_zone')), + // argv of every process this user may read, Linux only. `comm` is checked + // first so the 30 s poll reads one small file per process and the full + // command line only for the handful that could be a model server. + listProcessCommandLines: () => { + if (platform() !== 'linux') return []; + const out = []; + for (const pid of readdirSync('/proc')) { + if (!/^\d+$/.test(pid)) continue; + try { + const comm = readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); + if (!MODEL_SERVER_COMM.test(comm)) continue; + const argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean); + if (argv.length) out.push(argv); + } catch { + // Gone or unreadable: not ours to report. + } + } + return out; + }, readThermalZone: (zone) => readFileSync(`${THERMAL_ROOT}/${zone}/temp`, 'utf8'), }; +/** + * Process names that serve a model and say which one on their command line. + * llama.cpp's server takes the weights as `-m path.gguf`; that path is the + * one honest source of "what is this box serving" — a config file can say + * anything while the server runs something else. + */ +const MODEL_SERVER_COMM = /^llama-server/; + +/** Multi-part GGUF files carry a shard suffix; the model is the stem. */ +const GGUF_SHARD_SUFFIX = /-\d{5}-of-\d{5}(?=\.gguf$)/i; + +/** + * The served model's name from a model server's argv, or undefined. + * + * Exported for tests and for anything else that has a command line and wants + * the same answer the heartbeat publishes. + * + * @param {string[]} argv + * @returns {string|undefined} e.g. "Qwen3.8-Flash-Next-UD-IQ3_XXS.gguf" + */ +export function modelFromCommandLine(argv) { + if (!Array.isArray(argv) || !argv.length) return undefined; + if (!MODEL_SERVER_COMM.test(basename(String(argv[0])))) return undefined; + + let path; + for (let i = 1; i < argv.length; i += 1) { + const arg = String(argv[i]); + if (arg === '-m' || arg === '--model') { + path = argv[i + 1]; + break; + } + if (arg.startsWith('--model=')) { + path = arg.slice('--model='.length); + break; + } + } + if (!path) return undefined; + + const name = basename(String(path)).replace(GGUF_SHARD_SUFFIX, ''); + return name || undefined; +} + +/** + * Which model this host is serving, if any. + * + * An explicit name always wins: the operator knows what a box is for even + * when the server is between restarts. Otherwise, on Linux, the running + * llama-server's own command line answers — it is read on every poll, so a + * model swap shows within one heartbeat instead of freezing at the name the + * daemon started with. A host serving nothing publishes no model at all, + * per the omit-not-fake contract: an idle box and a box whose model we could + * not read must look the same as each other, not the same as a box serving + * something. + * + * @returns {string|undefined} + */ +function readModel(sources, explicit) { + const given = typeof explicit === 'string' ? explicit.trim() : ''; + if (given) return given; + + if (sources.platform() !== 'linux') return undefined; + const lines = sources.listProcessCommandLines?.() || []; + for (const argv of lines) { + const name = modelFromCommandLine(argv); + if (name) return name; + } + return undefined; +} + /** * CPU load, normalised so devices of different sizes are comparable. * @@ -256,10 +345,12 @@ function readLinuxTempC(sources) { * @param {string} [opts.kind] - role the fleet knows this box by ("car-pi", * "mac-mini"); the Pi already publishes this, so Macs use the same word * rather than inventing a second vocabulary for the same idea + * @param {string} [opts.model] - what this box serves, when the operator + * states it; otherwise discovered from a running model server on Linux * @param {object} [opts.sources] - injectable sensor reads, for tests * @returns {object} only the fields that were readable */ -export function collectHostTelemetry({ machine, kind, sources = defaultSources } = {}) { +export function collectHostTelemetry({ machine, kind, model, sources = defaultSources } = {}) { const host = {}; if (machine) host.machine = machine; if (kind) host.kind = kind; @@ -298,5 +389,12 @@ export function collectHostTelemetry({ machine, kind, sources = defaultSources } // Same: an unknown link is published as no link, not a wrong one. } + try { + const served = readModel(sources, model); + if (served) host.model = served; + } catch { + // A box we cannot ask publishes no model, never a stale one. + } + return host; } diff --git a/packages/user-intent-kit/test/host-telemetry.test.js b/packages/user-intent-kit/test/host-telemetry.test.js index 97b045e..70584cd 100644 --- a/packages/user-intent-kit/test/host-telemetry.test.js +++ b/packages/user-intent-kit/test/host-telemetry.test.js @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { collectHostTelemetry } from '../src/host-telemetry.js'; +import { collectHostTelemetry, modelFromCommandLine } from '../src/host-telemetry.js'; /** * Fake sensors. The real ones only report whatever the machine running the @@ -11,7 +11,8 @@ import { collectHostTelemetry } from '../src/host-telemetry.js'; * and "tests pass" would say nothing about the Pi. */ function sources({ platform = 'linux', load = [1.8, 1.7, 1.6], cpuCount = 4, zones = {}, - totalMem = 16e9, freeMem = 4e9, availMem = 12e9, commands = {} } = {}) { + totalMem = 16e9, freeMem = 4e9, availMem = 12e9, commands = {}, + processes = [] } = {}) { return { platform: () => platform, loadavg: () => load, @@ -23,6 +24,10 @@ function sources({ platform = 'linux', load = [1.8, 1.7, 1.6], cpuCount = 4, zon const key = `${cmd} ${(args || []).join(' ')}`; return (commands && commands[key]) || ''; }, + listProcessCommandLines: () => { + if (processes instanceof Error) throw processes; + return processes; + }, listThermalZones: () => Object.keys(zones), readThermalZone: (zone) => { const value = zones[zone]; @@ -259,3 +264,66 @@ test('zero available is a real reading', () => { const host = collectHostTelemetry({ sources: sources({ availMem: 0 }) }); assert.equal(host.mem_available_gb, 0); }); + +// --- served model (what the box is for, next to how it is doing) --- + +const LLAMA = ['/home/petrus/llm/llama.cpp/build/bin/llama-server', '-m', + '/home/petrus/llm/models/flash-next/Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf', + '--host', '0.0.0.0', '--port', '8080', '-c', '262144']; + +test('reads the served model from a running llama-server on Linux', () => { + const host = collectHostTelemetry({ sources: sources({ processes: [LLAMA] }) }); + assert.equal(host.model, 'Qwen3.8-Flash-Next-UD-IQ3_XXS.gguf'); +}); + +test('a single-file GGUF keeps its full name, extension included, like the Pi publishes', () => { + const argv = ['llama-server', '--model', '/models/Ling-3.0-tiny-Q4_K_M.gguf']; + assert.equal(modelFromCommandLine(argv), 'Ling-3.0-tiny-Q4_K_M.gguf'); + assert.equal(modelFromCommandLine(['llama-server', '--model=/models/a-b.gguf']), 'a-b.gguf'); +}); + +test('an explicit model name wins over discovery', () => { + const host = collectHostTelemetry({ model: ' gemma-4-local ', sources: sources({ processes: [LLAMA] }) }); + assert.equal(host.model, 'gemma-4-local'); +}); + +test('a host serving nothing publishes no model, not an empty one', () => { + for (const explicit of [undefined, '', ' ', 42]) { + const host = collectHostTelemetry({ model: explicit, sources: sources({ processes: [] }) }); + assert.ok(!('model' in host), `published model for ${JSON.stringify(explicit)}`); + } +}); + +test('only a model server counts; other processes with -m are ignored', () => { + const others = [['/usr/bin/python3', '-m', 'http.server'], ['bash', '-c', 'llama-server -m x.gguf']]; + const host = collectHostTelemetry({ sources: sources({ processes: others }) }); + assert.ok(!('model' in host)); + assert.equal(modelFromCommandLine(['/opt/llama-server', '--port', '8080']), undefined); + assert.equal(modelFromCommandLine([]), undefined); +}); + +test('macOS publishes only an explicit model; the process table is not consulted', () => { + const mac = sources({ platform: 'darwin', processes: [LLAMA] }); + assert.ok(!('model' in collectHostTelemetry({ sources: mac }))); + assert.equal(collectHostTelemetry({ model: 'lmstudio:qwen', sources: mac }).model, 'lmstudio:qwen'); +}); + +test('a hostile process table never takes the heartbeat down', () => { + const host = collectHostTelemetry({ sources: sources({ processes: new Error('EACCES') }) }); + assert.ok(!('model' in host)); + assert.ok('load_1m' in host, 'lost the other vitals along with the model'); +}); + +test('a voice stack with lower pids does not become the served model (VTA layout)', () => { + // pid order: whisper, piper and a python -m all come before llama-server + const table = [ + ['/opt/whisper/whisper-server', '-m', '/models/ggml-large-v3-turbo.bin', '--port', '8090'], + ['/usr/bin/piper', '--model', '/models/fi_FI-harri-medium.onnx'], + ['/usr/bin/python3', '-m', 'http.server'], + LLAMA, + ]; + const host = collectHostTelemetry({ sources: sources({ processes: table }) }); + assert.equal(host.model, 'Qwen3.8-Flash-Next-UD-IQ3_XXS.gguf'); + // and with the model server gone, the voice stack still is not "the model" + assert.ok(!('model' in collectHostTelemetry({ sources: sources({ processes: table.slice(0, 3) }) }))); +});