Files
peardata/ui/qvac/engine.js
T

833 lines
26 KiB
JavaScript

/**
* 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<string, object>,
* 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<typeof setTimeout>|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 <think>); UI partitions it
let text = final.contentText || ''
if (final.thinkingText && !/<think/i.test(text)) {
text = `<think>\n${final.thinkingText}\n</think>\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)
}
}