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