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

187 lines (186 loc) • 8.71 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/. /** * Window handling for `akm health`: `--window-compare` / `--windows` parsing, * per-window metric assembly, and delta computation between two windows. */ import { UsageError } from "../../core/errors.js"; import { readEvents } from "../../core/events.js"; import { DURATION_UNITS, parseDuration } from "../../core/time.js"; import { queryTaskHistory } from "../../storage/repositories/task-history-repository.js"; import { buildImproveSkipSummary, computeWallTimeStats, computeWindowProposalCoverage, countAgentFailureReasons, isAgentTaskHistoryRow, roundRate, summarizeImproveRuns, taskFailureDetail, } from "./improve-metrics.js"; import { readLlmUsageAggregate } from "./llm-usage.js"; import { buildPerRunSummaries } from "./task-runs.js"; import { ACTIVE_RUN_WARN_MS, IMPROVE_COMPLETED_EVENT, } from "./types.js"; /** * Parse a `--window-compare <duration>` shorthand into two adjacent windows * (current, prior). Duration syntax matches {@link parseHealthSince}. */ export function resolveWindowCompare(duration, now = () => Date.now()) { const trimmed = duration.trim(); // Canonical CLI unit grammar: `m` = minutes, `M` = months. Not lower-cased, // so case distinguishes the two. See core/time.ts DURATION_UNITS. const ms = parseDuration(trimmed, DURATION_UNITS); if (ms === null) { throw new UsageError("--window-compare must be a duration like '24h', '7d', or '30m'.", "INVALID_FLAG_VALUE"); } if (ms <= 0) { throw new UsageError("--window-compare must be a positive duration.", "INVALID_FLAG_VALUE"); } const nowMs = now(); const currentSince = new Date(nowMs - ms).toISOString(); const currentUntil = new Date(nowMs).toISOString(); const priorSince = new Date(nowMs - 2 * ms).toISOString(); const priorUntil = currentSince; return [ { name: "current", since: currentSince, until: currentUntil }, { name: "prior", since: priorSince, until: priorUntil }, ]; } /** * Parse a single repeatable `--windows` value of the form * `name=...,since=...,until=...`. All keys are optional EXCEPT name and since. */ export function parseWindowSpec(raw) { const fields = {}; for (const part of raw.split(",")) { const trimmed = part.trim(); if (!trimmed) continue; const eq = trimmed.indexOf("="); if (eq < 0) { throw new UsageError(`--windows entry must be a comma-separated list of key=value pairs: ${raw}`, "INVALID_FLAG_VALUE"); } const key = trimmed.slice(0, eq).trim(); const value = trimmed.slice(eq + 1).trim(); fields[key] = value; } if (!fields.name) { throw new UsageError(`--windows entry is missing required 'name': ${raw}`, "INVALID_FLAG_VALUE"); } if (!fields.since) { throw new UsageError(`--windows entry is missing required 'since': ${raw}`, "INVALID_FLAG_VALUE"); } return { name: fields.name, since: fields.since, ...(fields.until ? { until: fields.until } : {}), }; } /** * Metric paths diffed between the earliest and latest window (`deltas` in the * JSON result; the delta column of `--format md`). Paths are relative to a * {@link WindowResult}. */ export const INTERESTING_DELTA_PATHS = [ "improve.actions.reflect.failed", "improve.actions.distill.queued", "improve.actions.distill.llmFailed", "improve.consolidation.promoted", "improve.memoryInference.written", "improve.memoryInference.yieldRate", "improve.memoryInference.skippedNoFacts", "improve.autoAccept.promoted", "improve.autoAccept.validationFailed", "improve.coverage.acceptedProposals", "improve.coverage.distinctRefs", "improve.wallTime.medianMs", "improve.wallTime.p95Ms", "metrics.llmUsage.calls", "metrics.llmUsage.totalTokens", "metrics.llmUsage.totalDurationMs", "metrics.llmUsage.failures", ]; export function readNumericPath(obj, path) { const parts = path.split("."); let cursor = obj; for (const part of parts) { if (typeof cursor !== "object" || cursor === null) return 0; cursor = cursor[part]; } return typeof cursor === "number" && Number.isFinite(cursor) ? cursor : 0; } export function computeDeltas(first, last) { const out = {}; for (const path of INTERESTING_DELTA_PATHS) { const from = readNumericPath(first, path); const to = readNumericPath(last, path); if (from === 0 && to === 0) continue; let pctChange; if (from === 0) { pctChange = to === 0 ? 0 : "+inf"; } else { pctChange = Number((((to - from) / from) * 100).toFixed(2)); } out[path] = { from, to, pctChange }; } return out; } /** * Task-history-derived rates, shared by the main health path * (`health.ts`'s `gatherTaskHistoryPhase`) and {@link buildWindowMetrics} * below — previously computed twice with the only difference being whether * the caller had already bounded `taskRows` by an `until`. Pure projection of * already-queried rows; callers own the `since`/`until` query. */ export function computeTaskWindowRates(taskRows, now) { const failedTaskRows = taskRows.filter((row) => row.status === "failed"); const activeRows = taskRows.filter((row) => row.status === "active" && row.completed_at === null); const stuckActiveRows = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS); const agentRows = taskRows.filter((row) => isAgentTaskHistoryRow(row)); const agentFailures = agentRows.filter((row) => { const detail = taskFailureDetail(row); return typeof detail?.reason === "string" && detail.reason.length > 0; }); return { taskFailRate: taskRows.length === 0 ? 0 : failedTaskRows.length / taskRows.length, agentFailureRate: agentRows.length === 0 ? 0 : agentFailures.length / agentRows.length, agentFailureReasonCounts: countAgentFailureReasons(agentFailures), stuckActiveRuns: stuckActiveRows.length, stuckActiveRows, }; } /** * Improve-pipeline window summary, shared by the main health path * (`health.ts`) and {@link buildWindowMetrics} below: events-derived * invoked/completed/skipped counts, the `result_json` aggregate, wall-time * stats from the same per-run rows, and accepted-proposal coverage, all over * one `[since, until)`. */ export function buildImproveWindowSummary(db, stateDbPath, since, until) { const untilMs = new Date(until).getTime(); const eventsBeforeUntil = (type) => readEvents({ since, type }, { dbPath: stateDbPath }).events.filter((event) => new Date(event.ts ?? since).getTime() < untilMs); const { metrics: improve, runCount } = summarizeImproveRuns(db, since, until); improve.invoked = eventsBeforeUntil("improve_invoked").length; improve.completed = eventsBeforeUntil(IMPROVE_COMPLETED_EVENT).length; const skipSummary = buildImproveSkipSummary(eventsBeforeUntil("improve_skipped")); improve.skipped = skipSummary.skipped; improve.skipReasons = skipSummary.skipReasons; // Wall times come from the same improve-runs window as the per-run // reporting so counts and percentiles stay aligned with it. const perRunSummaries = buildPerRunSummaries(db, since, until); improve.wallTime = computeWallTimeStats(perRunSummaries.map((run) => run.wallTimeMs).filter((ms) => Number.isFinite(ms) && ms > 0)); improve.coverage = computeWindowProposalCoverage(db, since, until); return { improve, runCount, perRunSummaries }; } export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now()) { const untilMs = new Date(until).getTime(); const taskRows = queryTaskHistory(db, { since }).filter((row) => { const startMs = new Date(row.started_at).getTime(); return !Number.isFinite(untilMs) || startMs < untilMs; }); const rates = computeTaskWindowRates(taskRows, now); const { improve, runCount } = buildImproveWindowSummary(db, stateDbPath, since, until); const metrics = { taskFailRate: roundRate(rates.taskFailRate), agentFailureRate: roundRate(rates.agentFailureRate), agentFailureReasonCounts: rates.agentFailureReasonCounts, stuckActiveRuns: rates.stuckActiveRuns, llmUsage: readLlmUsageAggregate(stateDbPath, since, until), }; return { improve, metrics, runs: runCount }; }