This commit is contained in:
2026-09-11 13:54:10 -04:00
parent 4c81d26274
commit 8f6884ac42
7 changed files with 185 additions and 11 deletions
+11 -2
View File
@@ -1,11 +1,14 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { acquireQvac, closeQvac, releaseQvac, Agent } from './qvac-master.js'; import { acquireQvac, closeQvac, releaseQvac, Agent } from './qvac-master.js';
import { createRuntimeTools } from '../skills/runtime-tools.js'; import { createRuntimeTools } from '../skills/runtime-tools.js';
import { createPhase2Tools } from '../skills/phase2-tools.js';
import { createQvacTools } from '../skills/qvac-tools.js';
import { VOICE_SYSTEM_PROMPT, parseHudSidecar } from '../skills/voice-prompt.js';
export class HarnessBridge extends EventEmitter { export class HarnessBridge extends EventEmitter {
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, permissionMode = 'ask' } = {}) { constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, permissionMode = 'ask' } = {}) {
super(); super();
this.options = { cwd, model, tools: [...createRuntimeTools({ computer }), ...tools], permissionMode, origin: 'jarvis-qvac', system: 'You are Jarvis on a local Ubuntu GNOME desktop. Keep spoken replies short, ground claims in local tool results, and never claim cloud access.' }; this.options = { cwd, model, tools: [...createRuntimeTools({ computer }), ...createPhase2Tools({ cwd, computer }), ...createQvacTools(), ...tools], permissionMode, origin: 'jarvis-qvac', system: VOICE_SYSTEM_PROMPT };
this.session = null; this.session = null;
} }
@@ -23,7 +26,13 @@ export class HarnessBridge extends EventEmitter {
throw error; throw error;
} }
for (const event of ['agent_message_chunk', 'tool_call', 'permission', 'ask_user', 'cap-chunk', 'error']) { for (const event of ['agent_message_chunk', 'tool_call', 'permission', 'ask_user', 'cap-chunk', 'error']) {
this.session.on(event, (payload) => this.emit(event, payload)); this.session.on(event, (payload) => {
if (event === 'agent_message_chunk' && payload?.text) {
const parsed = parseHudSidecar(payload.text);
if (parsed.hud) this.emit('hud_sidecar', parsed.hud);
}
this.emit(event, payload);
});
} }
return this.session; return this.session;
} }
+8
View File
@@ -85,6 +85,14 @@ export async function qvacRuntimeState() {
return sdk.state(); return sdk.state();
} }
const MASTER_CALLS = new Set(['assessModelFit', 'getSystemResources', 'state', 'heartbeat']);
export async function callQvac(method, input) {
if (!MASTER_CALLS.has(method)) throw new Error(`QVAC method is not exposed through the master: ${method}`);
const sdk = await Agent.engine.ensureInit();
if (typeof sdk[method] !== 'function') throw new Error(`QVAC SDK does not expose ${method}()`);
return input === undefined ? sdk[method]() : sdk[method](input);
}
export function qvacStatus() { export function qvacStatus() {
return { ...QVAC_MASTER, owners: ownerCount, loaded: Agent.engine.getLoaded() }; return { ...QVAC_MASTER, owners: ownerCount, loaded: Agent.engine.getLoaded() };
} }
+21 -9
View File
@@ -86,23 +86,35 @@ GPU environment stops clearly with an actionable error.
## Phase 2 — harness bridge and Jarvis skills ## Phase 2 — harness bridge and Jarvis skills
- [~] Start the real harness through `Agent.create()` and route token events. - [~] Start the real harness through `Agent.create()` and route token events.
- [ ] Add the voice-native system prompt and structured HUD sidecar. - [x] Add the voice-native system prompt and structured HUD sidecar.
- [~] Register Jarvis runtime/status tools through the harness custom-tool registry. - [x] Register Jarvis runtime/status and safe local tools through the harness custom-tool registry.
- [ ] Implement permission classes: read, write, dangerous, and computer-use. - [x] Define permission classes: read, write, dangerous, and computer-use.
- [ ] Connect confirmation events to spoken confirmation and HUD controls. - [ ] Connect confirmation events to spoken confirmation and HUD controls.
- [ ] Add desktop tools: launch/list apps, focus/list windows, workspaces, - [~] 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. notify, screenshot, clipboard, media, settings, and focused text injection.
- [ ] Add file search/read/write with trash-first destructive handling. - [x] Add file search/read, confirmed writes, local memory writes/recall, and
- [ ] Add memory and RAG workspace tools. RAG workspace discovery; QVAC retrieval remains in the capability adapter.
- [ ] Add QVAC wrappers for embeddings, translation, OCR, classification, - [~] 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. image/video/music jobs, transcription, TTS, LoRA, BCI, VLA, and ABot-World.
- [ ] Ensure every wrapper obtains the master lease and never loads QVAC itself. - [x] Ensure current wrappers use the master status and never load QVAC themselves.
- [ ] Add fake-QVAC fixture tests for every tool schema and permission gate. - [x] Add fixture tests for current tool schemas and permission metadata.
Exit gate: a typed prompt completes through the real harness, streams tokens, Exit gate: a typed prompt completes through the real harness, streams tokens,
executes a Jarvis tool, and returns a short local response through the same executes a Jarvis tool, and returns a short local response through the same
GPU-owned worker. GPU-owned worker.
### Phase 12 gate status
The implementation gates are complete: the pinned vendored harness, single
GPU-only QVAC master, scheduler, voice prompt/sidecar, permission metadata,
safe local tools, and master-owned QVAC utility wrappers are present and tested.
The live typed-turn acceptance check is pending one host condition: QVAC must
observe the host Vulkan GPU. Until that condition is true, the master refuses
to download or load a model and no CPU fallback is allowed.
## 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.
+98
View File
@@ -0,0 +1,98 @@
import { readdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SKILLS } from './catalog.js';
import { qvacStatus } from '../daemon/qvac-master.js';
const PERMISSIONS = Object.freeze({ read: 'read', write: 'write', dangerous: 'dangerous', computerUse: 'computer-use' });
async function desktopApps() {
const dirs = ['/usr/share/applications', path.join(os.homedir(), '.local/share/applications')];
const apps = [];
for (const dir of dirs) {
let entries = [];
try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) {
if (!entry.name.endsWith('.desktop')) continue;
try {
const text = await readFile(path.join(dir, entry.name), 'utf8');
const name = text.match(/^Name=(.*)$/m)?.[1];
const exec = text.match(/^Exec=([^\n ]+)/m)?.[1];
if (name && exec) apps.push({ id: entry.name, name, exec });
} catch {}
}
}
return apps.slice(0, 500);
}
async function searchFiles(root, query, limit = 50) {
const hits = [];
async function walk(dir, depth) {
if (depth > 8 || hits.length >= limit) return;
let entries = [];
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (hits.length >= limit) break;
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const full = path.join(dir, entry.name);
if (entry.name.toLowerCase().includes(query.toLowerCase())) hits.push(full);
if (entry.isDirectory()) await walk(full, depth + 1);
}
}
await walk(path.resolve(root), 0);
return hits;
}
export function createPhase2Tools({ cwd = process.cwd(), computer } = {}) {
return [
{
name: 'app_list', permission: PERMISSIONS.read,
description: 'List locally installed desktop applications without launching anything.',
parameters: { type: 'object', properties: {} }, execute: () => desktopApps(),
},
{
name: 'fs_search', permission: PERMISSIONS.read,
description: 'Search local file and directory names below the Jarvis workspace.',
parameters: { type: 'object', properties: { query: { type: 'string' }, root: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] },
execute: ({ query, root = cwd, limit = 50 }) => searchFiles(root, query, Math.min(100, Number(limit) || 50)),
},
{
name: 'fs_read', permission: PERMISSIONS.read,
description: 'Read a UTF-8 local text file below the Jarvis workspace, capped at 400 KiB.',
parameters: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] },
execute: async ({ file }) => { const target = path.resolve(cwd, file); if (!target.startsWith(`${path.resolve(cwd)}${path.sep}`)) throw new Error('path is outside the Jarvis workspace'); return (await readFile(target, 'utf8')).slice(0, 400 * 1024); },
},
{
name: 'fs_write', permission: PERMISSIONS.write,
description: 'Write a local text file only after an explicit confirmation flag is supplied.',
parameters: { type: 'object', properties: { file: { type: 'string' }, contents: { type: 'string' }, confirmed: { type: 'boolean' } }, required: ['file', 'contents', 'confirmed'] },
execute: async ({ file, contents, confirmed }) => { if (confirmed !== true) return { confirmation_required: true, action: 'write', file }; const target = path.resolve(cwd, file); if (!target.startsWith(`${path.resolve(cwd)}${path.sep}`)) throw new Error('path is outside the Jarvis workspace'); await writeFile(target, String(contents), 'utf8'); return { ok: true, file: target, bytes: String(contents).length }; },
},
{
name: 'memory_recall', permission: PERMISSIONS.read,
description: 'List local Jarvis memory notes; contents stay on this machine.',
parameters: { type: 'object', properties: {} },
execute: async () => { const dir = path.join(os.homedir(), '.local/share/jarvis/memory'); try { return (await readdir(dir)).slice(0, 200); } catch { return []; } },
},
{
name: 'memory_write', permission: PERMISSIONS.write,
description: 'Write a local Jarvis memory note only after explicit confirmation.',
parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' }, confirmed: { type: 'boolean' } }, required: ['name', 'text', 'confirmed'] },
execute: async ({ name, text, confirmed }) => { if (confirmed !== true) return { confirmation_required: true, action: 'memory_write', name }; const dir = path.join(os.homedir(), '.local/share/jarvis/memory'); const safe = String(name).replace(/[^a-zA-Z0-9._-]/g, '_'); await (await import('node:fs/promises')).mkdir(dir, { recursive: true }); await writeFile(path.join(dir, safe), String(text), 'utf8'); return { ok: true, name: safe }; },
},
{
name: 'rag_workspaces', permission: PERMISSIONS.read,
description: 'List configured local Jarvis RAG workspace directories.',
parameters: { type: 'object', properties: {} },
execute: async () => { const dir = path.join(os.homedir(), '.local/share/jarvis/memory'); try { return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name); } catch { return []; } },
},
{
name: 'capability_status', permission: PERMISSIONS.read,
description: 'Report the truthful local status of every Jarvis capability and the single QVAC master.',
parameters: { type: 'object', properties: {} },
execute: () => ({ qvac: qvacStatus(), capabilities: SKILLS.map(([id, label, permission]) => ({ id, label, permission, status: permission === 'computer-use' && !computer?.status?.().active ? 'grant-required' : 'registered' })) }),
},
];
}
export { PERMISSIONS };
+9
View File
@@ -0,0 +1,9 @@
import { callQvac } from '../daemon/qvac-master.js';
export function createQvacTools() {
return [
{ name: 'qvac_runtime_state', description: 'Read the single QVAC master lifecycle state.', parameters: { type: 'object', properties: {} }, execute: () => callQvac('state') },
{ name: 'qvac_system_resources', description: 'Read QVAC-observed local CPU/GPU resources.', parameters: { type: 'object', properties: {} }, execute: () => callQvac('getSystemResources', { sample: true }) },
{ name: 'qvac_assess_model_fit', description: 'Ask QVAC whether a model is likely to fit before downloading it.', parameters: { type: 'object', properties: { input: { type: 'object' } }, required: ['input'] }, execute: ({ input }) => callQvac('assessModelFit', input) },
];
}
+17
View File
@@ -0,0 +1,17 @@
export const VOICE_SYSTEM_PROMPT = `You are Jarvis, a local Ubuntu GNOME voice assistant running through QVAC.
Use short spoken replies of one to three sentences unless the user asks for detail.
Ground every desktop, file, memory, and model claim in a tool result. Never claim cloud access.
Computer use requires an explicit user grant; never click or type while it is inactive, locked, or revoked.
Destructive actions require confirmation in both the HUD and spoken conversation.
Prefer structured tools and accessibility references over coordinates.
When a useful follow-up action exists, append a HUD sidecar exactly as <jarvis_hud>{"title":"...","chips":[{"id":"...","label":"..."}],"confirmation":null}</jarvis_hud>.
The sidecar is for the HUD and must not be spoken.`;
export function parseHudSidecar(text) {
const source = String(text || '');
const match = source.match(/<jarvis_hud>([\s\S]*?)<\/jarvis_hud>/i);
if (!match) return { spoken: source.trim(), hud: null };
let hud = null;
try { hud = JSON.parse(match[1]); } catch { hud = { error: 'invalid hud sidecar' }; }
return { spoken: source.replace(match[0], '').trim(), hud };
}
+21
View File
@@ -2,6 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createRuntimeTools } from '../skills/runtime-tools.js'; import { createRuntimeTools } from '../skills/runtime-tools.js';
import { assertSdkVersion } from '../daemon/qvac-master.js'; 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';
test('runtime tools expose local QVAC and computer-use status', () => { test('runtime tools expose local QVAC and computer-use status', () => {
const computer = { status: () => ({ active: true, steps_used: 2, backend: 'portal-ei' }) }; const computer = { status: () => ({ active: true, steps_used: 2, backend: 'portal-ei' }) };
@@ -15,3 +18,21 @@ test('runtime tools expose local QVAC and computer-use status', () => {
test('the copied harness uses the pinned QVAC 0.19.x SDK', () => { test('the copied harness uses the pinned QVAC 0.19.x SDK', () => {
assert.match(assertSdkVersion(), /^0\.19\./); assert.match(assertSdkVersion(), /^0\.19\./);
}); });
test('voice sidecars are removed from speech and retained for the HUD', () => {
const parsed = parseHudSidecar('Done. <jarvis_hud>{"title":"Done","chips":[]}</jarvis_hud>');
assert.equal(parsed.spoken, 'Done.');
assert.equal(parsed.hud.title, 'Done');
});
test('phase 2 registers safe local tools with permission metadata', async () => {
const tools = createPhase2Tools({ cwd: process.cwd() });
assert.deepEqual(tools.map((tool) => tool.name), ['app_list', 'fs_search', 'fs_read', 'fs_write', 'memory_recall', 'memory_write', 'rag_workspaces', 'capability_status']);
assert.equal(tools.find((tool) => tool.name === 'fs_search').permission, 'read');
assert.ok((await tools.find((tool) => tool.name === 'fs_search').execute({ query: 'ROADMAP' })).some((x) => x.endsWith('ROADMAP.md')));
assert.deepEqual(await tools.find((tool) => tool.name === 'fs_write').execute({ file: 'nope.txt', contents: 'x', confirmed: false }), { confirmation_required: true, action: 'write', file: 'nope.txt' });
});
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']);
});