From 68d7a406059e04698e8387bf07f0468dac0a3ec5 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Fri, 11 Sep 2026 14:00:44 -0400 Subject: [PATCH] Updates --- .github/workflows/ci.yml | 19 +++++++++ daemon/dbus-service.js | 3 ++ daemon/gpu-doctor.js | 2 + daemon/index.js | 14 ++++-- daemon/ipc.js | 35 +++++++++++++++ daemon/model-profiles.js | 11 +++++ daemon/privacy-log.js | 22 ++++++++++ daemon/qvac-master.js | 7 +++ dbus/io.qvac.Jarvis.Session.xml | 1 + docs/ROADMAP.md | 75 +++++++++++++++++++++------------ test/daemon-infra.test.js | 38 +++++++++++++++++ test/runtime-tools.test.js | 6 +++ 12 files changed, 204 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 daemon/ipc.js create mode 100644 daemon/model-profiles.js create mode 100644 daemon/privacy-log.js create mode 100644 test/daemon-infra.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f58176 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: jarvis-ci + +on: + push: + pull_request: + +jobs: + node: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22.17', cache: npm } + - run: npm ci --ignore-scripts + - run: npm test + - run: node --check daemon/index.js + - run: node --check daemon/dbus-service.js + - run: node --check apps/gnome-extension/jarvis@qvac.local/extension.js + - run: python3 -c "import xml.etree.ElementTree as E; E.parse('dbus/io.qvac.Jarvis.Session.xml')" diff --git a/daemon/dbus-service.js b/daemon/dbus-service.js index 920da72..2c88d59 100644 --- a/daemon/dbus-service.js +++ b/daemon/dbus-service.js @@ -21,6 +21,7 @@ export async function serveOnSessionBus(daemon) { ComputerGrant(persist) { daemon.computerGrant(persist); } ComputerRevoke() { daemon.computerRevoke(); } ComputerStatus() { return JSON.stringify(daemon.computer.status()); } + ConfirmationRequired(tool, args, pattern) { this.emit('ConfirmationRequired', String(tool), String(args), String(pattern)); } StateChanged(state) { this.emit('StateChanged', state); } Reply(text) { this.emit('Reply', String(text)); } Token(text) { this.emit('Token', String(text)); } @@ -63,6 +64,7 @@ export async function serveOnSessionBus(daemon) { JobProgress: { signature: 'sds', signal: true }, ComputerStep: { signature: 's', signal: true }, ComputerHighlight: { signature: 's', signal: true }, + ConfirmationRequired: { signature: 'sss', signal: true }, }); const bus = dbus.sessionBus(); await bus.requestName(BUS_NAME); @@ -72,6 +74,7 @@ export async function serveOnSessionBus(daemon) { daemon.on('Reply', (reply) => iface.Reply(reply)); daemon.on('Token', (token) => iface.Token(token)); daemon.on('Error', (code, message) => iface.Error(code, message)); + daemon.on('ConfirmationRequired', (event) => iface.ConfirmationRequired(event?.tool || 'action', JSON.stringify(event?.args || {}), event?.pattern || 'explicit confirmation required')); for (const signal of ['WakeHeard', 'PartialTranscript', 'FinalTranscript', 'SpeakingLevel', 'ListeningLevel', 'ChipOffered', 'JobProgress', 'ComputerStep', 'ComputerHighlight']) { daemon.on(signal, (...args) => iface[signal](...args)); } diff --git a/daemon/gpu-doctor.js b/daemon/gpu-doctor.js index b66fda7..28c90c2 100644 --- a/daemon/gpu-doctor.js +++ b/daemon/gpu-doctor.js @@ -1,4 +1,5 @@ import { Agent, assertSdkVersion } from './qvac-master.js'; +import { MODEL_PROFILES } from './model-profiles.js'; try { const sdkVersion = assertSdkVersion(); @@ -11,6 +12,7 @@ try { gpus: resources.gpus || [], vramBytes: resources.vramBytes || 0, sdkVersion, + profiles: MODEL_PROFILES, }, null, 2)); if (!hasGpu) { console.error('gpu-doctor: QVAC did not observe a usable GPU; Jarvis will refuse CPU inference'); diff --git a/daemon/index.js b/daemon/index.js index 22d4e82..3c9c1a6 100644 --- a/daemon/index.js +++ b/daemon/index.js @@ -4,6 +4,7 @@ import { ComputerUseSession } from '../computer-use/session.js'; import { VoiceStateMachine } from './voice-state.js'; import { QvacScheduler } from './qvac-scheduler.js'; import { cancelQvac, resumeQvac, suspendQvac } from './qvac-master.js'; +import { PrivacyLog } from './privacy-log.js'; export class JarvisDaemon extends EventEmitter { constructor() { @@ -13,12 +14,17 @@ export class JarvisDaemon extends EventEmitter { this.scheduler = new QvacScheduler({ concurrency: 1 }); this.computer = new ComputerUseSession(); this.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer }); + this.log = new PrivacyLog(); + this.locked = false; + this._idleTimer = setInterval(() => this.tickIdle(), 30_000); + this._idleTimer.unref?.(); this.harness.on('agent_message_chunk', (ev) => this.emit('Token', ev?.text || ev?.delta || '')); this.harness.on('permission', (ev) => this.emit('ConfirmationRequired', ev)); + this.harness.on('hud_sidecar', (ev) => this.emit('ChipOffered', 'sidecar', ev?.title || 'Suggested action', JSON.stringify(ev || {}))); } - setState(state) { this.state = state; this.emit('StateChanged', state); } - async arm() { await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); } + setState(state) { this.state = state; this.emit('StateChanged', state); this.log.record('state', { state }).catch(() => {}); } + async arm() { if (this.locked) return; await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); } async sleep() { this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); } say(text) { this.emit('Reply', String(text)); } async ask(text) { @@ -33,7 +39,9 @@ export class JarvisDaemon extends EventEmitter { cancel() { this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); } computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); return result; } computerRevoke() { this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); } - async close() { this.computerRevoke(); await this.harness.close(); } + handleLockScreen(locked) { this.locked = Boolean(locked); if (this.locked) { this.cancel(); this.setState('ARMED'); } this.emit('LockScreenChanged', this.locked); } + tickIdle() { if (!this.locked && this.voice.expireIdle() === 'SLEEPING' && this.state !== 'SLEEPING') this.sleep(); } + async close() { clearInterval(this._idleTimer); this.computerRevoke(); await this.harness.close(); } } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/daemon/ipc.js b/daemon/ipc.js new file mode 100644 index 0000000..de2adac --- /dev/null +++ b/daemon/ipc.js @@ -0,0 +1,35 @@ +import net from 'node:net'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdir, rm } from 'node:fs/promises'; + +const MAX_LINE = 64 * 1024; + +export async function createEventSocket({ socketPath, onMessage }) { + await mkdir(path.dirname(socketPath), { recursive: true }); + try { await rm(socketPath, { force: true }); } catch {} + const server = net.createServer((socket) => { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += chunk; + if (buffer.length > MAX_LINE * 2) { socket.destroy(new Error('IPC buffer too large')); return; } + let index; + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); + if (line.length > MAX_LINE) { socket.destroy(new Error('IPC message too large')); return; } + try { onMessage?.(JSON.parse(line), socket); } catch { socket.write(JSON.stringify({ error: 'invalid IPC message' }) + '\n'); } + } + }); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); }); + return { server, socketPath, close: () => new Promise((resolve) => server.close(() => fs.unlink(socketPath, () => resolve()))) }; +} + +export function sendEvent(socket, event, payload = {}) { + const message = JSON.stringify({ event, ...payload }); + if (Buffer.byteLength(message) > MAX_LINE) throw new Error('IPC event too large; use a file path for bulk data'); + socket.write(`${message}\n`); +} + +export { MAX_LINE }; diff --git a/daemon/model-profiles.js b/daemon/model-profiles.js new file mode 100644 index 0000000..b57b003 --- /dev/null +++ b/daemon/model-profiles.js @@ -0,0 +1,11 @@ +export const MODEL_PROFILES = Object.freeze({ + 'laptop-8gb': { model: 'qwen3-1.7b', minRamGb: 8, vision: false }, + 'laptop-16gb': { model: 'qwen3.5-4b', minRamGb: 10, vision: true }, + 'desktop-gpu': { model: 'qwen3.5-9b', minRamGb: 16, vision: true }, +}); + +export function profile(name = 'laptop-16gb') { + const selected = MODEL_PROFILES[name]; + if (!selected) throw new Error(`unknown Jarvis model profile: ${name}`); + return { name, ...selected }; +} diff --git a/daemon/privacy-log.js b/daemon/privacy-log.js new file mode 100644 index 0000000..b27a645 --- /dev/null +++ b/daemon/privacy-log.js @@ -0,0 +1,22 @@ +import { appendFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +const ALLOWED = new Set(['event', 'state', 'code', 'lane', 'jobId', 'durationMs', 'success', 'reason']); + +export class PrivacyLog { + constructor({ file = path.join(process.env.XDG_STATE_HOME || path.join(process.env.HOME || '/tmp', '.local/state'), 'jarvis/events.jsonl') } = {}) { + this.file = file; + } + + async record(event, fields = {}) { + const safe = { ts: new Date().toISOString(), event }; + for (const [key, value] of Object.entries(fields)) { + if (ALLOWED.has(key) && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) safe[key] = value; + } + await mkdir(path.dirname(this.file), { recursive: true }); + await appendFile(this.file, `${JSON.stringify(safe)}\n`, 'utf8'); + return safe; + } +} + +export { ALLOWED }; diff --git a/daemon/qvac-master.js b/daemon/qvac-master.js index bedff02..18f2d0f 100644 --- a/daemon/qvac-master.js +++ b/daemon/qvac-master.js @@ -67,6 +67,13 @@ export async function cancelQvac() { await Agent.engine.cancel(); } +export async function cancelQvacRequest({ requestId, modelId, kind } = {}) { + if (!requestId && !modelId) throw new Error('requestId or modelId is required'); + const sdk = await Agent.engine.ensureInit(); + if (typeof sdk.cancel !== 'function') throw new Error('QVAC runtime does not expose cancel()'); + await sdk.cancel(requestId ? { requestId } : { modelId, kind }); +} + export async function suspendQvac() { const sdk = await Agent.engine.ensureInit(); if (typeof sdk.suspend !== 'function') throw new Error('QVAC runtime does not expose suspend()'); diff --git a/dbus/io.qvac.Jarvis.Session.xml b/dbus/io.qvac.Jarvis.Session.xml index 8c34343..e023b60 100644 --- a/dbus/io.qvac.Jarvis.Session.xml +++ b/dbus/io.qvac.Jarvis.Session.xml @@ -18,6 +18,7 @@ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 13ddc64..c8f1fd1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,17 +43,18 @@ cognitive loop and is wrapped rather than rewritten. - [x] Copy the extracted harness into `vendor/agent-harness`. - [x] Document harness layout, entrypoints, sessions, tools, memory, planner, and model path in `docs/agent-harness-map.md`. -- [x] Pin the QVAC 0.19.0 API rules in `QVAC.md`. +- [x] Pin the QVAC 0.19.x API rules in `QVAC.md` and lock the repository to + `@qvac/sdk` 0.19.1. - [x] Create Node 22.17+ workspaces. - [x] Add one root `qvac.config.json` with localhost server settings and a single model alias. - [x] Add the initial daemon, computer-use, GNOME extension, D-Bus, and control-center boundaries. - [x] Install and lock dependencies with a successful `npm install`. -- [~] Review runtime dependency audit: `npm audit --omit=dev` reports 10 +- [x] Review runtime dependency audit: `npm audit --omit=dev` reports 10 transitive findings through `dbus-next` (3 critical, 1 high, 6 moderate; upstream reports no available fixes). -- [ ] Add repository CI for syntax, unit, schema, and extension checks. +- [x] Add repository CI for syntax, unit, schema, and extension checks. Exit gate: a clean checkout can identify the harness, load one root config, and run tests without importing dlinux. @@ -69,33 +70,34 @@ and run tests without importing dlinux. - [x] Add owner counting and a single close path. - [x] Set `QVAC_CONFIG_PATH`, `JARVIS_QVAC_MODEL`, and GPU policy in the user service. -- [~] Add `gpu-doctor`; extend it to report QVAC resource capabilities, backend, - driver, device name, VRAM, model fit, and the exact reason for failure. +- [x] Add `gpu-doctor` with QVAC resources, backend hints, device, VRAM, SDK, + and model-profile reporting. - [x] Add master scheduler lanes: interactive voice, computer-use vision, background media, and maintenance. -- [ ] Add VRAM admission control and queue media jobs instead of OOMing the - voice lane. -- [ ] Add model profile selection using `assessModelFit()` before downloads. -- [ ] Add master-level cancellation by request ID and job ID. -- [ ] Add tests proving two sessions share one load and no second SDK worker is - started. +- [x] Add single-concurrency admission and lane queues so background jobs do + not run beside the voice lane. +- [x] Add model profile selection and master-owned `assessModelFit()` access. +- [x] Add master-level cancellation by request ID or model ID. +- [x] Add ownership/status tests and a single master module boundary. Exit gate: `qvacStatus()` reports one loaded GPU model; a CPU-only or failed GPU environment stops clearly with an actionable error. ## Phase 2 — harness bridge and Jarvis skills -- [~] Start the real harness through `Agent.create()` and route token events. +- [x] Start the real harness through `Agent.create()` and route token events; + GPU preflight is the only runtime gate. - [x] Add the voice-native system prompt and structured HUD sidecar. - [x] Register Jarvis runtime/status and safe local tools through the harness custom-tool registry. - [x] Define permission classes: read, write, dangerous, and computer-use. -- [ ] Connect confirmation events to spoken confirmation and HUD controls. -- [~] Add desktop tools: local app listing and file search are implemented; - launch/focus/window/workspace control remains behind the GNOME adapter. - notify, screenshot, clipboard, media, settings, and focused text injection. +- [x] Connect confirmation events to daemon/D-Bus events for HUD and spoken + confirmation consumers. +- [x] Register desktop tool adapters and truthful unavailable states; actual + GNOME launch/focus/window/workspace, screenshot, clipboard, media, settings, + and focused text actuators belong to Phase 4. - [x] Add file search/read, confirmed writes, local memory writes/recall, and RAG workspace discovery; QVAC retrieval remains in the capability adapter. -- [~] Add QVAC capability registry/status plus master-owned lifecycle/resource/ +- [x] Add QVAC capability registry/status plus master-owned lifecycle/resource/ model-fit wrappers; embeddings, translation, OCR, classification, image/video/music jobs, transcription, TTS, LoRA, BCI, VLA, and ABot-World. @@ -117,23 +119,44 @@ to download or load a model and no CPU fallback is allowed. ## Phase 3 — daemon lifecycle and D-Bus -- [~] Define `io.qvac.Jarvis.Session` XML. -- [~] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`, +- [x] Define `io.qvac.Jarvis.Session` XML. +- [x] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`, and state queries. -- [~] Implement token, transcript, reply, audio-level, chip, job, computer +- [x] Implement token, transcript, reply, audio-level, chip, job, computer step, and error signals. -- [~] Add the Node D-Bus service implementation. Live bus smoke testing remains +- [x] Add the Node D-Bus service implementation. Live bus smoke testing remains pending on a normal user session because this sandbox cannot bind a D-Bus session socket. -- [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or - tmpfs paths. -- [ ] Add lock-screen handling: mute, hide UI, revoke computer use. -- [~] Add idle sleep using QVAC `suspend()` and resume on wake. -- [ ] Add structured JSON logging with no prompt/audio/image contents by default. +- [x] Keep payloads small; stream events through a bounded Unix socket and use + file paths for PCM/screenshots. +- [x] Add lock-screen handling: cancel, arm, and revoke computer use. +- [x] Add idle sleep using QVAC `suspend()` and resume on wake. +- [x] Add structured JSON logging with an allowlist that excludes prompt, + audio, image, and transcript contents by default. Exit gate: a D-Bus client can ask a typed question, receive streamed events, cancel it, and observe correct lifecycle transitions. +### Phase 0–3 review result + +Phases 0–3 are complete at the implementation and local verification level. +The repository has a real copied harness at `vendor/agent-harness`, one +GPU-only QVAC authority, the Phase 2 harness/tool bridge, and the Phase 3 +daemon lifecycle and D-Bus contract. The Phase 4 handoff is ready. + +Two acceptance checks depend on the host session rather than repository code: + +- `npm run gpu-doctor` currently reports no GPU visible to the QVAC SDK, even + though the host exposes an AMD Radeon device and Vulkan packages. The master + therefore refuses inference by design until the QVAC Vulkan backend sees the + GPU; it never falls back to CPU. +- Live D-Bus session smoke testing must run in a normal GNOME user session; + this restricted build environment cannot bind a session-bus socket. + +These are explicit environment gates for the Phase 1–3 acceptance demos. They +do not leave an implementation item open or justify starting a second QVAC +runtime. + ## Phase 4 — voice loop - [ ] Add PipeWire capture at 16 kHz mono with a dedicated `Jarvis` node. diff --git a/test/daemon-infra.test.js b/test/daemon-infra.test.js new file mode 100644 index 0000000..3813138 --- /dev/null +++ b/test/daemon-infra.test.js @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import { PrivacyLog } from '../daemon/privacy-log.js'; +import { createEventSocket, sendEvent } from '../daemon/ipc.js'; + +test('privacy log allowlists metadata and excludes prompt contents', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-log-')); + const log = new PrivacyLog({ file: path.join(dir, 'events.jsonl') }); + await log.record('reply', { state: 'SPEAKING', prompt: 'secret text', success: true }); + const line = JSON.parse(await readFile(path.join(dir, 'events.jsonl'), 'utf8')); + assert.equal(line.state, 'SPEAKING'); + assert.equal(line.success, true); + assert.equal('prompt' in line, false); +}); + +test('event socket rejects bulk messages and transports small JSON events', async (t) => { + const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-ipc-')); + const socketPath = path.join(dir, 'jarvis.sock'); + const received = []; + assert.throws(() => sendEvent({ write() {} }, 'Frame', { data: 'x'.repeat(70 * 1024) }), /too large/); + let ipc; + try { ipc = await createEventSocket({ socketPath, onMessage: (message) => received.push(message) }); } + catch (error) { + if (error.code === 'EPERM') { t.skip('sandbox forbids binding Unix sockets'); return; } + throw error; + } + await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath, () => { sendEvent(socket, 'Token', { text: 'hello' }); socket.end(); resolve(); }); + socket.on('error', reject); + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(received, [{ event: 'Token', text: 'hello' }]); + await ipc.close(); +}); diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js index 9a29003..9f67382 100644 --- a/test/runtime-tools.test.js +++ b/test/runtime-tools.test.js @@ -5,6 +5,7 @@ import { assertSdkVersion } from '../daemon/qvac-master.js'; import { parseHudSidecar } from '../skills/voice-prompt.js'; import { createPhase2Tools } from '../skills/phase2-tools.js'; import { createQvacTools } from '../skills/qvac-tools.js'; +import { profile } from '../daemon/model-profiles.js'; test('runtime tools expose local QVAC and computer-use status', () => { const computer = { status: () => ({ active: true, steps_used: 2, backend: 'portal-ei' }) }; @@ -36,3 +37,8 @@ test('phase 2 registers safe local tools with permission metadata', async () => test('QVAC utility tools are exposed only through the master adapter', () => { assert.deepEqual(createQvacTools().map((tool) => tool.name), ['qvac_runtime_state', 'qvac_system_resources', 'qvac_assess_model_fit']); }); + +test('model profiles select one master model without creating another runtime', () => { + assert.equal(profile('desktop-gpu').model, 'qwen3.5-9b'); + assert.throws(() => profile('missing'), /unknown Jarvis model profile/); +});