import { voiceSettings } from './voice-settings.js'; import { EventEmitter } from 'node:events'; import os from 'node:os'; import { acquireQvac, closeQvac, releaseQvac, Agent, QVAC_MASTER } from './qvac-master.js'; import { createRuntimeTools } from '../skills/runtime-tools.js'; import { createPhase2Tools, filesystemRoots } from '../skills/phase2-tools.js'; import { createQvacTools } from '../skills/qvac-tools.js'; import { voiceSystemPrompt, parseHudSidecar } from '../skills/voice-prompt.js'; import { createComputerObserveTools } from '../skills/computer-observe.js'; import { createComputerActTools } from '../skills/computer-act.js'; import { createPhase9GatewayTool } from '../skills/phase9-tools.js'; import { createBrowserTools } from '../skills/browser-tools.js'; import { createWebcamTools } from '../skills/webcam-tools.js'; import { ensureAgentWorkspace, normalizeAssistantName } from './agent-workspace.js'; export function harnessRoots(fsAccess) { if (fsAccess === 'filesystem') return ['/']; if (fsAccess === 'home') return [os.homedir()]; return []; } export class HarnessBridge extends EventEmitter { constructor({ cwd, model = QVAC_MASTER.model, tools = [], computer, observer, actuator, browser, camera, webcam, webcamNormalizer, permissionMode = 'ask', fsAccess } = {}) { super(); const settings = voiceSettings(); const access = fsAccess ?? settings.fsAccess; const assistantName = normalizeAssistantName(settings.assistantName); const workspace = cwd || ensureAgentWorkspace({ name: assistantName, prompt: settings.assistantPrompt }); const roots = harnessRoots(access); this.assistantName = assistantName; this.assistantPrompt = settings.assistantPrompt; this.options = { cwd: workspace, roots, model, tools: [ ...createRuntimeTools({ computer, camera }), ...createPhase2Tools({ cwd: workspace, computer, roots: filesystemRoots(access, workspace) }), ...createComputerObserveTools({ computer, observer }), ...createComputerActTools({ actuator }), ...createQvacTools(), ...createPhase9GatewayTool(), ...createBrowserTools({ browser }), ...createWebcamTools({ camera, capture: webcam, normalizer: webcamNormalizer }), ...tools, ], builtinTools: ['read_file', 'write_file', 'search_replace', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'fetch_page', 'google_search', 'web_search', 'wiki_search', 'hn_search', 'code_search', 'todo_write', 'task', 'update_goal', 'memory_search', 'memory_get', 'memory_write'], webFetch: true, browser, permissionMode, origin: 'jarvis-qvac', system: voiceSystemPrompt(assistantName, settings.assistantPrompt), voice: true, maxTurns: settings.maxTurns, maxShellCalls: settings.maxShellCalls, maxToolRounds: settings.maxToolRounds, }; this.session = null; } async start() { if (this.session) return this.session; if (process.env.JARVIS_QVAC_MODEL && this.options.model !== process.env.JARVIS_QVAC_MODEL) { throw new Error(`Jarvis uses one QVAC master model (${process.env.JARVIS_QVAC_MODEL}); requested ${this.options.model}`); } await acquireQvac(); this._acquired = true; try { this.session = await Agent.create(this.options); } catch (error) { releaseQvac(); this._acquired = false; await closeQvac(); throw error; } for (const event of ['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_result', 'permission', 'ask_user', 'cap-chunk', 'error']) { 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; } setIdentity(name, extra) { this.assistantName = normalizeAssistantName(name); this.assistantPrompt = extra || ''; this.refreshPrompt(); } refreshPrompt() { if (!this.options) return; this.options.system = voiceSystemPrompt(this.assistantName, this.assistantPrompt); } async ask(text) { this.refreshPrompt(); if (!this.session) await this.start(); // Some QVAC runs deliver the final assistant text through the stream but // omit it from the final envelope after a tool call. Keep the current // post-tool stream as a fallback so the daemon can still speak the reply. let streamed = ''; let postTool = ''; let sawTool = false; const onChunk = (payload) => { const chunk = String(payload?.text || payload?.delta || ''); streamed += chunk; if (sawTool) postTool += chunk; }; const onToolCall = () => { sawTool = true; postTool = ''; streamed = ''; }; const chunkSubscription = this.session.on('agent_message_chunk', onChunk); const toolSubscription = this.session.on('tool_call', onToolCall); const offChunk = typeof chunkSubscription === 'function' ? chunkSubscription : () => this.session.off?.('agent_message_chunk', onChunk); const offToolCall = typeof toolSubscription === 'function' ? toolSubscription : () => this.session.off?.('tool_call', onToolCall); try { const reply = await this.session.prompt(text); const spoken = String(reply?.text || '').trim() || postTool.trim() || streamed.trim(); if (reply && spoken && spoken !== String(reply.text || '').trim()) return { ...reply, text: spoken }; return reply; } finally { offChunk?.(); offToolCall?.(); } } cancel() { this.session?.cancel(); } async resetContext() { try { this.session?.cancel?.(); await this.session?.dispose?.(); } finally { this.session = null; if (this._acquired) { releaseQvac(); this._acquired = false; } } } async close() { try { await this.resetContext(); } finally { await closeQvac(); } } }