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 { 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');
}
+7
View File
@@ -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,
+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 };