FIX: QVAC Updates
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s

This commit is contained in:
Raven Scott
2026-07-30 15:23:43 -04:00
parent 79250e1a55
commit 4b5b78892f
25 changed files with 2886 additions and 345 deletions
+33 -2
View File
@@ -74,6 +74,24 @@ PEARDATA_SKIP_QVAC=1 npm run make:client:linux-x64
Produces a **tools-only** client (no LLM natives). QVAC tab still works via RPC tools. Produces a **tools-only** client (no LLM natives). QVAC tab still works via RPC tools.
### Bare runtime binary
QVAC spawns a **Bare** worker via `bare-runtime` + a platform package such as `bare-runtime-darwin-arm64`.
These ship as **production dependencies** and are **not** stripped by forge for the package target host. If you see:
```text
Could not load the Bare runtime binary for darwin-arm64
… bare-runtime-darwin-arm64 … missing
```
reinstall deps and rebuild the client:
```bash
npm ci
npm run make:client:darwin-arm64
```
### Native host matrix (LLM prebuilds) ### Native host matrix (LLM prebuilds)
`@qvac/llm-llamacpp` ships Bare prebuilds for: `@qvac/llm-llamacpp` ships Bare prebuilds for:
@@ -143,14 +161,27 @@ Without the SDK (or if load fails), the tab stays in **tools-only** mode: live a
| Method | Purpose | | Method | Purpose |
|--------|---------| |--------|---------|
| `getHostSnapshot` | Health + KPIs + anomalies/alerts + catalog/storage summary | | `getHostSnapshot` | Health + KPIs + anomalies/alerts + catalog/storage summary |
| `investigateHost` | One-shot diagnosis pack: findings, hot charts, top processes |
| `hotMetrics` | Charts with strongest recent change / anomaly signal |
| `relatedCharts` | Related charts for a seed id (catalog + weights) |
| `compareChartWindows` | Baseline vs highlight window comparison on one chart |
| `summarizeCharts` | Batch summarize up to 12 charts |
| `searchCharts` | `{ q, limit }` catalog search | | `searchCharts` | `{ q, limit }` catalog search |
| `summarizeChart` | `{ chart, after?, points? }` min/avg/max/last per dim | | `summarizeChart` | `{ chart, after?, points? }` min/avg/max/last per dim |
REST: `GET /api/v3/ai/snapshot`, `/api/v3/ai/charts`, `/api/v3/ai/chart/:id/summary`. REST: `GET /api/v3/ai/snapshot`, `/api/v3/ai/investigate`, `/api/v3/ai/hot`, `/api/v3/ai/related`, `/api/v3/ai/compare`, `/api/v3/ai/summarize`, `/api/v3/ai/charts`, `/api/v3/ai/chart/:id/summary`.
## Tools the chat can call ## Tools the chat can call
`host_snapshot`, `search_charts`, `summarize_chart`, `query_metric`, `list_anomalies`, `list_alerts`, `list_processes`, `query_logs`, `fleet_health`, `storage_info`, `local_knowledge`, `open_chart`, `open_view`, `silence_alert` (operator + confirm). Tools are **tiered** so small models stay within context:
| Tier | When | Tools |
|------|------|-------|
| **core** | All tool-enabled profiles | `investigate_host`, `host_snapshot`, `search_charts`, `summarize_chart`, `list_anomalies`, `list_alerts`, `list_processes`, `local_knowledge`, `open_chart`, `open_view` |
| **deep** | Recommended / Strong | `hot_metrics`, `related_charts`, `compare_chart_windows`, `summarize_charts`, `query_metric`, `get_weights`, `query_logs`, `fleet_health`, `list_child_peers`, `storage_info`, `agent_health`, `node_info`, `db_info`, `list_contexts`, `get_chart`, `list_jobs`, `get_alert` |
| **write** | Operator role + confirm | `silence_alert`, `ack_alert`, `run_job` |
On context overflow the engine first drops to **core** tools, then drops tool schemas entirely and retries.
## Settings ## Settings
+10 -1
View File
@@ -221,7 +221,6 @@ function configureQvacWorkerEnv() {
} }
function createWindow() { function createWindow() {
configureQvacWorkerEnv()
const icon = resolveAppIcon() const icon = resolveAppIcon()
const win = new BrowserWindow({ const win = new BrowserWindow({
width: pkg.pear?.gui?.width || 1280, width: pkg.pear?.gui?.width || 1280,
@@ -268,6 +267,14 @@ ipcMain.handle('peardata:exit', () => {
app.quit() app.quit()
}) })
// QVAC runs in main — never load @qvac/sdk in the renderer (blanks the UI)
try {
require('./qvac-service.cjs').registerIpc(ipcMain)
console.log('[peardata] QVAC main-process service registered')
} catch (err) {
console.warn('[peardata] QVAC service unavailable:', err?.message || err)
}
function windowFromEvent(evt) { function windowFromEvent(evt) {
try { try {
return BrowserWindow.fromWebContents(evt.sender) return BrowserWindow.fromWebContents(evt.sender)
@@ -308,6 +315,8 @@ app.whenReady().then(async () => {
ensureDir(storageDir()) ensureDir(storageDir())
const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..') const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
// Worker path for Bare spawn (main process) before any QVAC IPC
configureQvacWorkerEnv()
const dockIcon = resolveAppIcon() const dockIcon = resolveAppIcon()
if (dockIcon && process.platform === 'darwin' && app.dock) { if (dockIcon && process.platform === 'darwin' && app.dock) {
+310
View File
@@ -0,0 +1,310 @@
/**
* QVAC SDK host for the Electron **main** process.
*
* Loading @qvac/sdk (and Bare natives) in the renderer freezes/blanks the UI.
* All loadModel / completion / unload traffic goes through IPC from the GUI.
*/
'use strict'
const path = require('path')
const fs = require('fs')
/** @type {any} */
let sdk = null
/** @type {Promise<any>|null} */
let sdkLoadPromise = null
/** @type {string|null} */
let modelId = null
/** @type {string|null} */
let loadedChatModel = null
/** @type {Error|string|null} */
let lastError = null
function appRoot() {
try {
const { app } = require('electron')
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
} catch {
return path.resolve(__dirname, '..')
}
}
function detectInstalled() {
try {
// resolve only — @qvac/sdk is ESM, do not require()
const resolved = require.resolve('@qvac/sdk', { paths: [appRoot(), __dirname] })
return { installed: true, path: resolved, error: null }
} catch (err) {
const pkg = path.join(appRoot(), 'node_modules', '@qvac', 'sdk', 'package.json')
if (fs.existsSync(pkg)) return { installed: true, path: pkg, error: null }
return { installed: false, path: null, error: err?.message || String(err) }
}
}
/**
* @qvac/sdk is pure ESM — use dynamic import() from this CJS host.
* @returns {Promise<any>}
*/
async function ensureSdk() {
if (sdk) return sdk
if (sdkLoadPromise) return sdkLoadPromise
sdkLoadPromise = (async () => {
const det = detectInstalled()
if (!det.installed) {
const e = new Error(det.error || '@qvac/sdk not installed')
lastError = e
throw e
}
// Prefer package name so Node/Electron resolves exports (import condition).
// Fall back to file URL if bare specifier fails in packaged layout.
let mod
try {
mod = await import('@qvac/sdk')
} catch (nameErr) {
try {
const resolved =
det.path && det.path.endsWith('.js')
? det.path
: require.resolve('@qvac/sdk', {
paths: [appRoot(), process.cwd(), __dirname],
})
const href = path.isAbsolute(resolved)
? require('url').pathToFileURL(resolved).href
: resolved
mod = await import(href)
} catch (fileErr) {
const msg = [nameErr?.message, fileErr?.message].filter(Boolean).join(' | ')
lastError = new Error(msg)
throw lastError
}
}
// Support both namespace and default exports
sdk = mod?.default && typeof mod.default === 'object' ? { ...mod, ...mod.default } : mod
lastError = null
return sdk
})()
try {
return await sdkLoadPromise
} catch (err) {
sdkLoadPromise = null
throw err
}
}
/**
* @param {{ chatModel: string, tools?: boolean, ctxSize?: number }} opts
* @param {(p: any) => void} [onProgress]
*/
async function loadModel(opts, onProgress) {
const s = await ensureSdk()
const chatModel = String(opts.chatModel || 'QWEN3_1_7B_INST_Q4')
const modelSrc = s[chatModel] || chatModel
const ctxSize = Math.max(2048, Math.min(32768, Number(opts.ctxSize) || 8192))
if (modelId && s.unloadModel) {
try {
await s.unloadModel({ modelId })
} catch {
// ignore
}
modelId = null
}
const id = await s.loadModel({
modelSrc,
modelType: 'llm',
modelConfig: {
tools: Boolean(opts.tools),
ctx_size: ctxSize,
},
onProgress: (prog) => {
try {
onProgress?.(prog)
} catch {
// ignore UI callback errors
}
},
})
modelId = id
loadedChatModel = chatModel
lastError = null
return { ok: true, modelId: id, chatModel, ctxSize }
}
async function unloadModel() {
if (sdk && modelId && sdk.unloadModel) {
try {
await sdk.unloadModel({ modelId })
} catch {
// ignore
}
}
modelId = null
loadedChatModel = null
return { ok: true }
}
/**
* One completion round (tool loop stays in the renderer).
* @param {{ history: any[], tools?: any[] }} opts
* @param {(token: string) => void} [onToken]
*/
/**
* @param {{ history: any[], tools?: any[] }} opts
* @param {(token: string) => void} [onToken]
* @param {(token: string) => void} [onThinking]
*/
async function complete(opts, onToken, onThinking) {
const s = await ensureSdk()
if (!modelId) throw new Error('No model loaded')
const run = s.completion({
modelId,
history: opts.history || [],
stream: true,
tools: opts.tools,
// Best-effort: emit thinkingDelta when model uses <think> blocks
captureThinking: true,
})
let content = ''
let thinking = ''
/** @type {any[]} */
const toolCalls = []
if (run.events) {
for await (const ev of run.events) {
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
const t = ev.text || ev.delta || ''
thinking += t
onThinking?.(t)
} else if (ev.type === 'contentDelta' && ev.text) {
content += ev.text
onToken?.(ev.text)
} else if (ev.type === 'toolCall') {
// Prefer structured call; also support nested 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 (ev.type === 'toolCallError') {
// surface as content note; tool loop may continue
const msg = ev.error?.message || 'tool call error'
content += `\n[tool error: ${msg}]\n`
}
}
} else if (run.tokenStream) {
for await (const token of run.tokenStream) {
content += token
onToken?.(token)
}
}
const final = run.final ? await run.final : { contentText: content, toolCalls }
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
// Prefer full text that may still include think tags if captureThinking missed
const contentText = final.contentText || final.raw?.fullText || content
const thinkingText =
thinking ||
final.thinking ||
final.raw?.thinking ||
''
return {
contentText,
thinkingText,
toolCalls: calls,
stats: final.stats,
mode: 'qvac',
}
}
function getStatus() {
const det = detectInstalled()
return {
installed: det.installed,
sdkError: lastError ? String(lastError.message || lastError) : det.error,
modelId,
loadedChatModel,
ready: Boolean(modelId),
}
}
/**
* @param {import('electron').IpcMain} ipcMain
*/
function registerIpc(ipcMain) {
ipcMain.handle('peardata:qvac-detect', async () => detectInstalled())
ipcMain.handle('peardata:qvac-status', async () => getStatus())
ipcMain.handle('peardata:qvac-load', async (evt, args) => {
try {
const result = await loadModel(args || {}, (prog) => {
try {
evt.sender.send('peardata:qvac-progress', prog)
} catch {
// window gone
}
})
return result
} catch (err) {
lastError = err
return { ok: false, error: err?.message || String(err), mode: 'fallback' }
}
})
ipcMain.handle('peardata:qvac-unload', async () => {
try {
return await unloadModel()
} catch (err) {
return { ok: false, error: err?.message || String(err) }
}
})
ipcMain.handle('peardata:qvac-complete', async (evt, args) => {
try {
return await complete(
args || {},
(token) => {
try {
evt.sender.send('peardata:qvac-token', token)
} catch {
// ignore
}
},
(tok) => {
try {
evt.sender.send('peardata:qvac-thinking', tok)
} catch {
// ignore
}
}
)
} catch (err) {
lastError = err
return {
contentText: '',
thinkingText: '',
toolCalls: [],
error: err?.message || String(err),
mode: 'error',
}
}
})
}
module.exports = {
detectInstalled,
ensureSdk,
loadModel,
unloadModel,
complete,
getStatus,
registerIpc,
}
+55 -1
View File
@@ -139,7 +139,7 @@ const IGNORE_PREFIXES = [
const IGNORE_REGEX = [ const IGNORE_REGEX = [
/^\/node_modules\/bare-build-/, /^\/node_modules\/bare-build-/,
/^\/node_modules\/bare-runtime-/, // bare-runtime-<host> handled specially below (QVAC needs target host binary)
/^\/node_modules\/bare-pack-/, /^\/node_modules\/bare-pack-/,
/^\/node_modules\/@esbuild\//, /^\/node_modules\/@esbuild\//,
/\.md$/i, /\.md$/i,
@@ -156,6 +156,20 @@ const IGNORE_REGEX = [
/^\/node_modules\/[^/]+\/\.github\//, /^\/node_modules\/[^/]+\/\.github\//,
] ]
/**
* bare-runtime-darwin-arm64 etc. — keep only the package for the forge target host when QVAC is on.
* @param {string} file
*/
function isStrippedBareRuntimePlatform(file) {
if (!file.startsWith('/node_modules/bare-runtime-')) return false
// e.g. /node_modules/bare-runtime-darwin-arm64/...
const seg = file.split('/')[2] || ''
if (!seg.startsWith('bare-runtime-')) return false
if (!qvacEnabled) return true
const need = `bare-runtime-${packageHost}`
return seg !== need
}
function isForeignPrebuild(file) { function isForeignPrebuild(file) {
const marker = '/prebuilds/' const marker = '/prebuilds/'
const idx = file.indexOf(marker) const idx = file.indexOf(marker)
@@ -188,6 +202,15 @@ function shouldIgnore(file) {
if (isForeignPrebuild(file)) return true if (isForeignPrebuild(file)) return true
return false return false
} }
// Keep bare-runtime + bare-runtime-<targetHost> for QVAC Bare worker spawn
if (qvacEnabled) {
if (file === '/node_modules/bare-runtime' || file.startsWith('/node_modules/bare-runtime/')) {
return false
}
const need = `/node_modules/bare-runtime-${packageHost}`
if (file === need || file.startsWith(need + '/')) return false
}
if (isStrippedBareRuntimePlatform(file)) return true
for (const p of IGNORE_PREFIXES) { for (const p of IGNORE_PREFIXES) {
if (file === p || file.startsWith(p + '/') || file.startsWith(p)) return true if (file === p || file.startsWith(p + '/') || file.startsWith(p)) return true
} }
@@ -379,6 +402,37 @@ module.exports = {
console.log( console.log(
`[forge] rebuild: ${skipRebuild ? 'skip (onlyModules:[])' : 'enabled'}` `[forge] rebuild: ${skipRebuild ? 'skip (onlyModules:[])' : 'enabled'}`
) )
// QVAC Bare worker needs bare-runtime + platform binary in the app package
if (qvacEnabled) {
const platPkg = `bare-runtime-${packageHost}`
const platDir = path.join(__dirname, 'node_modules', platPkg)
if (!fs.existsSync(platDir)) {
let ver = '1.30.3'
try {
ver = require('bare-runtime/package.json').version
} catch {
// default
}
console.log(`[forge] installing ${platPkg}@${ver} for QVAC worker…`)
// --force: allow cross-host installs (linux CI packaging darwin/win32)
require('child_process').execFileSync(
process.platform === 'win32' ? 'npm.cmd' : 'npm',
[
'install',
`${platPkg}@${ver}`,
'--no-save',
'--no-audit',
'--no-fund',
'--force',
],
{ stdio: 'inherit', cwd: __dirname }
)
} else {
console.log(`[forge] QVAC bare platform runtime present: ${platPkg}`)
}
}
if (process.env.PEARDATA_SKIP_PREPACKAGE_BUNDLE === '1') { if (process.env.PEARDATA_SKIP_PREPACKAGE_BUNDLE === '1') {
const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs') const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs')
if (fs.existsSync(bundle)) { if (fs.existsSync(bundle)) {
+8 -1
View File
@@ -32,6 +32,7 @@
"bare-performance": "^2.0.0", "bare-performance": "^2.0.0",
"bare-process": "^4.5.1", "bare-process": "^4.5.1",
"bare-querystring": "^1.0.0", "bare-querystring": "^1.0.0",
"bare-runtime": "^1.30.3",
"bare-stream": "^2.7.0", "bare-stream": "^2.7.0",
"bare-string-decoder": "^1.0.0", "bare-string-decoder": "^1.0.0",
"bare-subprocess": "^5.2.3", "bare-subprocess": "^5.2.3",
@@ -69,7 +70,6 @@
"@electron-forge/plugin-base": "^7.11.2", "@electron-forge/plugin-base": "^7.11.2",
"@electron/get": "^3.1.0", "@electron/get": "^3.1.0",
"bare-build": "^1.0.2", "bare-build": "^1.0.2",
"bare-runtime": "1.30.3",
"brittle": "^4.1.0", "brittle": "^4.1.0",
"electron": "^33.4.11", "electron": "^33.4.11",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
@@ -77,6 +77,13 @@
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"optionalDependencies": {
"bare-runtime-darwin-arm64": "^1.30.3",
"bare-runtime-darwin-x64": "1.30.3",
"bare-runtime-linux-arm64": "1.30.3",
"bare-runtime-linux-x64": "1.30.3",
"bare-runtime-win32-x64": "1.30.3"
} }
}, },
"node_modules/@electron-forge/cli": { "node_modules/@electron-forge/cli": {
+8 -1
View File
@@ -98,6 +98,7 @@
"bare-performance": "^2.0.0", "bare-performance": "^2.0.0",
"bare-process": "^4.5.1", "bare-process": "^4.5.1",
"bare-querystring": "^1.0.0", "bare-querystring": "^1.0.0",
"bare-runtime": "^1.30.3",
"bare-stream": "^2.7.0", "bare-stream": "^2.7.0",
"bare-string-decoder": "^1.0.0", "bare-string-decoder": "^1.0.0",
"bare-subprocess": "^5.2.3", "bare-subprocess": "^5.2.3",
@@ -129,13 +130,19 @@
"which-runtime": "^1.4.0", "which-runtime": "^1.4.0",
"z32": "^1.1.0" "z32": "^1.1.0"
}, },
"optionalDependencies": {
"bare-runtime-darwin-arm64": "^1.30.3",
"bare-runtime-darwin-x64": "1.30.3",
"bare-runtime-linux-arm64": "1.30.3",
"bare-runtime-linux-x64": "1.30.3",
"bare-runtime-win32-x64": "1.30.3"
},
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.2", "@electron-forge/cli": "^7.11.2",
"@electron-forge/maker-zip": "^7.11.2", "@electron-forge/maker-zip": "^7.11.2",
"@electron-forge/plugin-base": "^7.11.2", "@electron-forge/plugin-base": "^7.11.2",
"@electron/get": "^3.1.0", "@electron/get": "^3.1.0",
"bare-build": "^1.0.2", "bare-build": "^1.0.2",
"bare-runtime": "1.30.3",
"brittle": "^4.1.0", "brittle": "^4.1.0",
"electron": "^33.4.11", "electron": "^33.4.11",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
+29
View File
@@ -0,0 +1,29 @@
{
"version": 1,
"bundleId": "03a99e64d3131c8a1eb6aedca6edac581d98e849be23db165c1d0c33f24d06d8",
"addons": [
"@qvac/llm-llamacpp",
"bare-buffer",
"bare-crypto",
"bare-dns",
"bare-fs",
"bare-inspect",
"bare-os",
"bare-path",
"bare-performance",
"bare-pipe",
"bare-signals",
"bare-tcp",
"bare-tls",
"bare-type",
"bare-url",
"bare-zlib",
"fs-native-extensions",
"quickbit-native",
"rabin-native",
"rocksdb-native",
"simdle-native",
"sodium-native",
"udx-native"
]
}
File diff suppressed because one or more lines are too long
+21 -16
View File
@@ -1,21 +1,26 @@
/** /**
* Desktop Bare worker entry for QVAC (Electron + Node). * QVAC SDK Worker Entry (auto-generated)
* Generated by: @qvac/sdk/commands bundleSdk
* Plugins: 1
* *
* Prefer the file produced by packaging: * - @qvac/sdk/llamacpp-completion/plugin
* - Electron Forge: `@qvac/sdk/electron-forge` → `qvac/worker.bundle.js`
* - Local: `npm run build:qvac-worker`
*
* This hand-written entry is a lean fallback for dev / pear when the SDK
* is installed but the bare-pack bundle has not been regenerated yet.
*
* Plugins match `qvac.config.json` (LLM completion only).
*
* @see https://docs.qvac.tether.io/tutorials/electron
*/ */
import { registerPlugin } from '@qvac/sdk/plugins'
import { llmPlugin } from '@qvac/sdk/llamacpp-completion/plugin'
registerPlugin(llmPlugin) import { initializeWorkerCore, ensureRPCSetup } from "file:///Users/raven/dev/peardata/node_modules/@qvac/sdk/dist/server/worker-core.js";
import { registerPlugin } from "file:///Users/raven/dev/peardata/node_modules/@qvac/sdk/dist/server/plugins/index.js";
import { getServerLogger } from "file:///Users/raven/dev/peardata/node_modules/@qvac/sdk/dist/logging/index.js";
// RPC loop without re-registering every built-in plugin. import { llmPlugin } from "file:///Users/raven/dev/peardata/node_modules/@qvac/sdk/dist/server/bare/plugins/llamacpp-completion/plugin.js";
await import('@qvac/sdk/worker-core')
const { hasRPCConfig } = initializeWorkerCore();
const logger = getServerLogger();
logger.info("🐻 QVAC Worker (custom bundle)");
logger.info("📦 Plugins: 1");
registerPlugin(llmPlugin);
// Auto-setup RPC if config present
if (hasRPCConfig) {
ensureRPCSetup();
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
+20
View File
@@ -48,6 +48,11 @@ import {
getHostSnapshot, getHostSnapshot,
searchCharts, searchCharts,
summarizeChart, summarizeChart,
investigateHost,
hotMetrics,
relatedCharts,
compareChartWindows,
summarizeCharts,
} from '../services/ai-tools.js' } from '../services/ai-tools.js'
import { import {
getRetentionConfig, getRetentionConfig,
@@ -169,6 +174,21 @@ export function registerMonitorHandlers(session) {
session.respond('summarizeChart', async (args) => summarizeChart(args || {}), { session.respond('summarizeChart', async (args) => summarizeChart(args || {}), {
hot: true, hot: true,
}) })
session.respond('investigateHost', async (args) => investigateHost(args || {}), {
hot: true,
})
session.respond('hotMetrics', async (args) => hotMetrics(args || {}), { hot: true })
session.respond('relatedCharts', async (args) => relatedCharts(args || {}), {
hot: true,
})
session.respond(
'compareChartWindows',
async (args) => compareChartWindows(args || {}),
{ hot: true }
)
session.respond('summarizeCharts', async (args) => summarizeCharts(args || {}), {
hot: true,
})
session.respond('queryLogs', async (args) => session.respond('queryLogs', async (args) =>
queryLogs({ ...(args || {}), role: session.role }) queryLogs({ ...(args || {}), role: session.role })
+61
View File
@@ -36,6 +36,11 @@ import {
getHostSnapshot, getHostSnapshot,
searchCharts, searchCharts,
summarizeChart, summarizeChart,
investigateHost,
hotMetrics,
relatedCharts,
compareChartWindows,
summarizeCharts,
} from '../services/ai-tools.js' } from '../services/ai-tools.js'
/** /**
@@ -383,6 +388,62 @@ export async function handleRest(pathname, query) {
if (path === '/api/v3/ai/snapshot') { if (path === '/api/v3/ai/snapshot') {
return json(await getHostSnapshot()) return json(await getHostSnapshot())
} }
if (path === '/api/v3/ai/investigate') {
return json(
await investigateHost({
processLimit: query.get('processLimit') || query.get('processes'),
hotLimit: query.get('hotLimit') || query.get('hot'),
})
)
}
if (path === '/api/v3/ai/hot') {
return json(
await hotMetrics({
limit: query.get('limit') != null ? Number(query.get('limit')) : undefined,
window: query.get('window') != null ? Number(query.get('window')) : undefined,
family: query.get('family') || query.get('q') || '',
})
)
}
if (path === '/api/v3/ai/related') {
return json(
await relatedCharts({
chart: query.get('chart') || query.get('id') || '',
limit: query.get('limit') != null ? Number(query.get('limit')) : undefined,
})
)
}
if (path === '/api/v3/ai/compare') {
return json(
await compareChartWindows({
chart: query.get('chart') || query.get('id') || '',
after: query.get('after') != null ? Number(query.get('after')) : undefined,
before: query.get('before') != null ? Number(query.get('before')) : undefined,
baselineAfter:
query.get('baseline_after') != null
? Number(query.get('baseline_after'))
: undefined,
baselineBefore:
query.get('baseline_before') != null
? Number(query.get('baseline_before'))
: undefined,
points: query.get('points') != null ? Number(query.get('points')) : undefined,
group: query.get('group') || 'average',
})
)
}
if (path === '/api/v3/ai/summarize') {
const charts = (query.get('charts') || query.get('chart') || '')
.split(/[\s,]+/)
.filter(Boolean)
return json(
await summarizeCharts({
charts,
after: query.get('after') != null ? Number(query.get('after')) : undefined,
points: query.get('points') != null ? Number(query.get('points')) : undefined,
})
)
}
if (path === '/api/v3/ai/charts') { if (path === '/api/v3/ai/charts') {
return json(searchCharts({ q: query.get('q') || '', limit: Number(query.get('limit')) || 30 })) return json(searchCharts({ q: query.get('q') || '', limit: Number(query.get('limit')) || 30 }))
} }
+369
View File
@@ -271,6 +271,375 @@ export async function summarizeChart(args = {}) {
} }
} }
/**
* Deep investigation pack — one call for “what's wrong / diagnose this host”.
* Combines snapshot + hot charts + top processes + open alerts.
* @param {{ processLimit?: number, hotLimit?: number }} [args]
*/
export async function investigateHost(args = {}) {
const processLimit = Math.min(25, Math.max(5, Number(args.processLimit) || 12))
const hotLimit = Math.min(30, Math.max(5, Number(args.hotLimit) || 12))
const snapshot = await getHostSnapshot()
const hot = await hotMetrics({ limit: hotLimit, window: 120 })
let processes = null
try {
const { listProcesses } = await import('./processes.js')
processes = listProcesses({ sort: 'cpu', limit: processLimit, filter: 'all' })
} catch (err) {
processes = { error: err?.message || String(err), supported: false }
}
const findings = []
const health = snapshot.health
if (health?.status && health.status !== 'ok' && health.status !== 'healthy') {
findings.push({
severity: health.critical ? 'critical' : 'warning',
area: 'health',
message: `Agent health status=${health.status} warnings=${health.warnings ?? 0} critical=${health.critical ?? 0}`,
})
}
const ramFree = snapshot.freeRamBytes
const ramTotal = snapshot.totalRamBytes
if (ramFree != null && ramTotal > 0 && ramFree / ramTotal < 0.08) {
findings.push({
severity: 'warning',
area: 'memory',
message: `Low free RAM: ${Math.round(ramFree / 1e6)} MiB free of ${Math.round(ramTotal / 1e6)} MiB`,
})
}
const load = snapshot.kpis?.load1?.value
const cores = snapshot.cores || 1
if (load != null && load > cores * 1.5) {
findings.push({
severity: load > cores * 3 ? 'critical' : 'warning',
area: 'load',
message: `Load1=${Number(load).toFixed(2)} vs ${cores} cores`,
})
}
for (const a of snapshot.anomalies || []) {
if (a.cleared) continue
findings.push({
severity: a.severity || 'warning',
area: 'anomaly',
chart: a.chart,
message: a.message || `${a.chart} anomaly`,
})
}
for (const a of snapshot.alerts || []) {
findings.push({
severity: a.severity || 'warning',
area: 'alert',
chart: a.chart,
message: a.message || a.id,
})
}
for (const h of (hot.results || []).slice(0, 6)) {
if (h.score >= 0.4) {
findings.push({
severity: h.score >= 0.75 ? 'warning' : 'info',
area: 'hot_metric',
chart: h.chart,
message: `${h.chart}: ${h.reason || 'elevated activity'} (score=${h.score.toFixed(2)})`,
})
}
}
const severityRank = { critical: 3, warning: 2, info: 1 }
findings.sort(
(a, b) => (severityRank[b.severity] || 0) - (severityRank[a.severity] || 0)
)
return {
ts: Date.now(),
hostname: snapshot.hostname,
summary: {
findingCount: findings.length,
topSeverity: findings[0]?.severity || 'ok',
health: health?.status || 'unknown',
anomalyCount: (snapshot.anomalies || []).length,
alertCount: (snapshot.alerts || []).length,
hotCharts: (hot.results || []).length,
},
findings: findings.slice(0, 24),
kpis: snapshot.kpis,
health,
hot: (hot.results || []).slice(0, hotLimit),
processes: processes?.processes
? {
supported: processes.supported,
summary: processes.summary,
top: processes.processes.slice(0, processLimit).map(compactProcess),
}
: processes,
storage: snapshot.storage,
catalog: snapshot.catalog,
}
}
/**
* Charts with the most recent change / anomaly signal (investigation starting points).
* @param {{ limit?: number, window?: number, family?: string }} [args]
*/
export async function hotMetrics(args = {}) {
const limit = Math.min(50, Math.max(1, Number(args.limit) || 15))
const window = Math.min(600, Math.max(20, Number(args.window) || 120))
const familyFilter = args.family ? String(args.family).toLowerCase() : ''
const store = getStore()
const anomalies = getAnomalyEngine()
const charts = store.listChartSummaries?.() || {}
const recentAnoms = anomalies.listRecent?.(80) || []
/** @type {Map<string, number>} */
const anomScore = new Map()
for (const a of recentAnoms) {
if (!a?.chart || a.cleared) continue
const prev = anomScore.get(a.chart) || 0
const s = a.severity === 'critical' ? 1 : a.severity === 'warning' ? 0.7 : 0.4
anomScore.set(a.chart, Math.max(prev, s + (Number(a.score) || 0) * 0.05))
}
/** @type {Array<object>} */
const scored = []
for (const [id, meta] of Object.entries(charts)) {
if (familyFilter) {
const fam = String(meta.family || meta.context || '').toLowerCase()
if (!fam.includes(familyFilter) && !id.toLowerCase().includes(familyFilter)) continue
}
let deltaScore = 0
let last = null
let avg = null
let reason = ''
try {
const q = await store.query({
chart: id,
after: -window,
points: Math.min(window, 90),
group: 'average',
})
const labels = Array.isArray(q.labels) ? q.labels.filter((l) => l && l !== 'time') : []
const data = Array.isArray(q.data) ? q.data : []
if (data.length >= 4 && labels.length) {
// Use first dim with finite values
let di = 0
for (let i = 0; i < labels.length; i++) {
if (data.some((row) => Number.isFinite(Number(row[i + 1])))) {
di = i
break
}
}
const vals = data
.map((row) => Number(row[di + 1]))
.filter((v) => Number.isFinite(v))
if (vals.length >= 4) {
const half = Math.floor(vals.length / 2)
const early = vals.slice(0, half)
const late = vals.slice(half)
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length
const mEarly = mean(early)
const mLate = mean(late)
last = vals[vals.length - 1]
avg = mean(vals)
const denom = Math.max(Math.abs(mEarly), Math.abs(avg), 1e-9)
const rel = Math.abs(mLate - mEarly) / denom
deltaScore = Math.min(1, rel)
if (rel >= 0.15) {
reason = `${labels[di]} ${mLate >= mEarly ? '↑' : '↓'} ${(rel * 100).toFixed(0)}% vs earlier window`
}
}
}
} catch {
// skip chart
}
const aScore = anomScore.get(id) || 0
const score = Math.min(1.5, deltaScore * 0.85 + aScore)
if (score < 0.12 && !aScore) continue
if (!reason && aScore) reason = 'recent anomaly'
if (!reason) reason = 'activity'
scored.push({
chart: id,
title: meta.title || id,
family: meta.family || '',
context: meta.context || '',
score: Math.round(score * 1000) / 1000,
last,
avg,
reason,
anomaly: Boolean(aScore),
})
}
scored.sort((a, b) => b.score - a.score || a.chart.localeCompare(b.chart))
return {
window,
count: scored.length,
results: scored.slice(0, limit),
}
}
/**
* Related charts for investigation (catalog + optional weights).
* @param {{ chart?: string, id?: string, limit?: number }} args
*/
export async function relatedCharts(args = {}) {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { error: 'chart required' }
const limit = Math.min(40, Math.max(1, Number(args.limit) || 12))
const store = getStore()
const catalog = store.listChartSummaries?.() || {}
if (!catalog[chart] && !CHART_BY_ID.has(chart)) {
return { error: 'unknown chart', chart }
}
let weights = null
try {
const { computeWeights } = await import('./weights.js')
const w = await computeWeights({ chart, limit: 40, method: 'alerts' })
weights = w?.results || w?.weights || null
} catch {
weights = null
}
const { rankRelatedCharts } = await import('../../shared/related-metrics.js')
const ranked = rankRelatedCharts(chart, catalog, null, { limit, weights })
return {
chart,
seed: catalog[chart] || chartSummary(CHART_BY_ID.get(chart)),
count: ranked.length,
results: ranked.map((r) => ({
id: r.id,
score: Math.round(r.score * 100) / 100,
reason: r.reason,
title: catalog[r.id]?.title || r.id,
family: catalog[r.id]?.family || '',
})),
}
}
/**
* Compare two time windows on one chart (baseline vs highlight).
* @param {{
* chart?: string,
* baselineAfter?: number,
* baselineBefore?: number,
* after?: number,
* before?: number,
* points?: number,
* }} args
*/
export async function compareChartWindows(args = {}) {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { error: 'chart required' }
const points = Math.min(300, Math.max(10, Number(args.points) || 60))
// Default: highlight last 5m, baseline prior 20m
const after = args.after != null ? Number(args.after) : -300
const before = args.before != null ? Number(args.before) : 0
const baselineAfter =
args.baselineAfter != null ? Number(args.baselineAfter) : -1500
const baselineBefore =
args.baselineBefore != null ? Number(args.baselineBefore) : -300
const [highlight, baseline] = await Promise.all([
summarizeChart({ chart, after, points, group: args.group || 'average' }),
summarizeChart({
chart,
after: baselineAfter,
points,
group: args.group || 'average',
}),
])
if (highlight.error) return highlight
if (baseline.error) return { ...baseline, phase: 'baseline' }
/** @type {Record<string, object>} */
const delta = {}
const dims = new Set([
...Object.keys(highlight.dims || {}),
...Object.keys(baseline.dims || {}),
])
for (const d of dims) {
const h = highlight.dims?.[d]
const b = baseline.dims?.[d]
if (!h && !b) continue
const hLast = h?.last
const bAvg = b?.avg
let rel = null
if (hLast != null && bAvg != null && Number.isFinite(hLast) && Number.isFinite(bAvg)) {
const denom = Math.max(Math.abs(bAvg), 1e-9)
rel = (hLast - bAvg) / denom
}
delta[d] = {
highlightLast: h?.last ?? null,
highlightAvg: h?.avg ?? null,
baselineAvg: b?.avg ?? null,
baselineMin: b?.min ?? null,
baselineMax: b?.max ?? null,
relChange: rel != null ? Math.round(rel * 1000) / 1000 : null,
}
}
return {
chart,
highlight: { after, before, points: highlight.points, dims: highlight.dims },
baseline: {
after: baselineAfter,
before: baselineBefore,
points: baseline.points,
dims: baseline.dims,
},
delta,
meta: highlight.meta,
}
}
/**
* Batch summarize several charts (compact for tool payloads).
* @param {{ charts?: string[], after?: number, points?: number }} args
*/
export async function summarizeCharts(args = {}) {
let charts = args.charts
if (typeof charts === 'string') {
charts = charts.split(/[\s,]+/).filter(Boolean)
}
if (!Array.isArray(charts) || !charts.length) {
return { error: 'charts array required' }
}
charts = charts.map(String).slice(0, 12)
const after = args.after != null ? Number(args.after) : -120
const points = Math.min(120, Math.max(10, Number(args.points) || 60))
const results = []
for (const chart of charts) {
const s = await summarizeChart({ chart, after, points })
if (s.error) {
results.push({ chart, error: s.error })
} else {
results.push({
chart,
points: s.points,
dims: s.dims,
latestTs: s.latestTs,
recentAnomalies: s.recentAnomalies,
})
}
}
return { after, points, count: results.length, results }
}
function compactProcess(p) {
if (!p) return p
return {
pid: p.pid,
name: p.name || p.comm,
user: p.user || p.username,
cpu: p.cpu,
mem: p.mem,
rss: p.rss,
state: p.state,
cmd: p.cmd ? String(p.cmd).slice(0, 120) : undefined,
}
}
function compactAnomaly(a) { function compactAnomaly(a) {
if (!a) return a if (!a) return a
return { return {
+10
View File
@@ -66,6 +66,11 @@ export const MethodRoles = Object.freeze({
getHostSnapshot: Roles.viewer, getHostSnapshot: Roles.viewer,
searchCharts: Roles.viewer, searchCharts: Roles.viewer,
summarizeChart: Roles.viewer, summarizeChart: Roles.viewer,
investigateHost: Roles.viewer,
hotMetrics: Roles.viewer,
relatedCharts: Roles.viewer,
compareChartWindows: Roles.viewer,
summarizeCharts: Roles.viewer,
// live subscription control // live subscription control
subscribeMetrics: Roles.viewer, subscribeMetrics: Roles.viewer,
@@ -159,5 +164,10 @@ export const HotMethods = Object.freeze(
Methods.getHostSnapshot, Methods.getHostSnapshot,
Methods.searchCharts, Methods.searchCharts,
Methods.summarizeChart, Methods.summarizeChart,
Methods.investigateHost,
Methods.hotMetrics,
Methods.relatedCharts,
Methods.compareChartWindows,
Methods.summarizeCharts,
]) ])
) )
+71 -6
View File
@@ -33,8 +33,79 @@ export function validateMethodArgs(method, args = {}) {
case 'listPeers': case 'listPeers':
case 'getWeights': case 'getWeights':
case 'getHostSnapshot': case 'getHostSnapshot':
case 'investigateHost':
case 'getDbInfo':
case 'listPeerLinks':
case 'listChildPeers':
case 'getFleetHealth':
case 'getStorageInfo':
case 'getRetentionConfig':
return { ok: true, args } return { ok: true, args }
case 'hotMetrics': {
const limit = args.limit == null ? 15 : Number(args.limit)
if (!Number.isFinite(limit) || limit < 1 || limit > 50) {
return { ok: false, error: 'limit must be 1..50' }
}
const window = args.window == null ? 120 : Number(args.window)
if (!Number.isFinite(window) || window < 20 || window > 600) {
return { ok: false, error: 'window must be 20..600' }
}
return {
ok: true,
args: {
...args,
limit,
window,
family: args.family != null ? String(args.family) : '',
},
}
}
case 'relatedCharts': {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { ok: false, error: 'chart or id is required' }
const limit = args.limit == null ? 12 : Number(args.limit)
if (!Number.isFinite(limit) || limit < 1 || limit > 40) {
return { ok: false, error: 'limit must be 1..40' }
}
return { ok: true, args: { ...args, chart, id: chart, limit } }
}
case 'compareChartWindows': {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { ok: false, error: 'chart or id is required' }
const points = args.points == null ? 60 : Number(args.points)
if (!Number.isFinite(points) || points < 1 || points > 300) {
return { ok: false, error: 'points must be 1..300' }
}
return { ok: true, args: { ...args, chart, id: chart, points } }
}
case 'summarizeCharts': {
let charts = args.charts
if (typeof charts === 'string') {
charts = charts.split(/[\s,]+/).filter(Boolean)
}
if (!Array.isArray(charts) || !charts.length) {
return { ok: false, error: 'charts array required' }
}
charts = charts.map(String).slice(0, 12)
const points = args.points == null ? 60 : Number(args.points)
if (!Number.isFinite(points) || points < 1 || points > 120) {
return { ok: false, error: 'points must be 1..120' }
}
return {
ok: true,
args: {
...args,
charts,
points,
after: args.after != null ? Number(args.after) : -120,
},
}
}
case 'searchCharts': { case 'searchCharts': {
const limit = args.limit == null ? 30 : Number(args.limit) const limit = args.limit == null ? 30 : Number(args.limit)
if (!Number.isFinite(limit) || limit < 1 || limit > 100) { if (!Number.isFinite(limit) || limit < 1 || limit > 100) {
@@ -259,12 +330,6 @@ export function validateMethodArgs(method, args = {}) {
} }
case 'exportSnapshot': case 'exportSnapshot':
case 'getDbInfo':
case 'listPeerLinks':
case 'getFleetHealth':
case 'listChildPeers':
case 'getStorageInfo':
case 'getRetentionConfig':
return { ok: true, args } return { ok: true, args }
case 'setRetentionConfig': { case 'setRetentionConfig': {
+131 -13
View File
@@ -5,7 +5,16 @@ import { suggestProfile, getProfile, QVAC_PROFILES } from '../ui/qvac/profiles.j
import { buildSystemPrompt } from '../ui/qvac/prompts.js' import { buildSystemPrompt } from '../ui/qvac/prompts.js'
import { buildRagContext, retrieve, catalogDocs, GUIDE_DOCS } from '../ui/qvac/rag.js' import { buildRagContext, retrieve, catalogDocs, GUIDE_DOCS } from '../ui/qvac/rag.js'
import { getStore } from '../server/services/store.js' import { getStore } from '../server/services/store.js'
import { searchCharts, summarizeChart, getHostSnapshot } from '../server/services/ai-tools.js' import {
searchCharts,
summarizeChart,
getHostSnapshot,
investigateHost,
hotMetrics,
relatedCharts,
compareChartWindows,
summarizeCharts,
} from '../server/services/ai-tools.js'
import { initAuthKeys } from '../server/core/auth-keys.js' import { initAuthKeys } from '../server/core/auth-keys.js'
import crypto from 'hypercore-crypto' import crypto from 'hypercore-crypto'
import b4a from 'b4a' import b4a from 'b4a'
@@ -16,25 +25,63 @@ initAuthKeys({
publicKeyHex: b4a.toString(crypto.keyPair(seed).publicKey, 'hex'), publicKeyHex: b4a.toString(crypto.keyPair(seed).publicKey, 'hex'),
}) })
const AI_METHODS = [
'getHostSnapshot',
'searchCharts',
'summarizeChart',
'investigateHost',
'hotMetrics',
'relatedCharts',
'compareChartWindows',
'summarizeCharts',
]
test('AI composite methods are viewer role', (t) => { test('AI composite methods are viewer role', (t) => {
for (const m of ['getHostSnapshot', 'searchCharts', 'summarizeChart']) { for (const m of AI_METHODS) {
t.is(MethodRoles[m], Roles.viewer, m) t.is(MethodRoles[m], Roles.viewer, m)
t.is(Methods[m], m) t.is(Methods[m], m)
} }
}) })
test('schema validates searchCharts + summarizeChart', (t) => { test('schema validates searchCharts + summarizeChart + deep AI', (t) => {
t.ok(validateMethodArgs('getHostSnapshot', {}).ok) t.ok(validateMethodArgs('getHostSnapshot', {}).ok)
t.ok(validateMethodArgs('investigateHost', {}).ok)
const s = validateMethodArgs('searchCharts', { q: 'cpu', limit: 10 }) const s = validateMethodArgs('searchCharts', { q: 'cpu', limit: 10 })
t.ok(s.ok) t.ok(s.ok)
t.is(s.args.q, 'cpu') t.is(s.args.q, 'cpu')
const bad = validateMethodArgs('searchCharts', { limit: 999 }) const bad = validateMethodArgs('searchCharts', { limit: 999 })
t.absent(bad.ok) t.absent(bad.ok)
const sum = validateMethodArgs('summarizeChart', { chart: 'system.cpu', points: 60 }) const sum = validateMethodArgs('summarizeChart', { chart: 'system.cpu', points: 60 })
t.ok(sum.ok) t.ok(sum.ok)
t.is(sum.args.chart, 'system.cpu') t.is(sum.args.chart, 'system.cpu')
const miss = validateMethodArgs('summarizeChart', {}) const miss = validateMethodArgs('summarizeChart', {})
t.absent(miss.ok) t.absent(miss.ok)
const hot = validateMethodArgs('hotMetrics', { limit: 10, window: 60 })
t.ok(hot.ok)
const hotBad = validateMethodArgs('hotMetrics', { window: 5 })
t.absent(hotBad.ok)
const rel = validateMethodArgs('relatedCharts', { chart: 'system.cpu' })
t.ok(rel.ok)
const relMiss = validateMethodArgs('relatedCharts', {})
t.absent(relMiss.ok)
const cmp = validateMethodArgs('compareChartWindows', { chart: 'system.cpu', points: 40 })
t.ok(cmp.ok)
const batch = validateMethodArgs('summarizeCharts', {
charts: ['system.cpu', 'system.ram'],
points: 30,
})
t.ok(batch.ok)
t.is(batch.args.charts.length, 2)
const batchStr = validateMethodArgs('summarizeCharts', { charts: 'system.cpu,system.ram' })
t.ok(batchStr.ok)
t.is(batchStr.args.charts.length, 2)
}) })
test('QVAC profiles suggest by RAM', (t) => { test('QVAC profiles suggest by RAM', (t) => {
@@ -45,11 +92,12 @@ test('QVAC profiles suggest by RAM', (t) => {
t.ok(QVAC_PROFILES.lite.chatModel.includes('QWEN')) t.ok(QVAC_PROFILES.lite.chatModel.includes('QWEN'))
}) })
test('system prompt forbids inventing metrics', (t) => { test('system prompt forbids inventing metrics and mentions investigate', (t) => {
const p = buildSystemPrompt({ peerAlias: 'lab', role: 'viewer', connected: true }) const p = buildSystemPrompt({ peerAlias: 'lab', role: 'viewer', connected: true })
t.ok(p.includes('Never invent')) t.ok(p.includes('Never invent'))
t.ok(p.includes('lab')) t.ok(p.includes('lab'))
t.ok(p.includes('viewer')) t.ok(p.includes('viewer'))
t.ok(p.includes('investigate_host'))
}) })
test('RAG retrieves guide + catalog', (t) => { test('RAG retrieves guide + catalog', (t) => {
@@ -94,12 +142,46 @@ test('searchCharts and summarizeChart against store', async (t) => {
const snap = await getHostSnapshot() const snap = await getHostSnapshot()
t.ok(snap.hostname) t.ok(snap.hostname)
t.ok(snap.kpis) t.ok(snap.kpis)
const inv = await investigateHost({ processLimit: 5, hotLimit: 8 })
t.ok(inv.summary)
t.ok(Array.isArray(inv.findings))
t.ok(inv.kpis)
const hot = await hotMetrics({ limit: 10, window: 60 })
t.ok(Array.isArray(hot.results))
const batch = await summarizeCharts({ charts: ['system.cpu'], after: -30, points: 20 })
t.ok(batch.results?.some((r) => r.chart === 'system.cpu' && r.dims))
const cmp = await compareChartWindows({ chart: 'system.cpu', points: 20 })
t.ok(cmp.delta || cmp.error == null)
t.ok(cmp.highlight || cmp.error)
// related may be empty without full catalog entry — should not throw
const rel = await relatedCharts({ chart: 'system.cpu', limit: 5 })
t.ok(rel.chart === 'system.cpu' || rel.error)
}) })
test('tool runner gates offline + silence role', async (t) => { test('tool runner gates offline + silence role + tiers', async (t) => {
const { createToolRunner, fallbackComplete, TOOL_DEFS } = await import('../ui/qvac/tools.js') const {
t.ok(TOOL_DEFS.some((d) => d.function.name === 'host_snapshot')) createToolRunner,
t.ok(TOOL_DEFS.some((d) => d.function.name === 'fleet_health')) fallbackComplete,
TOOL_DEFS,
toolDepthForProfile,
} = await import('../ui/qvac/tools.js')
// Flat @qvac/sdk shape (not nested function.name)
t.ok(TOOL_DEFS.some((d) => d.name === 'host_snapshot'))
t.ok(TOOL_DEFS.some((d) => d.name === 'investigate_host'))
t.ok(TOOL_DEFS.some((d) => d.name === 'hot_metrics'))
t.ok(TOOL_DEFS.some((d) => d.name === 'fleet_health'))
t.ok(TOOL_DEFS.every((d) => d.type === 'function' && d.name && d.parameters))
t.absent(TOOL_DEFS.some((d) => d.function))
t.is(toolDepthForProfile('tool-tiny'), 'core')
t.is(toolDepthForProfile('recommended'), 'deep')
t.is(toolDepthForProfile('strong'), 'deep')
let connected = false let connected = false
/** @type {string} */ /** @type {string} */
@@ -109,8 +191,22 @@ test('tool runner gates offline + silence role', async (t) => {
manager: { manager: {
request: async (m, a) => { request: async (m, a) => {
calls.push({ m, a }) calls.push({ m, a })
if (m === 'getHostSnapshot') return { hostname: 'lab', health: { status: 'ok' }, kpis: {} } if (m === 'getHostSnapshot') {
return { hostname: 'lab', health: { status: 'ok' }, kpis: {} }
}
if (m === 'investigateHost') {
return {
hostname: 'lab',
summary: { findingCount: 0, topSeverity: 'ok', health: 'ok' },
findings: [],
kpis: {},
}
}
if (m === 'listAnomalies') return { anomalies: [] } if (m === 'listAnomalies') return { anomalies: [] }
if (m === 'hotMetrics') return { window: 120, count: 0, results: [] }
if (m === 'listAlerts') return { alerts: [] }
if (m === 'getHealth') return { status: 'ok' }
if (m === 'listJobs') return { jobs: [] }
return { ok: true } return { ok: true }
}, },
active: null, active: null,
@@ -128,6 +224,10 @@ test('tool runner gates offline + silence role', async (t) => {
const snap = await tools.run('host_snapshot', {}) const snap = await tools.run('host_snapshot', {})
t.is(snap.hostname, 'lab') t.is(snap.hostname, 'lab')
const inv = await tools.run('investigate_host', {})
t.is(inv.hostname, 'lab')
t.ok(inv.summary)
const silence = await tools.run('silence_alert', { id: 'a1', confirmed: true }) const silence = await tools.run('silence_alert', { id: 'a1', confirmed: true })
t.ok(String(silence.error || '').includes('Operator') || silence.error) t.ok(String(silence.error || '').includes('Operator') || silence.error)
@@ -135,11 +235,26 @@ test('tool runner gates offline + silence role', async (t) => {
const silence2 = await tools.run('silence_alert', { id: 'a1', confirmed: false }) const silence2 = await tools.run('silence_alert', { id: 'a1', confirmed: false })
t.is(silence2.error, 'confirmation_required') t.is(silence2.error, 'confirmation_required')
const defsViewer = (() => { role = 'viewer'
role = 'viewer' const defsViewer = tools.defsForRole({ profileId: 'recommended' }).map((d) => d.name)
return tools.defsForRole().map((d) => d.function.name)
})()
t.absent(defsViewer.includes('silence_alert')) t.absent(defsViewer.includes('silence_alert'))
t.ok(defsViewer.includes('investigate_host'))
t.ok(defsViewer.includes('hot_metrics'))
t.ok(defsViewer.includes('related_charts'))
const defsTiny = tools.defsForRole({ profileId: 'tool-tiny' }).map((d) => d.name)
t.ok(defsTiny.includes('investigate_host'))
t.absent(defsTiny.includes('hot_metrics'))
t.absent(defsTiny.includes('query_metric'))
role = 'operator'
const defsOp = tools.defsForRole({ profileId: 'strong' }).map((d) => d.name)
t.ok(defsOp.includes('silence_alert'))
t.ok(defsOp.includes('ack_alert'))
t.ok(defsOp.includes('run_job'))
// Wire shape has no internal tier field
t.ok(tools.defsForRole().every((d) => d.tier == null))
const kn = await tools.run('local_knowledge', { q: 'charts' }) const kn = await tools.run('local_knowledge', { q: 'charts' })
t.ok(kn.context) t.ok(kn.context)
@@ -148,4 +263,7 @@ test('tool runner gates offline + silence role', async (t) => {
t.is(fb.mode, 'fallback') t.is(fb.mode, 'fallback')
t.ok(fb.contentText) t.ok(fb.contentText)
t.ok((fb.toolCalls || []).length >= 1) t.ok((fb.toolCalls || []).length >= 1)
const fb2 = await fallbackComplete("what's wrong with this host", tools)
t.ok((fb2.toolCalls || []).some((c) => c.name === 'investigate_host'))
}) })
+187
View File
@@ -0,0 +1,187 @@
/**
* Context window budgeting for small local QVAC models.
* Rough token estimate + history compaction before completion.
*/
/**
* Conservative token estimate (chars / 3.2) for English + JSON tool payloads.
* @param {unknown} text
*/
export function estimateTokens(text) {
const s = typeof text === 'string' ? text : JSON.stringify(text ?? '')
return Math.max(1, Math.ceil(s.length / 3.2))
}
/**
* @param {Array<{ role?: string, content?: string, name?: string }>} messages
*/
export function estimateMessagesTokens(messages) {
let n = 0
for (const m of messages || []) {
n += 4 // role framing
n += estimateTokens(m.content || '')
if (m.name) n += estimateTokens(m.name)
}
return n
}
/**
* @param {any[]} tools
*/
export function estimateToolsTokens(tools) {
if (!tools?.length) return 0
return estimateTokens(JSON.stringify(tools)) + 32
}
/**
* Compact chat history to fit a token budget.
* Always keeps: first system message(s), last user message, recent turns.
* Truncates large tool payloads; drops oldest middle messages.
*
* @param {Array<{ role: string, content: string, name?: string }>} messages
* @param {{
* maxTokens?: number,
* keepRecentUserTurns?: number,
* maxToolChars?: number,
* maxMsgChars?: number,
* }} [opts]
*/
export function compactMessages(messages, opts = {}) {
const maxTokens = Math.max(512, Number(opts.maxTokens) || 2800)
const keepRecentUserTurns = Math.max(1, Number(opts.keepRecentUserTurns) || 3)
const maxToolChars = Math.max(400, Number(opts.maxToolChars) || 2500)
const maxMsgChars = Math.max(800, Number(opts.maxMsgChars) || 4000)
const src = (messages || []).map((m) => ({
role: m.role,
content: String(m.content ?? ''),
...(m.name ? { name: m.name } : {}),
}))
if (!src.length) return src
// Split leading system messages
/** @type {typeof src} */
const systems = []
let i = 0
while (i < src.length && src[i].role === 'system') {
systems.push(truncateMsg(src[i], maxMsgChars * 2))
i++
}
const rest = src.slice(i)
// Find last user message index in rest
let lastUser = -1
for (let j = rest.length - 1; j >= 0; j--) {
if (rest[j].role === 'user') {
lastUser = j
break
}
}
// Truncate tool / long assistant bodies first
const trimmed = rest.map((m) => {
if (m.role === 'tool') return truncateMsg(m, maxToolChars)
if (m.role === 'assistant') return truncateMsg(m, maxMsgChars)
return truncateMsg(m, maxMsgChars)
})
// Keep recent window ending at last message, including last N user turns
let start = 0
if (lastUser >= 0) {
let users = 0
start = lastUser
for (let j = lastUser; j >= 0; j--) {
if (trimmed[j].role === 'user') {
users++
start = j
if (users >= keepRecentUserTurns) break
}
}
}
let window = trimmed.slice(start)
// Drop from front until under budget (keep systems + window)
const pack = () => [...systems, ...window]
while (window.length > 2 && estimateMessagesTokens(pack()) > maxTokens) {
// Prefer dropping oldest non-user if possible
if (window[0]?.role !== 'user' || window.length > 4) {
window = window.slice(1)
} else {
window = window.slice(1)
}
}
// Still over budget: hard-trim contents
if (estimateMessagesTokens(pack()) > maxTokens) {
window = window.map((m) =>
truncateMsg(m, m.role === 'tool' ? 600 : m.role === 'system' ? 2000 : 1200)
)
}
// Still over: keep only systems + last user + trailing assistant/tool chain
if (estimateMessagesTokens(pack()) > maxTokens) {
const last = window[window.length - 1]
const lastU = [...window].reverse().find((m) => m.role === 'user')
window = [lastU, last].filter(Boolean)
// dedupe if same ref
if (window.length === 2 && window[0] === window[1]) window = [window[0]]
}
// Add a short note if we dropped history
const dropped = rest.length - window.length
if (dropped > 0 && systems[0]) {
systems[0] = {
...systems[0],
content:
systems[0].content +
`\n\n[Context compacted: ${dropped} earlier messages omitted to fit the model window.]`,
}
}
return pack()
}
/**
* @param {{ role: string, content: string, name?: string }} m
* @param {number} maxChars
*/
function truncateMsg(m, maxChars) {
const c = String(m.content || '')
if (c.length <= maxChars) return m
return {
...m,
content: c.slice(0, maxChars - 20) + '\n…[truncated]',
}
}
/**
* Budget for prompt given model context size.
* Reserves space for generation + tool schemas.
* @param {number} ctxSize
* @param {number} toolsTokens
*/
export function promptBudget(ctxSize, toolsTokens = 0) {
const ctx = Math.max(2048, Number(ctxSize) || 4096)
// Leave room for model output + thinking
const reserveOut = Math.min(1024, Math.floor(ctx * 0.25))
const reserveTools = Math.min(toolsTokens, Math.floor(ctx * 0.2))
const budget = ctx - reserveOut - reserveTools - 64
return Math.max(800, budget)
}
/**
* Detect context-overflow style errors from QVAC / llama.cpp.
* @param {string} msg
*/
export function isContextOverflowError(msg) {
const s = String(msg || '').toLowerCase()
return (
s.includes('context window') ||
s.includes('context length') ||
s.includes('exceeds the model') ||
s.includes('prompt is too long') ||
s.includes('n_keep') ||
s.includes('too many tokens')
)
}
+456 -50
View File
@@ -2,10 +2,72 @@
* QVAC engine facade load models, stream completion, tool loop. * QVAC engine facade load models, stream completion, tool loop.
* Uses @qvac/sdk when available; otherwise tools-only fallback. * 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 { getProfile } from './profiles.js'
import { buildSystemPrompt } from './prompts.js' import { buildSystemPrompt } from './prompts.js'
import { fallbackComplete } from './tools.js' import { fallbackComplete } from './tools.js'
import { buildRagContext } from './rag.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 {{ * @param {{
@@ -16,9 +78,27 @@ import { buildRagContext } from './rag.js'
* log?: (msg: string) => void, * log?: (msg: string) => void,
* }} deps * }} 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) { export function createQvacEngine(deps) {
/** @type {any} */ /** @type {any} */
let sdk = null let sdk = null
/** @type {'direct'|'main'|null} */
let sdkMode = null
let sdkError = null let sdkError = null
/** @type {string|null} */ /** @type {string|null} */
let modelId = null let modelId = null
@@ -32,6 +112,7 @@ export function createQvacEngine(deps) {
/** @type {ReturnType<typeof setTimeout>|null} */ /** @type {ReturnType<typeof setTimeout>|null} */
let idleTimer = null let idleTimer = null
let lastActivity = Date.now() let lastActivity = Date.now()
const ipc = getElectronIpc()
function touchActivity() { function touchActivity() {
lastActivity = Date.now() lastActivity = Date.now()
@@ -55,30 +136,63 @@ export function createQvacEngine(deps) {
} }
/** /**
* Resolve @qvac/sdk for both ESM (pear run) and CJS Electron bundle. * Lightweight "is the package on disk?" check never evaluates @qvac/sdk.
* Packaged Electron: packages are external requires under app/node_modules. * @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] * @param {number} [timeoutMs]
*/ */
async function tryLoadSdk(timeoutMs = 8_000) { async function tryLoadSdk(timeoutMs = 8_000) {
if (sdk) return sdk 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 { try {
const load = (async () => { const load = (async () => {
// Prefer dynamic import (ESM / pear). Falls back to createRequire for CJS.
try { try {
return await import('@qvac/sdk') // eslint-disable-next-line no-undef
} catch (importErr) { if (typeof require === 'function') return require('@qvac/sdk')
try { } catch {
const { createRequire } = await import('module') // fall through
const req = createRequire(
typeof __filename !== 'undefined'
? __filename
: `${process.cwd()}/package.json`
)
return req('@qvac/sdk')
} catch {
throw importErr
}
} }
const { createRequire: cr } = await import('module')
const req = cr(
typeof __filename !== 'undefined' ? __filename : `${process.cwd()}/package.json`
)
return req('@qvac/sdk')
})() })()
const timed = const timed =
timeoutMs > 0 timeoutMs > 0
@@ -93,24 +207,30 @@ export function createQvacEngine(deps) {
]) ])
: load : load
sdk = await timed sdk = await timed
sdkMode = 'direct'
sdkError = null sdkError = null
return sdk return sdk
} catch (err) { } catch (err) {
sdkError = err?.message || String(err) sdkError = err?.message || String(err)
sdk = null sdk = null
sdkMode = null
return null return null
} }
} }
function getStatus() { function getStatus() {
const disk = detectSdkInstalled()
const loaded = Boolean(modelId) && (sdkMode === 'main' || Boolean(sdk))
return { return {
status, status,
modelId, modelId,
profileId, profileId,
sdkAvailable: Boolean(sdk) && !sdkError, sdkAvailable: loaded || disk.installed || sdkMode === 'main',
sdkError, sdkLoaded: loaded,
sdkMode,
sdkError: sdkError || (!disk.installed && !loaded ? disk.error : null),
progress: lastProgress, progress: lastProgress,
mode: sdk && modelId ? 'qvac' : status === 'fallback' || !sdk ? 'fallback' : status, mode: loaded && status === 'ready' ? 'qvac' : status === 'fallback' || !loaded ? 'fallback' : status,
lastActivity, lastActivity,
} }
} }
@@ -122,14 +242,6 @@ export function createQvacEngine(deps) {
: null : null
let totalRamBytes = null let totalRamBytes = null
let freeRamBytes = null let freeRamBytes = null
// Prefer already-loaded process/os globals — avoid hanging dynamic imports in UI
try {
if (typeof process !== 'undefined' && process.memoryUsage) {
// Node/Electron: totalmem via require('os') may work; try sync first
}
} catch {
// ignore
}
try { try {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const osMod = typeof require === 'function' ? require('os') : null const osMod = typeof require === 'function' ? require('os') : null
@@ -149,13 +261,14 @@ export function createQvacEngine(deps) {
} }
/** /**
* Lightweight host check never blocks on native SDK probes. * Lightweight host check for onboarding.
* Default: NEVER import @qvac/sdk (probeSdk must be explicitly true).
* @param {{ probeSdk?: boolean, timeoutMs?: number }} [opts] * @param {{ probeSdk?: boolean, timeoutMs?: number }} [opts]
*/ */
async function checkEnvironment(opts = {}) { async function checkEnvironment(opts = {}) {
status = 'checking' status = 'checking'
const host = hostPlatformInfo() const host = hostPlatformInfo()
// Optional async os.totalmem for pure ESM // Optional async os.totalmem for pure ESM only when sync path missed
if (host.totalRamBytes == null) { if (host.totalRamBytes == null) {
try { try {
const os = await Promise.race([ const os = await Promise.race([
@@ -171,11 +284,15 @@ export function createQvacEngine(deps) {
let sdkAvailable = Boolean(sdk) let sdkAvailable = Boolean(sdk)
let err = sdkError let err = sdkError
if (opts.probeSdk !== false && !sdk) { if (opts.probeSdk === true && !sdk) {
// Short probe only — full model load happens later on user action // Explicit full load — only for model download path, not Continue
const s = await tryLoadSdk(opts.timeoutMs ?? 2500) const s = await tryLoadSdk(opts.timeoutMs ?? 8000)
sdkAvailable = Boolean(s) sdkAvailable = Boolean(s)
err = sdkError err = sdkError
} else {
const disk = detectSdkInstalled()
sdkAvailable = disk.installed || Boolean(sdk)
if (!disk.installed && !sdk) err = disk.error
} }
status = 'idle' status = 'idle'
@@ -198,20 +315,70 @@ export function createQvacEngine(deps) {
const p = getProfile(profile) const p = getProfile(profile)
profileId = p.id profileId = p.id
touchActivity() 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() const s = await tryLoadSdk()
if (!s) { if (!s || s.__viaMain) {
status = 'fallback' status = 'fallback'
deps.log?.(`QVAC SDK unavailable (${sdkError}); using tools-only fallback`) deps.log?.(`QVAC SDK unavailable (${sdkError}); using tools-only fallback`)
return { ok: true, mode: 'fallback', profile: p.id } return { ok: true, mode: 'fallback', profile: p.id }
} }
const modelSrc = s[p.chatModel] || p.chatModel const modelSrc = s[p.chatModel] || p.chatModel
status = 'downloading'
lastProgress = { percentage: 0 }
loadAbort = new AbortController() loadAbort = new AbortController()
try { try {
// Unload previous model first
if (modelId) await unload() if (modelId) await unload()
const id = await s.loadModel({ const id = await s.loadModel({
@@ -219,7 +386,7 @@ export function createQvacEngine(deps) {
modelType: 'llm', modelType: 'llm',
modelConfig: { modelConfig: {
tools: p.tools, tools: p.tools,
ctx_size: 4096, ctx_size: p.ctxSize || 8192,
}, },
onProgress: (prog) => { onProgress: (prog) => {
lastProgress = prog lastProgress = prog
@@ -248,7 +415,13 @@ export function createQvacEngine(deps) {
clearTimeout(idleTimer) clearTimeout(idleTimer)
idleTimer = null idleTimer = null
} }
if (sdk && modelId && sdk.unloadModel) { if (ipc && sdkMode === 'main') {
try {
await ipc.invoke('peardata:qvac-unload')
} catch {
// ignore
}
} else if (sdk && modelId && sdk.unloadModel) {
try { try {
await sdk.unloadModel({ modelId }) await sdk.unloadModel({ modelId })
} catch { } catch {
@@ -274,13 +447,14 @@ export function createQvacEngine(deps) {
const userLast = [...history].reverse().find((m) => m.role === 'user') const userLast = [...history].reverse().find((m) => m.role === 'user')
let system = buildSystemPrompt(deps.getContext?.() || {}) let system = buildSystemPrompt(deps.getContext?.() || {})
// Light RAG only — full catalog dumps blow small context windows
if (prefs.rag !== false && userLast?.content) { if (prefs.rag !== false && userLast?.content) {
const rag = buildRagContext({ const rag = buildRagContext({
query: userLast.content, query: userLast.content,
catalog: deps.getCatalog?.() || {}, catalog: deps.getCatalog?.() || {},
topK: 5, topK: 3,
}) })
if (rag) system += `\n\n${rag}` if (rag) system += `\n\n${rag.slice(0, 1200)}`
} }
const fullHistory = [ const fullHistory = [
@@ -288,7 +462,7 @@ export function createQvacEngine(deps) {
...history.filter((m) => m.role !== 'system'), ...history.filter((m) => m.role !== 'system'),
] ]
if (sdk && modelId && sdk.completion) { if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
return completeWithSdk(fullHistory, profile, opts) return completeWithSdk(fullHistory, profile, opts)
} }
@@ -312,17 +486,142 @@ export function createQvacEngine(deps) {
} }
async function completeWithSdk(history, profile, opts) { async function completeWithSdk(history, profile, opts) {
const toolDefs = profile.tools ? deps.tools.defsForRole() : undefined let toolDefs = profile.tools
let messages = history.map((m) => ({ role: m.role, content: m.content })) ? deps.tools.defsForRole({ profileId: profile.id, profile })
: 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++) { for (let round = 0; round < 4; round++) {
touchActivity() 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({ const run = sdk.completion({
modelId, modelId,
history: messages, history: messages,
stream: true, stream: true,
tools: toolDefs, tools: toolDefs,
captureThinking: false, captureThinking: true,
}) })
let content = '' let content = ''
@@ -332,14 +631,17 @@ export function createQvacEngine(deps) {
if (run.events) { if (run.events) {
for await (const ev of run.events) { for await (const ev of run.events) {
opts.onEvent?.(ev) opts.onEvent?.(ev)
if (ev.type === 'contentDelta' && ev.text) { if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
opts.onThinking?.(ev.text || ev.delta)
} else if (ev.type === 'contentDelta' && ev.text) {
content += ev.text content += ev.text
opts.onToken?.(ev.text) opts.onToken?.(ev.text)
} else if (ev.type === 'toolCall') { } else if (ev.type === 'toolCall') {
const call = ev.call || ev.toolCall || ev
toolCalls.push({ toolCalls.push({
name: ev.name || ev.toolCall?.name, name: call.name || ev.name,
arguments: ev.arguments || ev.toolCall?.arguments || {}, arguments: call.arguments || ev.arguments || {},
id: ev.id || ev.toolCall?.id, id: call.id || ev.id,
}) })
} }
} }
@@ -377,7 +679,7 @@ export function createQvacEngine(deps) {
opts.onTool?.(name, args, result) opts.onTool?.(name, args, result)
messages.push({ messages.push({
role: 'tool', role: 'tool',
content: JSON.stringify(result).slice(0, 12_000), content: compactToolResult(name, result),
name, name,
}) })
} }
@@ -400,3 +702,107 @@ export function createQvacEngine(deps) {
touchActivity, 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)
}
}
+290 -75
View File
@@ -5,6 +5,7 @@ import { createQvacEngine } from './engine.js'
import { createToolRunner } from './tools.js' import { createToolRunner } from './tools.js'
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js' import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
import { SAMPLE_PROMPTS } from './prompts.js' import { SAMPLE_PROMPTS } from './prompts.js'
import { partitionThink, renderAssistantHtml } from './think.js'
/** /**
* @param {{ * @param {{
@@ -160,7 +161,9 @@ export function createQvacView(opts) {
const sdkLine = env.checking const sdkLine = env.checking
? '<li class="warn">QVAC SDK: checking…</li>' ? '<li class="warn">QVAC SDK: checking…</li>'
: `<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${ : `<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${
env.sdkAvailable ? 'available' : 'not installed — tools-only mode' env.sdkAvailable
? 'package found (loads only when you download a model)'
: 'not installed — tools-only mode'
}</li>` }</li>`
body.innerHTML = ` body.innerHTML = `
<ul class="qvac-check-list"> <ul class="qvac-check-list">
@@ -228,7 +231,7 @@ export function createQvacView(opts) {
} }
if (wizardStep === 1) { if (wizardStep === 1) {
// Always paint a complete card with actions first — never wait on SDK import. // Paint immediately. Never import @qvac/sdk here — that freezes packaged Electron.
const card = stepCard( const card = stepCard(
'System check', 'System check',
'<p class="muted">Preparing environment check…</p>', '<p class="muted">Preparing environment check…</p>',
@@ -245,27 +248,30 @@ export function createQvacView(opts) {
paintSystemCheck(card, quick) paintSystemCheck(card, quick)
bindSystemCheckActions(card) bindSystemCheckActions(card)
engine // Yield a frame so the card paints before any async work
.checkEnvironment({ probeSdk: true, timeoutMs: 2500 }) requestAnimationFrame(() => {
.then((env) => { engine
if (wizardStep !== 1) return .checkEnvironment({ probeSdk: false })
if (!card.isConnected) return .then((env) => {
paintSystemCheck(card, { ...env, checking: false }) if (wizardStep !== 1) return
bindSystemCheckActions(card) if (!card.isConnected) return
syncModelChip() paintSystemCheck(card, { ...env, checking: false })
}) bindSystemCheckActions(card)
.catch((err) => { syncModelChip()
if (wizardStep !== 1 || !card.isConnected) return
paintSystemCheck(card, {
checking: false,
sdkAvailable: false,
sdkError: err?.message || String(err),
platform: quick.platform,
arch: quick.arch,
totalRamBytes: null,
}) })
bindSystemCheckActions(card) .catch((err) => {
}) if (wizardStep !== 1 || !card.isConnected) return
paintSystemCheck(card, {
checking: false,
sdkAvailable: false,
sdkError: err?.message || String(err),
platform: quick.platform,
arch: quick.arch,
totalRamBytes: null,
})
bindSystemCheckActions(card)
})
})
return return
} }
@@ -300,7 +306,17 @@ export function createQvacView(opts) {
onClick: () => { onClick: () => {
wizardStep = 3 wizardStep = 3
renderWizard() renderWizard()
startLoad() // Yield so the loading card paints before main-process model load starts
requestAnimationFrame(() => {
setTimeout(() => {
startLoad().catch((err) => {
opts.log?.(`QVAC startLoad: ${err?.message || err}`)
finishToolsOnly(
`Model load failed (${err?.message || err}). Continuing in tools-only mode.`
)
})
}, 50)
})
}, },
}, },
] ]
@@ -450,31 +466,156 @@ export function createQvacView(opts) {
} }
} }
/**
* @param {string} role
* @param {string} content
* @param {{ tools?: any[], streaming?: boolean }} [meta]
*/
function appendMsg(role, content, meta = {}) { function appendMsg(role, content, meta = {}) {
messages.push({ role, content, tools: meta.tools }) const entry = { role, content, tools: meta.tools, thinking: '' }
messages.push(entry)
const list = opts.els.messages const list = opts.els.messages
if (!list) return if (!list) return null
const div = document.createElement('div') const div = document.createElement('div')
div.className = `qvac-msg qvac-msg-${role}` div.className = `qvac-msg qvac-msg-${role}`
const body = document.createElement('div') const body = document.createElement('div')
body.className = 'qvac-msg-body' body.className = 'qvac-msg-body'
body.innerHTML = formatMdLite(content) if (role === 'assistant') {
body.innerHTML = renderAssistantHtml(content, formatMdLite, {
openThink: Boolean(meta.streaming),
})
} else {
body.innerHTML = formatMdLite(content)
}
div.appendChild(body) div.appendChild(body)
if (meta.tools?.length) { if (meta.tools?.length) {
const chips = document.createElement('div') div.appendChild(renderToolChips(meta.tools))
chips.className = 'qvac-tool-chips'
for (const t of meta.tools) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
c.title = JSON.stringify(t.args || {}).slice(0, 200)
chips.appendChild(c)
}
div.appendChild(chips)
} }
list.appendChild(div) list.appendChild(div)
list.scrollTop = list.scrollHeight list.scrollTop = list.scrollHeight
return body return { div, body, entry }
}
function renderToolChips(tools) {
const chips = document.createElement('div')
chips.className = 'qvac-tool-chips'
for (const t of tools) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
const preview =
t.result != null
? JSON.stringify(t.result).slice(0, 280)
: JSON.stringify(t.args || {}).slice(0, 200)
c.title = preview
chips.appendChild(c)
}
return chips
}
/** Near bottom of a scroll container? (for stick-to-bottom while streaming) */
function isNearBottom(el, threshold = 56) {
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
}
/**
* Keep the think panel pinned to the latest tokens while streaming.
* Uses rAF so rapid token events coalesce into one smooth follow.
* @param {HTMLElement|null|undefined} thinkBody
* @param {{ force?: boolean }} [opts]
*/
function followThinkScroll(thinkBody, { force = true } = {}) {
if (!thinkBody) return
if (!force && !isNearBottom(thinkBody)) return
const run = () => {
thinkBody._qvacScrollRaf = 0
// Instant pin each frame — feels continuous as text grows (smooth
// scroll-behavior fights high-frequency stream updates).
thinkBody.scrollTop = thinkBody.scrollHeight
}
if (thinkBody._qvacScrollRaf) cancelAnimationFrame(thinkBody._qvacScrollRaf)
thinkBody._qvacScrollRaf = requestAnimationFrame(run)
}
/** Keep the chat list following the live assistant message. */
function followMessagesScroll({ force = true } = {}) {
const list = opts.els.messages
if (!list) return
if (!force && !isNearBottom(list)) return
if (list._qvacScrollRaf) cancelAnimationFrame(list._qvacScrollRaf)
list._qvacScrollRaf = requestAnimationFrame(() => {
list._qvacScrollRaf = 0
list.scrollTop = list.scrollHeight
})
}
/**
* Paint / update assistant HTML. While streaming with an open think panel,
* updates the think body in place so scroll position can stay pinned.
* @param {HTMLElement|null|undefined} bodyEl
* @param {string} raw
* @param {boolean} streaming
*/
function paintAssistant(bodyEl, raw, streaming) {
if (!bodyEl) return
const parts = partitionThink(raw)
let details = bodyEl.querySelector('details.qvac-think')
let thinkBody = bodyEl.querySelector('.qvac-think-body')
let answerEl = bodyEl.querySelector('.qvac-msg-answer')
// Fast path: structure already exists and we still have thinking text —
// update DOM in place so the think scroller doesn't jump to top each token.
if (parts.thinking && details && thinkBody) {
details.open = true
const summary = details.querySelector('.qvac-think-summary')
let live = summary?.querySelector('.qvac-think-live')
const showLive = Boolean(streaming || parts.thinkingOpen)
if (showLive && summary && !live) {
live = document.createElement('span')
live.className = 'qvac-think-live'
live.textContent = 'live'
summary.appendChild(live)
} else if (!showLive && live) {
live.remove()
}
const stick = streaming || isNearBottom(thinkBody)
thinkBody.innerHTML = formatMdLite(parts.thinking)
if (stick) followThinkScroll(thinkBody, { force: true })
if (parts.answer) {
if (!answerEl) {
answerEl = document.createElement('div')
answerEl.className = 'qvac-msg-answer'
bodyEl.appendChild(answerEl)
}
answerEl.className = 'qvac-msg-answer'
answerEl.innerHTML = formatMdLite(parts.answer)
} else if (streaming || parts.thinkingOpen) {
if (!answerEl) {
answerEl = document.createElement('div')
bodyEl.appendChild(answerEl)
}
answerEl.className = 'qvac-msg-answer qvac-msg-pending muted'
answerEl.textContent = 'Working…'
} else if (answerEl) {
answerEl.remove()
}
if (streaming) followMessagesScroll({ force: true })
return
}
// Full re-render (first paint, or no think block)
bodyEl.innerHTML = renderAssistantHtml(raw, formatMdLite, { openThink: streaming })
thinkBody = bodyEl.querySelector('.qvac-think-body')
if (streaming || parts.thinkingOpen) {
followThinkScroll(thinkBody, { force: true })
followMessagesScroll({ force: true })
} else if (thinkBody && isNearBottom(thinkBody)) {
followThinkScroll(thinkBody, { force: true })
}
} }
async function send() { async function send() {
@@ -485,49 +626,67 @@ export function createQvacView(opts) {
busy = true busy = true
opts.els.sendBtn && (opts.els.sendBtn.disabled = true) opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
appendMsg('user', text) appendMsg('user', text)
const streamBody = appendMsg('assistant', '…') const assistantUi = appendMsg('assistant', '…', { streaming: true })
const streamBody = assistantUi?.body
const toolLog = [] const toolLog = []
let acc = '' let acc = ''
let thinkingAcc = ''
setStatus('Thinking…', 'busy') setStatus('Thinking…', 'busy')
try { try {
// History for the model: clean answers only (no think tags)
const hist = messages const hist = messages
.filter((m) => m.role === 'user' || m.role === 'assistant') .filter((m) => m.role === 'user' || m.role === 'assistant')
.slice(0, -1) // drop placeholder assistant .slice(0, -1)
.map((m) => ({ role: m.role, content: m.content })) .map((m) => {
if (m.role !== 'assistant') return { role: m.role, content: m.content }
const { answer } = partitionThink(m.content)
return { role: 'assistant', content: answer || m.content }
})
hist.push({ role: 'user', content: text }) hist.push({ role: 'user', content: text })
const result = await engine.complete(hist, { const result = await engine.complete(hist, {
onToken: (t) => { onToken: (t) => {
acc += t acc += t
if (streamBody) streamBody.innerHTML = formatMdLite(acc || '…') paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
opts.els.messages && (opts.els.messages.scrollTop = opts.els.messages.scrollHeight) },
onThinking: (t) => {
thinkingAcc += t
// If model streams think separately, wrap for partitioner
const raw = thinkingAcc
? `<think>\n${thinkingAcc}\n</think>\n${acc}`
: acc
paintAssistant(streamBody, raw, true)
}, },
onTool: (name, args, res) => { onTool: (name, args, res) => {
toolLog.push({ name, args, result: res }) toolLog.push({ name, args, result: res })
setStatus(`Tool: ${name}`, 'busy')
// Live tool chips while model works
if (assistantUi?.div) {
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
if (chips) chips.remove()
assistantUi.div.appendChild(renderToolChips(toolLog))
followMessagesScroll({ force: true })
}
}, },
}) })
acc = result.contentText || acc acc = result.contentText || acc
if (streamBody) streamBody.innerHTML = formatMdLite(acc) if (thinkingAcc && !/<think/i.test(acc)) {
// update last message in state acc = `<think>\n${thinkingAcc}\n</think>\n${acc}`
}
paintAssistant(streamBody, acc, false)
followMessagesScroll({ force: true })
const parts = partitionThink(acc)
const last = messages[messages.length - 1] const last = messages[messages.length - 1]
if (last?.role === 'assistant') { if (last?.role === 'assistant') {
// Store full text (with think) for UI re-open; history path strips think
last.content = acc last.content = acc
last.thinking = parts.thinking
last.tools = toolLog last.tools = toolLog
} }
if (toolLog.length && streamBody?.parentElement) { if (toolLog.length && assistantUi?.div) {
let chips = streamBody.parentElement.querySelector('.qvac-tool-chips') let chips = assistantUi.div.querySelector('.qvac-tool-chips')
if (!chips) { if (chips) chips.remove()
chips = document.createElement('div') assistantUi.div.appendChild(renderToolChips(toolLog))
chips.className = 'qvac-tool-chips'
streamBody.parentElement.appendChild(chips)
}
chips.innerHTML = ''
for (const t of toolLog) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
chips.appendChild(c)
}
} }
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok') setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} catch (err) { } catch (err) {
@@ -541,6 +700,14 @@ export function createQvacView(opts) {
} }
} }
/** Merge separate thinking stream + content stream for progressive UI. */
function mergeThinkStream(thinking, content) {
if (thinking && !/<think/i.test(content)) {
return `<think>\n${thinking}\n</think>\n${content}`
}
return content || (thinking ? `<think>\n${thinking}` : '…')
}
function newChat() { function newChat() {
messages = [] messages = []
if (opts.els.messages) opts.els.messages.innerHTML = '' if (opts.els.messages) opts.els.messages.innerHTML = ''
@@ -574,33 +741,81 @@ export function createQvacView(opts) {
opts.els.newChatBtn?.addEventListener('click', () => newChat()) opts.els.newChatBtn?.addEventListener('click', () => newChat())
} }
/** @type {Promise<void>|null} */
let restorePromise = null
/**
* If onboarding finished with a full model, reload it via main-process IPC
* (never require SDK in the renderer).
*/
async function restoreSavedModel() {
const mode = settings().qvacMode
const profile = settings().qvacProfile || 'recommended'
if (mode !== 'qvac' || !profile) return
const st = engine.getStatus()
if (st.status === 'ready' && st.sdkLoaded) return
if (st.status === 'downloading' || st.status === 'loading') return
setStatus('Loading saved model…', 'busy')
syncModelChip()
try {
const result = await engine.loadProfile(profile, {
onProgress: (p) => {
const pct = p?.percentage
if (pct != null) setStatus(`Loading model ${Number(pct).toFixed(0)}%…`, 'busy')
syncModelChip()
},
})
if (result.mode === 'qvac' && result.ok !== false) {
persist({ qvacMode: 'qvac', qvacProfile: profile })
setStatus('Ready', 'ok')
} else {
persist({ qvacMode: result.mode || 'fallback' })
setStatus(
result.error
? `Ready (tools-only: ${result.error})`
: 'Ready (tools-only)',
'warn'
)
}
} catch (err) {
setStatus(`Ready (tools-only: ${err?.message || err})`, 'warn')
}
syncModelChip()
}
function enter() { function enter() {
showPane() showPane()
renderSamples() renderSamples()
syncModelChip() syncModelChip()
if (isOnboarded() && !messages.length) { if (isOnboarded()) {
// Auto warm fallback path; full model load is manual after onboarding // Restore model in background if user previously completed Download & load
engine.tryLoadSdk().then(() => { if (!restorePromise) {
restorePromise = restoreSavedModel().finally(() => {
restorePromise = null
})
}
if (!messages.length) {
const mode = settings().qvacMode const mode = settings().qvacMode
if (mode === 'qvac' && settings().qvacProfile) { appendMsg(
setStatus('Loading saved model…', 'busy') 'assistant',
engine.loadProfile(settings().qvacProfile).then(() => { mode === 'qvac'
setStatus('Ready', 'ok') ? 'QVAC ready — restoring your saved model if needed. Ask about host health, metrics, or processes.'
syncModelChip() : 'QVAC ready (tools-only). Use **Setup → Download & load** for full local chat, or ask for live metrics now.'
}) )
} else { }
syncModelChip()
}
})
appendMsg(
'assistant',
'QVAC ready. Try “Summarize host health” or pick a sample prompt.'
)
} }
} }
bind() bind()
// Warm model as soon as the view is constructed (if already onboarded)
if (isOnboarded() && settings().qvacMode === 'qvac') {
restorePromise = restoreSavedModel().finally(() => {
restorePromise = null
})
}
return { return {
enter, enter,
leave: () => { leave: () => {
+5
View File
@@ -16,6 +16,7 @@
* minRamGb: number, * minRamGb: number,
* minDiskGb: number, * minDiskGb: number,
* approxDownloadGb: number, * approxDownloadGb: number,
* ctxSize: number,
* }} QvacProfile * }} QvacProfile
*/ */
@@ -31,6 +32,7 @@ export const QVAC_PROFILES = {
minRamGb: 4, minRamGb: 4,
minDiskGb: 2, minDiskGb: 2,
approxDownloadGb: 0.5, approxDownloadGb: 0.5,
ctxSize: 4096,
}, },
recommended: { recommended: {
id: 'recommended', id: 'recommended',
@@ -42,6 +44,7 @@ export const QVAC_PROFILES = {
minRamGb: 8, minRamGb: 8,
minDiskGb: 5, minDiskGb: 5,
approxDownloadGb: 2.5, approxDownloadGb: 2.5,
ctxSize: 8192,
}, },
strong: { strong: {
id: 'strong', id: 'strong',
@@ -53,6 +56,7 @@ export const QVAC_PROFILES = {
minRamGb: 16, minRamGb: 16,
minDiskGb: 8, minDiskGb: 8,
approxDownloadGb: 3.5, approxDownloadGb: 3.5,
ctxSize: 8192,
}, },
'tool-tiny': { 'tool-tiny': {
id: 'tool-tiny', id: 'tool-tiny',
@@ -64,6 +68,7 @@ export const QVAC_PROFILES = {
minRamGb: 6, minRamGb: 6,
minDiskGb: 3, minDiskGb: 3,
approxDownloadGb: 1, approxDownloadGb: 1,
ctxSize: 4096,
}, },
} }
+22 -2
View File
@@ -20,22 +20,42 @@ export function buildSystemPrompt(ctx = {}) {
'You run on the operator desktop; metrics come from the connected agent via tools.', 'You run on the operator desktop; metrics come from the connected agent via tools.',
'', '',
'Rules:', 'Rules:',
'- Never invent metric values, timestamps, or alert states. Use tools first.', '- Never invent metric values, timestamps, or alert states. Always call tools first for live data.',
'- If the agent is disconnected or a tool fails, say so clearly.', '- If the agent is disconnected or a tool fails, say so clearly.',
'- Prefer short, operational answers: severity, numbers, chart ids, next steps.', '- Prefer short, operational answers: severity, numbers, chart ids, next steps.',
'- Cite chart ids (e.g. system.cpu) when discussing metrics.', '- Cite chart ids (e.g. system.cpu) when discussing metrics.',
'- You are read-only unless the user explicitly asks for an operator action and their role allows it.', '- You are read-only unless the user explicitly asks for an operator action and their role allows it.',
'- Do not claim cloud access; all inference is local via QVAC.', '- Do not claim cloud access; all inference is local via QVAC.',
'- Use local_knowledge for product how-tos; use host_snapshot / summarize_chart for live numbers.',
'- Prefer open_chart / open_view when the user asks to see something in the UI.', '- Prefer open_chart / open_view when the user asks to see something in the UI.',
'', '',
'Tool playbook (use these exact tool names and chart ids):',
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
' Lighter alternative: host_snapshot. Do NOT use md.health for general health.',
'- Spikes / unusual activity → hot_metrics, then summarize_chart or compare_chart_windows on top hits.',
'- CPU high / load → investigate_host or host_snapshot + summarize_chart chart=system.cpu + list_processes.',
'- Memory → summarize_chart chart=system.ram (and search_charts q=mem if needed).',
'- Disk / IO / docker / redis / nginx / postgres → search_charts, then summarize_chart on a concrete id.',
'- Follow-up on one chart → related_charts, get_weights, compare_chart_windows, summarize_charts (batch).',
'- Alerts / anomalies → list_anomalies and/or list_alerts; get_alert for one id.',
'- Fleet → fleet_health + list_child_peers.',
'- Storage / retention / prune questions → storage_info (+ local_knowledge for product how-to).',
'- Logs → query_logs source=anomaly (or journal/audit when role allows).',
'- Agent meta → agent_health, node_info, db_info, list_contexts, list_jobs.',
'- Product how-to → local_knowledge; do not invent UI steps.',
'- Operator actions (silence_alert, ack_alert, run_job): explain first, then call with confirmed=true only after user agrees.',
'- NEVER use chart id "md.health" for general host health — that is MD RAID only and is often empty.',
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
'',
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}.`, `Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}.`,
].join('\n') ].join('\n')
} }
export const SAMPLE_PROMPTS = [ export const SAMPLE_PROMPTS = [
'Summarize host health right now', 'Summarize host health right now',
"What's wrong — investigate this host",
'Why might CPU be high?', 'Why might CPU be high?',
'Show hot / spiking metrics',
'List charts related to disk or io', 'List charts related to disk or io',
'Any open alerts or anomalies?', 'Any open alerts or anomalies?',
'Top processes by CPU if available', 'Top processes by CPU if available',
+74
View File
@@ -0,0 +1,74 @@
/**
* Split Qwen-style thinking blocks from assistant text for UI.
* Supports complete and in-progress streams.
*
* Common tags: <think></think>, <think></think>, <thinking>
*/
const TAG = String.raw`think|thinking|redacted[_-]?thinking|redacted[_-]?reasoning`
const THINK_OPEN = new RegExp(`<\\s*(?:${TAG})\\s*>`, 'i')
const THINK_PAIR = new RegExp(
`<\\s*(?:${TAG})\\s*>([\\s\\S]*?)<\\s*/\\s*(?:${TAG})\\s*>`,
'gi'
)
/**
* @param {string} text
* @returns {{ thinking: string, answer: string, thinkingOpen: boolean }}
*/
export function partitionThink(text) {
const full = String(text || '')
/** @type {string[]} */
const blocks = []
let answer = full.replace(THINK_PAIR, (_, body) => {
const t = String(body || '').trim()
if (t) blocks.push(t)
return ''
})
// Incomplete open block at end of stream
let thinkingOpen = false
const openMatch = answer.match(THINK_OPEN)
if (openMatch && openMatch.index != null) {
thinkingOpen = true
const openIdx = openMatch.index
const after = answer.slice(openIdx + openMatch[0].length)
if (after.trim()) blocks.push(after.trim())
answer = answer.slice(0, openIdx)
}
return {
thinking: blocks.join('\n\n').trim(),
answer: answer.replace(/^\s+/, '').replace(/\s+$/, ''),
thinkingOpen,
}
}
/**
* Render assistant body HTML: optional collapsible think + answer.
* @param {string} raw
* @param {(s: string) => string} formatBody
* @param {{ openThink?: boolean }} [opts]
*/
export function renderAssistantHtml(raw, formatBody, opts = {}) {
const { thinking, answer, thinkingOpen } = partitionThink(raw)
const open = opts.openThink !== false && (thinkingOpen || Boolean(thinking))
let html = ''
if (thinking) {
html += `<details class="qvac-think"${open ? ' open' : ''}>
<summary class="qvac-think-summary">
<span class="qvac-think-ico" aria-hidden="true"></span>
<span>Thinking</span>
${thinkingOpen ? '<span class="qvac-think-live">live</span>' : ''}
</summary>
<div class="qvac-think-body">${formatBody(thinking)}</div>
</details>`
}
const ans = answer || (!thinking ? '…' : '')
if (ans) {
html += `<div class="qvac-msg-answer">${formatBody(ans)}</div>`
} else if (thinkingOpen) {
html += `<div class="qvac-msg-answer qvac-msg-pending muted">Working…</div>`
}
return html || formatBody(raw || '…')
}
+605 -172
View File
@@ -1,199 +1,472 @@
/** /**
* QVAC tool schemas + handlers PearData RPC / UI navigation. * QVAC tool schemas + handlers PearData RPC / UI navigation.
*
* Tool defs use the **@qvac/sdk** wire shape (flat), not OpenAI nested
* `{ type, function: { name, parameters } }`:
* { type: 'function', name, description, parameters: { type:'object', properties, required? } }
* Property values may only include type / description / enum (see @qvac/sdk toolSchema).
*
* Tools are tiered so small-context models stay lean:
* core always (tool-tiny + recommended + strong)
* deep recommended + strong (investigation / fleet / storage)
* write operator+ only (silence / ack / run_job)
*/ */
import { Methods, Roles, roleAllows } from '../../shared/protocol.js' import { Methods, Roles, roleAllows } from '../../shared/protocol.js'
import { buildRagContext } from './rag.js' import { buildRagContext } from './rag.js'
/** @param {Record<string, { type: string, description?: string, enum?: any[] }>} properties */
function params(properties = {}, required) {
const out = {
type: 'object',
properties: properties || {},
}
if (required?.length) out.required = required
return out
}
/** /**
* OpenAI-style tool definitions for QVAC completion({ tools }). * @typedef {'core'|'deep'|'write'} ToolTier
* @typedef {{ type: 'function', name: string, description: string, parameters: object, tier?: ToolTier }} ToolDef
*/ */
/** @type {ToolDef[]} */
export const TOOL_DEFS = [ export const TOOL_DEFS = [
// ── core: diagnosis + navigation ─────────────────────────────────────
{ {
type: 'function', type: 'function',
function: { name: 'investigate_host',
name: 'host_snapshot', tier: 'core',
description: description:
'Get a compact live snapshot: health, KPIs (cpu/ram/load/net/io), recent anomalies/alerts, catalog size.', 'BEST first call for "what\'s wrong" / diagnose / investigate. One shot: findings ranked by severity, KPIs, hot charts, top processes, anomalies/alerts. Prefer this over calling host_snapshot + list_processes + list_anomalies separately.',
parameters: { type: 'object', properties: {}, additionalProperties: false }, parameters: params({
}, processLimit: { type: 'number', description: 'Top processes (default 12, max 25)' },
hotLimit: { type: 'number', description: 'Hot charts to include (default 12, max 30)' },
}),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'host_snapshot',
name: 'search_charts', tier: 'core',
description: 'Search the metrics chart catalog by free text (id, title, context, family).', description:
parameters: { 'Compact live host KPIs (cpu, ram, load, net, io), health, recent anomalies/alerts. Use when you need a lighter snapshot than investigate_host.',
type: 'object', parameters: params({}),
properties: { },
q: { type: 'string', description: 'Search query' }, {
limit: { type: 'number', description: 'Max results (default 20)' }, type: 'function',
name: 'search_charts',
tier: 'core',
description: 'Search the metrics chart catalog by free text (id, title, context, family).',
parameters: params(
{
q: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results (default 20)' },
},
['q']
),
},
{
type: 'function',
name: 'summarize_chart',
tier: 'core',
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id e.g. system.cpu' },
after: {
type: 'number',
description: 'Seconds relative (e.g. -300) or absolute unix',
}, },
required: ['q'], points: { type: 'number', description: 'Max points (default 90)' },
}, },
}, ['chart']
),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'list_anomalies',
name: 'summarize_chart', tier: 'core',
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.', description: 'List recent anomaly events.',
parameters: { parameters: params({
type: 'object', limit: { type: 'number', description: 'Max events' },
properties: { }),
chart: { type: 'string', description: 'Chart id e.g. system.cpu' }, },
after: { type: 'number', description: 'Seconds relative (e.g. -300) or absolute unix' }, {
points: { type: 'number', description: 'Max points (default 90)' }, type: 'function',
}, name: 'list_alerts',
required: ['chart'], tier: 'core',
description: 'List configured/open alerts on the agent.',
parameters: params({}),
},
{
type: 'function',
name: 'list_processes',
tier: 'core',
description: 'Live process table (when agent enables PEARDATA_PROCESSES).',
parameters: params({
sort: {
type: 'string',
description: 'cpu|rss|name',
enum: ['cpu', 'rss', 'name'],
}, },
}, limit: { type: 'number', description: 'Max rows' },
filter: { type: 'string', description: 'Filter string (all|user|…)' },
}),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'local_knowledge',
name: 'query_metric', tier: 'core',
description: 'Raw queryData for a chart time series.', description:
parameters: { 'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.',
type: 'object', parameters: params(
properties: { {
chart: { type: 'string' }, q: { type: 'string', description: 'Question or keywords' },
after: { type: 'number' },
points: { type: 'number' },
group: { type: 'string', enum: ['average', 'min', 'max', 'sum'] },
},
required: ['chart'],
}, },
}, ['q']
),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'open_chart',
name: 'list_anomalies', tier: 'core',
description: 'List recent anomaly events.', description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
parameters: { parameters: params(
type: 'object', {
properties: { limit: { type: 'number' } }, chart: { type: 'string', description: 'Chart id' },
ts: { type: 'number', description: 'Event time ms' },
}, },
}, ['chart']
),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'open_view',
name: 'list_alerts', tier: 'core',
description: 'List configured/open alerts on the agent.', description:
parameters: { type: 'object', properties: {} }, 'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
}, parameters: params(
}, {
{ view: {
type: 'function', type: 'string',
function: { description: 'View name',
name: 'list_processes', enum: [
description: 'Live process table (when agent enables PEARDATA_PROCESSES).', 'overview',
parameters: { 'charts',
type: 'object', 'processes',
properties: { 'alerts',
sort: { type: 'string' }, 'logs',
limit: { type: 'number' }, 'fleet',
filter: { type: 'string' }, 'settings',
'qvac',
],
}, },
}, },
}, ['view']
),
},
// ── deep: investigation, fleet, storage, catalog ───────────────────
{
type: 'function',
name: 'hot_metrics',
tier: 'deep',
description:
'Charts with strongest recent change / anomaly signal. Great investigation starting points when investigate_host is too broad.',
parameters: params({
limit: { type: 'number', description: 'Max charts (default 15, max 50)' },
window: { type: 'number', description: 'Lookback seconds (default 120, 20..600)' },
family: { type: 'string', description: 'Optional family/context filter e.g. disk, docker' },
}),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'related_charts',
name: 'query_logs', tier: 'deep',
description: 'Query agent logs: source journal|anomaly|audit, optional free-text q.', description:
parameters: { 'Related charts for a seed chart (catalog family/context + optional alert weights). Use after finding a suspicious chart.',
type: 'object', parameters: params(
properties: { {
source: { type: 'string', enum: ['journal', 'anomaly', 'audit'] }, chart: { type: 'string', description: 'Seed chart id' },
q: { type: 'string' }, limit: { type: 'number', description: 'Max results (default 12)' },
limit: { type: 'number' }, },
['chart']
),
},
{
type: 'function',
name: 'compare_chart_windows',
tier: 'deep',
description:
'Compare two time windows on one chart (highlight vs baseline). Defaults: last 5m vs prior 20m. Returns per-dim relative change.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
after: { type: 'number', description: 'Highlight window start (default -300)' },
before: { type: 'number', description: 'Highlight window end (default 0)' },
baselineAfter: {
type: 'number',
description: 'Baseline window start (default -1500)',
},
baselineBefore: {
type: 'number',
description: 'Baseline window end (default -300)',
},
points: { type: 'number', description: 'Points per window (default 60)' },
},
['chart']
),
},
{
type: 'function',
name: 'summarize_charts',
tier: 'deep',
description: 'Batch summarize up to 12 charts in one call (compact stats).',
parameters: params(
{
charts: {
type: 'string',
description: 'Comma or space separated chart ids (max 12)',
},
after: { type: 'number', description: 'Window start (default -120)' },
points: { type: 'number', description: 'Points per chart (default 60)' },
},
['charts']
),
},
{
type: 'function',
name: 'query_metric',
tier: 'deep',
description: 'Raw queryData time series for a chart (full points). Prefer summarize_chart when stats suffice.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
after: { type: 'number', description: 'Window start (relative or unix)' },
points: { type: 'number', description: 'Max points' },
group: {
type: 'string',
description: 'Aggregation: average|min|max|sum',
enum: ['average', 'min', 'max', 'sum'],
}, },
}, },
}, ['chart']
),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'get_weights',
name: 'fleet_health', tier: 'deep',
description: 'Fleet / parent-child health summary when parent mode is enabled.', description:
parameters: { type: 'object', properties: {} }, 'Metric correlation / influence weights for a chart (alerts method). Helps find what moves with an incident chart.',
}, parameters: params(
}, {
{ chart: { type: 'string', description: 'Chart id' },
type: 'function', limit: { type: 'number', description: 'Max related (default 20)' },
function: {
name: 'storage_info',
description: 'Agent storage usage and retention config.',
parameters: { type: 'object', properties: {} },
},
},
{
type: 'function',
function: {
name: 'local_knowledge',
description:
'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.',
parameters: {
type: 'object',
properties: {
q: { type: 'string' },
},
required: ['q'],
}, },
}, ['chart']
),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'query_logs',
name: 'open_chart', tier: 'deep',
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).', description:
parameters: { 'Query agent logs: source journal|anomaly|audit, optional free-text q. journal/audit may require admin on the agent.',
type: 'object', parameters: params({
properties: { source: {
chart: { type: 'string' }, type: 'string',
ts: { type: 'number', description: 'Event time ms' }, description: 'journal|anomaly|audit',
}, enum: ['journal', 'anomaly', 'audit'],
required: ['chart'],
}, },
}, q: { type: 'string', description: 'Search text' },
limit: { type: 'number', description: 'Max lines' },
}),
}, },
{ {
type: 'function', type: 'function',
function: { name: 'fleet_health',
name: 'open_view', tier: 'deep',
description: description: 'Fleet / parent-child health summary when parent mode is enabled.',
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac', parameters: params({}),
parameters: {
type: 'object',
properties: {
view: { type: 'string' },
},
required: ['view'],
},
},
}, },
{ {
type: 'function', type: 'function',
function: { name: 'list_child_peers',
name: 'silence_alert', tier: 'deep',
description: description: 'List child peers in fleet/parent mode (hostname, key, hops).',
'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.', parameters: params({}),
parameters: { },
type: 'object', {
properties: { type: 'function',
id: { type: 'string' }, name: 'storage_info',
durationMs: { type: 'number', description: 'Silence duration ms (default 3600000)' }, tier: 'deep',
confirmed: { type: 'boolean', description: 'Must be true after user confirms' }, description: 'Agent storage usage and retention config (warm/history sizes).',
}, parameters: params({}),
required: ['id'], },
{
type: 'function',
name: 'agent_health',
tier: 'deep',
description: 'Raw agent health object from anomaly engine (status, warnings, critical).',
parameters: params({}),
},
{
type: 'function',
name: 'node_info',
tier: 'deep',
description: 'Agent node metadata: version, hostname, platform, uptime, collectors.',
parameters: params({}),
},
{
type: 'function',
name: 'db_info',
tier: 'deep',
description: 'HyperDB / replication db info for the agent store.',
parameters: params({}),
},
{
type: 'function',
name: 'list_contexts',
tier: 'deep',
description: 'List metric contexts (families of charts) on the agent.',
parameters: params({}),
},
{
type: 'function',
name: 'get_chart',
tier: 'deep',
description: 'Chart metadata: title, family, units, dimensions (no series points).',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
}, },
}, ['chart']
),
},
{
type: 'function',
name: 'list_jobs',
tier: 'deep',
description: 'List on-demand agent jobs (collectOnce, snapshot, retrainAnomaly, …).',
parameters: params({}),
},
{
type: 'function',
name: 'get_alert',
tier: 'deep',
description: 'Fetch one alert by id.',
parameters: params(
{
id: { type: 'string', description: 'Alert id' },
},
['id']
),
},
// ── write: operator actions (gated by role + confirm) ────────────────
{
type: 'function',
name: 'silence_alert',
tier: 'write',
description:
'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.',
parameters: params(
{
id: { type: 'string', description: 'Alert id' },
durationMs: {
type: 'number',
description: 'Silence duration ms (default 3600000)',
},
confirmed: {
type: 'boolean',
description: 'Must be true after user confirms',
},
},
['id']
),
},
{
type: 'function',
name: 'ack_alert',
tier: 'write',
description: 'Operator only: acknowledge an alert by id (requires confirm).',
parameters: params(
{
id: { type: 'string', description: 'Alert id' },
confirmed: {
type: 'boolean',
description: 'Must be true after user confirms',
},
},
['id']
),
},
{
type: 'function',
name: 'run_job',
tier: 'write',
description:
'Operator only: run an on-demand job (collectOnce, snapshot, retrainAnomaly, gcBuffers, …). Requires confirm.',
parameters: params(
{
name: {
type: 'string',
description: 'Job name from list_jobs',
},
confirmed: {
type: 'boolean',
description: 'Must be true after user confirms',
},
},
['name']
),
}, },
] ]
/** Tools that work without an agent connection. */
const LOCAL_TOOLS = new Set(['open_view', 'open_chart', 'local_knowledge'])
/** Operator write tools. */
const WRITE_TOOLS = new Set(
TOOL_DEFS.filter((t) => t.tier === 'write').map((t) => t.name)
)
/**
* Map profile id max tool tier depth.
* @param {string} profileId
* @returns {'core'|'deep'}
*/
export function toolDepthForProfile(profileId) {
const id = String(profileId || 'recommended')
if (id === 'lite' || id === 'tool-tiny') return 'core'
return 'deep'
}
/**
* @param {ToolDef} def
* @param {'core'|'deep'} depth
* @param {string} role
*/
function includeTool(def, depth, role) {
const tier = def.tier || 'core'
if (tier === 'write') return roleAllows(role, Roles.operator)
if (tier === 'deep') return depth === 'deep'
return true
}
/**
* Strip internal `tier` before sending schemas to the model.
* @param {ToolDef[]} defs
*/
function wireDefs(defs) {
return defs.map(({ type, name, description, parameters }) => ({
type,
name,
description,
parameters,
}))
}
/** /**
* @param {{ * @param {{
* manager: { request: (m: string, a?: object) => Promise<any>, active: any }, * manager: { request: (m: string, a?: object) => Promise<any>, active: any },
@@ -211,14 +484,24 @@ export function createToolRunner(deps) {
* @param {object} args * @param {object} args
*/ */
async function run(name, args = {}) { async function run(name, args = {}) {
const localOk = ['open_view', 'open_chart', 'local_knowledge'].includes(name) if (!LOCAL_TOOLS.has(name) && !deps.isConnected?.()) {
if (!deps.isConnected?.() && !localOk) {
return { error: 'No agent connected. Connect from the Connect tab first.' } return { error: 'No agent connected. Connect from the Connect tab first.' }
} }
if (WRITE_TOOLS.has(name)) {
const role = deps.getRole?.() || Roles.viewer
if (!roleAllows(role, Roles.operator)) {
return { error: 'Operator role required for this action' }
}
}
const req = (m, a) => deps.manager.request(m, a || {}) const req = (m, a) => deps.manager.request(m, a || {})
try { try {
switch (name) { switch (name) {
case 'investigate_host':
return await req(Methods.investigateHost, {
processLimit: args.processLimit,
hotLimit: args.hotLimit,
})
case 'host_snapshot': case 'host_snapshot':
return await req(Methods.getHostSnapshot, {}) return await req(Methods.getHostSnapshot, {})
case 'search_charts': case 'search_charts':
@@ -233,6 +516,20 @@ export function createToolRunner(deps) {
points: args.points, points: args.points,
group: args.group, group: args.group,
}) })
case 'summarize_charts': {
let charts = args.charts
if (typeof charts === 'string') {
charts = charts.split(/[\s,]+/).filter(Boolean)
}
if (!Array.isArray(charts) && args.chart) {
charts = [args.chart]
}
return await req(Methods.summarizeCharts, {
charts,
after: args.after,
points: args.points,
})
}
case 'query_metric': case 'query_metric':
return await req(Methods.queryData, { return await req(Methods.queryData, {
chart: args.chart, chart: args.chart,
@@ -240,10 +537,39 @@ export function createToolRunner(deps) {
points: args.points ?? 90, points: args.points ?? 90,
group: args.group || 'average', group: args.group || 'average',
}) })
case 'hot_metrics':
return await req(Methods.hotMetrics, {
limit: args.limit,
window: args.window,
family: args.family || args.q || '',
})
case 'related_charts':
return await req(Methods.relatedCharts, {
chart: args.chart || args.id,
limit: args.limit,
})
case 'compare_chart_windows':
return await req(Methods.compareChartWindows, {
chart: args.chart || args.id,
after: args.after,
before: args.before,
baselineAfter: args.baselineAfter,
baselineBefore: args.baselineBefore,
points: args.points,
group: args.group,
})
case 'get_weights':
return await req(Methods.getWeights, {
chart: args.chart || args.id,
limit: args.limit ?? 20,
method: args.method || 'alerts',
})
case 'list_anomalies': case 'list_anomalies':
return await req(Methods.listAnomalies, { limit: args.limit ?? 30 }) return await req(Methods.listAnomalies, { limit: args.limit ?? 30 })
case 'list_alerts': case 'list_alerts':
return await req(Methods.listAlerts, {}) return await req(Methods.listAlerts, {})
case 'get_alert':
return await req(Methods.getAlert, { id: args.id })
case 'list_processes': case 'list_processes':
return await req(Methods.listProcesses, { return await req(Methods.listProcesses, {
sort: args.sort || 'cpu', sort: args.sort || 'cpu',
@@ -258,6 +584,8 @@ export function createToolRunner(deps) {
}) })
case 'fleet_health': case 'fleet_health':
return await req(Methods.getFleetHealth, {}) return await req(Methods.getFleetHealth, {})
case 'list_child_peers':
return await req(Methods.listChildPeers, {})
case 'storage_info': { case 'storage_info': {
const [storage, retention] = await Promise.all([ const [storage, retention] = await Promise.all([
req(Methods.getStorageInfo, {}), req(Methods.getStorageInfo, {}),
@@ -265,6 +593,20 @@ export function createToolRunner(deps) {
]) ])
return { storage, retention } return { storage, retention }
} }
case 'agent_health':
return await req(Methods.getHealth, {})
case 'node_info':
return await req(Methods.getNodeInfo, {})
case 'db_info':
return await req(Methods.getDbInfo, {})
case 'list_contexts':
return await req(Methods.listContexts, {})
case 'get_chart':
return await req(Methods.getChart, {
id: args.chart || args.id,
})
case 'list_jobs':
return await req(Methods.listJobs, {})
case 'local_knowledge': { case 'local_knowledge': {
const ctx = buildRagContext({ const ctx = buildRagContext({
query: String(args.q || ''), query: String(args.q || ''),
@@ -282,10 +624,6 @@ export function createToolRunner(deps) {
return { ok: true, view: args.view } return { ok: true, view: args.view }
} }
case 'silence_alert': { case 'silence_alert': {
const role = deps.getRole?.() || Roles.viewer
if (!roleAllows(role, Roles.operator)) {
return { error: 'Operator role required to silence alerts' }
}
if (!args.confirmed) { if (!args.confirmed) {
return { return {
error: 'confirmation_required', error: 'confirmation_required',
@@ -302,6 +640,33 @@ export function createToolRunner(deps) {
durationMs: args.durationMs ?? 3_600_000, durationMs: args.durationMs ?? 3_600_000,
}) })
} }
case 'ack_alert': {
if (!args.confirmed) {
return {
error: 'confirmation_required',
message: `Confirm acknowledging alert ${args.id} before retrying with confirmed=true`,
}
}
const ok =
(await deps.confirmAction?.(`Acknowledge alert ${args.id}?`)) !== false
if (!ok) return { error: 'User declined acknowledge' }
return await req(Methods.ackAlert, { id: args.id })
}
case 'run_job': {
if (!args.confirmed) {
return {
error: 'confirmation_required',
message: `Confirm running job "${args.name}" before retrying with confirmed=true`,
}
}
const ok =
(await deps.confirmAction?.(`Run job "${args.name}" on the agent?`)) !== false
if (!ok) return { error: 'User declined run_job' }
return await req(Methods.runJob, {
name: args.name,
args: args.args || {},
})
}
default: default:
return { error: `Unknown tool: ${name}` } return { error: `Unknown tool: ${name}` }
} }
@@ -311,15 +676,18 @@ export function createToolRunner(deps) {
} }
/** /**
* Tools available for the current role. * Tools available for the current role + profile depth.
* @param {{ profileId?: string, profile?: { id?: string }, depth?: 'core'|'deep' }} [opts]
*/ */
function defsForRole() { function defsForRole(opts = {}) {
const role = deps.getRole?.() || Roles.viewer const role = deps.getRole?.() || Roles.viewer
if (roleAllows(role, Roles.operator)) return TOOL_DEFS const profileId = opts.profileId || opts.profile?.id || 'recommended'
return TOOL_DEFS.filter((t) => t.function.name !== 'silence_alert') const depth = opts.depth || toolDepthForProfile(profileId)
const filtered = TOOL_DEFS.filter((t) => includeTool(t, depth, role))
return wireDefs(filtered)
} }
return { run, defsForRole, TOOL_DEFS } return { run, defsForRole, TOOL_DEFS, toolDepthForProfile }
} }
/** /**
@@ -333,7 +701,6 @@ export async function fallbackComplete(userText, tools, opts = {}) {
/** @type {Array<{ name: string, args: object, result: any }>} */ /** @type {Array<{ name: string, args: object, result: any }>} */
const calls = [] const calls = []
// Product / how-to questions can skip live snapshot
const howTo = const howTo =
q.includes('how do') || q.includes('how do') ||
q.includes('what is qvac') || q.includes('what is qvac') ||
@@ -341,7 +708,21 @@ export async function fallbackComplete(userText, tools, opts = {}) {
q.includes('keyboard') || q.includes('keyboard') ||
q.includes('retention') q.includes('retention')
if (!howTo) { const wantsDiagnose =
!howTo &&
(q.includes("what's wrong") ||
q.includes('whats wrong') ||
q.includes('diagnose') ||
q.includes('investigat') ||
q.includes('summarize host') ||
q.includes('host health') ||
q.includes('what is wrong') ||
(q.includes('health') && !q.includes('md.')))
if (wantsDiagnose) {
const inv = await tools.run('investigate_host', { processLimit: 10, hotLimit: 10 })
calls.push({ name: 'investigate_host', args: {}, result: inv })
} else if (!howTo) {
const snap = await tools.run('host_snapshot', {}) const snap = await tools.run('host_snapshot', {})
calls.push({ name: 'host_snapshot', args: {}, result: snap }) calls.push({ name: 'host_snapshot', args: {}, result: snap })
} }
@@ -355,6 +736,10 @@ export async function fallbackComplete(userText, tools, opts = {}) {
const p = await tools.run('list_processes', { limit: 10 }) const p = await tools.run('list_processes', { limit: 10 })
calls.push({ name: 'list_processes', args: { limit: 10 }, result: p }) calls.push({ name: 'list_processes', args: { limit: 10 }, result: p })
} }
if (q.includes('hot') || q.includes('spiking') || q.includes('unusual')) {
const h = await tools.run('hot_metrics', { limit: 12 })
calls.push({ name: 'hot_metrics', args: { limit: 12 }, result: h })
}
if ( if (
q.includes('chart') || q.includes('chart') ||
q.includes('metric') || q.includes('metric') ||
@@ -370,6 +755,13 @@ export async function fallbackComplete(userText, tools, opts = {}) {
const s = await tools.run('search_charts', { q: term, limit: 12 }) const s = await tools.run('search_charts', { q: term, limit: 12 })
calls.push({ name: 'search_charts', args: { q: term }, result: s }) calls.push({ name: 'search_charts', args: { q: term }, result: s })
} }
if (q.includes('related') || q.includes('correlat')) {
const chartMatch = q.match(/\b([a-z][a-z0-9_.-]+\.[a-z0-9_.-]+)\b/)
if (chartMatch) {
const r = await tools.run('related_charts', { chart: chartMatch[1], limit: 10 })
calls.push({ name: 'related_charts', args: { chart: chartMatch[1] }, result: r })
}
}
if (q.includes('anomal') || q.includes('alert')) { if (q.includes('anomal') || q.includes('alert')) {
const a = await tools.run('list_anomalies', { limit: 15 }) const a = await tools.run('list_anomalies', { limit: 15 })
calls.push({ name: 'list_anomalies', args: {}, result: a }) calls.push({ name: 'list_anomalies', args: {}, result: a })
@@ -399,7 +791,10 @@ export async function fallbackComplete(userText, tools, opts = {}) {
calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s }) calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s })
} }
const snap = calls.find((c) => c.name === 'host_snapshot')?.result || null const snap =
calls.find((c) => c.name === 'investigate_host')?.result ||
calls.find((c) => c.name === 'host_snapshot')?.result ||
null
const text = formatFallbackAnswer(userText, snap, calls, howTo) const text = formatFallbackAnswer(userText, snap, calls, howTo)
return { contentText: text, toolCalls: calls, mode: 'fallback' } return { contentText: text, toolCalls: calls, mode: 'fallback' }
} }
@@ -411,30 +806,54 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
const lines = [] const lines = []
if (snap && !snap.error) { if (snap && !snap.error) {
lines.push(`**Host snapshot** (${snap.hostname || 'agent'})`) if (snap.findings || snap.summary) {
if (snap.health) { lines.push(`**Investigation** (${snap.hostname || 'agent'})`)
lines.push( if (snap.summary) {
`- Health: status=${snap.health.status || '—'}, warnings=${snap.health.warnings ?? '—'}, critical=${snap.health.critical ?? '—'}` lines.push(
) `- findings=${snap.summary.findingCount ?? 0} top=${snap.summary.topSeverity || 'ok'} health=${snap.summary.health || '—'}`
} )
if (snap.kpis) {
for (const [k, v] of Object.entries(snap.kpis)) {
if (!v) continue
lines.push(`- ${k}: **${fmt(v.value)}** (${v.chart}.${v.dim})`)
} }
} for (const f of (snap.findings || []).slice(0, 8)) {
if (snap.anomalies?.length) { lines.push(`- [${f.severity || 'info'}] ${f.area || ''}: ${f.message || ''}`)
lines.push(`- Recent anomalies: ${snap.anomalies.length}`) }
for (const a of snap.anomalies.slice(0, 5)) { if (snap.kpis) {
lines.push(` · ${a.severity || '?'} ${a.chart || ''}${a.message || ''}`) for (const [k, v] of Object.entries(snap.kpis)) {
if (!v) continue
lines.push(`- ${k}: **${fmt(v.value)}** (${v.chart}.${v.dim})`)
}
}
if (snap.processes?.top?.length) {
lines.push('**Top processes**')
for (const p of snap.processes.top.slice(0, 6)) {
lines.push(`- pid ${p.pid} ${p.name || ''} cpu=${fmt(p.cpu)}`)
}
} }
} else { } else {
lines.push('- No recent anomalies in the snapshot.') lines.push(`**Host snapshot** (${snap.hostname || 'agent'})`)
if (snap.health) {
lines.push(
`- Health: status=${snap.health.status || '—'}, warnings=${snap.health.warnings ?? '—'}, critical=${snap.health.critical ?? '—'}`
)
}
if (snap.kpis) {
for (const [k, v] of Object.entries(snap.kpis)) {
if (!v) continue
lines.push(`- ${k}: **${fmt(v.value)}** (${v.chart}.${v.dim})`)
}
}
if (snap.anomalies?.length) {
lines.push(`- Recent anomalies: ${snap.anomalies.length}`)
for (const a of snap.anomalies.slice(0, 5)) {
lines.push(` · ${a.severity || '?'} ${a.chart || ''}${a.message || ''}`)
}
} else {
lines.push('- No recent anomalies in the snapshot.')
}
} }
} }
for (const c of calls) { for (const c of calls) {
if (c.name === 'host_snapshot') continue if (c.name === 'host_snapshot' || c.name === 'investigate_host') continue
if (c.name === 'local_knowledge' && c.result?.context) { if (c.name === 'local_knowledge' && c.result?.context) {
lines.push(`\n**Local knowledge**\n${c.result.context}`) lines.push(`\n**Local knowledge**\n${c.result.context}`)
} }
@@ -444,6 +863,18 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
lines.push(`- \`${r.id}\`${r.title || ''}`) lines.push(`- \`${r.id}\`${r.title || ''}`)
} }
} }
if (c.name === 'hot_metrics' && c.result?.results) {
lines.push(`\n**Hot metrics** (${c.result.results.length})`)
for (const r of c.result.results.slice(0, 8)) {
lines.push(`- \`${r.chart}\` score=${fmt(r.score)}${r.reason || ''}`)
}
}
if (c.name === 'related_charts' && c.result?.results) {
lines.push(`\n**Related to ${c.result.chart}**`)
for (const r of c.result.results.slice(0, 8)) {
lines.push(`- \`${r.id}\` (${fmt(r.score)}) ${r.reason || ''}`)
}
}
if (c.name === 'summarize_chart' && c.result?.dims) { if (c.name === 'summarize_chart' && c.result?.dims) {
lines.push(`\n**${c.result.chart}** (${c.result.points} pts, source=${c.result.source})`) lines.push(`\n**${c.result.chart}** (${c.result.points} pts, source=${c.result.source})`)
for (const [dim, st] of Object.entries(c.result.dims)) { for (const [dim, st] of Object.entries(c.result.dims)) {
@@ -495,7 +926,9 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
} }
if (!lines.length) { if (!lines.length) {
lines.push('No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).') lines.push(
'No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).'
)
} }
lines.push( lines.push(
+83
View File
@@ -3896,6 +3896,89 @@ html[data-theme='light'] .proc-detail-cmd {
border: 1px solid rgba(52, 211, 153, 0.28); border: 1px solid rgba(52, 211, 153, 0.28);
} }
/* Thinking panel (Qwen <think> blocks) */
.qvac-think {
margin: 0 0 10px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, #a78bfa 35%, var(--border-color));
background: color-mix(in srgb, #a78bfa 8%, var(--bg-secondary));
overflow: hidden;
}
.qvac-think-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
list-style: none;
font-size: 12px;
font-weight: 600;
color: #c4b5fd;
user-select: none;
}
.qvac-think-summary::-webkit-details-marker {
display: none;
}
.qvac-think-ico {
opacity: 0.85;
font-size: 11px;
}
.qvac-think-live {
margin-left: auto;
font-size: 10px;
font-weight: 500;
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, #a78bfa 22%, transparent);
color: #ddd6fe;
animation: qvac-pulse 1.2s ease-in-out infinite;
}
@keyframes qvac-pulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.qvac-think-body {
padding: 8px 12px 10px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
max-height: 260px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid color-mix(in srgb, #a78bfa 20%, var(--border-color));
opacity: 0.92;
/* User-driven scrolls feel smooth; live stream pins via JS each frame */
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
/* While the model is still thinking, pin without fighting the stream */
.qvac-think[open]:has(.qvac-think-live) .qvac-think-body {
scroll-behavior: auto;
}
.qvac-msg-answer {
line-height: 1.5;
}
.qvac-msg-pending {
font-size: 13px;
font-style: italic;
}
.qvac-composer { .qvac-composer {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
+11 -5
View File
@@ -79,17 +79,23 @@ If a build was made with `PEARDATA_SKIP_QVAC=1`, the tab stays tools-only (metri
## Tools available to chat ## Tools available to chat
Tools are tiered (core always; deep on Recommended/Strong; write for operators).
| Tool | Purpose | | Tool | Purpose |
|------|---------| |------|---------|
| `host_snapshot` | Live health + KPIs | | `investigate_host` | **Best first call** — findings, hot charts, top processes |
| `search_charts` / `summarize_chart` / `query_metric` | Catalog + time series | | `host_snapshot` | Lighter live health + KPIs |
| `list_anomalies` / `list_alerts` | Anomaly / alert state | | `hot_metrics` / `related_charts` / `compare_chart_windows` | Investigation drill-down |
| `search_charts` / `summarize_chart` / `summarize_charts` / `query_metric` | Catalog + time series |
| `get_weights` | Correlation / influence for a chart |
| `list_anomalies` / `list_alerts` / `get_alert` | Anomaly / alert state |
| `list_processes` | Top processes (when enabled on agent) | | `list_processes` | Top processes (when enabled on agent) |
| `query_logs` | Journal / anomaly / audit lines | | `query_logs` | Journal / anomaly / audit lines |
| `fleet_health` / `storage_info` | Fleet + retention | | `fleet_health` / `list_child_peers` / `storage_info` | Fleet + retention |
| `agent_health` / `node_info` / `db_info` / `list_contexts` / `get_chart` / `list_jobs` | Agent meta |
| `local_knowledge` | Offline product how-tos | | `local_knowledge` | Offline product how-tos |
| `open_chart` / `open_view` | Navigate the desktop UI | | `open_chart` / `open_view` | Navigate the desktop UI |
| `silence_alert` | Operator only, with confirmation | | `silence_alert` / `ack_alert` / `run_job` | Operator only, with confirmation |
## Privacy ## Privacy