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
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/.
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;
}