/** * QVAC tool schemas + handlers → PearData RPC / UI navigation. * * Tool defs use the **@qvac/sdk** wire shape (flat), not OpenAI nested * `{ type, function: { name, parameters } }`: * { type: 'function', name, description, parameters: { type:'object', properties, required? } } * Property values may only include type / description / enum (see @qvac/sdk toolSchema). * * Tools are tiered so small-context models stay lean: * core → always (tool-tiny + recommended + strong) * deep → recommended + strong (investigation / fleet / storage) * write → operator+ only (silence / ack / run_job) */ import { Methods, Roles, roleAllows } from '../../shared/protocol.js' import { buildRagContext } from './rag.js' /** @param {Record} properties */ function params(properties = {}, required) { const out = { type: 'object', properties: properties || {}, } if (required?.length) out.required = required return out } /** * @typedef {'core'|'deep'|'write'} ToolTier * @typedef {{ type: 'function', name: string, description: string, parameters: object, tier?: ToolTier }} ToolDef */ /** Shared optional navigate param for UI-affecting tools */ const NAVIGATE_PROP = { navigate: { type: 'boolean', description: 'true only if the user asked to open/see that view, or they confirmed auto-navigate. Default false keeps them on the current tab.', }, } /** @type {ToolDef[]} */ export const TOOL_DEFS = [ // ── core: diagnosis + navigation ───────────────────────────────────── { type: 'function', name: 'investigate_host', tier: 'core', description: 'BEST first call for "what\'s wrong" / diagnose / investigate. One shot: findings ranked by severity, KPIs, hot charts, top processes, anomalies/alerts. Prefer this over calling host_snapshot + list_processes + list_anomalies separately.', parameters: params({ processLimit: { type: 'number', description: 'Top processes (default 12, max 25)' }, hotLimit: { type: 'number', description: 'Hot charts to include (default 12, max 30)' }, }), }, { type: 'function', name: 'host_snapshot', tier: 'core', description: 'Compact live host KPIs (cpu, ram, load, net, io), health, recent anomalies/alerts. Use when you need a lighter snapshot than investigate_host.', parameters: params({}), }, { type: 'function', name: 'search_charts', tier: 'core', description: 'Search the metrics chart catalog by free text (id, title, context, family).', parameters: params( { q: { type: 'string', description: 'Search query' }, limit: { type: 'number', description: 'Max results (default 20)' }, }, ['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', }, points: { type: 'number', description: 'Max points (default 90)' }, }, ['chart'] ), }, { type: 'function', name: 'list_anomalies', tier: 'core', description: 'List recent anomaly events.', parameters: params({ limit: { type: 'number', description: 'Max events' }, }), }, { type: 'function', name: 'list_alerts', 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', name: 'local_knowledge', tier: 'core', description: 'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.', parameters: params( { q: { type: 'string', description: 'Question or keywords' }, }, ['q'] ), }, { type: 'function', name: 'open_chart', tier: 'core', description: 'Prepare one chart on the Charts wall (pin/focus). Does NOT switch tabs unless navigate=true or user allows auto-navigate.', parameters: params( { chart: { type: 'string', description: 'Chart id' }, ts: { type: 'number', description: 'Event time ms (pauses near event)' }, pin: { type: 'boolean', description: 'Pin chart to board (default true)' }, mode: { type: 'string', description: 'Chart type: line|area|stacked|bar|multibar|pie', enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'], }, preset: { type: 'string', description: 'Time preset e.g. 5m|15m|1h|6h|24h', }, ...NAVIGATE_PROP, }, ['chart'] ), }, { type: 'function', name: 'show_charts', tier: 'core', description: 'PRIMARY chart tool: pin charts, set window/type on the Charts wall. Stays on QVAC unless navigate=true (user asked to see graphs) or auto-navigate allows.', parameters: params( { charts: { type: 'string', description: 'Comma/space separated chart ids (max 24), e.g. system.cpu,system.ram', }, pin: { type: 'boolean', description: 'Pin charts to board (default true)', }, boardOnly: { type: 'boolean', description: 'Show only pinned board (default true when pinning)', }, focus: { type: 'string', description: 'Chart id to focus/expand' }, ts: { type: 'number', description: 'Event time ms for focus' }, preset: { type: 'string', description: 'Time preset: 5m|15m|30m|1h|3h|6h|12h|24h', }, seconds: { type: 'number', description: 'Custom lookback window seconds (alternative to preset)', }, mode: { type: 'string', description: 'Apply chart type to all listed charts', enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'], }, filter: { type: 'string', description: 'Charts wall search filter text', }, related: { type: 'boolean', description: 'Open related-charts panel for focus chart', }, playing: { type: 'boolean', description: 'Live play (true) or pause (false)', }, ...NAVIGATE_PROP, }, ['charts'] ), }, { type: 'function', name: 'pin_charts', tier: 'core', description: 'Pin or unpin charts on the Charts board (no tab switch unless navigate=true).', parameters: params( { charts: { type: 'string', description: 'Comma/space separated chart ids', }, pin: { type: 'boolean', description: 'true=pin (default), false=unpin', }, ...NAVIGATE_PROP, }, ['charts'] ), }, { type: 'function', name: 'set_chart_type', tier: 'core', description: 'Change visualization for a chart: line, area, stacked, bar, multibar, pie.', parameters: params( { chart: { type: 'string', description: 'Chart id' }, mode: { type: 'string', description: 'line|area|stacked|bar|multibar|pie', enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'], }, ...NAVIGATE_PROP, }, ['chart', 'mode'] ), }, { type: 'function', name: 'set_time_window', tier: 'core', description: 'Set Charts wall time window (preset or seconds) and play/pause live.', parameters: params({ preset: { type: 'string', description: '5m|15m|30m|1h|3h|6h|12h|24h', }, seconds: { type: 'number', description: 'Lookback seconds' }, playing: { type: 'boolean', description: 'Live updates on/off' }, ...NAVIGATE_PROP, }), }, { type: 'function', name: 'filter_charts', tier: 'core', description: 'Filter the Charts wall by free text (same as the search box).', parameters: params( { q: { type: 'string', description: 'Filter query (empty clears)' }, ...NAVIGATE_PROP, }, ['q'] ), }, { type: 'function', name: 'show_related_ui', tier: 'core', description: 'Open the Related charts panel for a seed chart on the Charts wall.', parameters: params( { chart: { type: 'string', description: 'Seed chart id' }, ...NAVIGATE_PROP, }, ['chart'] ), }, { type: 'function', name: 'run_correlations', tier: 'deep', description: 'Run Metric Correlations around a chart or timestamp on the Charts wall.', parameters: params({ chart: { type: 'string', description: 'Optional seed chart id' }, ts: { type: 'number', description: 'Center timestamp ms (default now)' }, window: { type: 'number', description: 'Highlight window seconds' }, ...NAVIGATE_PROP, }), }, { type: 'function', name: 'open_view', tier: 'core', description: 'Switch the user to a desktop view. Only when they asked to go there, or navigate=true / auto-navigate allows. Views: overview|charts|dashboard|processes|alerts|logs|fleet|settings|qvac', parameters: params( { view: { type: 'string', description: 'View name', enum: [ 'overview', 'charts', 'dashboard', 'processes', 'alerts', 'logs', 'fleet', 'settings', 'qvac', ], }, ...NAVIGATE_PROP, }, ['view'] ), }, // ── custom dashboards (user boards the agent can build/edit) ────────── { type: 'function', name: 'list_dashboards', tier: 'core', description: 'List custom dashboards (id, name, chart tiles).', parameters: params({}), }, { type: 'function', name: 'create_dashboard', tier: 'core', description: 'Create a custom live dashboard. Pass name + charts. Tab switch only with navigate=true or auto-navigate allow.', parameters: params( { name: { type: 'string', description: 'Dashboard name' }, description: { type: 'string', description: 'Optional description' }, charts: { type: 'string', description: 'Comma/space chart ids e.g. system.cpu,system.ram,system.io', }, mode: { type: 'string', description: 'Default tile mode line|area|bar|…', enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'], }, ...NAVIGATE_PROP, }, ['name'] ), }, { type: 'function', name: 'update_dashboard', tier: 'core', description: 'Rename/update a dashboard (id optional = active). Can replace tiles via charts list.', parameters: params({ id: { type: 'string', description: 'Dashboard id' }, name: { type: 'string', description: 'New name' }, description: { type: 'string', description: 'New description' }, charts: { type: 'string', description: 'If set, replace all tiles with these chart ids', }, mode: { type: 'string', description: 'Mode when replacing tiles' }, }), }, { type: 'function', name: 'delete_dashboard', tier: 'core', description: 'Delete a custom dashboard by id.', parameters: params( { id: { type: 'string', description: 'Dashboard id' }, }, ['id'] ), }, { type: 'function', name: 'add_dashboard_charts', tier: 'core', description: 'Add chart tiles to a dashboard (id optional = active board).', parameters: params( { id: { type: 'string', description: 'Dashboard id (default active)' }, charts: { type: 'string', description: 'Comma/space chart ids to add', }, mode: { type: 'string', description: 'Tile chart type', enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'], }, ...NAVIGATE_PROP, }, ['charts'] ), }, { type: 'function', name: 'remove_dashboard_charts', tier: 'core', description: 'Remove chart tiles from a dashboard.', parameters: params( { id: { type: 'string', description: 'Dashboard id (default active)' }, charts: { type: 'string', description: 'Comma/space chart ids to remove', }, }, ['charts'] ), }, { type: 'function', name: 'open_dashboard', tier: 'core', description: 'Select a dashboard board. Switches to Dashboard tab only with navigate=true or auto-navigate allow (user asked to open it).', parameters: params({ id: { type: 'string', description: 'Dashboard id (optional)' }, ...NAVIGATE_PROP, }), }, // ── 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', name: 'related_charts', tier: 'deep', description: 'Related charts for a seed chart (catalog family/context + optional alert weights). Use after finding a suspicious chart.', parameters: params( { chart: { type: 'string', description: 'Seed chart id' }, limit: { type: 'number', description: 'Max results (default 12)' }, }, ['chart'] ), }, { type: 'function', name: 'compare_chart_windows', tier: 'deep', description: 'Compare two time windows on one chart (highlight vs baseline). Defaults: last 5m vs prior 20m. Returns per-dim relative change.', parameters: params( { chart: { type: 'string', description: 'Chart id' }, after: { type: 'number', description: 'Highlight window start (default -300)' }, before: { type: 'number', description: 'Highlight window end (default 0)' }, baselineAfter: { type: 'number', description: 'Baseline window start (default -1500)', }, baselineBefore: { type: 'number', description: 'Baseline window end (default -300)', }, points: { type: 'number', description: 'Points per window (default 60)' }, }, ['chart'] ), }, { type: 'function', name: 'summarize_charts', tier: 'deep', description: 'Batch summarize up to 12 charts in one call (compact stats).', parameters: params( { charts: { type: 'string', description: 'Comma or space separated chart ids (max 12)', }, after: { type: 'number', description: 'Window start (default -120)' }, points: { type: 'number', description: 'Points per chart (default 60)' }, }, ['charts'] ), }, { type: 'function', name: 'query_metric', tier: 'deep', description: 'Raw queryData time series for a chart (full points). Prefer summarize_chart when stats suffice.', parameters: params( { chart: { type: 'string', description: 'Chart id' }, after: { type: 'number', description: 'Window start (relative or unix)' }, points: { type: 'number', description: 'Max points' }, group: { type: 'string', description: 'Aggregation: average|min|max|sum', enum: ['average', 'min', 'max', 'sum'], }, }, ['chart'] ), }, { type: 'function', name: 'get_weights', tier: 'deep', description: 'Metric correlation / influence weights for a chart (alerts method). Helps find what moves with an incident chart.', parameters: params( { chart: { type: 'string', description: 'Chart id' }, limit: { type: 'number', description: 'Max related (default 20)' }, }, ['chart'] ), }, { type: 'function', name: 'query_logs', tier: 'deep', description: 'Query agent logs: source journal|anomaly|audit, optional free-text q. journal/audit may require admin on the agent.', parameters: params({ source: { type: 'string', description: 'journal|anomaly|audit', enum: ['journal', 'anomaly', 'audit'], }, q: { type: 'string', description: 'Search text' }, limit: { type: 'number', description: 'Max lines' }, }), }, { type: 'function', name: 'fleet_health', tier: 'deep', description: 'Fleet / parent-child health summary when parent mode is enabled.', parameters: params({}), }, { type: 'function', name: 'list_child_peers', tier: 'deep', description: 'List child peers in fleet/parent mode (hostname, key, hops).', parameters: params({}), }, { type: 'function', name: 'storage_info', tier: 'deep', description: 'Agent storage usage and retention config (warm/history sizes).', parameters: params({}), }, { type: 'function', name: 'agent_health', tier: 'deep', description: 'Raw agent health object from anomaly engine (status, warnings, critical).', parameters: params({}), }, { type: 'function', name: 'node_info', tier: 'deep', description: 'Agent node metadata: version, hostname, platform, uptime, collectors.', parameters: params({}), }, { type: 'function', name: 'db_info', tier: 'deep', description: 'HyperDB / replication db info for the agent store.', parameters: params({}), }, { type: 'function', name: 'list_contexts', tier: 'deep', description: 'List metric contexts (families of charts) on the agent.', parameters: params({}), }, { type: 'function', name: 'get_chart', tier: 'deep', description: 'Chart metadata: title, family, units, dimensions (no series points).', parameters: params( { chart: { type: 'string', description: 'Chart id' }, }, ['chart'] ), }, { type: 'function', name: 'list_jobs', tier: 'deep', description: 'List on-demand agent jobs (collectOnce, snapshot, retrainAnomaly, …).', parameters: params({}), }, { type: 'function', name: 'get_alert', tier: 'deep', description: 'Fetch one alert by id.', parameters: params( { id: { type: 'string', description: 'Alert id' }, }, ['id'] ), }, // ── write: operator actions (gated by role + confirm) ──────────────── { type: 'function', name: 'silence_alert', tier: 'write', description: 'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.', parameters: 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 (desktop UI / local RAG). */ const LOCAL_TOOLS = new Set([ 'open_view', 'open_chart', 'show_charts', 'pin_charts', 'set_chart_type', 'set_time_window', 'filter_charts', 'show_related_ui', 'run_correlations', 'list_dashboards', 'create_dashboard', 'update_dashboard', 'delete_dashboard', 'add_dashboard_charts', 'remove_dashboard_charts', 'open_dashboard', '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, })) } /** * Resolve auto-navigate preference: off | ask | on * @param {*} deps */ function autoNavMode(deps) { const raw = deps.getAutoNavigate?.() ?? deps.autoNavigate ?? 'ask' const s = String(raw || 'ask').toLowerCase() if (s === 'on' || s === 'true' || s === 'always') return 'on' if (s === 'off' || s === 'false' || s === 'never') return 'off' return 'ask' } /** * Switch desktop view only when allowed. * @param {string} view * @param {*} deps * @param {object} [args] * @returns {Promise<{ navigated: boolean, reason: string }>} */ async function maybeNavigate(view, deps, args = {}) { const force = args.navigate === true || args.navigate === 'true' || args.navigate === 1 || args.navigate === '1' const mode = autoNavMode(deps) const label = String(view || 'that view') if (force || mode === 'on') { deps.onOpenView?.(view) return { navigated: true, reason: force ? 'navigate=true' : 'auto_on' } } if (mode === 'ask') { const ok = (await deps.confirmAction?.( `QVAC wants to open the “${label}” view. Switch now?` )) === true if (ok) { deps.onOpenView?.(view) return { navigated: true, reason: 'user_confirmed' } } return { navigated: false, reason: 'user_declined' } } // off return { navigated: false, reason: 'auto_off' } } /** * @param {{ * manager: { request: (m: string, a?: object) => Promise, active: any }, * getRole: () => string, * isConnected: () => boolean, * getCatalog?: () => Record, * onOpenChart?: (chartId: string, ts?: number, opts?: object) => void, * onOpenView?: (view: string) => void, * getAutoNavigate?: () => 'off'|'ask'|'on'|string, * autoNavigate?: 'off'|'ask'|'on'|string, * charts?: { * showCharts?: (opts: object) => object, * setPinned?: (ids: string|string[], opts?: object) => object, * setChartMode?: (id: string, mode: string) => object, * setTimeWindow?: (opts: object) => object, * setFilter?: (q: string) => object, * showRelated?: (id: string) => Promise|void, * openFocus?: (id: string) => void, * correlateAround?: (ts: number, opts?: object) => void, * runMetricCorrelations?: (opts?: object) => Promise, * scrollToChart?: (id: string) => void, * }, * dashboards?: { * list?: () => object, * create?: (a: object) => object, * update?: (a: object) => object, * remove?: (a: object) => object, * addCharts?: (a: object) => object, * removeCharts?: (a: object) => object, * open?: (a: object) => object, * }, * confirmAction?: (message: string) => boolean|Promise, * }} deps */ export function createToolRunner(deps) { function parseChartList(raw) { if (Array.isArray(raw)) return raw.map(String).filter(Boolean) return String(raw || '') .split(/[\s,]+/) .map((s) => s.trim()) .filter(Boolean) } /** * @param {string} name * @param {object} args */ async function run(name, args = {}) { if (!LOCAL_TOOLS.has(name) && !deps.isConnected?.()) { return { error: 'No agent connected. Connect from the Connect tab first.' } } if (WRITE_TOOLS.has(name)) { const role = deps.getRole?.() || Roles.viewer if (!roleAllows(role, Roles.operator)) { return { error: 'Operator role required for this action' } } } const req = (m, a) => deps.manager.request(m, a || {}) const chartsApi = deps.charts || {} try { switch (name) { case 'investigate_host': return await req(Methods.investigateHost, { processLimit: args.processLimit, hotLimit: args.hotLimit, }) case 'host_snapshot': return await req(Methods.getHostSnapshot, {}) case 'search_charts': return await req(Methods.searchCharts, { q: args.q || '', limit: args.limit ?? 20, }) case 'summarize_chart': return await req(Methods.summarizeChart, { chart: args.chart || args.id, after: args.after, points: args.points, group: args.group, }) case 'summarize_charts': { let charts = args.charts if (typeof charts === 'string') { charts = charts.split(/[\s,]+/).filter(Boolean) } if (!Array.isArray(charts) && args.chart) { charts = [args.chart] } return await req(Methods.summarizeCharts, { charts, after: args.after, points: args.points, }) } case 'query_metric': return await req(Methods.queryData, { chart: args.chart, after: args.after ?? -90, points: args.points ?? 90, group: args.group || 'average', }) case 'hot_metrics': return await req(Methods.hotMetrics, { limit: args.limit, window: args.window, family: args.family || args.q || '', }) case 'related_charts': return await req(Methods.relatedCharts, { chart: args.chart || args.id, limit: args.limit, }) case 'compare_chart_windows': return await req(Methods.compareChartWindows, { chart: args.chart || args.id, after: args.after, before: args.before, baselineAfter: args.baselineAfter, baselineBefore: args.baselineBefore, points: args.points, group: args.group, }) case 'get_weights': return await req(Methods.getWeights, { chart: args.chart || args.id, limit: args.limit ?? 20, method: args.method || 'alerts', }) case 'list_anomalies': return await req(Methods.listAnomalies, { limit: args.limit ?? 30 }) case 'list_alerts': return await req(Methods.listAlerts, {}) case 'get_alert': return await req(Methods.getAlert, { id: args.id }) case 'list_processes': return await req(Methods.listProcesses, { sort: args.sort || 'cpu', limit: args.limit ?? 25, filter: args.filter || 'all', }) case 'query_logs': return await req(Methods.queryLogs, { source: args.source || 'anomaly', q: args.q || '', limit: args.limit ?? 40, }) case 'fleet_health': return await req(Methods.getFleetHealth, {}) case 'list_child_peers': return await req(Methods.listChildPeers, {}) case 'storage_info': { const [storage, retention] = await Promise.all([ req(Methods.getStorageInfo, {}), req(Methods.getRetentionConfig, {}), ]) return { storage, retention } } case 'agent_health': return await req(Methods.getHealth, {}) case 'node_info': return await req(Methods.getNodeInfo, {}) case 'db_info': return await req(Methods.getDbInfo, {}) case 'list_contexts': return await req(Methods.listContexts, {}) case 'get_chart': return await req(Methods.getChart, { id: args.chart || args.id, }) case 'list_jobs': return await req(Methods.listJobs, {}) case 'local_knowledge': { const ctx = buildRagContext({ query: String(args.q || ''), catalog: deps.getCatalog?.() || {}, topK: 8, }) return { context: ctx || 'No matching local knowledge.', q: args.q } } case 'open_chart': { const chart = String(args.chart || args.id || '') if (!chart) return { error: 'chart required' } const nav = await maybeNavigate('charts', deps, args) let result if (chartsApi.showCharts) { result = chartsApi.showCharts({ charts: [chart], pin: args.pin !== false, boardOnly: args.pin !== false, focus: chart, ts: args.ts, mode: args.mode, preset: args.preset, openFocus: nav.navigated, }) } else { deps.onOpenChart?.(chart, args.ts, { pin: args.pin !== false, mode: args.mode, preset: args.preset, navigate: nav.navigated, }) result = { ok: true, opened: chart } } return { ...result, navigated: nav.navigated, navigateReason: nav.reason } } case 'show_charts': { const list = parseChartList(args.charts || args.chart || args.ids) if (!list.length) return { error: 'charts required' } const nav = await maybeNavigate('charts', deps, args) if (!chartsApi.showCharts) { if (nav.navigated) deps.onOpenChart?.(list[0], args.ts) return { ok: true, charts: list, partial: true, navigated: nav.navigated, navigateReason: nav.reason, } } const result = chartsApi.showCharts({ charts: list, pin: args.pin !== false, boardOnly: args.boardOnly != null ? Boolean(args.boardOnly) : args.pin !== false, focus: args.focus || list[0], ts: args.ts, preset: args.preset, seconds: args.seconds, mode: args.mode, filter: args.filter, related: Boolean(args.related), playing: args.playing, openFocus: nav.navigated && list.length === 1, }) return { ...result, navigated: nav.navigated, navigateReason: nav.reason } } case 'pin_charts': { const list = parseChartList(args.charts || args.chart) if (!list.length) return { error: 'charts required' } const nav = await maybeNavigate('charts', deps, args) if (!chartsApi.setPinned) return { error: 'charts API unavailable' } const result = chartsApi.setPinned(list, { pin: args.pin !== false && args.pin !== 'false', }) return { ...result, navigated: nav.navigated, navigateReason: nav.reason } } case 'set_chart_type': { const chart = String(args.chart || args.id || '') const mode = String(args.mode || args.type || 'line') if (!chart) return { error: 'chart required' } const nav = await maybeNavigate('charts', deps, args) if (!chartsApi.setChartMode) return { error: 'charts API unavailable' } const result = chartsApi.setChartMode(chart, mode) return { ...result, navigated: nav.navigated, navigateReason: nav.reason } } case 'set_time_window': { const nav = await maybeNavigate('charts', deps, args) if (!chartsApi.setTimeWindow) return { error: 'charts API unavailable' } const result = chartsApi.setTimeWindow({ preset: args.preset, seconds: args.seconds ?? args.after, playing: args.playing, }) return { ...result, navigated: nav.navigated, navigateReason: nav.reason } } case 'filter_charts': { const nav = await maybeNavigate('charts', deps, args) if (!chartsApi.setFilter) return { error: 'charts API unavailable' } const result = chartsApi.setFilter(args.q != null ? args.q : args.filter || '') return { ...result, navigated: nav.navigated, navigateReason: nav.reason } } case 'show_related_ui': { const chart = String(args.chart || args.id || '') if (!chart) return { error: 'chart required' } const nav = await maybeNavigate('charts', deps, args) chartsApi.scrollToChart?.(chart) await chartsApi.showRelated?.(chart) return { ok: true, chart, related: true, navigated: nav.navigated, navigateReason: nav.reason, } } case 'run_correlations': { const nav = await maybeNavigate('charts', deps, args) const ts = args.ts != null ? Number(args.ts) : Date.now() if (chartsApi.correlateAround) { chartsApi.correlateAround(ts, { chart: args.chart, window: args.window, }) return { ok: true, ts, chart: args.chart || null, navigated: nav.navigated, navigateReason: nav.reason, } } if (chartsApi.runMetricCorrelations) { const res = await chartsApi.runMetricCorrelations({ chart: args.chart, ts, window: args.window, }) return { ok: true, ...(res || {}), navigated: nav.navigated, navigateReason: nav.reason, } } return { error: 'correlations API unavailable' } } case 'open_view': { // Explicit navigation tool — still gated by auto-nav / navigate flag const view = String(args.view || 'overview') const nav = await maybeNavigate(view, deps, { ...args, // open_view implies intent to navigate when auto is on/ask; // with auto off, still require navigate=true navigate: args.navigate === true || args.navigate === 'true' ? true : args.navigate, }) // When mode is ask/on, maybeNavigate already handled. When off without force, no-op. if (!nav.navigated && autoNavMode(deps) !== 'off') { // ask declined already returned; on should have navigated } // For open_view with auto on, force path already navigated. // With auto off, require navigate=true (maybeNavigate handles). // With auto ask without prior force: maybeNavigate already asked. // Special case: if auto is off and navigate not set, try one confirm as explicit open_view if (!nav.navigated && autoNavMode(deps) === 'off' && args.navigate == null) { const ok = (await deps.confirmAction?.( `Switch to the “${view}” view? (You can enable Auto-navigate in QVAC Settings.)` )) === true if (ok) { deps.onOpenView?.(view) return { ok: true, view, navigated: true, navigateReason: 'user_confirmed' } } return { ok: true, view, navigated: false, navigateReason: 'user_declined', note: 'Stayed on current tab. Pass navigate=true when the user asks to open a view.', } } return { ok: true, view, navigated: nav.navigated, navigateReason: nav.reason, note: nav.navigated ? undefined : 'View not switched. User did not allow navigation.', } } case 'list_dashboards': { if (!deps.dashboards?.list) return { error: 'dashboards API unavailable' } return deps.dashboards.list() } case 'create_dashboard': { if (!deps.dashboards?.create) return { error: 'dashboards API unavailable' } const r = deps.dashboards.create({ name: args.name, description: args.description, charts: args.charts || args.chart, mode: args.mode || 'area', tiles: args.tiles, }) const nav = await maybeNavigate('dashboard', deps, args) return { ...r, navigated: nav.navigated, navigateReason: nav.reason } } case 'update_dashboard': { if (!deps.dashboards?.update) return { error: 'dashboards API unavailable' } return deps.dashboards.update({ id: args.id || args.dashboardId, name: args.name, description: args.description, charts: args.charts, mode: args.mode, tiles: args.tiles, }) } case 'delete_dashboard': { if (!deps.dashboards?.remove) return { error: 'dashboards API unavailable' } return deps.dashboards.remove({ id: args.id || args.dashboardId }) } case 'add_dashboard_charts': { if (!deps.dashboards?.addCharts) return { error: 'dashboards API unavailable' } const r = deps.dashboards.addCharts({ id: args.id || args.dashboardId, charts: args.charts || args.chart, mode: args.mode, replace: args.replace, }) const nav = await maybeNavigate('dashboard', deps, args) return { ...r, navigated: nav.navigated, navigateReason: nav.reason } } case 'remove_dashboard_charts': { if (!deps.dashboards?.removeCharts) return { error: 'dashboards API unavailable' } return deps.dashboards.removeCharts({ id: args.id || args.dashboardId, charts: args.charts || args.chart, }) } case 'open_dashboard': { if (!deps.dashboards?.open) { const nav = await maybeNavigate('dashboard', deps, { ...args, navigate: args.navigate != null ? args.navigate : true, }) return { ok: true, view: 'dashboard', navigated: nav.navigated, navigateReason: nav.reason, } } // Select board first, then maybe switch tab const r = deps.dashboards.open({ id: args.id || args.dashboardId, navigate: false, }) const nav = await maybeNavigate('dashboard', deps, { ...args, // open_dashboard is an open request: default navigate ask/on, but still confirm navigate: args.navigate != null ? args.navigate : undefined, }) // If open always showed view before, prevent double — dashboards.open in app still navigates return { ...r, navigated: nav.navigated, navigateReason: nav.reason } } case 'silence_alert': { if (!args.confirmed) { return { error: 'confirmation_required', message: `Confirm silencing alert ${args.id} before retrying with confirmed=true`, } } const ok = (await deps.confirmAction?.( `Silence alert ${args.id} for ${Math.round((args.durationMs || 3_600_000) / 60000)} minutes?` )) !== false if (!ok) return { error: 'User declined silence' } return await req(Methods.silenceAlert, { id: args.id, durationMs: args.durationMs ?? 3_600_000, }) } case 'ack_alert': { if (!args.confirmed) { return { error: 'confirmation_required', message: `Confirm acknowledging alert ${args.id} before retrying with confirmed=true`, } } const ok = (await deps.confirmAction?.(`Acknowledge alert ${args.id}?`)) !== false if (!ok) return { error: 'User declined acknowledge' } return await req(Methods.ackAlert, { id: args.id }) } case 'run_job': { if (!args.confirmed) { return { error: 'confirmation_required', message: `Confirm running job "${args.name}" before retrying with confirmed=true`, } } const ok = (await deps.confirmAction?.(`Run job "${args.name}" on the agent?`)) !== false if (!ok) return { error: 'User declined run_job' } return await req(Methods.runJob, { name: args.name, args: args.args || {}, }) } default: return { error: `Unknown tool: ${name}` } } } catch (err) { return { error: err?.message || String(err) } } } /** * Tools available for the current role + profile depth. * @param {{ profileId?: string, profile?: { id?: string }, depth?: 'core'|'deep' }} [opts] */ function defsForRole(opts = {}) { const role = deps.getRole?.() || Roles.viewer const profileId = opts.profileId || opts.profile?.id || 'recommended' const depth = opts.depth || toolDepthForProfile(profileId) const filtered = TOOL_DEFS.filter((t) => includeTool(t, depth, role)) return wireDefs(filtered) } return { run, defsForRole, TOOL_DEFS, toolDepthForProfile } } /** * Naive tool-using fallback when QVAC SDK is not installed. * @param {string} userText * @param {{ run: (name: string, args?: object) => Promise }} tools * @param {{ catalog?: Record, rag?: boolean }} [opts] */ export async function fallbackComplete(userText, tools, opts = {}) { const q = String(userText || '').toLowerCase() /** @type {Array<{ name: string, args: object, result: any }>} */ const calls = [] const howTo = q.includes('how do') || q.includes('what is qvac') || q.includes('how to') || q.includes('keyboard') || q.includes('retention') const wantsDiagnose = !howTo && (q.includes("what's wrong") || q.includes('whats wrong') || q.includes('diagnose') || q.includes('investigat') || q.includes('summarize host') || q.includes('host health') || q.includes('what is wrong') || (q.includes('health') && !q.includes('md.'))) if (wantsDiagnose) { const inv = await tools.run('investigate_host', { processLimit: 10, hotLimit: 10 }) calls.push({ name: 'investigate_host', args: {}, result: inv }) } else if (!howTo) { const snap = await tools.run('host_snapshot', {}) calls.push({ name: 'host_snapshot', args: {}, result: snap }) } if (opts.rag !== false) { const kn = await tools.run('local_knowledge', { q: userText }) calls.push({ name: 'local_knowledge', args: { q: userText }, result: kn }) } if (q.includes('process') || q.includes('top ')) { const p = await tools.run('list_processes', { limit: 10 }) calls.push({ name: 'list_processes', args: { limit: 10 }, result: p }) } if (q.includes('hot') || q.includes('spiking') || q.includes('unusual')) { const h = await tools.run('hot_metrics', { limit: 12 }) calls.push({ name: 'hot_metrics', args: { limit: 12 }, result: h }) } if ( q.includes('chart') || q.includes('metric') || q.includes('disk') || q.includes('redis') || q.includes('docker') || q.includes('nginx') || q.includes('postgres') ) { const term = (q.match(/\b(redis|docker|disk|cpu|ram|net|io|postgres|nginx|mem)\b/) || [])[0] || 'system' const s = await tools.run('search_charts', { q: term, limit: 12 }) calls.push({ name: 'search_charts', args: { q: term }, result: s }) } if (q.includes('related') || q.includes('correlat')) { const chartMatch = q.match(/\b([a-z][a-z0-9_.-]+\.[a-z0-9_.-]+)\b/) if (chartMatch) { const r = await tools.run('related_charts', { chart: chartMatch[1], limit: 10 }) calls.push({ name: 'related_charts', args: { chart: chartMatch[1] }, result: r }) } } if (q.includes('anomal') || q.includes('alert')) { const a = await tools.run('list_anomalies', { limit: 15 }) calls.push({ name: 'list_anomalies', args: {}, result: a }) if (q.includes('alert')) { const al = await tools.run('list_alerts', {}) calls.push({ name: 'list_alerts', args: {}, result: al }) } } if (q.includes('fleet') || q.includes('child')) { const f = await tools.run('fleet_health', {}) calls.push({ name: 'fleet_health', args: {}, result: f }) } if (q.includes('log') || q.includes('journal')) { const l = await tools.run('query_logs', { source: 'anomaly', limit: 20 }) calls.push({ name: 'query_logs', args: { source: 'anomaly' }, result: l }) } if (q.includes('storage') || q.includes('retention') || q.includes('prune')) { const s = await tools.run('storage_info', {}) calls.push({ name: 'storage_info', args: {}, result: s }) } if (/\bcpu\b/.test(q) || q.includes('load')) { const s = await tools.run('summarize_chart', { chart: 'system.cpu', points: 60 }) calls.push({ name: 'summarize_chart', args: { chart: 'system.cpu' }, result: s }) } if (/\bram\b/.test(q) || q.includes('memory')) { const s = await tools.run('summarize_chart', { chart: 'system.ram', points: 60 }) calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s }) } const snap = calls.find((c) => c.name === 'investigate_host')?.result || calls.find((c) => c.name === 'host_snapshot')?.result || null const text = formatFallbackAnswer(userText, snap, calls, howTo) return { contentText: text, toolCalls: calls, mode: 'fallback' } } function formatFallbackAnswer(userText, snap, calls, howTo) { if (!howTo && snap?.error) { return `I could not reach the agent: ${snap.error}\n\nConnect an agent from the Connect tab, then ask again.\n\n(Running in **tools-only fallback** — install @qvac/sdk for full local LLM chat.)` } const lines = [] if (snap && !snap.error) { if (snap.findings || snap.summary) { lines.push(`**Investigation** (${snap.hostname || 'agent'})`) if (snap.summary) { lines.push( `- findings=${snap.summary.findingCount ?? 0} top=${snap.summary.topSeverity || 'ok'} health=${snap.summary.health || '—'}` ) } for (const f of (snap.findings || []).slice(0, 8)) { lines.push(`- [${f.severity || 'info'}] ${f.area || ''}: ${f.message || ''}`) } if (snap.kpis) { for (const [k, v] of Object.entries(snap.kpis)) { if (!v) continue lines.push(`- ${k}: **${fmt(v.value)}** (${v.chart}.${v.dim})`) } } if (snap.processes?.top?.length) { lines.push('**Top processes**') for (const p of snap.processes.top.slice(0, 6)) { lines.push(`- pid ${p.pid} ${p.name || ''} cpu=${fmt(p.cpu)}`) } } } else { lines.push(`**Host snapshot** (${snap.hostname || 'agent'})`) if (snap.health) { lines.push( `- 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) { if (c.name === 'host_snapshot' || c.name === 'investigate_host') continue if (c.name === 'local_knowledge' && c.result?.context) { lines.push(`\n**Local knowledge**\n${c.result.context}`) } if (c.name === 'search_charts' && c.result?.results) { lines.push(`\n**Charts matching “${c.args.q}”** (${c.result.results.length})`) for (const r of c.result.results.slice(0, 8)) { lines.push(`- \`${r.id}\` — ${r.title || ''}`) } } if (c.name === 'hot_metrics' && c.result?.results) { lines.push(`\n**Hot metrics** (${c.result.results.length})`) for (const r of c.result.results.slice(0, 8)) { lines.push(`- \`${r.chart}\` score=${fmt(r.score)} — ${r.reason || ''}`) } } if (c.name === 'related_charts' && c.result?.results) { lines.push(`\n**Related to ${c.result.chart}**`) for (const r of c.result.results.slice(0, 8)) { lines.push(`- \`${r.id}\` (${fmt(r.score)}) ${r.reason || ''}`) } } if (c.name === 'summarize_chart' && c.result?.dims) { lines.push(`\n**${c.result.chart}** (${c.result.points} pts, source=${c.result.source})`) for (const [dim, st] of Object.entries(c.result.dims)) { lines.push( `- ${dim}: last=${fmt(st.last)} avg=${fmt(st.avg)} min=${fmt(st.min)} max=${fmt(st.max)}` ) } } if (c.name === 'list_processes' && c.result?.processes) { lines.push('\n**Top processes**') for (const p of (c.result.processes || []).slice(0, 8)) { lines.push( `- pid ${p.pid} ${p.name || p.comm || ''} cpu=${fmt(p.cpu)}% rss=${fmt(p.rss)}` ) } } if (c.name === 'list_anomalies' && c.result?.anomalies) { lines.push(`\n**Anomalies** (${c.result.anomalies.length})`) for (const a of c.result.anomalies.slice(0, 8)) { lines.push(`- ${a.severity} ${a.chart}: ${a.message || ''}`) } } if (c.name === 'list_alerts' && c.result?.alerts) { lines.push(`\n**Alerts** (${c.result.alerts.length})`) for (const a of (c.result.alerts || []).slice(0, 8)) { lines.push(`- ${a.id || a.chart}: ${a.severity || ''} ${a.message || a.name || ''}`) } } if (c.name === 'fleet_health' && c.result) { lines.push( `\n**Fleet** enabled=${c.result.enabled} children=${(c.result.children || []).length}` ) if (c.result.summary) { lines.push(`- summary: ${JSON.stringify(c.result.summary)}`) } } if (c.name === 'query_logs' && (c.result?.entries || c.result?.lines || c.result?.events)) { const rows = c.result.entries || c.result.lines || c.result.events || [] lines.push(`\n**Logs** (${rows.length})`) for (const row of rows.slice(0, 6)) { const msg = row.message || row.msg || row.line || JSON.stringify(row).slice(0, 120) lines.push(`- ${msg}`) } } if (c.name === 'storage_info' && c.result) { lines.push('\n**Storage / retention**') lines.push('```\n' + JSON.stringify(c.result, null, 0).slice(0, 800) + '\n```') } } if (!lines.length) { lines.push( 'No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).' ) } lines.push( `\n_Question: ${userText}_\n_Mode: tools-only fallback (install **@qvac/sdk** for Qwen local chat)._` ) return lines.join('\n') } function fmt(v) { const n = Number(v) if (!Number.isFinite(n)) return '—' if (Math.abs(n) >= 100) return n.toFixed(0) if (Math.abs(n) >= 10) return n.toFixed(1) return n.toFixed(2) }