@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
+11
-3
@@ -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]}`) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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()');
|
||||
|
||||
Reference in New Issue
Block a user