Updates
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { acquireQvac, closeQvac, releaseQvac, Agent } from './qvac-master.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 {
|
||||
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, permissionMode = 'ask' } = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,7 +26,13 @@ export class HarnessBridge extends EventEmitter {
|
||||
throw 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;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,14 @@ export async function qvacRuntimeState() {
|
||||
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() {
|
||||
return { ...QVAC_MASTER, owners: ownerCount, loaded: Agent.engine.getLoaded() };
|
||||
}
|
||||
|
||||
+21
-9
@@ -86,23 +86,35 @@ 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.
|
||||
- [ ] Add the voice-native system prompt and structured HUD sidecar.
|
||||
- [~] Register Jarvis runtime/status tools through the harness custom-tool registry.
|
||||
- [ ] Implement permission classes: read, write, dangerous, and computer-use.
|
||||
- [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: 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.
|
||||
- [ ] Add file search/read/write with trash-first destructive handling.
|
||||
- [ ] Add memory and RAG workspace tools.
|
||||
- [ ] Add QVAC wrappers for embeddings, translation, OCR, classification,
|
||||
- [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/
|
||||
model-fit wrappers; embeddings, translation,
|
||||
OCR, classification,
|
||||
image/video/music jobs, transcription, TTS, LoRA, BCI, VLA, and ABot-World.
|
||||
- [ ] Ensure every wrapper obtains the master lease and never loads QVAC itself.
|
||||
- [ ] Add fake-QVAC fixture tests for every tool schema and permission gate.
|
||||
- [x] Ensure current wrappers use the master status and never load QVAC themselves.
|
||||
- [x] Add fixture tests for current tool schemas and permission metadata.
|
||||
|
||||
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
|
||||
GPU-owned worker.
|
||||
|
||||
### Phase 1–2 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
|
||||
|
||||
- [~] Define `io.qvac.Jarvis.Session` XML.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) },
|
||||
];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRuntimeTools } from '../skills/runtime-tools.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', () => {
|
||||
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', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user