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

214 lines (213 loc) • 9.81 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/. /** * `akm metrics` — one read-only report over what akm has already recorded: * asset usage, feedback, utility, LLM usage, task runs, proposals and * workflow spend. This module reads the stores and hands the rows to the pure * aggregation in `collect.ts`; the shape is `AkmMetricsResult` (`types.ts`). * * Nothing is written, and nothing is migrated: `state.db` is opened read-only, * and one with migrations pending is skipped with a note. A missing `state.db` * gives an empty report and a missing `index.db` an empty utility section, each * with a note, never an error. */ import fs from "node:fs"; import { defineJsonCommand, output } from "../../cli/shared.js"; import { parseBundleRef } from "../../core/asset/asset-ref.js"; import { loadConfig } from "../../core/config/config.js"; import { UsageError } from "../../core/errors.js"; import { readEvents } from "../../core/events.js"; import { getDbPath } from "../../core/paths.js"; import { getStateDbPath, listPendingStateMigrations } from "../../core/state-db.js"; import { lookupBundleRefsReadonly } from "../../indexer/indexer.js"; import { USAGE_EVENT_RETENTION_DAYS } from "../../indexer/usage/usage-events.js"; import { LLM_USAGE_EVENT } from "../../llm/usage-persist.js"; import { getOutputMode } from "../../output/context.js"; import { openDatabase } from "../../storage/database.js"; import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js"; import { listUtilityWithRefs } from "../../storage/repositories/index-utility-repository.js"; import { countProposalsByStatus, listIndexCompletedEvents, listLowestOutcomeAssets, listSelectEvents, listUsageEventRows, summarizeWorkflowRuns, } from "../../storage/repositories/metrics-repository.js"; import { queryTaskHistory } from "../../storage/repositories/task-history-repository.js"; import { applyReadonlyPragmas } from "../../storage/sqlite-pragmas.js"; import { parseHealthSince } from "../health.js"; import { computeAcceptRateBySource } from "../health/accept-rate.js"; import { emptyLlmUsageAggregate, summarizeLlmUsage } from "../health/llm-usage.js"; import { buildMetricsResult, DEFAULT_EVENT_RETENTION_DAYS, indexRunsFromEvents, llmRowsFromEvents, retentionNotes, } from "./collect.js"; const DEFAULT_SINCE = "30d"; /** Cap on every ranked list. */ const TOP = 20; /** Usage rows from people; improve, task and audit rows are akm's own reads. */ const SOURCE = "user"; /** * Whether the report carries the raw window rows: the HTML dashboard * re-aggregates from them client-side, so `--format html` always includes * them, and `--detail full` includes them for any other format. */ export function metricsIncludeRows(mode) { return mode.format === "html" || mode.detail === "full"; } /** * `select` events keyed on the durable ref. The events stream stores the ref * as typed (`knowledge/x` for the default bundle), while `usage_events` stores * `bundle//conceptId`; resolving here keeps one asset from showing as two rows. * A ref that does not resolve (registry results, removed assets) is kept as is. */ async function durableSelects(selects) { const unqualified = []; for (const ref of new Set(selects.map((select) => select.ref))) { try { const parsed = parseBundleRef(ref); if (!parsed.bundle) unqualified.push({ ref, parsed }); } catch { // Not a local bundle ref: keep the ref as recorded. } } const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed)); const resolved = new Map(unqualified.map(({ ref }, i) => [ref, entries[i]?.itemRef ?? ref])); return selects.map((select) => ({ ts: select.ts, ref: resolved.get(select.ref) ?? select.ref })); } /** `index.db` utility rows, or a note saying why there are none. */ function readUtility() { let db; try { db = openReadonlyExistingDatabase(getDbPath()); if (!db) return { note: "index.db was not found, so the utility section is empty. Run 'akm index' to build it." }; return { utility: listUtilityWithRefs(db) }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { note: `index.db could not be read (${message}), so the utility section is empty.` }; } finally { if (db) closeDatabase(db); } } function readEventRetention() { try { const days = loadConfig().improve?.eventRetentionDays; return { eventRetentionDays: typeof days === "number" ? days : DEFAULT_EVENT_RETENTION_DAYS }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { eventRetentionDays: DEFAULT_EVENT_RETENTION_DAYS, note: `The config could not be loaded (${message}), so event retention assumes ${DEFAULT_EVENT_RETENTION_DAYS} days.`, }; } } function readAcceptRate(stateDb, notes) { try { return computeAcceptRateBySource(undefined, { db: stateDb }); } catch (error) { notes.push(`Proposal accept rate is unavailable (${error instanceof Error ? error.message : String(error)}).`); return []; } } /** Read every store and build the report. */ export async function akmMetrics(options = {}) { const nowMs = (options.now ?? Date.now)(); const sinceIso = parseHealthSince(options.since ?? DEFAULT_SINCE); const untilIso = new Date(nowMs).toISOString(); if (Date.parse(sinceIso) >= Date.parse(untilIso)) { throw new UsageError(`--since (${sinceIso}) must be earlier than now (${untilIso}).`, "INVALID_FLAG_VALUE"); } const notes = []; const config = readEventRetention(); if (config.note) notes.push(config.note); notes.push(...retentionNotes({ sinceIso, nowMs, usageRetentionDays: USAGE_EVENT_RETENTION_DAYS, eventRetentionDays: config.eventRetentionDays, })); const index = readUtility(); if (index.note) notes.push(index.note); const filter = { sinceIso, untilIso, source: SOURCE }; notes.push("Selects come from the events stream, which records no source, so they are counted for every source."); const input = { window: { since: sinceIso, until: untilIso }, source: SOURCE, top: TOP, includeRows: options.includeRows === true, usage: [], selects: [], utility: index.utility, outcomes: [], llm: emptyLlmUsageAggregate(), llmRows: [], indexRuns: [], tasks: [], proposals: { byStatus: {}, acceptRateBySource: [] }, workflows: { runs: 0, byStatus: {}, tokens: 0, byModel: {} }, notes, }; const stateDbPath = getStateDbPath(); if (!fs.existsSync(stateDbPath)) { notes.push("state.db was not found, so every section except utility is empty."); return buildMetricsResult(input); } let pending; try { pending = listPendingStateMigrations(stateDbPath); } catch (error) { notes.push(`state.db could not be read (${error instanceof Error ? error.message : String(error)}), so every section except utility is empty.`); return buildMetricsResult(input); } if (pending.length > 0) { notes.push(`state.db has ${pending.length} pending migration${pending.length === 1 ? "" : "s"}; 'akm metrics' never migrates, so every section except utility is empty. Run 'akm migrate apply'.`); return buildMetricsResult(input); } const stateDb = openDatabase(stateDbPath, { readonly: true, create: false }); let selects = []; try { applyReadonlyPragmas(stateDb); input.usage = listUsageEventRows(stateDb, filter); selects = listSelectEvents(stateDb, sinceIso, untilIso); input.outcomes = listLowestOutcomeAssets(stateDb, TOP); input.indexRuns = indexRunsFromEvents(listIndexCompletedEvents(stateDb, sinceIso, untilIso)); input.tasks = queryTaskHistory(stateDb, { since: sinceIso, until: untilIso }); input.proposals = { byStatus: countProposalsByStatus(stateDb, sinceIso, untilIso), acceptRateBySource: readAcceptRate(stateDb, notes), }; input.workflows = summarizeWorkflowRuns(stateDb, sinceIso, untilIso); const untilMs = Date.parse(untilIso); const llmEvents = readEvents({ since: sinceIso, type: LLM_USAGE_EVENT }, { db: stateDb, readOnly: true }).events; const inWindow = llmEvents.filter((event) => Date.parse(event.ts) < untilMs); input.llm = summarizeLlmUsage(inWindow); if (input.includeRows) input.llmRows = llmRowsFromEvents(inWindow); } finally { stateDb.close(); } input.selects = await durableSelects(selects); return buildMetricsResult(input); } export const metricsCommand = defineJsonCommand({ meta: { name: "metrics", description: "Experimental: report asset usage, feedback, utility, LLM usage, tasks, proposals and workflow spend from local records. Read-only. --format html writes a self-contained dashboard.", }, args: { since: { type: "string", description: `Window start: ISO, date, epoch ms, or 24h / 7d / 30d (default ${DEFAULT_SINCE})`, }, }, async run({ args }) { output("metrics", await akmMetrics({ since: args.since, includeRows: metricsIncludeRows(getOutputMode()), })); }, });