From 1acd37166890eba3233826837eaac8482ac23d71 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Fri, 11 Sep 2026 13:40:53 -0400 Subject: [PATCH] Updates --- daemon/dbus-service.js | 54 ++++++++++++++++++++++++++++++++++++++++ daemon/index.js | 23 +++++++++++++++-- daemon/qvac-master.js | 7 ++++++ daemon/qvac-scheduler.js | 40 +++++++++++++++++++++++++++++ daemon/voice-state.js | 52 ++++++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 7 +++--- test/daemon.test.js | 26 +++++++++++++++++++ 7 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 daemon/dbus-service.js create mode 100644 daemon/qvac-scheduler.js create mode 100644 daemon/voice-state.js create mode 100644 test/daemon.test.js diff --git a/daemon/dbus-service.js b/daemon/dbus-service.js new file mode 100644 index 0000000..7967465 --- /dev/null +++ b/daemon/dbus-service.js @@ -0,0 +1,54 @@ +const IFACE = 'io.qvac.Jarvis.Session'; +const OBJECT = '/io/qvac/Jarvis'; +const BUS_NAME = 'io.qvac.Jarvis'; + +export async function serveOnSessionBus(daemon) { + const dbus = await import('dbus-next'); + const { Interface } = dbus.interface; + class Session extends Interface { + constructor() { super(IFACE); } + Arm() { daemon.arm(); } + Sleep() { daemon.sleep(); } + Shutdown() { daemon.close(); } + Say(text) { return daemon.say?.(text); } + Ask(text) { return daemon.ask(text); } + Cancel() { daemon.cancel(); } + SetMode(mode) { daemon.mode = mode; daemon.emit('ModeChanged', mode); } + GetState() { return daemon.state; } + ComputerGrant(persist) { daemon.computerGrant(persist); } + ComputerRevoke() { daemon.computerRevoke(); } + ComputerStatus() { return JSON.stringify(daemon.computer.status()); } + StateChanged(state) { this.emit('StateChanged', state); } + Reply(text) { this.emit('Reply', String(text)); } + Token(text) { this.emit('Token', String(text)); } + Error(code, message) { this.emit('Error', String(code), String(message)); } + } + Interface.configureMembers(Session, { + Arm: { inSignature: '', outSignature: '', method: 'Arm' }, + Sleep: { inSignature: '', outSignature: '', method: 'Sleep' }, + Shutdown: { inSignature: '', outSignature: '', method: 'Shutdown' }, + Say: { inSignature: 's', outSignature: '', method: 'Say' }, + Ask: { inSignature: 's', outSignature: '', method: 'Ask' }, + Cancel: { inSignature: '', outSignature: '', method: 'Cancel' }, + SetMode: { inSignature: 's', outSignature: '', method: 'SetMode' }, + GetState: { inSignature: '', outSignature: 's', method: 'GetState' }, + ComputerGrant: { inSignature: 'b', outSignature: '', method: 'ComputerGrant' }, + ComputerRevoke: { inSignature: '', outSignature: '', method: 'ComputerRevoke' }, + ComputerStatus: { inSignature: '', outSignature: 's', method: 'ComputerStatus' }, + StateChanged: { signature: 's', signal: true }, + Reply: { signature: 's', signal: true }, + Token: { signature: 's', signal: true }, + Error: { signature: 'ss', signal: true }, + }); + const bus = dbus.sessionBus(); + await bus.requestName(BUS_NAME); + const iface = new Session(); + bus.export(OBJECT, iface); + daemon.on('StateChanged', (state) => iface.StateChanged(state)); + daemon.on('Reply', (reply) => iface.Reply(reply)); + daemon.on('Token', (token) => iface.Token(token)); + daemon.on('Error', (code, message) => iface.Error(code, message)); + return bus; +} + +export { BUS_NAME, OBJECT, IFACE }; diff --git a/daemon/index.js b/daemon/index.js index 0667a8b..bf3ec91 100644 --- a/daemon/index.js +++ b/daemon/index.js @@ -1,11 +1,15 @@ import { EventEmitter } from 'node:events'; import { HarnessBridge } from './harness-bridge.js'; import { ComputerUseSession } from '../computer-use/session.js'; +import { VoiceStateMachine } from './voice-state.js'; +import { QvacScheduler } from './qvac-scheduler.js'; export class JarvisDaemon extends EventEmitter { constructor() { super(); this.state = 'ARMED'; + this.voice = new VoiceStateMachine(); + this.scheduler = new QvacScheduler({ concurrency: 1 }); this.computer = new ComputerUseSession(); this.harness = new HarnessBridge({ cwd: process.cwd() }); this.harness.on('agent_message_chunk', (ev) => this.emit('Token', ev?.text || ev?.delta || '')); @@ -13,8 +17,19 @@ export class JarvisDaemon extends EventEmitter { } setState(state) { this.state = state; this.emit('StateChanged', state); } - async ask(text) { this.setState('THINKING'); try { const reply = await this.harness.ask(text); this.emit('Reply', reply); return reply; } finally { this.setState('LISTENING'); } } - cancel() { this.harness.cancel(); this.computer.revoke(); this.setState('ARMED'); } + arm() { this.voice.wake(); this.setState('LISTENING'); } + sleep() { this.voice.sleep(); this.setState('SLEEPING'); } + say(text) { this.emit('Reply', String(text)); } + async ask(text) { + this.voice.utterance(); this.setState('THINKING'); + try { + const reply = await this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' }); + this.voice.speak(); this.setState('SPEAKING'); this.emit('Reply', reply); return reply; + } catch (error) { + this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error; + } + } + cancel() { this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); } 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(); } @@ -22,6 +37,10 @@ export class JarvisDaemon extends EventEmitter { if (import.meta.url === `file://${process.argv[1]}`) { const daemon = new JarvisDaemon(); + import('./dbus-service.js').then(({ serveOnSessionBus }) => serveOnSessionBus(daemon)).catch((error) => { + daemon.emit('Error', 'DBUS_UNAVAILABLE', error.message); + console.error(`jarvisd: D-Bus unavailable: ${error.message}`); + }); process.on('SIGINT', () => daemon.close().finally(() => process.exit(0))); console.log('jarvisd scaffold ready; use the D-Bus adapter when installed'); } diff --git a/daemon/qvac-master.js b/daemon/qvac-master.js index 8139ac9..df6faf1 100644 --- a/daemon/qvac-master.js +++ b/daemon/qvac-master.js @@ -19,6 +19,13 @@ export const QVAC_MASTER = Object.freeze({ export async function acquireQvac() { ownerCount += 1; if (!loadPromise) { + const resources = await Agent.engine.resources(); + const gpuVisible = (resources.gpus?.length || 0) > 0 || + Boolean(resources.drivers?.vulkan || resources.drivers?.cuda || resources.drivers?.opencl || resources.gpu); + if (!gpuVisible) { + ownerCount = Math.max(0, ownerCount - 1); + throw new Error('Jarvis requires a QVAC-visible GPU backend; run npm run gpu-doctor'); + } loadPromise = Agent.engine.load({ model: QVAC_MASTER.model, tools: true, diff --git a/daemon/qvac-scheduler.js b/daemon/qvac-scheduler.js new file mode 100644 index 0000000..b894ce4 --- /dev/null +++ b/daemon/qvac-scheduler.js @@ -0,0 +1,40 @@ +export class QvacScheduler { + constructor({ concurrency = 1 } = {}) { + this.concurrency = Math.max(1, concurrency); + this.running = 0; + this.queue = []; + } + + run(task, { lane = 'background', signal } = {}) { + return new Promise((resolve, reject) => { + this.queue.push({ task, lane, signal, resolve, reject, enqueuedAt: Date.now() }); + this.queue.sort((a, b) => this.priority(b.lane) - this.priority(a.lane) || a.enqueuedAt - b.enqueuedAt); + this.pump(); + }); + } + + priority(lane) { return { voice: 3, 'computer-use': 2, background: 1, maintenance: 0 }[lane] ?? 0; } + + pump() { + while (this.running < this.concurrency && this.queue.length) { + const job = this.queue.shift(); + if (job.signal?.aborted) { job.reject(new Error('QVAC job cancelled before start')); continue; } + this.running += 1; + Promise.resolve().then(() => job.task(job.signal)).then(job.resolve, job.reject).finally(() => { + this.running -= 1; + this.pump(); + }); + } + } + + cancelQueued(predicate = () => true) { + const kept = []; + for (const job of this.queue) { + if (predicate(job)) job.reject(new Error('QVAC job cancelled')); + else kept.push(job); + } + this.queue = kept; + } + + status() { return { running: this.running, queued: this.queue.map((j) => j.lane) }; } +} diff --git a/daemon/voice-state.js b/daemon/voice-state.js new file mode 100644 index 0000000..d97481b --- /dev/null +++ b/daemon/voice-state.js @@ -0,0 +1,52 @@ +const STATES = ['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']; +const COMMANDS = new Map([ + ['cancel', 'cancel'], ['stop', 'cancel'], ['never mind', 'cancel'], + ['hands off', 'cancel'], ['stop clicking', 'cancel'], ["that's enough", 'cancel'], + ['go to sleep', 'sleep'], ['privacy mode', 'sleep'], +]); + +export class VoiceStateMachine { + constructor({ now = () => Date.now(), idleMs = 30 * 60 * 1000 } = {}) { + this.now = now; + this.idleMs = idleMs; + this.state = 'ARMED'; + this.lastActivity = now(); + } + + transition(next, reason = 'unspecified') { + if (!STATES.includes(next)) throw new Error(`unknown Jarvis state: ${next}`); + const allowed = { + ARMED: ['LISTENING', 'SLEEPING'], LISTENING: ['THINKING', 'ARMED', 'SLEEPING'], + THINKING: ['SPEAKING', 'ARMED', 'LISTENING'], SPEAKING: ['LISTENING', 'ARMED'], + SLEEPING: ['LISTENING', 'ARMED'], + }; + if (next !== this.state && !allowed[this.state].includes(next)) { + throw new Error(`invalid state transition ${this.state} -> ${next}`); + } + const previous = this.state; + this.state = next; + this.lastActivity = this.now(); + return { previous, state: next, reason }; + } + + wake() { return this.transition('LISTENING', 'wake'); } + utterance() { return this.transition('THINKING', 'utterance'); } + speak() { return this.transition('SPEAKING', 'reply'); } + finishSpeaking() { return this.transition('LISTENING', 'playback-finished'); } + cancel() { return this.transition('ARMED', 'cancel'); } + sleep() { return this.transition('SLEEPING', 'idle-or-privacy'); } + + command(text) { + const command = COMMANDS.get(String(text || '').trim().toLowerCase()); + if (command === 'cancel') this.cancel(); + if (command === 'sleep') this.sleep(); + return command || null; + } + + expireIdle() { + if (this.state !== 'SLEEPING' && this.now() - this.lastActivity >= this.idleMs) this.sleep(); + return this.state; + } +} + +export { STATES }; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8b8a8d5..49f89a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,12 +61,13 @@ and run tests without importing dlinux. - [x] Serialize model loading and share the resulting harness engine. - [x] Request `device: "gpu"`, `gpu_layers: 99`, and GPU multimodal projection. - [x] Reject a CPU result instead of accepting QVAC's internal fallback. +- [x] Preflight QVAC GPU visibility before downloading or loading a model. - [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. -- [ ] Add master scheduler lanes: interactive voice, computer-use vision, +- [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. @@ -101,9 +102,9 @@ GPU-owned worker. ## Phase 3 — daemon lifecycle and D-Bus - [~] Define `io.qvac.Jarvis.Session` XML. -- [ ] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`, +- [~] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`, and state queries. -- [ ] Implement token, transcript, reply, audio-level, chip, job, computer +- [~] Implement token, transcript, reply, audio-level, chip, job, computer step, and error signals. - [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or tmpfs paths. diff --git a/test/daemon.test.js b/test/daemon.test.js new file mode 100644 index 0000000..a2bc85f --- /dev/null +++ b/test/daemon.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { VoiceStateMachine } from '../daemon/voice-state.js'; +import { QvacScheduler } from '../daemon/qvac-scheduler.js'; + +test('voice state machine handles wake, reply, cancel, and idle sleep', () => { + let now = 0; + const voice = new VoiceStateMachine({ now: () => now, idleMs: 100 }); + voice.wake(); voice.utterance(); voice.speak(); voice.finishSpeaking(); + assert.equal(voice.state, 'LISTENING'); + assert.equal(voice.command('hands off'), 'cancel'); + assert.equal(voice.state, 'ARMED'); + voice.wake(); now = 101; voice.expireIdle(); + assert.equal(voice.state, 'SLEEPING'); +}); + +test('QVAC scheduler prioritizes voice and keeps one active job', async () => { + const scheduler = new QvacScheduler(); + const order = []; + const first = scheduler.run(async () => { order.push('first'); await new Promise((r) => setTimeout(r, 5)); }, { lane: 'background' }); + const media = scheduler.run(async () => order.push('media'), { lane: 'background' }); + const voice = scheduler.run(async () => order.push('voice'), { lane: 'voice' }); + await Promise.all([first, media, voice]); + assert.deepEqual(order, ['first', 'voice', 'media']); + assert.deepEqual(scheduler.status(), { running: 0, queued: [] }); +});