/** * Tool-friendly composite reads for QVAC / local AI copilots. * Keep these lean — no LLM, no model load on the agent. */ import os from 'os' import { APP_VERSION } from '../../shared/protocol.js' import { CHART_BY_ID, chartSummary } from '../../shared/metrics.js' import { getStore } from './store.js' import { getCollector } from './collector.js' import { getAnomalyEngine } from './anomaly.js' import { listAlerts } from './alerts.js' import { getStorageInfo, getRetentionConfig } from './retention.js' import { getServerPublicKeyHex } from '../core/auth-keys.js' const KPI_CHARTS = [ { id: 'system.cpu', dim: 'user', label: 'cpu_user', alt: ['used'] }, { id: 'system.ram', dim: 'used', label: 'ram_used' }, { id: 'system.load', dim: 'load1', label: 'load1' }, { id: 'system.net', dim: 'received', label: 'net_rx' }, { id: 'system.io', dim: 'reads', label: 'io_reads', alt: ['in'] }, ] /** * Latest value for a chart dimension from hot memory (no HyperDB). * @param {string} chartId * @param {string} dim * @param {string[]} [alts] */ function latestDim(chartId, dim, alts = []) { const store = getStore() const entry = store.series?.get?.(chartId) const pts = entry?.points if (!pts?.length) return null const last = pts[pts.length - 1] const vals = last?.values || {} if (vals[dim] != null && Number.isFinite(Number(vals[dim]))) { return { value: Number(vals[dim]), ts: last.ts, dim } } for (const a of alts) { if (vals[a] != null && Number.isFinite(Number(vals[a]))) { return { value: Number(vals[a]), ts: last.ts, dim: a } } } // first finite numeric for (const [k, v] of Object.entries(vals)) { if (v != null && Number.isFinite(Number(v))) { return { value: Number(v), ts: last.ts, dim: k } } } return null } /** * Compact host snapshot for AI tools. * @returns {Promise} */ export async function getHostSnapshot() { const store = getStore() const collector = getCollector() const anomalies = getAnomalyEngine() const health = anomalies.getHealth() const recent = anomalies.listRecent?.(20) || [] const openAlerts = (listAlerts() || []).filter( (a) => a && !a.acked && !a.silenced && a.severity !== 'cleared' ) /** @type {Record} */ const kpis = {} for (const k of KPI_CHARTS) { const hit = latestDim(k.id, k.dim, k.alt || []) kpis[k.label] = hit ? { value: hit.value, ts: hit.ts, dim: hit.dim, chart: k.id } : null } // Derived CPU used ≈ 100 - idle when available const idle = latestDim('system.cpu', 'idle') if (idle && kpis.cpu_user == null) { kpis.cpu_used = { value: Math.max(0, Math.min(100, 100 - idle.value)), ts: idle.ts, dim: 'used_est', chart: 'system.cpu', } } else if (idle) { kpis.cpu_used = { value: Math.max(0, Math.min(100, 100 - idle.value)), ts: idle.ts, dim: 'used_est', chart: 'system.cpu', } } let storage = null let retention = null try { storage = await getStorageInfo() } catch { storage = null } try { retention = getRetentionConfig() } catch { retention = null } const mem = store.memoryStats?.() || {} const charts = store.listChartSummaries?.() || {} return { ts: Date.now(), hostname: os.hostname(), platform: os.platform(), release: os.release(), cores: os.cpus()?.length || 0, totalRamBytes: os.totalmem(), freeRamBytes: os.freemem(), agentVersion: APP_VERSION, publicKeyHex: getServerPublicKeyHex(), sampleCount: collector?.sampleCount ?? 0, health, kpis, anomalies: recent.slice(0, 12).map(compactAnomaly), alerts: openAlerts.slice(0, 12).map(compactAlert), catalog: { chartCount: Object.keys(charts).length, memory: { charts: mem.charts ?? 0, tier0Points: mem.tier0Points ?? 0, tier1Points: mem.tier1Points ?? 0, }, }, storage: storage ? { dataDir: storage.dataDir, usage: storage.usage, memory: storage.memory, } : null, retention: retention || null, } } /** * Search chart catalog by free text. * @param {{ q?: string, limit?: number }} args */ export function searchCharts(args = {}) { const q = String(args.q || '') .trim() .toLowerCase() const limit = Math.min(100, Math.max(1, Number(args.limit) || 30)) const store = getStore() const charts = store.listChartSummaries?.() || {} /** @type {Array} */ const rows = [] for (const [id, meta] of Object.entries(charts)) { const hay = [ id, meta.title, meta.context, meta.family, meta.plugin, meta.units, ...(Array.isArray(meta.dimensions) ? meta.dimensions.map((d) => (typeof d === 'string' ? d : d?.id || d?.name || '')) : []), ] .filter(Boolean) .join(' ') .toLowerCase() if (!q || hay.includes(q)) { rows.push({ id, title: meta.title || id, context: meta.context || '', family: meta.family || '', units: meta.units || '', plugin: meta.plugin || '', chartType: meta.chartType || meta.chart_type || 'line', }) } } // Prefer exact id / title prefix matches rows.sort((a, b) => { if (!q) return a.id.localeCompare(b.id) const as = scoreMatch(a, q) const bs = scoreMatch(b, q) if (as !== bs) return bs - as return a.id.localeCompare(b.id) }) return { q, count: rows.length, results: rows.slice(0, limit), } } function scoreMatch(row, q) { let s = 0 if (row.id === q) s += 100 if (row.id.startsWith(q)) s += 40 if (row.id.includes(q)) s += 20 if ((row.title || '').toLowerCase().includes(q)) s += 15 if ((row.context || '').toLowerCase().includes(q)) s += 8 if ((row.family || '').toLowerCase().includes(q)) s += 5 return s } /** * Windowed summary for one chart. * @param {{ chart?: string, id?: string, after?: number, points?: number, group?: string }} args */ export async function summarizeChart(args = {}) { const chart = String(args.chart || args.id || '') if (!chart) return { error: 'chart required' } const store = getStore() const def = CHART_BY_ID.get(chart) const meta = store.getMeta?.(chart) || (def ? chartSummary(def) : null) if (!meta && !def) return { error: 'unknown chart', chart } const points = Math.min(600, Math.max(10, Number(args.points) || 90)) const after = args.after != null ? Number(args.after) : -Math.min(points, 300) const q = await store.query({ chart, after, points, group: args.group || 'average', }) if (q.error) return q const labels = Array.isArray(q.labels) ? q.labels.filter((l) => l && l !== 'time') : [] const data = Array.isArray(q.data) ? q.data : [] /** @type {Record} */ const dims = {} for (let di = 0; di < labels.length; di++) { const name = labels[di] let min = Infinity let max = -Infinity let sum = 0 let n = 0 let last = 0 for (const row of data) { const v = Number(row[di + 1]) if (!Number.isFinite(v)) continue min = Math.min(min, v) max = Math.max(max, v) sum += v n++ last = v } if (n) { dims[name] = { min, max, avg: sum / n, last, n } } } const anomalies = getAnomalyEngine() const thr = anomalies.getThreshold?.(chart) || anomalies.thresholds?.get?.(chart) || null const recentForChart = (anomalies.listRecent?.(50) || []).filter((a) => a.chart === chart) return { chart, meta: meta || chartSummary(def), source: q.source || 'memory', points: data.length, after, dims, latestTs: data.length ? data[data.length - 1][0] : null, threshold: thr, recentAnomalies: recentForChart.slice(0, 5).map(compactAnomaly), } } /** * 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} */ 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} */ 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} */ const delta = {} const dims = new Set([ ...Object.keys(highlight.dims || {}), ...Object.keys(baseline.dims || {}), ]) for (const d of dims) { const h = highlight.dims?.[d] const b = baseline.dims?.[d] if (!h && !b) continue const hLast = h?.last const bAvg = b?.avg let rel = null if (hLast != null && bAvg != null && Number.isFinite(hLast) && Number.isFinite(bAvg)) { const denom = Math.max(Math.abs(bAvg), 1e-9) rel = (hLast - bAvg) / denom } delta[d] = { highlightLast: h?.last ?? null, highlightAvg: h?.avg ?? null, baselineAvg: b?.avg ?? null, baselineMin: b?.min ?? null, baselineMax: b?.max ?? null, relChange: rel != null ? Math.round(rel * 1000) / 1000 : null, } } return { chart, highlight: { after, before, points: highlight.points, dims: highlight.dims }, baseline: { after: baselineAfter, before: baselineBefore, points: baseline.points, dims: baseline.dims, }, delta, meta: highlight.meta, } } /** * Batch summarize several charts (compact for tool payloads). * @param {{ charts?: string[], after?: number, points?: number }} args */ export async function summarizeCharts(args = {}) { let charts = args.charts if (typeof charts === 'string') { charts = charts.split(/[\s,]+/).filter(Boolean) } if (!Array.isArray(charts) || !charts.length) { return { error: 'charts array required' } } charts = charts.map(String).slice(0, 12) const after = args.after != null ? Number(args.after) : -120 const points = Math.min(120, Math.max(10, Number(args.points) || 60)) const results = [] for (const chart of charts) { const s = await summarizeChart({ chart, after, points }) if (s.error) { results.push({ chart, error: s.error }) } else { results.push({ chart, points: s.points, dims: s.dims, latestTs: s.latestTs, recentAnomalies: s.recentAnomalies, }) } } return { after, points, count: results.length, results } } function compactProcess(p) { if (!p) return p return { pid: p.pid, name: p.name || p.comm, user: p.user || p.username, cpu: p.cpu, mem: p.mem, rss: p.rss, state: p.state, cmd: p.cmd ? String(p.cmd).slice(0, 120) : undefined, } } function compactAnomaly(a) { if (!a) return a return { chart: a.chart, severity: a.severity, message: a.message, score: a.score, ts: a.ts, cleared: Boolean(a.cleared), } } function compactAlert(a) { if (!a) return a return { id: a.id, chart: a.chart, severity: a.severity, message: a.message || a.name, ts: a.ts, silenced: Boolean(a.silenced), } }