/** * QVAC engine facade — load models, stream completion, tool loop. * Uses @qvac/sdk when available; otherwise tools-only fallback. */ import { createRequire } from 'module' import { existsSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { getProfile } from './profiles.js' import { buildSystemPrompt } from './prompts.js' import { fallbackComplete } from './tools.js' import { buildRagContext } from './rag.js' import { compactMessages, estimateToolsTokens, isContextOverflowError, promptBudget, } from './context.js' /** Resolve paths without evaluating package main (safe during onboarding). */ function resolveSdkPackagePath() { // eslint-disable-next-line no-undef if (typeof require === 'function') { try { return require.resolve('@qvac/sdk') } catch { // continue } } try { let base try { base = fileURLToPath(import.meta.url) } catch { base = join(process.cwd(), 'package.json') } const req = createRequire(base) return req.resolve('@qvac/sdk') } catch { // fall through to disk probe } const candidates = [ join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'package.json'), join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'dist', 'index.js'), ] try { // eslint-disable-next-line no-undef if (typeof __dirname !== 'undefined') { candidates.push(join(__dirname, '..', 'node_modules', '@qvac', 'sdk', 'package.json')) candidates.push(join(__dirname, '..', '..', 'node_modules', '@qvac', 'sdk', 'package.json')) } } catch { // ignore } try { // Packaged Electron: Resources/app/node_modules // eslint-disable-next-line no-undef if (typeof process !== 'undefined' && process.resourcesPath) { candidates.push( join(process.resourcesPath, 'app', 'node_modules', '@qvac', 'sdk', 'package.json') ) } } catch { // ignore } for (const p of candidates) { if (existsSync(p)) return p } return null } /** * @param {{ * tools: { run: Function, defsForRole: () => any[] }, * getContext: () => object, * getCatalog?: () => Record, * getPrefs?: () => { rag?: boolean, idleUnloadMin?: number }, * log?: (msg: string) => void, * }} deps */ /** * Electron renderer? Never require('@qvac/sdk') here — use main-process IPC. */ function getElectronIpc() { try { // eslint-disable-next-line no-undef if (typeof process !== 'undefined' && process.type === 'renderer') { // eslint-disable-next-line no-undef return typeof require === 'function' ? require('electron').ipcRenderer : null } } catch { // not electron } return null } export function createQvacEngine(deps) { /** @type {any} */ let sdk = null /** @type {'direct'|'main'|null} */ let sdkMode = null let sdkError = null /** @type {string|null} */ let modelId = null /** @type {string|null} */ let profileId = null /** @type {'idle'|'checking'|'downloading'|'loading'|'ready'|'error'|'fallback'} */ let status = 'idle' let lastProgress = null /** @type {AbortController|null} */ let loadAbort = null /** @type {ReturnType|null} */ let idleTimer = null let lastActivity = Date.now() const ipc = getElectronIpc() function touchActivity() { lastActivity = Date.now() scheduleIdleUnload() } function scheduleIdleUnload() { if (idleTimer) clearTimeout(idleTimer) idleTimer = null const mins = Number(deps.getPrefs?.()?.idleUnloadMin) if (!mins || mins <= 0 || !modelId) return idleTimer = setTimeout( () => { if (Date.now() - lastActivity >= mins * 60_000 && modelId) { deps.log?.(`QVAC idle unload after ${mins}m`) unload().catch(() => {}) } }, mins * 60_000 + 500 ) } /** * Lightweight "is the package on disk?" check — never evaluates @qvac/sdk. * @returns {{ installed: boolean, error: string|null }} */ function detectSdkInstalled() { if (sdk || modelId) return { installed: true, error: null } if (ipc) { // Sync detect via invoke is async; use path resolve for UI, main confirms on load const path = resolveSdkPackagePath() if (path) return { installed: true, error: null } // Packaged app may resolve from Resources/app — still try disk return { installed: Boolean(resolveSdkPackagePath()), error: null } } const path = resolveSdkPackagePath() if (path) return { installed: true, error: null } return { installed: false, error: '@qvac/sdk not installed in this app' } } /** * Ensure we can talk to QVAC (main IPC in Electron, direct require only outside renderer). * @param {number} [timeoutMs] */ async function tryLoadSdk(timeoutMs = 8_000) { if (sdk || (sdkMode === 'main' && modelId)) { return sdk || { __viaMain: true } } if (ipc) { try { const det = await ipc.invoke('peardata:qvac-detect') if (!det?.installed) { sdkError = det?.error || '@qvac/sdk not installed' return null } sdkMode = 'main' sdk = { __viaMain: true } sdkError = null deps.log?.('QVAC: using main-process SDK host (safe for Electron UI)') return sdk } catch (err) { sdkError = err?.message || String(err) return null } } // Non-Electron (e.g. pear / node): load SDK in-process try { const load = (async () => { try { // eslint-disable-next-line no-undef if (typeof require === 'function') return require('@qvac/sdk') } catch { // fall through } const { createRequire: cr } = await import('module') const req = cr( typeof __filename !== 'undefined' ? __filename : `${process.cwd()}/package.json` ) return req('@qvac/sdk') })() const timed = timeoutMs > 0 ? Promise.race([ load, new Promise((_, reject) => { setTimeout( () => reject(new Error(`@qvac/sdk import timed out after ${timeoutMs}ms`)), timeoutMs ) }), ]) : load sdk = await timed sdkMode = 'direct' sdkError = null return sdk } catch (err) { sdkError = err?.message || String(err) sdk = null sdkMode = null return null } } function getStatus() { const disk = detectSdkInstalled() const loaded = Boolean(modelId) && (sdkMode === 'main' || Boolean(sdk)) return { status, modelId, profileId, sdkAvailable: loaded || disk.installed || sdkMode === 'main', sdkLoaded: loaded, sdkMode, sdkError: sdkError || (!disk.installed && !loaded ? disk.error : null), progress: lastProgress, mode: loaded && status === 'ready' ? 'qvac' : status === 'fallback' || !loaded ? 'fallback' : status, lastActivity, } } function hostPlatformInfo() { const mem = typeof performance !== 'undefined' && performance.memory ? performance.memory.jsHeapSizeLimit : null let totalRamBytes = null let freeRamBytes = null try { // eslint-disable-next-line no-undef const osMod = typeof require === 'function' ? require('os') : null if (osMod) { totalRamBytes = osMod.totalmem?.() freeRamBytes = osMod.freemem?.() } } catch { // ESM-only } return { totalRamBytes: totalRamBytes ?? mem, freeRamBytes, platform: typeof process !== 'undefined' ? process.platform : 'unknown', arch: typeof process !== 'undefined' ? process.arch : 'unknown', } } /** * Lightweight host check for onboarding. * Default: NEVER import @qvac/sdk (probeSdk must be explicitly true). * @param {{ probeSdk?: boolean, timeoutMs?: number }} [opts] */ async function checkEnvironment(opts = {}) { status = 'checking' const host = hostPlatformInfo() // Optional async os.totalmem for pure ESM only when sync path missed if (host.totalRamBytes == null) { try { const os = await Promise.race([ import('os'), new Promise((_, rej) => setTimeout(() => rej(new Error('os import timeout')), 1500)), ]) host.totalRamBytes = os.totalmem?.() ?? host.totalRamBytes host.freeRamBytes = os.freemem?.() ?? host.freeRamBytes } catch { // keep nulls } } let sdkAvailable = Boolean(sdk) let err = sdkError if (opts.probeSdk === true && !sdk) { // Explicit full load — only for model download path, not Continue const s = await tryLoadSdk(opts.timeoutMs ?? 8000) sdkAvailable = Boolean(s) err = sdkError } else { const disk = detectSdkInstalled() sdkAvailable = disk.installed || Boolean(sdk) if (!disk.installed && !sdk) err = disk.error } status = 'idle' return { sdkAvailable, sdkError: err, totalRamBytes: host.totalRamBytes, freeRamBytes: host.freeRamBytes, resources: null, platform: host.platform, arch: host.arch, } } /** * @param {string} profile * @param {{ onProgress?: (p: any) => void }} [opts] */ async function loadProfile(profile, opts = {}) { const p = getProfile(profile) profileId = p.id touchActivity() status = 'downloading' lastProgress = { percentage: 0 } // Electron: main-process host (never require SDK in renderer) if (ipc) { /** @type {(( _e: any, prog: any) => void)|null} */ let onProg = null try { const bridge = await tryLoadSdk() if (!bridge) { status = 'fallback' return { ok: true, mode: 'fallback', profile: p.id, error: sdkError } } onProg = (_e, prog) => { lastProgress = prog status = prog?.percentage >= 100 ? 'loading' : 'downloading' opts.onProgress?.(prog) } ipc.on('peardata:qvac-progress', onProg) const result = await ipc.invoke('peardata:qvac-load', { chatModel: p.chatModel, tools: p.tools, ctxSize: p.ctxSize || 8192, }) if (!result?.ok) { status = 'fallback' const msg = result?.error || 'load failed' sdkError = msg deps.log?.(`QVAC main load failed: ${msg}`) return { ok: false, error: msg, mode: 'fallback', profile: p.id } } modelId = result.modelId status = 'ready' touchActivity() deps.log?.(`QVAC model ready (main): ${p.chatModel} → ${modelId}`) return { ok: true, mode: 'qvac', modelId, profile: p.id } } catch (err) { const msg = err?.message || String(err) status = 'fallback' sdkError = msg deps.log?.(`QVAC load failed: ${msg}`) return { ok: false, error: msg, mode: 'fallback', profile: p.id } } finally { if (onProg) { try { ipc.removeListener('peardata:qvac-progress', onProg) } catch { // ignore } } } } const s = await tryLoadSdk() if (!s || s.__viaMain) { status = 'fallback' deps.log?.(`QVAC SDK unavailable (${sdkError}); using tools-only fallback`) return { ok: true, mode: 'fallback', profile: p.id } } const modelSrc = s[p.chatModel] || p.chatModel loadAbort = new AbortController() try { if (modelId) await unload() const id = await s.loadModel({ modelSrc, modelType: 'llm', modelConfig: { tools: p.tools, ctx_size: p.ctxSize || 8192, }, onProgress: (prog) => { lastProgress = prog status = prog?.percentage >= 100 ? 'loading' : 'downloading' opts.onProgress?.(prog) }, }) modelId = id status = 'ready' touchActivity() deps.log?.(`QVAC model ready: ${p.chatModel} → ${id}`) return { ok: true, mode: 'qvac', modelId: id, profile: p.id } } catch (err) { status = 'error' const msg = err?.message || String(err) deps.log?.(`QVAC load failed: ${msg}`) status = 'fallback' return { ok: false, error: msg, mode: 'fallback', profile: p.id } } finally { loadAbort = null } } async function unload() { if (idleTimer) { clearTimeout(idleTimer) idleTimer = null } if (ipc && sdkMode === 'main') { try { await ipc.invoke('peardata:qvac-unload') } catch { // ignore } } else if (sdk && modelId && sdk.unloadModel) { try { await sdk.unloadModel({ modelId }) } catch { // ignore } } modelId = null if (status === 'ready' || status === 'loading' || status === 'downloading') status = 'idle' } /** * @param {Array<{ role: string, content: string }>} history * @param {{ * onToken?: (t: string) => void, * onEvent?: (e: any) => void, * onTool?: (name: string, args: object, result: any) => void, * }} [opts] */ async function complete(history, opts = {}) { touchActivity() const profile = getProfile(profileId || 'recommended') const prefs = deps.getPrefs?.() || {} const userLast = [...history].reverse().find((m) => m.role === 'user') const ctx = { ...(deps.getContext?.() || {}), subAgents: prefs.subAgents !== false, } let system = buildSystemPrompt(ctx) // Light RAG only — full catalog dumps blow small context windows if (prefs.rag !== false && userLast?.content) { const rag = buildRagContext({ query: userLast.content, catalog: deps.getCatalog?.() || {}, topK: 3, }) if (rag) system += `\n\n${rag.slice(0, 1200)}` } // Multi-agent briefing from parallel specialists (optional) if (opts.agentBriefing) { system += `\n\n${String(opts.agentBriefing).slice(0, 5000)}` } const fullHistory = [ { role: 'system', content: system }, ...history.filter((m) => m.role !== 'system'), ] if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) { return completeWithSdk(fullHistory, profile, opts, prefs) } status = status === 'ready' ? status : 'fallback' // If multi-agent pack already ran, prefer its synthesis over naive fallback if (opts.agentFallbackText) { const text = String(opts.agentFallbackText) if (opts.onToken) { const chunk = 24 for (let i = 0; i < text.length; i += chunk) opts.onToken(text.slice(i, i + chunk)) } opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' }) return { contentText: text, toolCalls: opts.agentToolCalls || [], mode: 'fallback' } } const result = await fallbackComplete(userLast?.content || '', deps.tools, { catalog: deps.getCatalog?.() || {}, rag: prefs.rag !== false, }) for (const c of result.toolCalls || []) { opts.onTool?.(c.name, c.args, c.result) } if (opts.onToken) { const text = result.contentText || '' const chunk = 24 for (let i = 0; i < text.length; i += chunk) { opts.onToken(text.slice(i, i + chunk)) } } opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' }) return result } async function completeWithSdk(history, profile, opts, prefs = {}) { const depthPref = prefs.toolDepth || 'auto' const depth = depthPref === 'core' || depthPref === 'deep' ? depthPref : undefined let toolDefs = profile.tools ? deps.tools.defsForRole({ profileId: profile.id, profile, depth }) : undefined const ctxSize = profile.ctxSize || 8192 let toolsTok = estimateToolsTokens(toolDefs) let budget = promptBudget(ctxSize, toolsTok) let overflowRetries = 0 let messages = compactMessages( history.map((m) => ({ role: m.role, content: m.content, name: m.name })), { maxTokens: budget, keepRecentUserTurns: 3, maxToolChars: 2000 } ) for (let round = 0; round < 4; round++) { touchActivity() // Re-compact each tool round (payloads grow fast) messages = compactMessages(messages, { maxTokens: budget, keepRecentUserTurns: round === 0 ? 3 : 2, maxToolChars: round === 0 ? 2000 : 1000, maxMsgChars: round === 0 ? 3000 : 1600, }) // Electron main-process completion (streaming via IPC events) if (sdkMode === 'main' && ipc) { /** @type {(( _e: any, t: string) => void)|null} */ let onTok = null /** @type {(( _e: any, t: string) => void)|null} */ let onThink = null try { onTok = (_e, t) => opts.onToken?.(t) onThink = (_e, t) => opts.onThinking?.(t) ipc.on('peardata:qvac-token', onTok) ipc.on('peardata:qvac-thinking', onThink) const final = await ipc.invoke('peardata:qvac-complete', { history: messages, tools: toolDefs, }) if (final?.error && !final.contentText) { if (isContextOverflowError(final.error) && overflowRetries < 3) { overflowRetries++ budget = Math.floor(budget * 0.55) if (overflowRetries === 1 && toolDefs?.length) { // First overflow: drop deep tools, keep core toolDefs = deps.tools.defsForRole({ profileId: profile.id, depth: 'core', }) toolsTok = estimateToolsTokens(toolDefs) budget = promptBudget(ctxSize, toolsTok) } else if (overflowRetries >= 2) { // Drop tools schema on last retries — frees a lot of context toolDefs = undefined toolsTok = 0 budget = promptBudget(ctxSize, 0) } messages = compactMessages(messages, { maxTokens: Math.max(600, budget), keepRecentUserTurns: 1, maxToolChars: 500, maxMsgChars: 900, }) deps.log?.( `QVAC context overflow — compact #${overflowRetries} budget~${budget}` ) opts.onToken?.( '\n_[Context compacted to fit the model window — retrying…]_\n' ) round = Math.max(-1, round - 1) // retry this tool-loop round continue } return { contentText: `Model error: ${final.error}`, toolCalls: [], mode: 'fallback', } } const calls = (final.toolCalls || []).filter(Boolean) // Prefer raw content (may include ); UI partitions it let text = final.contentText || '' if (final.thinkingText && !/\n${final.thinkingText}\n\n${text}` } if (!calls.length) { return { contentText: text, toolCalls: [], mode: 'qvac', stats: final.stats, } } messages = [...messages, { role: 'assistant', content: text }] for (const tc of calls) { const name = tc.name || tc.function?.name let args = tc.arguments || tc.function?.arguments || {} if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } } const result = await deps.tools.run(name, args) opts.onTool?.(name, args, result) // Compact tool results for model context (keep numbers, drop noise) const payload = compactToolResult(name, result) messages.push({ role: 'tool', content: payload, name, }) } continue } finally { if (onTok) { try { ipc.removeListener('peardata:qvac-token', onTok) } catch { // ignore } } if (onThink) { try { ipc.removeListener('peardata:qvac-thinking', onThink) } catch { // ignore } } } } const run = sdk.completion({ modelId, history: messages, stream: true, tools: toolDefs, captureThinking: true, }) let content = '' /** @type {Array<{ name: string, arguments?: any, id?: string }>} */ const toolCalls = [] if (run.events) { for await (const ev of run.events) { opts.onEvent?.(ev) if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) { opts.onThinking?.(ev.text || ev.delta) } else if (ev.type === 'contentDelta' && ev.text) { content += ev.text opts.onToken?.(ev.text) } else if (ev.type === 'toolCall') { const call = ev.call || ev.toolCall || ev toolCalls.push({ name: call.name || ev.name, arguments: call.arguments || ev.arguments || {}, id: call.id || ev.id, }) } } } else if (run.tokenStream) { for await (const token of run.tokenStream) { content += token opts.onToken?.(token) } } const final = run.final ? await run.final : { contentText: content, toolCalls } const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean) if (!calls.length) { return { contentText: final.contentText || content, toolCalls: [], mode: 'qvac', stats: final.stats, } } messages = [...messages, { role: 'assistant', content: final.contentText || content || '' }] for (const tc of calls) { const name = tc.name || tc.function?.name let args = tc.arguments || tc.function?.arguments || {} if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } } const result = await deps.tools.run(name, args) opts.onTool?.(name, args, result) messages.push({ role: 'tool', content: compactToolResult(name, result), name, }) } } return { contentText: 'Tool loop limit reached. Try a more specific question.', toolCalls: [], mode: 'qvac', } } return { checkEnvironment, loadProfile, unload, complete, getStatus, tryLoadSdk, touchActivity, } } /** * Keep tool JSON small enough for small local models while preserving KPIs. * @param {string} name * @param {any} result */ function compactToolResult(name, result) { try { if (result == null) return 'null' if (typeof result === 'string') return result.slice(0, 8_000) if (result.error) return JSON.stringify({ error: result.error }).slice(0, 4_000) if (name === 'investigate_host' && typeof result === 'object') { const slim = { hostname: result.hostname, summary: result.summary, findings: (result.findings || []).slice(0, 16), kpis: result.kpis, health: result.health, hot: (result.hot || []).slice(0, 10), processes: result.processes?.top ? { supported: result.processes.supported, top: (result.processes.top || []).slice(0, 10), } : result.processes, catalog: result.catalog, } return JSON.stringify(slim).slice(0, 12_000) } if (name === 'host_snapshot' && typeof result === 'object') { const slim = { hostname: result.hostname, health: result.health, kpis: result.kpis, anomalies: (result.anomalies || []).slice(0, 8), alerts: (result.alerts || []).slice(0, 8), catalog: result.catalog, cores: result.cores, totalRamBytes: result.totalRamBytes, freeRamBytes: result.freeRamBytes, } return JSON.stringify(slim).slice(0, 10_000) } if ( (name === 'summarize_chart' || name === 'compare_chart_windows') && typeof result === 'object' ) { return JSON.stringify(result).slice(0, 8_000) } if (name === 'summarize_charts' && result.results) { return JSON.stringify({ after: result.after, count: result.count, results: (result.results || []).slice(0, 12), }).slice(0, 10_000) } if (name === 'hot_metrics' && result.results) { return JSON.stringify({ window: result.window, count: result.count, results: (result.results || []).slice(0, 15), }).slice(0, 8_000) } if (name === 'related_charts' && result.results) { return JSON.stringify({ chart: result.chart, count: result.count, results: (result.results || []).slice(0, 12), }).slice(0, 6_000) } if (name === 'list_processes' && result.processes) { return JSON.stringify({ processes: (result.processes || []).slice(0, 12).map((p) => ({ pid: p.pid, name: p.name || p.comm, cpu: p.cpu, rss: p.rss, })), }).slice(0, 6_000) } if (name === 'query_metric' && typeof result === 'object') { // Raw series can be huge — keep labels + last N rows const data = Array.isArray(result.data) ? result.data.slice(-40) : result.data return JSON.stringify({ labels: result.labels, points: data?.length, data, error: result.error, }).slice(0, 8_000) } return JSON.stringify(result).slice(0, 10_000) } catch { return String(result).slice(0, 4_000) } }