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

153 lines (152 loc) • 7.51 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/. import { classifyImproveAction } from "../../core/improve-types.js"; /** * Compute the cheap aggregate metrics blob from a full improve result. * * Pure function — no I/O. Used by {@link recordImproveRun} to populate * `metrics_json`. Exposed for tests and for any future call site that wants * the same aggregation logic without hitting state.db. */ export function computeImproveRunMetrics(result) { const plannedCount = Array.isArray(result.plannedRefs) ? result.plannedRefs.length : 0; const actions = Array.isArray(result.actions) ? result.actions : []; const actionsCount = actions.length; let acceptedCount = 0; let rejectedCount = 0; let skippedCount = 0; let errorCount = 0; for (const action of actions) { // Bucketing delegated to the shared classifyImproveAction so this aggregate // and the improve_completed event in improve.ts can never disagree, and so a // new union variant is a compile error rather than a silent drop. Gated skips // (cooldown / signal-delta / distill pool-delta) bucket to "skipped", NOT // "rejected" — only a guard-rejected produced change is a true rejection. // "noop" (memory-prune) is intentionally counted in none of the buckets. switch (classifyImproveAction(action.mode)) { case "accepted": acceptedCount++; break; case "rejected": rejectedCount++; break; case "skipped": skippedCount++; break; case "error": errorCount++; break; case "noop": break; } } const autoAcceptedCount = result.triage?.promoted ?? 0; // C1 (13-bus-factor): distill-skipped rows are folded into the bounded // `distillSkipped` aggregate and no longer live in `actions`. Add the // aggregate total to the skipped + total-actions counters so metrics_json // keeps aggregate metrics aligned with the logical action count. const distillSkippedTotal = result.distillSkipped?.total ?? 0; skippedCount += distillSkippedTotal; return { plannedCount, actionsCount: actionsCount + distillSkippedTotal, acceptedCount, rejectedCount, skippedCount, autoAcceptedCount, errorCount, }; } /** * Insert a single improve-run row into `improve_runs`. Uses parameterised SQL. * * Idempotency: the table's PRIMARY KEY is `id`, so re-running with the same * runId would error. Callers mint a fresh runId per invocation via * {@link buildImproveRunId} so this is not a concern in practice — but the * default behaviour is INSERT (not REPLACE) so accidental dupes surface as * a SQLite constraint error rather than silently overwriting a prior record. * * The `metrics` parameter defaults to the output of * {@link computeImproveRunMetrics} when not supplied. Pass an explicit * `metrics` object to override the derivation (e.g. tests). */ export function recordImproveRun(db, input) { const metricsObj = input.metrics ?? computeImproveRunMetrics(input.result); db.prepare(` INSERT INTO improve_runs (id, started_at, completed_at, stash_dir, dry_run, strategy, scope_mode, scope_value, guidance, ok, result_json, metrics_json, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(input.id, input.startedAt, input.completedAt, input.stashDir, input.dryRun ? 1 : 0, input.strategy, input.scopeMode, input.scopeValue, input.guidance, input.ok ? 1 : 0, JSON.stringify(input.result), JSON.stringify(metricsObj), JSON.stringify(input.metadata ?? {})); } /** * Read real (non-dry-run) improve_runs rows whose `started_at` falls in the * window `[since, until)`. When `until` is omitted the window is open-ended * (`started_at >= since`). Rows are returned newest-first (`ORDER BY * started_at DESC`). * * Owns the SQL formerly inlined in commands/health.ts (`loadImproveRunRows`). * The `dry_run = 0` filter is first-class so dry-run probes never pollute * productivity audits. * * Connection-lifetime rule (WS5): `.all()` fully materializes the result set * into a plain array before returning — no live cursor escapes the caller's * `openStateDatabase` scope. */ export function queryImproveRuns(db, since, until) { const sql = until ? "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, strategy, result_json FROM improve_runs WHERE started_at >= ? AND started_at < ? AND dry_run = 0 ORDER BY started_at DESC" : "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, strategy, result_json FROM improve_runs WHERE started_at >= ? AND dry_run = 0 ORDER BY started_at DESC"; return (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since)); } const IMPROVE_RUN_ROW_COLUMNS = "id, started_at, completed_at, stash_dir, dry_run, strategy, scope_mode, scope_value, guidance, ok, result_json, metrics_json, metadata_json"; /** * Look up a single `improve_runs` row by its id (#944 — `akm improve report --run <id>`). * `id` is the PRIMARY KEY, so at most one row matches. Returns `undefined` * for an unknown id rather than throwing — the caller decides how to report * "no such run". */ export function getImproveRunById(db, id) { return db.prepare(`SELECT ${IMPROVE_RUN_ROW_COLUMNS} FROM improve_runs WHERE id = ?`).get(id); } /** * Look up the most recent real (non-dry-run) `improve_runs` row (#944 — * `akm improve report`'s default target, and `--last`). Same `dry_run = 0` * filter as {@link queryImproveRuns} so a dry-run preview never becomes the * implicit "last run" a report reads. */ export function getLatestImproveRun(db) { return db .prepare(`SELECT ${IMPROVE_RUN_ROW_COLUMNS} FROM improve_runs WHERE dry_run = 0 ORDER BY started_at DESC LIMIT 1`) .get(); } /** * #950: count real (non-dry-run) improve_runs rows whose `started_at` falls * in `[since, now)` — same filter as {@link queryImproveRuns}, but a `SELECT * COUNT(*)` for a caller (the `engine-last-used` gate) that only needs to * know whether any run happened, not the rows themselves (which would pull * every `result_json` blob in the window just to test for zero). */ export function countImproveRunsSince(db, since) { const row = db .prepare("SELECT COUNT(*) AS cnt FROM improve_runs WHERE started_at >= ? AND dry_run = 0") .get(since); return row.cnt; } /** * Delete improve_runs rows older than `retentionDays` (default: 90). Mirrors * {@link purgeOldEvents} — same default, same return shape (number of rows * actually deleted), same disabled-when-non-finite semantics. * * Safe to call from the improve post-loop maintenance pass alongside * `purgeOldEvents(db, retentionDays)`. */ export function purgeOldImproveRuns(db, retentionDays = 90) { if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0; const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); const result = db.prepare("DELETE FROM improve_runs WHERE started_at < ?").run(cutoff); const changes = result.changes ?? 0; return typeof changes === "bigint" ? Number(changes) : changes; }