Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/user-intent-kit/bin/uik-daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

Expand All @@ -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();

Expand Down
6 changes: 4 additions & 2 deletions packages/user-intent-kit/src/adapters/desktop.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ export class DesktopAdapter {
#pollTimer;
#machine;
#kind;
#model;
#pollIntervalMs;

/**
* @param {import('../client.js').IntentClient} client
* @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;
}
Expand Down Expand Up @@ -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 {
Expand Down
100 changes: 99 additions & 1 deletion packages/user-intent-kit/src/host-telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the model when discovery becomes empty

When a Linux llama-server stops, its command line becomes unreadable, or an explicit model setting is removed, readModel() returns undefined and this branch simply omits model. DesktopAdapter sends the result through patchDevice(), whose partial-update semantics preserve omitted fields, so any previously published model remains on the fresh device record indefinitely while the daemon continues heartbeating. Send the API's explicit clearing value or otherwise replace the prior model when discovery transitions to empty.

Useful? React with 👍 / 👎.

} catch {
// A box we cannot ask publishes no model, never a stale one.
}

return host;
}
72 changes: 70 additions & 2 deletions packages/user-intent-kit/test/host-telemetry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
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
* suite happens to expose, so on a Mac the Linux thermal path would never run
* 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,
Expand All @@ -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];
Expand Down Expand Up @@ -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) }) })));
});
Loading