Updates
This commit is contained in:
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }; }
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
Reference in New Issue
Block a user