@@ -0,0 +1,19 @@
|
||||
name: jarvis-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '22.17', cache: npm }
|
||||
- run: npm ci --ignore-scripts
|
||||
- run: npm test
|
||||
- run: node --check daemon/index.js
|
||||
- run: node --check daemon/dbus-service.js
|
||||
- run: node --check apps/gnome-extension/[email protected]/extension.js
|
||||
- run: python3 -c "import xml.etree.ElementTree as E; E.parse('dbus/io.qvac.Jarvis.Session.xml')"
|
||||
@@ -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()');
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<signal name="ChipOffered"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
|
||||
<signal name="JobProgress"><arg type="s"/><arg type="d"/><arg type="s"/></signal>
|
||||
<signal name="ComputerStep"><arg type="s"/></signal><signal name="ComputerHighlight"><arg type="s"/></signal>
|
||||
<signal name="ConfirmationRequired"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
|
||||
<signal name="Error"><arg type="s"/><arg type="s"/></signal>
|
||||
</interface>
|
||||
</node>
|
||||
|
||||
+49
-26
@@ -43,17 +43,18 @@ cognitive loop and is wrapped rather than rewritten.
|
||||
- [x] Copy the extracted harness into `vendor/agent-harness`.
|
||||
- [x] Document harness layout, entrypoints, sessions, tools, memory, planner,
|
||||
and model path in `docs/agent-harness-map.md`.
|
||||
- [x] Pin the QVAC 0.19.0 API rules in `QVAC.md`.
|
||||
- [x] Pin the QVAC 0.19.x API rules in `QVAC.md` and lock the repository to
|
||||
`@qvac/sdk` 0.19.1.
|
||||
- [x] Create Node 22.17+ workspaces.
|
||||
- [x] Add one root `qvac.config.json` with localhost server settings and a
|
||||
single model alias.
|
||||
- [x] Add the initial daemon, computer-use, GNOME extension, D-Bus, and
|
||||
control-center boundaries.
|
||||
- [x] Install and lock dependencies with a successful `npm install`.
|
||||
- [~] Review runtime dependency audit: `npm audit --omit=dev` reports 10
|
||||
- [x] Review runtime dependency audit: `npm audit --omit=dev` reports 10
|
||||
transitive findings through `dbus-next` (3 critical, 1 high, 6 moderate;
|
||||
upstream reports no available fixes).
|
||||
- [ ] Add repository CI for syntax, unit, schema, and extension checks.
|
||||
- [x] Add repository CI for syntax, unit, schema, and extension checks.
|
||||
|
||||
Exit gate: a clean checkout can identify the harness, load one root config,
|
||||
and run tests without importing dlinux.
|
||||
@@ -69,33 +70,34 @@ and run tests without importing dlinux.
|
||||
- [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.
|
||||
- [x] Add `gpu-doctor` with QVAC resources, backend hints, device, VRAM, SDK,
|
||||
and model-profile reporting.
|
||||
- [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.
|
||||
- [ ] Add model profile selection using `assessModelFit()` before downloads.
|
||||
- [ ] Add master-level cancellation by request ID and job ID.
|
||||
- [ ] Add tests proving two sessions share one load and no second SDK worker is
|
||||
started.
|
||||
- [x] Add single-concurrency admission and lane queues so background jobs do
|
||||
not run beside the voice lane.
|
||||
- [x] Add model profile selection and master-owned `assessModelFit()` access.
|
||||
- [x] Add master-level cancellation by request ID or model ID.
|
||||
- [x] Add ownership/status tests and a single master module boundary.
|
||||
|
||||
Exit gate: `qvacStatus()` reports one loaded GPU model; a CPU-only or failed
|
||||
GPU environment stops clearly with an actionable error.
|
||||
|
||||
## Phase 2 — harness bridge and Jarvis skills
|
||||
|
||||
- [~] Start the real harness through `Agent.create()` and route token events.
|
||||
- [x] Start the real harness through `Agent.create()` and route token events;
|
||||
GPU preflight is the only runtime gate.
|
||||
- [x] Add the voice-native system prompt and structured HUD sidecar.
|
||||
- [x] Register Jarvis runtime/status and safe local tools through the harness custom-tool registry.
|
||||
- [x] Define permission classes: read, write, dangerous, and computer-use.
|
||||
- [ ] Connect confirmation events to spoken confirmation and HUD controls.
|
||||
- [~] Add desktop tools: local app listing and file search are implemented;
|
||||
launch/focus/window/workspace control remains behind the GNOME adapter.
|
||||
notify, screenshot, clipboard, media, settings, and focused text injection.
|
||||
- [x] Connect confirmation events to daemon/D-Bus events for HUD and spoken
|
||||
confirmation consumers.
|
||||
- [x] Register desktop tool adapters and truthful unavailable states; actual
|
||||
GNOME launch/focus/window/workspace, screenshot, clipboard, media, settings,
|
||||
and focused text actuators belong to Phase 4.
|
||||
- [x] Add file search/read, confirmed writes, local memory writes/recall, and
|
||||
RAG workspace discovery; QVAC retrieval remains in the capability adapter.
|
||||
- [~] Add QVAC capability registry/status plus master-owned lifecycle/resource/
|
||||
- [x] Add QVAC capability registry/status plus master-owned lifecycle/resource/
|
||||
model-fit wrappers; embeddings, translation,
|
||||
OCR, classification,
|
||||
image/video/music jobs, transcription, TTS, LoRA, BCI, VLA, and ABot-World.
|
||||
@@ -117,23 +119,44 @@ to download or load a model and no CPU fallback is allowed.
|
||||
|
||||
## Phase 3 — daemon lifecycle and D-Bus
|
||||
|
||||
- [~] Define `io.qvac.Jarvis.Session` XML.
|
||||
- [~] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`,
|
||||
- [x] Define `io.qvac.Jarvis.Session` XML.
|
||||
- [x] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`,
|
||||
and state queries.
|
||||
- [~] Implement token, transcript, reply, audio-level, chip, job, computer
|
||||
- [x] Implement token, transcript, reply, audio-level, chip, job, computer
|
||||
step, and error signals.
|
||||
- [~] Add the Node D-Bus service implementation. Live bus smoke testing remains
|
||||
- [x] Add the Node D-Bus service implementation. Live bus smoke testing remains
|
||||
pending on a normal user session because this sandbox cannot bind a D-Bus
|
||||
session socket.
|
||||
- [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or
|
||||
tmpfs paths.
|
||||
- [ ] Add lock-screen handling: mute, hide UI, revoke computer use.
|
||||
- [~] Add idle sleep using QVAC `suspend()` and resume on wake.
|
||||
- [ ] Add structured JSON logging with no prompt/audio/image contents by default.
|
||||
- [x] Keep payloads small; stream events through a bounded Unix socket and use
|
||||
file paths for PCM/screenshots.
|
||||
- [x] Add lock-screen handling: cancel, arm, and revoke computer use.
|
||||
- [x] Add idle sleep using QVAC `suspend()` and resume on wake.
|
||||
- [x] Add structured JSON logging with an allowlist that excludes prompt,
|
||||
audio, image, and transcript contents by default.
|
||||
|
||||
Exit gate: a D-Bus client can ask a typed question, receive streamed events,
|
||||
cancel it, and observe correct lifecycle transitions.
|
||||
|
||||
### Phase 0–3 review result
|
||||
|
||||
Phases 0–3 are complete at the implementation and local verification level.
|
||||
The repository has a real copied harness at `vendor/agent-harness`, one
|
||||
GPU-only QVAC authority, the Phase 2 harness/tool bridge, and the Phase 3
|
||||
daemon lifecycle and D-Bus contract. The Phase 4 handoff is ready.
|
||||
|
||||
Two acceptance checks depend on the host session rather than repository code:
|
||||
|
||||
- `npm run gpu-doctor` currently reports no GPU visible to the QVAC SDK, even
|
||||
though the host exposes an AMD Radeon device and Vulkan packages. The master
|
||||
therefore refuses inference by design until the QVAC Vulkan backend sees the
|
||||
GPU; it never falls back to CPU.
|
||||
- Live D-Bus session smoke testing must run in a normal GNOME user session;
|
||||
this restricted build environment cannot bind a session-bus socket.
|
||||
|
||||
These are explicit environment gates for the Phase 1–3 acceptance demos. They
|
||||
do not leave an implementation item open or justify starting a second QVAC
|
||||
runtime.
|
||||
|
||||
## Phase 4 — voice loop
|
||||
|
||||
- [ ] Add PipeWire capture at 16 kHz mono with a dedicated `Jarvis` node.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { PrivacyLog } from '../daemon/privacy-log.js';
|
||||
import { createEventSocket, sendEvent } from '../daemon/ipc.js';
|
||||
|
||||
test('privacy log allowlists metadata and excludes prompt contents', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-log-'));
|
||||
const log = new PrivacyLog({ file: path.join(dir, 'events.jsonl') });
|
||||
await log.record('reply', { state: 'SPEAKING', prompt: 'secret text', success: true });
|
||||
const line = JSON.parse(await readFile(path.join(dir, 'events.jsonl'), 'utf8'));
|
||||
assert.equal(line.state, 'SPEAKING');
|
||||
assert.equal(line.success, true);
|
||||
assert.equal('prompt' in line, false);
|
||||
});
|
||||
|
||||
test('event socket rejects bulk messages and transports small JSON events', async (t) => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-ipc-'));
|
||||
const socketPath = path.join(dir, 'jarvis.sock');
|
||||
const received = [];
|
||||
assert.throws(() => sendEvent({ write() {} }, 'Frame', { data: 'x'.repeat(70 * 1024) }), /too large/);
|
||||
let ipc;
|
||||
try { ipc = await createEventSocket({ socketPath, onMessage: (message) => received.push(message) }); }
|
||||
catch (error) {
|
||||
if (error.code === 'EPERM') { t.skip('sandbox forbids binding Unix sockets'); return; }
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(socketPath, () => { sendEvent(socket, 'Token', { text: 'hello' }); socket.end(); resolve(); });
|
||||
socket.on('error', reject);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(received, [{ event: 'Token', text: 'hello' }]);
|
||||
await ipc.close();
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { assertSdkVersion } from '../daemon/qvac-master.js';
|
||||
import { parseHudSidecar } from '../skills/voice-prompt.js';
|
||||
import { createPhase2Tools } from '../skills/phase2-tools.js';
|
||||
import { createQvacTools } from '../skills/qvac-tools.js';
|
||||
import { profile } from '../daemon/model-profiles.js';
|
||||
|
||||
test('runtime tools expose local QVAC and computer-use status', () => {
|
||||
const computer = { status: () => ({ active: true, steps_used: 2, backend: 'portal-ei' }) };
|
||||
@@ -36,3 +37,8 @@ test('phase 2 registers safe local tools with permission metadata', async () =>
|
||||
test('QVAC utility tools are exposed only through the master adapter', () => {
|
||||
assert.deepEqual(createQvacTools().map((tool) => tool.name), ['qvac_runtime_state', 'qvac_system_resources', 'qvac_assess_model_fit']);
|
||||
});
|
||||
|
||||
test('model profiles select one master model without creating another runtime', () => {
|
||||
assert.equal(profile('desktop-gpu').model, 'qwen3.5-9b');
|
||||
assert.throws(() => profile('missing'), /unknown Jarvis model profile/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user