FIX: QVAC Updates
This commit is contained in:
+33
-2
@@ -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.
|
||||
|
||||
### 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)
|
||||
|
||||
`@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 |
|
||||
|--------|---------|
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
`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
|
||||
|
||||
|
||||
+10
-1
@@ -221,7 +221,6 @@ function configureQvacWorkerEnv() {
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
configureQvacWorkerEnv()
|
||||
const icon = resolveAppIcon()
|
||||
const win = new BrowserWindow({
|
||||
width: pkg.pear?.gui?.width || 1280,
|
||||
@@ -268,6 +267,14 @@ ipcMain.handle('peardata:exit', () => {
|
||||
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) {
|
||||
try {
|
||||
return BrowserWindow.fromWebContents(evt.sender)
|
||||
@@ -308,6 +315,8 @@ app.whenReady().then(async () => {
|
||||
ensureDir(storageDir())
|
||||
|
||||
const appRoot = app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
|
||||
// Worker path for Bare spawn (main process) before any QVAC IPC
|
||||
configureQvacWorkerEnv()
|
||||
|
||||
const dockIcon = resolveAppIcon()
|
||||
if (dockIcon && process.platform === 'darwin' && app.dock) {
|
||||
|
||||
@@ -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
@@ -139,7 +139,7 @@ const IGNORE_PREFIXES = [
|
||||
|
||||
const IGNORE_REGEX = [
|
||||
/^\/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\/@esbuild\//,
|
||||
/\.md$/i,
|
||||
@@ -156,6 +156,20 @@ const IGNORE_REGEX = [
|
||||
/^\/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) {
|
||||
const marker = '/prebuilds/'
|
||||
const idx = file.indexOf(marker)
|
||||
@@ -188,6 +202,15 @@ function shouldIgnore(file) {
|
||||
if (isForeignPrebuild(file)) return true
|
||||
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) {
|
||||
if (file === p || file.startsWith(p + '/') || file.startsWith(p)) return true
|
||||
}
|
||||
@@ -379,6 +402,37 @@ module.exports = {
|
||||
console.log(
|
||||
`[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') {
|
||||
const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs')
|
||||
if (fs.existsSync(bundle)) {
|
||||
|
||||
Generated
+8
-1
@@ -32,6 +32,7 @@
|
||||
"bare-performance": "^2.0.0",
|
||||
"bare-process": "^4.5.1",
|
||||
"bare-querystring": "^1.0.0",
|
||||
"bare-runtime": "^1.30.3",
|
||||
"bare-stream": "^2.7.0",
|
||||
"bare-string-decoder": "^1.0.0",
|
||||
"bare-subprocess": "^5.2.3",
|
||||
@@ -69,7 +70,6 @@
|
||||
"@electron-forge/plugin-base": "^7.11.2",
|
||||
"@electron/get": "^3.1.0",
|
||||
"bare-build": "^1.0.2",
|
||||
"bare-runtime": "1.30.3",
|
||||
"brittle": "^4.1.0",
|
||||
"electron": "^33.4.11",
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -77,6 +77,13 @@
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
|
||||
+8
-1
@@ -98,6 +98,7 @@
|
||||
"bare-performance": "^2.0.0",
|
||||
"bare-process": "^4.5.1",
|
||||
"bare-querystring": "^1.0.0",
|
||||
"bare-runtime": "^1.30.3",
|
||||
"bare-stream": "^2.7.0",
|
||||
"bare-string-decoder": "^1.0.0",
|
||||
"bare-subprocess": "^5.2.3",
|
||||
@@ -129,13 +130,19 @@
|
||||
"which-runtime": "^1.4.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": {
|
||||
"@electron-forge/cli": "^7.11.2",
|
||||
"@electron-forge/maker-zip": "^7.11.2",
|
||||
"@electron-forge/plugin-base": "^7.11.2",
|
||||
"@electron/get": "^3.1.0",
|
||||
"bare-build": "^1.0.2",
|
||||
"bare-runtime": "1.30.3",
|
||||
"brittle": "^4.1.0",
|
||||
"electron": "^33.4.11",
|
||||
"esbuild": "^0.25.0",
|
||||
|
||||
@@ -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
@@ -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:
|
||||
* - 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
|
||||
* - @qvac/sdk/llamacpp-completion/plugin
|
||||
*/
|
||||
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.
|
||||
await import('@qvac/sdk/worker-core')
|
||||
import { llmPlugin } from "file:///Users/raven/dev/peardata/node_modules/@qvac/sdk/dist/server/bare/plugins/llamacpp-completion/plugin.js";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -48,6 +48,11 @@ import {
|
||||
getHostSnapshot,
|
||||
searchCharts,
|
||||
summarizeChart,
|
||||
investigateHost,
|
||||
hotMetrics,
|
||||
relatedCharts,
|
||||
compareChartWindows,
|
||||
summarizeCharts,
|
||||
} from '../services/ai-tools.js'
|
||||
import {
|
||||
getRetentionConfig,
|
||||
@@ -169,6 +174,21 @@ export function registerMonitorHandlers(session) {
|
||||
session.respond('summarizeChart', async (args) => summarizeChart(args || {}), {
|
||||
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) =>
|
||||
queryLogs({ ...(args || {}), role: session.role })
|
||||
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
getHostSnapshot,
|
||||
searchCharts,
|
||||
summarizeChart,
|
||||
investigateHost,
|
||||
hotMetrics,
|
||||
relatedCharts,
|
||||
compareChartWindows,
|
||||
summarizeCharts,
|
||||
} from '../services/ai-tools.js'
|
||||
|
||||
/**
|
||||
@@ -383,6 +388,62 @@ export async function handleRest(pathname, query) {
|
||||
if (path === '/api/v3/ai/snapshot') {
|
||||
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') {
|
||||
return json(searchCharts({ q: query.get('q') || '', limit: Number(query.get('limit')) || 30 }))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
if (!a) return a
|
||||
return {
|
||||
|
||||
@@ -66,6 +66,11 @@ export const MethodRoles = Object.freeze({
|
||||
getHostSnapshot: Roles.viewer,
|
||||
searchCharts: Roles.viewer,
|
||||
summarizeChart: Roles.viewer,
|
||||
investigateHost: Roles.viewer,
|
||||
hotMetrics: Roles.viewer,
|
||||
relatedCharts: Roles.viewer,
|
||||
compareChartWindows: Roles.viewer,
|
||||
summarizeCharts: Roles.viewer,
|
||||
|
||||
// live subscription control
|
||||
subscribeMetrics: Roles.viewer,
|
||||
@@ -159,5 +164,10 @@ export const HotMethods = Object.freeze(
|
||||
Methods.getHostSnapshot,
|
||||
Methods.searchCharts,
|
||||
Methods.summarizeChart,
|
||||
Methods.investigateHost,
|
||||
Methods.hotMetrics,
|
||||
Methods.relatedCharts,
|
||||
Methods.compareChartWindows,
|
||||
Methods.summarizeCharts,
|
||||
])
|
||||
)
|
||||
|
||||
+71
-6
@@ -33,8 +33,79 @@ export function validateMethodArgs(method, args = {}) {
|
||||
case 'listPeers':
|
||||
case 'getWeights':
|
||||
case 'getHostSnapshot':
|
||||
case 'investigateHost':
|
||||
case 'getDbInfo':
|
||||
case 'listPeerLinks':
|
||||
case 'listChildPeers':
|
||||
case 'getFleetHealth':
|
||||
case 'getStorageInfo':
|
||||
case 'getRetentionConfig':
|
||||
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': {
|
||||
const limit = args.limit == null ? 30 : Number(args.limit)
|
||||
if (!Number.isFinite(limit) || limit < 1 || limit > 100) {
|
||||
@@ -259,12 +330,6 @@ export function validateMethodArgs(method, args = {}) {
|
||||
}
|
||||
|
||||
case 'exportSnapshot':
|
||||
case 'getDbInfo':
|
||||
case 'listPeerLinks':
|
||||
case 'getFleetHealth':
|
||||
case 'listChildPeers':
|
||||
case 'getStorageInfo':
|
||||
case 'getRetentionConfig':
|
||||
return { ok: true, args }
|
||||
|
||||
case 'setRetentionConfig': {
|
||||
|
||||
+130
-12
@@ -5,7 +5,16 @@ import { suggestProfile, getProfile, QVAC_PROFILES } from '../ui/qvac/profiles.j
|
||||
import { buildSystemPrompt } from '../ui/qvac/prompts.js'
|
||||
import { buildRagContext, retrieve, catalogDocs, GUIDE_DOCS } from '../ui/qvac/rag.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 crypto from 'hypercore-crypto'
|
||||
import b4a from 'b4a'
|
||||
@@ -16,25 +25,63 @@ initAuthKeys({
|
||||
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) => {
|
||||
for (const m of ['getHostSnapshot', 'searchCharts', 'summarizeChart']) {
|
||||
for (const m of AI_METHODS) {
|
||||
t.is(MethodRoles[m], Roles.viewer, 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('investigateHost', {}).ok)
|
||||
|
||||
const s = validateMethodArgs('searchCharts', { q: 'cpu', limit: 10 })
|
||||
t.ok(s.ok)
|
||||
t.is(s.args.q, 'cpu')
|
||||
const bad = validateMethodArgs('searchCharts', { limit: 999 })
|
||||
t.absent(bad.ok)
|
||||
|
||||
const sum = validateMethodArgs('summarizeChart', { chart: 'system.cpu', points: 60 })
|
||||
t.ok(sum.ok)
|
||||
t.is(sum.args.chart, 'system.cpu')
|
||||
const miss = validateMethodArgs('summarizeChart', {})
|
||||
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) => {
|
||||
@@ -45,11 +92,12 @@ test('QVAC profiles suggest by RAM', (t) => {
|
||||
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 })
|
||||
t.ok(p.includes('Never invent'))
|
||||
t.ok(p.includes('lab'))
|
||||
t.ok(p.includes('viewer'))
|
||||
t.ok(p.includes('investigate_host'))
|
||||
})
|
||||
|
||||
test('RAG retrieves guide + catalog', (t) => {
|
||||
@@ -94,12 +142,46 @@ test('searchCharts and summarizeChart against store', async (t) => {
|
||||
const snap = await getHostSnapshot()
|
||||
t.ok(snap.hostname)
|
||||
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) => {
|
||||
const { createToolRunner, fallbackComplete, TOOL_DEFS } = await import('../ui/qvac/tools.js')
|
||||
t.ok(TOOL_DEFS.some((d) => d.function.name === 'host_snapshot'))
|
||||
t.ok(TOOL_DEFS.some((d) => d.function.name === 'fleet_health'))
|
||||
test('tool runner gates offline + silence role + tiers', async (t) => {
|
||||
const {
|
||||
createToolRunner,
|
||||
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
|
||||
/** @type {string} */
|
||||
@@ -109,8 +191,22 @@ test('tool runner gates offline + silence role', async (t) => {
|
||||
manager: {
|
||||
request: async (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 === '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 }
|
||||
},
|
||||
active: null,
|
||||
@@ -128,6 +224,10 @@ test('tool runner gates offline + silence role', async (t) => {
|
||||
const snap = await tools.run('host_snapshot', {})
|
||||
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 })
|
||||
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 })
|
||||
t.is(silence2.error, 'confirmation_required')
|
||||
|
||||
const defsViewer = (() => {
|
||||
role = 'viewer'
|
||||
return tools.defsForRole().map((d) => d.function.name)
|
||||
})()
|
||||
const defsViewer = tools.defsForRole({ profileId: 'recommended' }).map((d) => d.name)
|
||||
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' })
|
||||
t.ok(kn.context)
|
||||
@@ -148,4 +263,7 @@ test('tool runner gates offline + silence role', async (t) => {
|
||||
t.is(fb.mode, 'fallback')
|
||||
t.ok(fb.contentText)
|
||||
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'))
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
+455
-49
@@ -2,10 +2,72 @@
|
||||
* QVAC engine facade — load models, stream completion, tool loop.
|
||||
* Uses @qvac/sdk when available; otherwise tools-only fallback.
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
import { existsSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { getProfile } from './profiles.js'
|
||||
import { buildSystemPrompt } from './prompts.js'
|
||||
import { fallbackComplete } from './tools.js'
|
||||
import { buildRagContext } from './rag.js'
|
||||
import {
|
||||
compactMessages,
|
||||
estimateToolsTokens,
|
||||
isContextOverflowError,
|
||||
promptBudget,
|
||||
} from './context.js'
|
||||
|
||||
/** Resolve paths without evaluating package main (safe during onboarding). */
|
||||
function resolveSdkPackagePath() {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof require === 'function') {
|
||||
try {
|
||||
return require.resolve('@qvac/sdk')
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
try {
|
||||
let base
|
||||
try {
|
||||
base = fileURLToPath(import.meta.url)
|
||||
} catch {
|
||||
base = join(process.cwd(), 'package.json')
|
||||
}
|
||||
const req = createRequire(base)
|
||||
return req.resolve('@qvac/sdk')
|
||||
} catch {
|
||||
// fall through to disk probe
|
||||
}
|
||||
const candidates = [
|
||||
join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'package.json'),
|
||||
join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'dist', 'index.js'),
|
||||
]
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof __dirname !== 'undefined') {
|
||||
candidates.push(join(__dirname, '..', 'node_modules', '@qvac', 'sdk', 'package.json'))
|
||||
candidates.push(join(__dirname, '..', '..', 'node_modules', '@qvac', 'sdk', 'package.json'))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
// Packaged Electron: Resources/app/node_modules
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof process !== 'undefined' && process.resourcesPath) {
|
||||
candidates.push(
|
||||
join(process.resourcesPath, 'app', 'node_modules', '@qvac', 'sdk', 'package.json')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
@@ -16,9 +78,27 @@ import { buildRagContext } from './rag.js'
|
||||
* log?: (msg: string) => void,
|
||||
* }} deps
|
||||
*/
|
||||
/**
|
||||
* Electron renderer? Never require('@qvac/sdk') here — use main-process IPC.
|
||||
*/
|
||||
function getElectronIpc() {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof process !== 'undefined' && process.type === 'renderer') {
|
||||
// eslint-disable-next-line no-undef
|
||||
return typeof require === 'function' ? require('electron').ipcRenderer : null
|
||||
}
|
||||
} catch {
|
||||
// not electron
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createQvacEngine(deps) {
|
||||
/** @type {any} */
|
||||
let sdk = null
|
||||
/** @type {'direct'|'main'|null} */
|
||||
let sdkMode = null
|
||||
let sdkError = null
|
||||
/** @type {string|null} */
|
||||
let modelId = null
|
||||
@@ -32,6 +112,7 @@ export function createQvacEngine(deps) {
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let idleTimer = null
|
||||
let lastActivity = Date.now()
|
||||
const ipc = getElectronIpc()
|
||||
|
||||
function touchActivity() {
|
||||
lastActivity = Date.now()
|
||||
@@ -55,30 +136,63 @@ export function createQvacEngine(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve @qvac/sdk for both ESM (pear run) and CJS Electron bundle.
|
||||
* Packaged Electron: packages are external requires under app/node_modules.
|
||||
* Lightweight "is the package on disk?" check — never evaluates @qvac/sdk.
|
||||
* @returns {{ installed: boolean, error: string|null }}
|
||||
*/
|
||||
function detectSdkInstalled() {
|
||||
if (sdk || modelId) return { installed: true, error: null }
|
||||
if (ipc) {
|
||||
// Sync detect via invoke is async; use path resolve for UI, main confirms on load
|
||||
const path = resolveSdkPackagePath()
|
||||
if (path) return { installed: true, error: null }
|
||||
// Packaged app may resolve from Resources/app — still try disk
|
||||
return { installed: Boolean(resolveSdkPackagePath()), error: null }
|
||||
}
|
||||
const path = resolveSdkPackagePath()
|
||||
if (path) return { installed: true, error: null }
|
||||
return { installed: false, error: '@qvac/sdk not installed in this app' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we can talk to QVAC (main IPC in Electron, direct require only outside renderer).
|
||||
* @param {number} [timeoutMs]
|
||||
*/
|
||||
async function tryLoadSdk(timeoutMs = 8_000) {
|
||||
if (sdk) 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 {
|
||||
const load = (async () => {
|
||||
// Prefer dynamic import (ESM / pear). Falls back to createRequire for CJS.
|
||||
try {
|
||||
return await import('@qvac/sdk')
|
||||
} catch (importErr) {
|
||||
try {
|
||||
const { createRequire } = await import('module')
|
||||
const req = createRequire(
|
||||
typeof __filename !== 'undefined'
|
||||
? __filename
|
||||
: `${process.cwd()}/package.json`
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof require === 'function') return require('@qvac/sdk')
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const { createRequire: cr } = await import('module')
|
||||
const req = cr(
|
||||
typeof __filename !== 'undefined' ? __filename : `${process.cwd()}/package.json`
|
||||
)
|
||||
return req('@qvac/sdk')
|
||||
} catch {
|
||||
throw importErr
|
||||
}
|
||||
}
|
||||
})()
|
||||
const timed =
|
||||
timeoutMs > 0
|
||||
@@ -93,24 +207,30 @@ export function createQvacEngine(deps) {
|
||||
])
|
||||
: load
|
||||
sdk = await timed
|
||||
sdkMode = 'direct'
|
||||
sdkError = null
|
||||
return sdk
|
||||
} catch (err) {
|
||||
sdkError = err?.message || String(err)
|
||||
sdk = null
|
||||
sdkMode = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
const disk = detectSdkInstalled()
|
||||
const loaded = Boolean(modelId) && (sdkMode === 'main' || Boolean(sdk))
|
||||
return {
|
||||
status,
|
||||
modelId,
|
||||
profileId,
|
||||
sdkAvailable: Boolean(sdk) && !sdkError,
|
||||
sdkError,
|
||||
sdkAvailable: loaded || disk.installed || sdkMode === 'main',
|
||||
sdkLoaded: loaded,
|
||||
sdkMode,
|
||||
sdkError: sdkError || (!disk.installed && !loaded ? disk.error : null),
|
||||
progress: lastProgress,
|
||||
mode: sdk && modelId ? 'qvac' : status === 'fallback' || !sdk ? 'fallback' : status,
|
||||
mode: loaded && status === 'ready' ? 'qvac' : status === 'fallback' || !loaded ? 'fallback' : status,
|
||||
lastActivity,
|
||||
}
|
||||
}
|
||||
@@ -122,14 +242,6 @@ export function createQvacEngine(deps) {
|
||||
: null
|
||||
let totalRamBytes = 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 {
|
||||
// eslint-disable-next-line no-undef
|
||||
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]
|
||||
*/
|
||||
async function checkEnvironment(opts = {}) {
|
||||
status = 'checking'
|
||||
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) {
|
||||
try {
|
||||
const os = await Promise.race([
|
||||
@@ -171,11 +284,15 @@ export function createQvacEngine(deps) {
|
||||
|
||||
let sdkAvailable = Boolean(sdk)
|
||||
let err = sdkError
|
||||
if (opts.probeSdk !== false && !sdk) {
|
||||
// Short probe only — full model load happens later on user action
|
||||
const s = await tryLoadSdk(opts.timeoutMs ?? 2500)
|
||||
if (opts.probeSdk === true && !sdk) {
|
||||
// Explicit full load — only for model download path, not Continue
|
||||
const s = await tryLoadSdk(opts.timeoutMs ?? 8000)
|
||||
sdkAvailable = Boolean(s)
|
||||
err = sdkError
|
||||
} else {
|
||||
const disk = detectSdkInstalled()
|
||||
sdkAvailable = disk.installed || Boolean(sdk)
|
||||
if (!disk.installed && !sdk) err = disk.error
|
||||
}
|
||||
|
||||
status = 'idle'
|
||||
@@ -198,20 +315,70 @@ export function createQvacEngine(deps) {
|
||||
const p = getProfile(profile)
|
||||
profileId = p.id
|
||||
touchActivity()
|
||||
status = 'downloading'
|
||||
lastProgress = { percentage: 0 }
|
||||
|
||||
// Electron: main-process host (never require SDK in renderer)
|
||||
if (ipc) {
|
||||
/** @type {(( _e: any, prog: any) => void)|null} */
|
||||
let onProg = null
|
||||
try {
|
||||
const bridge = await tryLoadSdk()
|
||||
if (!bridge) {
|
||||
status = 'fallback'
|
||||
return { ok: true, mode: 'fallback', profile: p.id, error: sdkError }
|
||||
}
|
||||
onProg = (_e, prog) => {
|
||||
lastProgress = prog
|
||||
status = prog?.percentage >= 100 ? 'loading' : 'downloading'
|
||||
opts.onProgress?.(prog)
|
||||
}
|
||||
ipc.on('peardata:qvac-progress', onProg)
|
||||
const result = await ipc.invoke('peardata:qvac-load', {
|
||||
chatModel: p.chatModel,
|
||||
tools: p.tools,
|
||||
ctxSize: p.ctxSize || 8192,
|
||||
})
|
||||
if (!result?.ok) {
|
||||
status = 'fallback'
|
||||
const msg = result?.error || 'load failed'
|
||||
sdkError = msg
|
||||
deps.log?.(`QVAC main load failed: ${msg}`)
|
||||
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
||||
}
|
||||
modelId = result.modelId
|
||||
status = 'ready'
|
||||
touchActivity()
|
||||
deps.log?.(`QVAC model ready (main): ${p.chatModel} → ${modelId}`)
|
||||
return { ok: true, mode: 'qvac', modelId, profile: p.id }
|
||||
} catch (err) {
|
||||
const msg = err?.message || String(err)
|
||||
status = 'fallback'
|
||||
sdkError = msg
|
||||
deps.log?.(`QVAC load failed: ${msg}`)
|
||||
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
||||
} finally {
|
||||
if (onProg) {
|
||||
try {
|
||||
ipc.removeListener('peardata:qvac-progress', onProg)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = await tryLoadSdk()
|
||||
if (!s) {
|
||||
if (!s || s.__viaMain) {
|
||||
status = 'fallback'
|
||||
deps.log?.(`QVAC SDK unavailable (${sdkError}); using tools-only fallback`)
|
||||
return { ok: true, mode: 'fallback', profile: p.id }
|
||||
}
|
||||
|
||||
const modelSrc = s[p.chatModel] || p.chatModel
|
||||
status = 'downloading'
|
||||
lastProgress = { percentage: 0 }
|
||||
loadAbort = new AbortController()
|
||||
|
||||
try {
|
||||
// Unload previous model first
|
||||
if (modelId) await unload()
|
||||
|
||||
const id = await s.loadModel({
|
||||
@@ -219,7 +386,7 @@ export function createQvacEngine(deps) {
|
||||
modelType: 'llm',
|
||||
modelConfig: {
|
||||
tools: p.tools,
|
||||
ctx_size: 4096,
|
||||
ctx_size: p.ctxSize || 8192,
|
||||
},
|
||||
onProgress: (prog) => {
|
||||
lastProgress = prog
|
||||
@@ -248,7 +415,13 @@ export function createQvacEngine(deps) {
|
||||
clearTimeout(idleTimer)
|
||||
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 {
|
||||
await sdk.unloadModel({ modelId })
|
||||
} catch {
|
||||
@@ -274,13 +447,14 @@ export function createQvacEngine(deps) {
|
||||
const userLast = [...history].reverse().find((m) => m.role === 'user')
|
||||
let system = buildSystemPrompt(deps.getContext?.() || {})
|
||||
|
||||
// Light RAG only — full catalog dumps blow small context windows
|
||||
if (prefs.rag !== false && userLast?.content) {
|
||||
const rag = buildRagContext({
|
||||
query: userLast.content,
|
||||
catalog: deps.getCatalog?.() || {},
|
||||
topK: 5,
|
||||
topK: 3,
|
||||
})
|
||||
if (rag) system += `\n\n${rag}`
|
||||
if (rag) system += `\n\n${rag.slice(0, 1200)}`
|
||||
}
|
||||
|
||||
const fullHistory = [
|
||||
@@ -288,7 +462,7 @@ export function createQvacEngine(deps) {
|
||||
...history.filter((m) => m.role !== 'system'),
|
||||
]
|
||||
|
||||
if (sdk && modelId && sdk.completion) {
|
||||
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
||||
return completeWithSdk(fullHistory, profile, opts)
|
||||
}
|
||||
|
||||
@@ -312,17 +486,142 @@ export function createQvacEngine(deps) {
|
||||
}
|
||||
|
||||
async function completeWithSdk(history, profile, opts) {
|
||||
const toolDefs = profile.tools ? deps.tools.defsForRole() : undefined
|
||||
let messages = history.map((m) => ({ role: m.role, content: m.content }))
|
||||
let toolDefs = profile.tools
|
||||
? 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++) {
|
||||
touchActivity()
|
||||
// Re-compact each tool round (payloads grow fast)
|
||||
messages = compactMessages(messages, {
|
||||
maxTokens: budget,
|
||||
keepRecentUserTurns: round === 0 ? 3 : 2,
|
||||
maxToolChars: round === 0 ? 2000 : 1000,
|
||||
maxMsgChars: round === 0 ? 3000 : 1600,
|
||||
})
|
||||
|
||||
// Electron main-process completion (streaming via IPC events)
|
||||
if (sdkMode === 'main' && ipc) {
|
||||
/** @type {(( _e: any, t: string) => void)|null} */
|
||||
let onTok = null
|
||||
/** @type {(( _e: any, t: string) => void)|null} */
|
||||
let onThink = null
|
||||
try {
|
||||
onTok = (_e, t) => opts.onToken?.(t)
|
||||
onThink = (_e, t) => opts.onThinking?.(t)
|
||||
ipc.on('peardata:qvac-token', onTok)
|
||||
ipc.on('peardata:qvac-thinking', onThink)
|
||||
const final = await ipc.invoke('peardata:qvac-complete', {
|
||||
history: messages,
|
||||
tools: toolDefs,
|
||||
})
|
||||
if (final?.error && !final.contentText) {
|
||||
if (isContextOverflowError(final.error) && overflowRetries < 3) {
|
||||
overflowRetries++
|
||||
budget = Math.floor(budget * 0.55)
|
||||
if (overflowRetries === 1 && toolDefs?.length) {
|
||||
// First overflow: drop deep tools, keep core
|
||||
toolDefs = deps.tools.defsForRole({
|
||||
profileId: profile.id,
|
||||
depth: 'core',
|
||||
})
|
||||
toolsTok = estimateToolsTokens(toolDefs)
|
||||
budget = promptBudget(ctxSize, toolsTok)
|
||||
} else if (overflowRetries >= 2) {
|
||||
// Drop tools schema on last retries — frees a lot of context
|
||||
toolDefs = undefined
|
||||
toolsTok = 0
|
||||
budget = promptBudget(ctxSize, 0)
|
||||
}
|
||||
messages = compactMessages(messages, {
|
||||
maxTokens: Math.max(600, budget),
|
||||
keepRecentUserTurns: 1,
|
||||
maxToolChars: 500,
|
||||
maxMsgChars: 900,
|
||||
})
|
||||
deps.log?.(
|
||||
`QVAC context overflow — compact #${overflowRetries} budget~${budget}`
|
||||
)
|
||||
opts.onToken?.(
|
||||
'\n_[Context compacted to fit the model window — retrying…]_\n'
|
||||
)
|
||||
round = Math.max(-1, round - 1) // retry this tool-loop round
|
||||
continue
|
||||
}
|
||||
return {
|
||||
contentText: `Model error: ${final.error}`,
|
||||
toolCalls: [],
|
||||
mode: 'fallback',
|
||||
}
|
||||
}
|
||||
const calls = (final.toolCalls || []).filter(Boolean)
|
||||
// Prefer raw content (may include <think>); UI partitions it
|
||||
let text = final.contentText || ''
|
||||
if (final.thinkingText && !/<think/i.test(text)) {
|
||||
text = `<think>\n${final.thinkingText}\n</think>\n${text}`
|
||||
}
|
||||
if (!calls.length) {
|
||||
return {
|
||||
contentText: text,
|
||||
toolCalls: [],
|
||||
mode: 'qvac',
|
||||
stats: final.stats,
|
||||
}
|
||||
}
|
||||
messages = [...messages, { role: 'assistant', content: text }]
|
||||
for (const tc of calls) {
|
||||
const name = tc.name || tc.function?.name
|
||||
let args = tc.arguments || tc.function?.arguments || {}
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args)
|
||||
} catch {
|
||||
args = {}
|
||||
}
|
||||
}
|
||||
const result = await deps.tools.run(name, args)
|
||||
opts.onTool?.(name, args, result)
|
||||
// Compact tool results for model context (keep numbers, drop noise)
|
||||
const payload = compactToolResult(name, result)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: payload,
|
||||
name,
|
||||
})
|
||||
}
|
||||
continue
|
||||
} finally {
|
||||
if (onTok) {
|
||||
try {
|
||||
ipc.removeListener('peardata:qvac-token', onTok)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (onThink) {
|
||||
try {
|
||||
ipc.removeListener('peardata:qvac-thinking', onThink)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const run = sdk.completion({
|
||||
modelId,
|
||||
history: messages,
|
||||
stream: true,
|
||||
tools: toolDefs,
|
||||
captureThinking: false,
|
||||
captureThinking: true,
|
||||
})
|
||||
|
||||
let content = ''
|
||||
@@ -332,14 +631,17 @@ export function createQvacEngine(deps) {
|
||||
if (run.events) {
|
||||
for await (const ev of run.events) {
|
||||
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
|
||||
opts.onToken?.(ev.text)
|
||||
} else if (ev.type === 'toolCall') {
|
||||
const call = ev.call || ev.toolCall || ev
|
||||
toolCalls.push({
|
||||
name: ev.name || ev.toolCall?.name,
|
||||
arguments: ev.arguments || ev.toolCall?.arguments || {},
|
||||
id: ev.id || ev.toolCall?.id,
|
||||
name: call.name || ev.name,
|
||||
arguments: call.arguments || ev.arguments || {},
|
||||
id: call.id || ev.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -377,7 +679,7 @@ export function createQvacEngine(deps) {
|
||||
opts.onTool?.(name, args, result)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result).slice(0, 12_000),
|
||||
content: compactToolResult(name, result),
|
||||
name,
|
||||
})
|
||||
}
|
||||
@@ -400,3 +702,107 @@ export function createQvacEngine(deps) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+266
-51
@@ -5,6 +5,7 @@ import { createQvacEngine } from './engine.js'
|
||||
import { createToolRunner } from './tools.js'
|
||||
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
|
||||
import { SAMPLE_PROMPTS } from './prompts.js'
|
||||
import { partitionThink, renderAssistantHtml } from './think.js'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
@@ -160,7 +161,9 @@ export function createQvacView(opts) {
|
||||
const sdkLine = env.checking
|
||||
? '<li class="warn">QVAC SDK: checking…</li>'
|
||||
: `<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>`
|
||||
body.innerHTML = `
|
||||
<ul class="qvac-check-list">
|
||||
@@ -228,7 +231,7 @@ export function createQvacView(opts) {
|
||||
}
|
||||
|
||||
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(
|
||||
'System check',
|
||||
'<p class="muted">Preparing environment check…</p>',
|
||||
@@ -245,8 +248,10 @@ export function createQvacView(opts) {
|
||||
paintSystemCheck(card, quick)
|
||||
bindSystemCheckActions(card)
|
||||
|
||||
// Yield a frame so the card paints before any async work
|
||||
requestAnimationFrame(() => {
|
||||
engine
|
||||
.checkEnvironment({ probeSdk: true, timeoutMs: 2500 })
|
||||
.checkEnvironment({ probeSdk: false })
|
||||
.then((env) => {
|
||||
if (wizardStep !== 1) return
|
||||
if (!card.isConnected) return
|
||||
@@ -266,6 +271,7 @@ export function createQvacView(opts) {
|
||||
})
|
||||
bindSystemCheckActions(card)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,7 +306,17 @@ export function createQvacView(opts) {
|
||||
onClick: () => {
|
||||
wizardStep = 3
|
||||
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 = {}) {
|
||||
messages.push({ role, content, tools: meta.tools })
|
||||
const entry = { role, content, tools: meta.tools, thinking: '' }
|
||||
messages.push(entry)
|
||||
const list = opts.els.messages
|
||||
if (!list) return
|
||||
if (!list) return null
|
||||
const div = document.createElement('div')
|
||||
div.className = `qvac-msg qvac-msg-${role}`
|
||||
const body = document.createElement('div')
|
||||
body.className = 'qvac-msg-body'
|
||||
if (role === 'assistant') {
|
||||
body.innerHTML = renderAssistantHtml(content, formatMdLite, {
|
||||
openThink: Boolean(meta.streaming),
|
||||
})
|
||||
} else {
|
||||
body.innerHTML = formatMdLite(content)
|
||||
}
|
||||
div.appendChild(body)
|
||||
if (meta.tools?.length) {
|
||||
const chips = document.createElement('div')
|
||||
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)
|
||||
div.appendChild(renderToolChips(meta.tools))
|
||||
}
|
||||
list.appendChild(div)
|
||||
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() {
|
||||
@@ -485,49 +626,67 @@ export function createQvacView(opts) {
|
||||
busy = true
|
||||
opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
|
||||
appendMsg('user', text)
|
||||
const streamBody = appendMsg('assistant', '…')
|
||||
const assistantUi = appendMsg('assistant', '…', { streaming: true })
|
||||
const streamBody = assistantUi?.body
|
||||
const toolLog = []
|
||||
let acc = ''
|
||||
let thinkingAcc = ''
|
||||
setStatus('Thinking…', 'busy')
|
||||
try {
|
||||
// History for the model: clean answers only (no think tags)
|
||||
const hist = messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(0, -1) // drop placeholder assistant
|
||||
.map((m) => ({ role: m.role, content: m.content }))
|
||||
.slice(0, -1)
|
||||
.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 })
|
||||
|
||||
const result = await engine.complete(hist, {
|
||||
onToken: (t) => {
|
||||
acc += t
|
||||
if (streamBody) streamBody.innerHTML = formatMdLite(acc || '…')
|
||||
opts.els.messages && (opts.els.messages.scrollTop = opts.els.messages.scrollHeight)
|
||||
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
|
||||
},
|
||||
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) => {
|
||||
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
|
||||
if (streamBody) streamBody.innerHTML = formatMdLite(acc)
|
||||
// update last message in state
|
||||
if (thinkingAcc && !/<think/i.test(acc)) {
|
||||
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]
|
||||
if (last?.role === 'assistant') {
|
||||
// Store full text (with think) for UI re-open; history path strips think
|
||||
last.content = acc
|
||||
last.thinking = parts.thinking
|
||||
last.tools = toolLog
|
||||
}
|
||||
if (toolLog.length && streamBody?.parentElement) {
|
||||
let chips = streamBody.parentElement.querySelector('.qvac-tool-chips')
|
||||
if (!chips) {
|
||||
chips = document.createElement('div')
|
||||
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)
|
||||
}
|
||||
if (toolLog.length && assistantUi?.div) {
|
||||
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
|
||||
if (chips) chips.remove()
|
||||
assistantUi.div.appendChild(renderToolChips(toolLog))
|
||||
}
|
||||
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
|
||||
} 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() {
|
||||
messages = []
|
||||
if (opts.els.messages) opts.els.messages.innerHTML = ''
|
||||
@@ -574,33 +741,81 @@ export function createQvacView(opts) {
|
||||
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() {
|
||||
showPane()
|
||||
renderSamples()
|
||||
syncModelChip()
|
||||
if (isOnboarded() && !messages.length) {
|
||||
// Auto warm fallback path; full model load is manual after onboarding
|
||||
engine.tryLoadSdk().then(() => {
|
||||
const mode = settings().qvacMode
|
||||
if (mode === 'qvac' && settings().qvacProfile) {
|
||||
setStatus('Loading saved model…', 'busy')
|
||||
engine.loadProfile(settings().qvacProfile).then(() => {
|
||||
setStatus('Ready', 'ok')
|
||||
syncModelChip()
|
||||
if (isOnboarded()) {
|
||||
// Restore model in background if user previously completed Download & load
|
||||
if (!restorePromise) {
|
||||
restorePromise = restoreSavedModel().finally(() => {
|
||||
restorePromise = null
|
||||
})
|
||||
} else {
|
||||
syncModelChip()
|
||||
}
|
||||
})
|
||||
if (!messages.length) {
|
||||
const mode = settings().qvacMode
|
||||
appendMsg(
|
||||
'assistant',
|
||||
'QVAC ready. Try “Summarize host health” or pick a sample prompt.'
|
||||
mode === 'qvac'
|
||||
? 'QVAC ready — restoring your saved model if needed. Ask about host health, metrics, or processes.'
|
||||
: 'QVAC ready (tools-only). Use **Setup → Download & load** for full local chat, or ask for live metrics now.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bind()
|
||||
|
||||
// Warm model as soon as the view is constructed (if already onboarded)
|
||||
if (isOnboarded() && settings().qvacMode === 'qvac') {
|
||||
restorePromise = restoreSavedModel().finally(() => {
|
||||
restorePromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
enter,
|
||||
leave: () => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* minRamGb: number,
|
||||
* minDiskGb: number,
|
||||
* approxDownloadGb: number,
|
||||
* ctxSize: number,
|
||||
* }} QvacProfile
|
||||
*/
|
||||
|
||||
@@ -31,6 +32,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 4,
|
||||
minDiskGb: 2,
|
||||
approxDownloadGb: 0.5,
|
||||
ctxSize: 4096,
|
||||
},
|
||||
recommended: {
|
||||
id: 'recommended',
|
||||
@@ -42,6 +44,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 8,
|
||||
minDiskGb: 5,
|
||||
approxDownloadGb: 2.5,
|
||||
ctxSize: 8192,
|
||||
},
|
||||
strong: {
|
||||
id: 'strong',
|
||||
@@ -53,6 +56,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 16,
|
||||
minDiskGb: 8,
|
||||
approxDownloadGb: 3.5,
|
||||
ctxSize: 8192,
|
||||
},
|
||||
'tool-tiny': {
|
||||
id: 'tool-tiny',
|
||||
@@ -64,6 +68,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 6,
|
||||
minDiskGb: 3,
|
||||
approxDownloadGb: 1,
|
||||
ctxSize: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -20,22 +20,42 @@ export function buildSystemPrompt(ctx = {}) {
|
||||
'You run on the operator desktop; metrics come from the connected agent via tools.',
|
||||
'',
|
||||
'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.',
|
||||
'- Prefer short, operational answers: severity, numbers, chart ids, next steps.',
|
||||
'- 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.',
|
||||
'- 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.',
|
||||
'',
|
||||
'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` : ''}.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const SAMPLE_PROMPTS = [
|
||||
'Summarize host health right now',
|
||||
"What's wrong — investigate this host",
|
||||
'Why might CPU be high?',
|
||||
'Show hot / spiking metrics',
|
||||
'List charts related to disk or io',
|
||||
'Any open alerts or anomalies?',
|
||||
'Top processes by CPU if available',
|
||||
|
||||
@@ -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 || '…')
|
||||
}
|
||||
+577
-144
@@ -1,199 +1,472 @@
|
||||
/**
|
||||
* 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 { buildRagContext } from './rag.js'
|
||||
|
||||
/**
|
||||
* OpenAI-style tool definitions for QVAC completion({ tools }).
|
||||
*/
|
||||
export const TOOL_DEFS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'host_snapshot',
|
||||
description:
|
||||
'Get a compact live snapshot: health, KPIs (cpu/ram/load/net/io), recent anomalies/alerts, catalog size.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_charts',
|
||||
description: 'Search the metrics chart catalog by free text (id, title, context, family).',
|
||||
parameters: {
|
||||
/** @param {Record<string, { type: string, description?: string, enum?: any[] }>} properties */
|
||||
function params(properties = {}, required) {
|
||||
const out = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
properties: properties || {},
|
||||
}
|
||||
if (required?.length) out.required = required
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {'core'|'deep'|'write'} ToolTier
|
||||
* @typedef {{ type: 'function', name: string, description: string, parameters: object, tier?: ToolTier }} ToolDef
|
||||
*/
|
||||
|
||||
/** @type {ToolDef[]} */
|
||||
export const TOOL_DEFS = [
|
||||
// ── core: diagnosis + navigation ─────────────────────────────────────
|
||||
{
|
||||
type: 'function',
|
||||
name: 'investigate_host',
|
||||
tier: 'core',
|
||||
description:
|
||||
'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: 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',
|
||||
name: 'host_snapshot',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Compact live host KPIs (cpu, ram, load, net, io), health, recent anomalies/alerts. Use when you need a lighter snapshot than investigate_host.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
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)' },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
},
|
||||
['q']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'summarize_chart',
|
||||
tier: 'core',
|
||||
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id e.g. system.cpu' },
|
||||
after: { type: 'number', description: 'Seconds relative (e.g. -300) or absolute unix' },
|
||||
after: {
|
||||
type: 'number',
|
||||
description: 'Seconds relative (e.g. -300) or absolute unix',
|
||||
},
|
||||
points: { type: 'number', description: 'Max points (default 90)' },
|
||||
},
|
||||
required: ['chart'],
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'query_metric',
|
||||
description: 'Raw queryData for a chart time series.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chart: { type: 'string' },
|
||||
after: { type: 'number' },
|
||||
points: { type: 'number' },
|
||||
group: { type: 'string', enum: ['average', 'min', 'max', 'sum'] },
|
||||
},
|
||||
required: ['chart'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_anomalies',
|
||||
tier: 'core',
|
||||
description: 'List recent anomaly events.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { limit: { type: 'number' } },
|
||||
},
|
||||
},
|
||||
parameters: params({
|
||||
limit: { type: 'number', description: 'Max events' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_alerts',
|
||||
tier: 'core',
|
||||
description: 'List configured/open alerts on the agent.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_processes',
|
||||
tier: 'core',
|
||||
description: 'Live process table (when agent enables PEARDATA_PROCESSES).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sort: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
filter: { type: 'string' },
|
||||
},
|
||||
},
|
||||
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',
|
||||
function: {
|
||||
name: 'query_logs',
|
||||
description: 'Query agent logs: source journal|anomaly|audit, optional free-text q.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', enum: ['journal', 'anomaly', 'audit'] },
|
||||
q: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'fleet_health',
|
||||
description: 'Fleet / parent-child health summary when parent mode is enabled.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'storage_info',
|
||||
description: 'Agent storage usage and retention config.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'local_knowledge',
|
||||
tier: 'core',
|
||||
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'],
|
||||
},
|
||||
parameters: params(
|
||||
{
|
||||
q: { type: 'string', description: 'Question or keywords' },
|
||||
},
|
||||
['q']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'open_chart',
|
||||
tier: 'core',
|
||||
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chart: { type: 'string' },
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id' },
|
||||
ts: { type: 'number', description: 'Event time ms' },
|
||||
},
|
||||
required: ['chart'],
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'open_view',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
view: { type: 'string' },
|
||||
},
|
||||
required: ['view'],
|
||||
parameters: params(
|
||||
{
|
||||
view: {
|
||||
type: 'string',
|
||||
description: 'View name',
|
||||
enum: [
|
||||
'overview',
|
||||
'charts',
|
||||
'processes',
|
||||
'alerts',
|
||||
'logs',
|
||||
'fleet',
|
||||
'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',
|
||||
function: {
|
||||
name: 'related_charts',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Related charts for a seed chart (catalog family/context + optional alert weights). Use after finding a suspicious chart.',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Seed chart id' },
|
||||
limit: { type: 'number', description: 'Max results (default 12)' },
|
||||
},
|
||||
['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',
|
||||
name: 'get_weights',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'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' },
|
||||
limit: { type: 'number', description: 'Max related (default 20)' },
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'query_logs',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Query agent logs: source journal|anomaly|audit, optional free-text q. journal/audit may require admin on the agent.',
|
||||
parameters: params({
|
||||
source: {
|
||||
type: 'string',
|
||||
description: 'journal|anomaly|audit',
|
||||
enum: ['journal', 'anomaly', 'audit'],
|
||||
},
|
||||
q: { type: 'string', description: 'Search text' },
|
||||
limit: { type: 'number', description: 'Max lines' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'fleet_health',
|
||||
tier: 'deep',
|
||||
description: 'Fleet / parent-child health summary when parent mode is enabled.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'list_child_peers',
|
||||
tier: 'deep',
|
||||
description: 'List child peers in fleet/parent mode (hostname, key, hops).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'storage_info',
|
||||
tier: 'deep',
|
||||
description: 'Agent storage usage and retention config (warm/history sizes).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
durationMs: { type: 'number', description: 'Silence duration ms (default 3600000)' },
|
||||
confirmed: { type: 'boolean', description: 'Must be true after user confirms' },
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Alert id' },
|
||||
durationMs: {
|
||||
type: 'number',
|
||||
description: 'Silence duration ms (default 3600000)',
|
||||
},
|
||||
required: ['id'],
|
||||
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 {{
|
||||
* manager: { request: (m: string, a?: object) => Promise<any>, active: any },
|
||||
@@ -211,14 +484,24 @@ export function createToolRunner(deps) {
|
||||
* @param {object} args
|
||||
*/
|
||||
async function run(name, args = {}) {
|
||||
const localOk = ['open_view', 'open_chart', 'local_knowledge'].includes(name)
|
||||
if (!deps.isConnected?.() && !localOk) {
|
||||
if (!LOCAL_TOOLS.has(name) && !deps.isConnected?.()) {
|
||||
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 || {})
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'investigate_host':
|
||||
return await req(Methods.investigateHost, {
|
||||
processLimit: args.processLimit,
|
||||
hotLimit: args.hotLimit,
|
||||
})
|
||||
case 'host_snapshot':
|
||||
return await req(Methods.getHostSnapshot, {})
|
||||
case 'search_charts':
|
||||
@@ -233,6 +516,20 @@ export function createToolRunner(deps) {
|
||||
points: args.points,
|
||||
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':
|
||||
return await req(Methods.queryData, {
|
||||
chart: args.chart,
|
||||
@@ -240,10 +537,39 @@ export function createToolRunner(deps) {
|
||||
points: args.points ?? 90,
|
||||
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':
|
||||
return await req(Methods.listAnomalies, { limit: args.limit ?? 30 })
|
||||
case 'list_alerts':
|
||||
return await req(Methods.listAlerts, {})
|
||||
case 'get_alert':
|
||||
return await req(Methods.getAlert, { id: args.id })
|
||||
case 'list_processes':
|
||||
return await req(Methods.listProcesses, {
|
||||
sort: args.sort || 'cpu',
|
||||
@@ -258,6 +584,8 @@ export function createToolRunner(deps) {
|
||||
})
|
||||
case 'fleet_health':
|
||||
return await req(Methods.getFleetHealth, {})
|
||||
case 'list_child_peers':
|
||||
return await req(Methods.listChildPeers, {})
|
||||
case 'storage_info': {
|
||||
const [storage, retention] = await Promise.all([
|
||||
req(Methods.getStorageInfo, {}),
|
||||
@@ -265,6 +593,20 @@ export function createToolRunner(deps) {
|
||||
])
|
||||
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': {
|
||||
const ctx = buildRagContext({
|
||||
query: String(args.q || ''),
|
||||
@@ -282,10 +624,6 @@ export function createToolRunner(deps) {
|
||||
return { ok: true, view: args.view }
|
||||
}
|
||||
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) {
|
||||
return {
|
||||
error: 'confirmation_required',
|
||||
@@ -302,6 +640,33 @@ export function createToolRunner(deps) {
|
||||
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:
|
||||
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
|
||||
if (roleAllows(role, Roles.operator)) return TOOL_DEFS
|
||||
return TOOL_DEFS.filter((t) => t.function.name !== 'silence_alert')
|
||||
const profileId = opts.profileId || opts.profile?.id || 'recommended'
|
||||
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 }>} */
|
||||
const calls = []
|
||||
|
||||
// Product / how-to questions can skip live snapshot
|
||||
const howTo =
|
||||
q.includes('how do') ||
|
||||
q.includes('what is qvac') ||
|
||||
@@ -341,7 +708,21 @@ export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
q.includes('keyboard') ||
|
||||
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', {})
|
||||
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 })
|
||||
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 (
|
||||
q.includes('chart') ||
|
||||
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 })
|
||||
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')) {
|
||||
const a = await tools.run('list_anomalies', { limit: 15 })
|
||||
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 })
|
||||
}
|
||||
|
||||
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)
|
||||
return { contentText: text, toolCalls: calls, mode: 'fallback' }
|
||||
}
|
||||
@@ -411,6 +806,29 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
|
||||
const lines = []
|
||||
if (snap && !snap.error) {
|
||||
if (snap.findings || snap.summary) {
|
||||
lines.push(`**Investigation** (${snap.hostname || 'agent'})`)
|
||||
if (snap.summary) {
|
||||
lines.push(
|
||||
`- findings=${snap.summary.findingCount ?? 0} top=${snap.summary.topSeverity || 'ok'} health=${snap.summary.health || '—'}`
|
||||
)
|
||||
}
|
||||
for (const f of (snap.findings || []).slice(0, 8)) {
|
||||
lines.push(`- [${f.severity || 'info'}] ${f.area || ''}: ${f.message || ''}`)
|
||||
}
|
||||
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.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 {
|
||||
lines.push(`**Host snapshot** (${snap.hostname || 'agent'})`)
|
||||
if (snap.health) {
|
||||
lines.push(
|
||||
@@ -432,9 +850,10 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
lines.push('- No recent anomalies in the snapshot.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 || ''}`)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
lines.push(`\n**${c.result.chart}** (${c.result.points} pts, source=${c.result.source})`)
|
||||
for (const [dim, st] of Object.entries(c.result.dims)) {
|
||||
@@ -495,7 +926,9 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -3896,6 +3896,89 @@ html[data-theme='light'] .proc-detail-cmd {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
+11
-5
@@ -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 are tiered (core always; deep on Recommended/Strong; write for operators).
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `host_snapshot` | Live health + KPIs |
|
||||
| `search_charts` / `summarize_chart` / `query_metric` | Catalog + time series |
|
||||
| `list_anomalies` / `list_alerts` | Anomaly / alert state |
|
||||
| `investigate_host` | **Best first call** — findings, hot charts, top processes |
|
||||
| `host_snapshot` | Lighter live health + KPIs |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user