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
JavaScript
// 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,
};
}