UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

402 lines (401 loc) • 18.1 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Pure `AkmHealthResult` (+ report options) → `HealthReportViewModel` * extractor for the health HTML report (chunk-9 WI-9.5a / H2, anchors C.6). * * Every computed number, string, boolean, and series the report shows is * derived here — deltas, trends, staleness, rate/duration formatting, the * synthesized verdict components, the summary-table rows, the action-item * inputs. NOTHING in this module emits HTML: `html-report.ts` holds the thin * VM→HTML fragment renderers that consume this shape (`buildEchartsTag`, * `isoTimeTag`, the `<li>`/card builders, …). * * Determinism: nothing here depends on Date.now()/Math.random(). Runs are * sorted by startedAt so the view model — and therefore the rendered report — * is byte-identical for identical inputs. */ import { TASK_FAIL_RATE_WARN, } from "./types.js"; /** Distill skip-reasons hidden from the breakdown chart (steady-state noise). */ const DISTILL_REASONS_HIDDEN = new Set(["no new signal since last proposal"]); // ── Small pure formatters (ports of render.py helpers; no HTML) ───────────── export function num(value) { return Math.round(value).toLocaleString("en-US"); } /** Compact token/count formatter, e.g. 127345 → "127K", 1_500_000 → "1.5M". */ export function compact(value) { const v = Math.round(value); if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1).replace(/\.0$/, "")}K`; return String(v); } /** Humanize a camelCase / kebab / snake enum into reader-facing text. */ export function humanize(raw) { return raw .replace(/[-_]/g, " ") .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/\s+/g, " ") .trim() .replace(/^./, (c) => c.toUpperCase()); } export function fmtMs(ms) { return ms ? `${(ms / 60000).toFixed(1)}m` : "—"; } export function pct(rate, digits) { return `${(rate * 100).toFixed(digits)}%`; } /** pctChange may be a number or an "+inf"/"-inf" sentinel (prior window was 0). */ function coercePct(raw) { if (typeof raw === "number") return raw; if (typeof raw !== "string") return undefined; const s = raw.trim().toLowerCase(); if (s === "+inf" || s === "inf" || s === "infinity" || s === "+infinity") return 1e9; if (s === "-inf" || s === "-infinity") return -1e9; const parsed = Number.parseFloat(s.replace(/%$/, "")); return Number.isFinite(parsed) ? parsed : undefined; } function reshapeRun(r) { const cons = r.consolidation; const mi = r.memoryInference; const wall = r.wallTimeMs || 0; const consMs = cons.durationMs || 0; const miMs = mi.durationMs || 0; return { id: r.id, resultStatus: r.resultStatus ?? "valid", resultComplete: r.resultComplete ?? (r.resultStatus === undefined || r.resultStatus === "valid"), taskId: r.taskId ?? "manual", strategy: r.strategy, startedAt: r.startedAt, completedAt: r.completedAt, wallTimeMs: wall, ok: r.ok, consDurationMs: consMs, miDurationMs: miMs, otherMs: Math.max(0, wall - consMs - miMs), promoted: cons.promoted, merged: cons.merged, deleted: cons.deleted, contradicted: cons.contradicted, judgedNoAction: cons.judgedNoAction, miWritten: mi.written, distillByReason: r.actions.distill.skippedByReason, reflectOk: r.actions.reflect.ok, reflectFailed: r.actions.reflect.failed, derived: r.memorySummary.derived, eligible: r.memorySummary.eligible, lintFlagged: r.lintFlagged, lintFixed: r.lintFixed, }; } function compareRuns(a, b) { return (a.startedAt.localeCompare(b.startedAt) || a.completedAt.localeCompare(b.completedAt) || a.id.localeCompare(b.id)); } // ── Trend classification (port of collect.py classify) ────────────────────── function classify(deltas, metricKeys, lowerIsBetter = false) { const changes = metricKeys .map((key) => coercePct(deltas[key]?.pctChange)) .filter((c) => c !== undefined); if (changes.length === 0) return "flat"; const avg = changes.reduce((acc, c) => acc + c, 0) / changes.length; if (Math.abs(avg) < 5) return "flat"; const direction = avg > 0 ? "up" : "down"; if (lowerIsBetter) return avg > 0 ? "down" : "up"; return direction; } function buildTrend(deltas) { const decisionQuality = classify(deltas, ["improve.memoryInference.yieldRate", "improve.consolidation.promoted"]); const outputVolume = classify(deltas, ["improve.consolidation.promoted", "improve.memoryInference.written"]); const latency = classify(deltas, ["improve.wallTime.medianMs", "improve.wallTime.p95Ms"], true); const score = [decisionQuality, outputVolume, latency].reduce((acc, d) => acc + (d === "up" ? 1 : d === "down" ? -1 : 0), 0); const overall = score >= 1 ? "improving" : score <= -1 ? "degrading" : "mixed"; return { decisionQuality, outputVolume, latency, overall }; } function readSemSearch(advisories) { const check = advisories.find((a) => a.name === "semantic-search-runtime"); if (!check) return { blocked: false, detail: "" }; const evidence = check.evidence ?? {}; const status = String(evidence.status ?? "unknown"); const blocked = check.status !== "pass" || status.toLowerCase().includes("block"); const entries = typeof evidence.entryCount === "number" ? evidence.entryCount : 0; const embeddings = typeof evidence.embeddingCount === "number" ? evidence.embeddingCount : 0; return { blocked, detail: `${num(entries)} entries, ${num(embeddings)} embeddings` }; } // ── Report-window → time-slice candidates (data only; HTML wrapping is the // renderer's job) ───────────────────────────────────────────────────────── /** Parse an akm window string (`24h`, `7d`, `30m`, `2w`) to milliseconds; 0 if unparseable. */ export function windowToMs(window) { const m = /^(\d+)\s*([mhdw])$/i.exec(window.trim()); if (!m) return 0; const n = Number(m[1]); const mult = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2].toLowerCase()] ?? 0; return n * mult; } /** * Candidate sub-window slices strictly shorter than the report window, * largest-first, capped at 4 — the data half of the filter bar's `<option>`s * (the "All" option and the `<option>` tags themselves are the renderer's job). */ function computeSliceCandidates(window) { const windowMs = windowToMs(window); const HOUR = 3_600_000; const DAY = 86_400_000; const candidates = [ [6 * HOUR, "6h"], [12 * HOUR, "12h"], [DAY, "1d"], [3 * DAY, "3d"], [7 * DAY, "7d"], [14 * DAY, "14d"], ]; return candidates .filter(([ms]) => windowMs > 0 && ms < windowMs) .sort((a, b) => b[0] - a[0]) .slice(0, 4); } function partitionRuns(result) { const allRuns = (result.runs ?? []).map(reshapeRun).sort(compareRuns); return { invalidRuns: allRuns.filter((run) => run.resultStatus === "invalid"), runs: allRuns.filter((run) => run.resultStatus !== "invalid"), }; } /** Aggregates (collect.py step 6) + the WS-5 sub-rollups + their formatted-rate strings. */ function buildAggregatesPhase(result, runsPhase) { const { runs, invalidRuns } = runsPhase; const improve = result.improve; const cons = improve.consolidation; const mi = improve.memoryInference; const wallTime = improve.wallTime; const coverage = improve.coverage; // #576: real per-stage LLM token/time accounting (replaces the GPU-time // proxy). Optional-guarded so reports built from older health JSON without // the aggregate still render. const llm = result.metrics.llmUsage ?? { calls: 0, totalDurationMs: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, reasoningTokens: 0, byStage: {}, byProcess: {}, byEngine: {}, }; const totalRuns = runs.length; const failedRuns = runs.filter((r) => !r.ok).length; const invoked = improve.invoked || totalRuns; const completed = improve.completed || totalRuns - failedRuns; const miWritten = mi.written; return { consolidation: cons, memoryInference: mi, wallTime, coverage, llm, memorySummary: improve.memorySummary, includedResultRows: improve.resultRows?.included ?? totalRuns, skippedInvalidResultRows: improve.resultRows?.skipped.invalid ?? invalidRuns.length, invoked, completed, miWritten, completionRate: invoked ? `${Math.round((100 * completed) / invoked)}%` : "0%", completionPct: invoked ? (100 * completed) / invoked : 100, taskFailRate: pct(result.metrics.taskFailRate, 1), agentFailRate: pct(result.metrics.agentFailureRate, 2), miYieldRate: pct(mi.yieldRate, 1), medianDurMin: (wallTime.medianMs / 60000).toFixed(1), p95DurMin: (wallTime.p95Ms / 60000).toFixed(1), avgPromoted: totalRuns ? String(Math.round(cons.promoted / totalRuns)) : "0", chunkFail: `${num(cons.failedChunks)} / ${num(cons.totalChunks)}`, llmTokensCompact: compact(llm.totalTokens), totalRuns, failedRuns, }; } /** * Meta (collect.py steps 10-11) + freshness. Staleness is computed * deterministically (no Date.now()) from the gap between the window start * (`since`) and the newest run we have: if no run landed in the window, or * the newest run sits in the first 25% of a window wider than the 6h * threshold (i.e. a long idle tail), we flag stale. */ function buildMetaPhase(result, runs) { const sinceIso = result.since; const reportDate = sinceIso.slice(0, 10); const reportTitle = reportDate ? `AKM Health Report — ${reportDate}` : "AKM Health Report"; const lastRun = runs[runs.length - 1]; const generatedAt = lastRun ? lastRun.completedAt || lastRun.startedAt || sinceIso : sinceIso; const latest = [...runs] .filter((run) => run.resultComplete) .sort(compareRuns) .at(-1); const STALE_MS = 6 * 60 * 60 * 1000; const latestRunMs = lastRun ? Date.parse(lastRun.completedAt || lastRun.startedAt) : NaN; const sinceMs = Date.parse(sinceIso); const generatedMs = Date.parse(generatedAt); const idleTailMs = Number.isFinite(latestRunMs) && Number.isFinite(generatedMs) ? Math.max(0, generatedMs - latestRunMs) : 0; const isStale = runs.length === 0 || (Number.isFinite(latestRunMs) && Number.isFinite(sinceMs) && idleTailMs > STALE_MS); return { sinceIso, reportDate, reportTitle, generatedAt, isStale, lastRun, latest }; } const STATUS_LABEL = { pass: "PASS", warn: "WARN", fail: "FAIL" }; /** * Synthesized verdict (one sentence + 2-3 drivers). The verdict WORD is the * authoritative health status (hard checks). Concerns (failed improve runs, * blocked search, pending proposals) are advisory and do NOT gate that word, * so a green PASS is never paired with red "drivers" as if they were * failures. When the status is PASS we frame concerns as "watch:" alongside * the trend; only a degraded status (WARN/FAIL) leads with them. */ function buildVerdictPhase(result, trend, sem, proposals, failedRuns) { const statusLabel = STATUS_LABEL[result.status]; const failOk = result.metrics.taskFailRate < TASK_FAIL_RATE_WARN; const concerns = []; if (failedRuns > 0) concerns.push(`${failedRuns} failed run${failedRuns === 1 ? "" : "s"}`); if (sem.blocked) concerns.push("semantic search blocked"); if (proposals.length > 0) concerns.push(`${proposals.length} pending proposal${proposals.length === 1 ? "" : "s"}`); const trendClause = trend.overall === "improving" ? "throughput and latency improving" : trend.overall === "degrading" ? "throughput or latency degrading" : "throughput and latency steady"; const verdictWord = statusLabel; let verdictRest; if (result.status === "pass") { const watch = concerns.length > 0 ? `; watch: ${concerns.slice(0, 3).join(", ")}` : ""; verdictRest = `healthy, ${trendClause}${watch}`; } else { const lead = concerns.length > 0 ? concerns.slice(0, 3).join("; ") : trendClause; verdictRest = trend.overall === "degrading" && concerns.length > 0 ? `${lead}; ${trendClause}` : lead; } return { status: result.status, failOk, statusLabel, concerns, trendClause, verdictWord, verdictRest }; } /** Distill skip-reason chart categories (collect.py chart payload step). */ function computeDistillReasons(runs) { return [...new Set(runs.flatMap((r) => Object.keys(r.distillByReason)))] .filter((reason) => !DISTILL_REASONS_HIDDEN.has(reason)) .sort(); } /** Proposals grouped by `source`, sorted alphabetically (drain-proposals action item). */ function groupProposalsBySource(proposals) { const bySource = new Map(); for (const p of proposals) bySource.set(p.source, (bySource.get(p.source) ?? 0) + 1); return [...bySource.entries()].sort(([a], [b]) => a.localeCompare(b)); } /** Summary table rows: the base metric set + the WS-5 coverage/minting/perf/degradation extensions, when present. */ function buildSummaryRows(aggregates, trend) { const { consolidation: cons, wallTime, llm, coverage } = aggregates; const summaryRows = [ ["Task fail rate", aggregates.taskFailRate, "flat"], ["Agent fail rate", aggregates.agentFailRate, "flat"], ["Improve completion", `${num(aggregates.completed)} / ${num(aggregates.invoked)}`, "flat"], [ "MI yield rate", aggregates.miYieldRate, trend.decisionQuality, "Memory-inference yield: share of considered candidates that produced a written fact.", ], ["MI written", num(aggregates.miWritten), trend.outputVolume, "Memory-inference: facts written this window."], ["Consolidation promoted", num(cons.promoted), trend.outputVolume], ["Consolidation merged", num(cons.merged), "flat"], ["Consolidation deleted", num(cons.deleted), "flat"], ["Consolidation contradicted", num(cons.contradicted), "flat"], [ "Consolidation judged: no action", num(cons.judgedNoAction), "flat", "Candidates reviewed but intentionally left unchanged (the 'judgedNoAction' field).", ], ["Chunk failure", aggregates.chunkFail, "flat"], [ "Stash derived", num(aggregates.memorySummary.derived), "up", "Whole-bundle recount of derived assets at report time — not a per-run sum.", ], ["Median wall time", fmtMs(wallTime.medianMs), trend.latency], ["P95 wall time", fmtMs(wallTime.p95Ms), trend.latency], // #576: real LLM accounting (replaces the GPU-time proxy). ["LLM calls", num(llm.calls), "flat"], ["LLM total tokens", num(llm.totalTokens), "flat"], ["LLM prompt tokens", num(llm.promptTokens), "flat"], ["LLM completion tokens", num(llm.completionTokens), "flat"], [ "LLM reasoning tokens", num(llm.reasoningTokens), "flat", "Tokens spent on model reasoning/thinking, billed separately from prompt and completion.", ], ["LLM wall time", fmtMs(llm.totalDurationMs), trend.latency], [ "Coverage accepted", num(coverage.acceptedProposals), "flat", "Accepted proposals in the window (raw volume — includes repeated rewrites of the same asset).", ], [ "Distinct assets touched", num(coverage.distinctRefs), "flat", "Distinct assets touched by accepted proposals in the window.", ], ]; return summaryRows; } /** * Extract the health HTML report's view model from an `AkmHealthResult` + * report options. Pure — no HTML, no I/O, no clock/RNG reads. Byte-identical * output for identical inputs, mirroring the pre-split * `buildHealthHtmlReplacements` arithmetic exactly. File-level decomposed * (chunk-9 WI-9.5a) into one phase helper per section above; this function is * the extract-then-compose glue. */ export function buildHealthReportViewModel(result, opts) { const deltas = opts.deltas ?? {}; const proposals = opts.proposals; const runsPhase = partitionRuns(result); const sem = readSemSearch(result.advisories); const trend = buildTrend(deltas); const aggregates = buildAggregatesPhase(result, runsPhase); const meta = buildMetaPhase(result, runsPhase.runs); const verdict = buildVerdictPhase(result, trend, sem, proposals, aggregates.failedRuns); const distillReasons = computeDistillReasons(runsPhase.runs); const proposalsBySource = groupProposalsBySource(proposals); const summaryRows = buildSummaryRows(aggregates, trend); return { window: opts.window, compare: opts.compare, comparisonMode: opts.comparisonMode ?? "duration", ...meta, runs: runsPhase.runs, invalidRuns: runsPhase.invalidRuns, ...verdict, ...aggregates, trend, deltas, sem, proposals, proposalsBySource, summaryRows, sliceCandidates: computeSliceCandidates(opts.window), distillReasons, advisories: result.advisories, }; }