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
+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 };
}