Files
peardata/server/services/ai-tools.js
T
Raven Scott 4b5b78892f
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s
FIX: QVAC Updates
2026-07-30 15:23:43 -04:00

666 lines
20 KiB
JavaScript

/**
* 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<object>}
*/
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<string, { value: number, ts: number, dim: string, chart: string }|null>} */
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<object>} */
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<string, { min: number, max: number, avg: number, last: number, n: number }>} */
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<string, number>} */
const anomScore = new Map()
for (const a of recentAnoms) {
if (!a?.chart || a.cleared) continue
const prev = anomScore.get(a.chart) || 0
const s = a.severity === 'critical' ? 1 : a.severity === 'warning' ? 0.7 : 0.4
anomScore.set(a.chart, Math.max(prev, s + (Number(a.score) || 0) * 0.05))
}
/** @type {Array<object>} */
const scored = []
for (const [id, meta] of Object.entries(charts)) {
if (familyFilter) {
const fam = String(meta.family || meta.context || '').toLowerCase()
if (!fam.includes(familyFilter) && !id.toLowerCase().includes(familyFilter)) continue
}
let deltaScore = 0
let last = null
let avg = null
let reason = ''
try {
const q = await store.query({
chart: id,
after: -window,
points: Math.min(window, 90),
group: 'average',
})
const labels = Array.isArray(q.labels) ? q.labels.filter((l) => l && l !== 'time') : []
const data = Array.isArray(q.data) ? q.data : []
if (data.length >= 4 && labels.length) {
// Use first dim with finite values
let di = 0
for (let i = 0; i < labels.length; i++) {
if (data.some((row) => Number.isFinite(Number(row[i + 1])))) {
di = i
break
}
}
const vals = data
.map((row) => Number(row[di + 1]))
.filter((v) => Number.isFinite(v))
if (vals.length >= 4) {
const half = Math.floor(vals.length / 2)
const early = vals.slice(0, half)
const late = vals.slice(half)
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length
const mEarly = mean(early)
const mLate = mean(late)
last = vals[vals.length - 1]
avg = mean(vals)
const denom = Math.max(Math.abs(mEarly), Math.abs(avg), 1e-9)
const rel = Math.abs(mLate - mEarly) / denom
deltaScore = Math.min(1, rel)
if (rel >= 0.15) {
reason = `${labels[di]} ${mLate >= mEarly ? '↑' : '↓'} ${(rel * 100).toFixed(0)}% vs earlier window`
}
}
}
} catch {
// skip chart
}
const aScore = anomScore.get(id) || 0
const score = Math.min(1.5, deltaScore * 0.85 + aScore)
if (score < 0.12 && !aScore) continue
if (!reason && aScore) reason = 'recent anomaly'
if (!reason) reason = 'activity'
scored.push({
chart: id,
title: meta.title || id,
family: meta.family || '',
context: meta.context || '',
score: Math.round(score * 1000) / 1000,
last,
avg,
reason,
anomaly: Boolean(aScore),
})
}
scored.sort((a, b) => b.score - a.score || a.chart.localeCompare(b.chart))
return {
window,
count: scored.length,
results: scored.slice(0, limit),
}
}
/**
* Related charts for investigation (catalog + optional weights).
* @param {{ chart?: string, id?: string, limit?: number }} args
*/
export async function relatedCharts(args = {}) {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { error: 'chart required' }
const limit = Math.min(40, Math.max(1, Number(args.limit) || 12))
const store = getStore()
const catalog = store.listChartSummaries?.() || {}
if (!catalog[chart] && !CHART_BY_ID.has(chart)) {
return { error: 'unknown chart', chart }
}
let weights = null
try {
const { computeWeights } = await import('./weights.js')
const w = await computeWeights({ chart, limit: 40, method: 'alerts' })
weights = w?.results || w?.weights || null
} catch {
weights = null
}
const { rankRelatedCharts } = await import('../../shared/related-metrics.js')
const ranked = rankRelatedCharts(chart, catalog, null, { limit, weights })
return {
chart,
seed: catalog[chart] || chartSummary(CHART_BY_ID.get(chart)),
count: ranked.length,
results: ranked.map((r) => ({
id: r.id,
score: Math.round(r.score * 100) / 100,
reason: r.reason,
title: catalog[r.id]?.title || r.id,
family: catalog[r.id]?.family || '',
})),
}
}
/**
* Compare two time windows on one chart (baseline vs highlight).
* @param {{
* chart?: string,
* baselineAfter?: number,
* baselineBefore?: number,
* after?: number,
* before?: number,
* points?: number,
* }} args
*/
export async function compareChartWindows(args = {}) {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { error: 'chart required' }
const points = Math.min(300, Math.max(10, Number(args.points) || 60))
// Default: highlight last 5m, baseline prior 20m
const after = args.after != null ? Number(args.after) : -300
const before = args.before != null ? Number(args.before) : 0
const baselineAfter =
args.baselineAfter != null ? Number(args.baselineAfter) : -1500
const baselineBefore =
args.baselineBefore != null ? Number(args.baselineBefore) : -300
const [highlight, baseline] = await Promise.all([
summarizeChart({ chart, after, points, group: args.group || 'average' }),
summarizeChart({
chart,
after: baselineAfter,
points,
group: args.group || 'average',
}),
])
if (highlight.error) return highlight
if (baseline.error) return { ...baseline, phase: 'baseline' }
/** @type {Record<string, object>} */
const delta = {}
const dims = new Set([
...Object.keys(highlight.dims || {}),
...Object.keys(baseline.dims || {}),
])
for (const d of dims) {
const h = highlight.dims?.[d]
const b = baseline.dims?.[d]
if (!h && !b) continue
const hLast = h?.last
const bAvg = b?.avg
let rel = null
if (hLast != null && bAvg != null && Number.isFinite(hLast) && Number.isFinite(bAvg)) {
const denom = Math.max(Math.abs(bAvg), 1e-9)
rel = (hLast - bAvg) / denom
}
delta[d] = {
highlightLast: h?.last ?? null,
highlightAvg: h?.avg ?? null,
baselineAvg: b?.avg ?? null,
baselineMin: b?.min ?? null,
baselineMax: b?.max ?? null,
relChange: rel != null ? Math.round(rel * 1000) / 1000 : null,
}
}
return {
chart,
highlight: { after, before, points: highlight.points, dims: highlight.dims },
baseline: {
after: baselineAfter,
before: baselineBefore,
points: baseline.points,
dims: baseline.dims,
},
delta,
meta: highlight.meta,
}
}
/**
* Batch summarize several charts (compact for tool payloads).
* @param {{ charts?: string[], after?: number, points?: number }} args
*/
export async function summarizeCharts(args = {}) {
let charts = args.charts
if (typeof charts === 'string') {
charts = charts.split(/[\s,]+/).filter(Boolean)
}
if (!Array.isArray(charts) || !charts.length) {
return { error: 'charts array required' }
}
charts = charts.map(String).slice(0, 12)
const after = args.after != null ? Number(args.after) : -120
const points = Math.min(120, Math.max(10, Number(args.points) || 60))
const results = []
for (const chart of charts) {
const s = await summarizeChart({ chart, after, points })
if (s.error) {
results.push({ chart, error: s.error })
} else {
results.push({
chart,
points: s.points,
dims: s.dims,
latestTs: s.latestTs,
recentAnomalies: s.recentAnomalies,
})
}
}
return { after, points, count: results.length, results }
}
function compactProcess(p) {
if (!p) return p
return {
pid: p.pid,
name: p.name || p.comm,
user: p.user || p.username,
cpu: p.cpu,
mem: p.mem,
rss: p.rss,
state: p.state,
cmd: p.cmd ? String(p.cmd).slice(0, 120) : undefined,
}
}
function compactAnomaly(a) {
if (!a) return a
return {
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),
}
}