This commit is contained in:
2026-09-11 13:40:53 -04:00
parent 0f00e11823
commit 1acd371668
7 changed files with 204 additions and 5 deletions
+54
View File
@@ -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 };
+21 -2
View File
@@ -1,11 +1,15 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { HarnessBridge } from './harness-bridge.js'; import { HarnessBridge } from './harness-bridge.js';
import { ComputerUseSession } from '../computer-use/session.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 { export class JarvisDaemon extends EventEmitter {
constructor() { constructor() {
super(); super();
this.state = 'ARMED'; this.state = 'ARMED';
this.voice = new VoiceStateMachine();
this.scheduler = new QvacScheduler({ concurrency: 1 });
this.computer = new ComputerUseSession(); this.computer = new ComputerUseSession();
this.harness = new HarnessBridge({ cwd: process.cwd() }); this.harness = new HarnessBridge({ cwd: process.cwd() });
this.harness.on('agent_message_chunk', (ev) => this.emit('Token', ev?.text || ev?.delta || '')); 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); } 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'); } } arm() { this.voice.wake(); this.setState('LISTENING'); }
cancel() { this.harness.cancel(); this.computer.revoke(); this.setState('ARMED'); } 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; } 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' })); } computerRevoke() { this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
async close() { this.computerRevoke(); await this.harness.close(); } 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]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
const daemon = new JarvisDaemon(); 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))); process.on('SIGINT', () => daemon.close().finally(() => process.exit(0)));
console.log('jarvisd scaffold ready; use the D-Bus adapter when installed'); console.log('jarvisd scaffold ready; use the D-Bus adapter when installed');
} }
+7
View File
@@ -19,6 +19,13 @@ export const QVAC_MASTER = Object.freeze({
export async function acquireQvac() { export async function acquireQvac() {
ownerCount += 1; ownerCount += 1;
if (!loadPromise) { 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({ loadPromise = Agent.engine.load({
model: QVAC_MASTER.model, model: QVAC_MASTER.model,
tools: true, tools: true,
+40
View File
@@ -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) }; }
}
+52
View File
@@ -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 };
+4 -3
View File
@@ -61,12 +61,13 @@ and run tests without importing dlinux.
- [x] Serialize model loading and share the resulting harness engine. - [x] Serialize model loading and share the resulting harness engine.
- [x] Request `device: "gpu"`, `gpu_layers: 99`, and GPU multimodal projection. - [x] Request `device: "gpu"`, `gpu_layers: 99`, and GPU multimodal projection.
- [x] Reject a CPU result instead of accepting QVAC's internal fallback. - [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] Add owner counting and a single close path.
- [x] Set `QVAC_CONFIG_PATH`, `JARVIS_QVAC_MODEL`, and GPU policy in the user - [x] Set `QVAC_CONFIG_PATH`, `JARVIS_QVAC_MODEL`, and GPU policy in the user
service. service.
- [~] Add `gpu-doctor`; extend it to report QVAC resource capabilities, backend, - [~] Add `gpu-doctor`; extend it to report QVAC resource capabilities, backend,
driver, device name, VRAM, model fit, and the exact reason for failure. 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. background media, and maintenance.
- [ ] Add VRAM admission control and queue media jobs instead of OOMing the - [ ] Add VRAM admission control and queue media jobs instead of OOMing the
voice lane. voice lane.
@@ -101,9 +102,9 @@ GPU-owned worker.
## Phase 3 — daemon lifecycle and D-Bus ## Phase 3 — daemon lifecycle and D-Bus
- [~] Define `io.qvac.Jarvis.Session` XML. - [~] 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. 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. step, and error signals.
- [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or - [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or
tmpfs paths. tmpfs paths.
+26
View File
@@ -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: [] });
});