124 lines
5.0 KiB
JavaScript
124 lines
5.0 KiB
JavaScript
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 { 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, permissionMode = 'ask', fsAccess } = {}) {
|
|
super();
|
|
const settings = voiceSettings();
|
|
const access = fsAccess ?? settings.fsAccess;
|
|
const assistantName = normalizeAssistantName(settings.assistantName);
|
|
const workspace = cwd || ensureAgentWorkspace({ name: assistantName });
|
|
const roots = harnessRoots(access);
|
|
this.options = {
|
|
cwd: workspace,
|
|
roots,
|
|
model,
|
|
tools: [
|
|
...createRuntimeTools({ computer }),
|
|
...createPhase2Tools({ cwd: workspace, computer, roots: filesystemRoots(access, workspace) }),
|
|
...createComputerObserveTools({ computer, observer }),
|
|
...createComputerActTools({ actuator }),
|
|
...createQvacTools(),
|
|
...createPhase9GatewayTool(),
|
|
...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'],
|
|
webFetch: true,
|
|
permissionMode,
|
|
origin: 'jarvis-qvac',
|
|
system: voiceSystemPrompt(assistantName),
|
|
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;
|
|
}
|
|
|
|
async ask(text) {
|
|
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(); }
|
|
}
|
|
}
|