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
440 lines (439 loc) • 18.4 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 { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
import { decodeTaskHistoryMetadata } from "../../storage/repositories/task-history-repository.js";
import { computeWallTimeStats } from "../health/improve-metrics.js";
import { computeValenceScore } from "../improve/feedback-valence.js";
const DAY_MS = 86_400_000;
/** Utility is a score in [0, 1], reported in ten equal buckets. */
const UTILITY_BUCKETS = 10;
/** Mean result counts are rounded to two decimals. */
const MEAN_SCALE = 100;
/** `improve.eventRetentionDays` default (`runRetentionPurgePass`); 0 disables the purge. */
export const DEFAULT_EVENT_RETENTION_DAYS = 90;
// ── Filters ─────────────────────────────────────────────────────────────────
// ── Notes ───────────────────────────────────────────────────────────────────
/**
* One note per store whose retention is shorter than the window, naming the
* store, how long it keeps rows and where the data effectively starts. A
* retention of 0 or less means the store is never purged.
*/
export function retentionNotes(args) {
const stores = [
{ store: "usage_events (search, show, curate and feedback rows)", days: args.usageRetentionDays },
{ store: "events (selects, LLM calls and index runs)", days: args.eventRetentionDays },
];
const notes = [];
const sinceMs = Date.parse(args.sinceIso);
for (const { store, days } of stores) {
if (!Number.isFinite(days) || days <= 0)
continue;
const effectiveMs = args.nowMs - days * DAY_MS;
if (sinceMs >= effectiveMs)
continue;
notes.push(`${store} keeps ${days} days, so the window starts ${args.sinceIso} but its data starts no earlier than ${new Date(effectiveMs).toISOString()}.`);
}
return notes;
}
// ── Row mapping ─────────────────────────────────────────────────────────────
/** `usage_events.created_at` (`YYYY-MM-DD HH:MM:SS`, UTC) as an ISO-8601 string. */
export function usageCreatedAtToIso(createdAt) {
const text = createdAt.includes("T") ? createdAt : `${createdAt.replace(" ", "T")}Z`;
const ms = Date.parse(text);
return Number.isNaN(ms) ? createdAt : new Date(ms).toISOString();
}
function parseMetadata(text) {
if (!text)
return {};
try {
const parsed = JSON.parse(text);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
}
catch {
return {};
}
}
function finiteNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Map one `usage_events` row to the shape reported (and carried to the HTML dashboard). */
export function toUsageRow(row) {
const metadata = parseMetadata(row.metadata);
const resultCount = finiteNumber(metadata.resultCount);
const totalMs = finiteNumber(metadata.totalMs);
const reason = typeof metadata.reason === "string" && metadata.reason.trim() ? metadata.reason : undefined;
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter((tag) => typeof tag === "string")
: undefined;
return {
id: row.id,
at: usageCreatedAtToIso(row.created_at),
eventType: row.event_type,
...(row.entry_ref ? { ref: row.entry_ref } : {}),
...(row.query ? { query: row.query } : {}),
...(row.signal === "positive" || row.signal === "negative" ? { signal: row.signal } : {}),
source: row.source,
...(resultCount !== undefined ? { resultCount } : {}),
...(totalMs !== undefined ? { totalMs } : {}),
...(reason !== undefined ? { reason } : {}),
...(tags && tags.length > 0 ? { tags } : {}),
};
}
/**
* `llm_usage` events summed per UTC day x stage x process x engine x model x
* outcome: the rows the dashboard re-aggregates. One row per call would be the
* largest thing in the page, and the page only ever filters them by date.
* Events that do not decode are skipped; rows come out in first-seen order.
*/
export function llmRowsFromEvents(events) {
const rows = new Map();
for (const event of events) {
const record = decodeLlmUsageRecord(event.metadata);
if (!record)
continue;
const day = event.ts.slice(0, 10);
const key = JSON.stringify([day, record.stage, record.process, record.engine, record.model, record.outcome]);
let row = rows.get(key);
if (!row) {
row = {
day,
...(record.stage !== undefined ? { stage: record.stage } : {}),
...(record.process !== undefined ? { process: record.process } : {}),
...(record.engine !== undefined ? { engine: record.engine } : {}),
...(record.model !== undefined ? { model: record.model } : {}),
outcome: record.outcome,
calls: 0,
durationMs: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
};
rows.set(key, row);
}
row.calls += 1;
row.durationMs += record.durationMs;
row.promptTokens += record.promptTokens ?? 0;
row.completionTokens += record.completionTokens ?? 0;
// A call without a total counts as prompt + completion, as the dashboard did per call.
row.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
row.reasoningTokens += record.reasoningTokens ?? 0;
}
return [...rows.values()];
}
/** `index_completed` events as index runs; rows without a numeric `totalMs` are skipped. */
export function indexRunsFromEvents(events) {
const runs = [];
for (const event of events) {
const metadata = parseMetadata(event.metadata_json);
const totalMs = finiteNumber(metadata.totalMs);
if (totalMs === undefined)
continue;
runs.push({ at: event.ts, mode: metadata.mode === "full" ? "full" : "incremental", totalMs });
}
return runs;
}
// ── Small helpers ───────────────────────────────────────────────────────────
function median(values) {
return values.length === 0 ? null : computeWallTimeStats(values).medianMs;
}
function mean(values) {
if (values.length === 0)
return null;
return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * MEAN_SCALE) / MEAN_SCALE;
}
function byCountThenKey(key) {
return (a, b) => b.count - a.count || key(a).localeCompare(key(b));
}
// ── Sections ────────────────────────────────────────────────────────────────
function buildUsage(input, rows) {
// A search or curate writes one summary row (no ref) plus one row per hit;
// the summary row is the unit counted.
const isUnit = (row) => row.ref === undefined;
const days = new Map();
const bump = (at, field) => {
const day = at.slice(0, 10);
const entry = days.get(day) ?? { day, search: 0, show: 0, curate: 0, feedback: 0 };
entry[field] += 1;
days.set(day, entry);
};
const assets = new Map();
const asset = (ref) => {
let entry = assets.get(ref);
if (!entry) {
entry = { ref, shows: 0, searchHits: 0, selects: 0, positive: 0, negative: 0 };
assets.set(ref, entry);
}
return entry;
};
const touch = (entry, at) => {
if (entry.lastUsedAt === undefined || at > entry.lastUsedAt)
entry.lastUsedAt = at;
};
const queries = new Map();
const zeroQueries = new Map();
const bySource = {};
const searchMs = [];
let searches = 0;
let searchesWithHits = 0;
let zeroResultSearches = 0;
let shows = 0;
let curates = 0;
for (const row of rows) {
bySource[row.source] = (bySource[row.source] ?? 0) + 1;
if (row.eventType === "search") {
if (row.ref !== undefined) {
const entry = asset(row.ref);
entry.searchHits += 1;
touch(entry, row.at);
}
if (!isUnit(row))
continue;
searches += 1;
bump(row.at, "search");
if ((row.resultCount ?? 0) > 0)
searchesWithHits += 1;
if (row.resultCount === 0)
zeroResultSearches += 1;
if (row.totalMs !== undefined)
searchMs.push(row.totalMs);
const query = row.query?.trim();
if (query) {
const entry = queries.get(query) ?? { count: 0, results: [], lastAt: row.at };
entry.count += 1;
if (row.resultCount !== undefined)
entry.results.push(row.resultCount);
if (row.at > entry.lastAt)
entry.lastAt = row.at;
queries.set(query, entry);
if (row.resultCount === 0) {
const zero = zeroQueries.get(query) ?? { count: 0, lastAt: row.at };
zero.count += 1;
if (row.at > zero.lastAt)
zero.lastAt = row.at;
zeroQueries.set(query, zero);
}
}
}
else if (row.eventType === "curate") {
if (isUnit(row)) {
curates += 1;
bump(row.at, "curate");
}
}
else if (row.eventType === "show") {
shows += 1;
bump(row.at, "show");
if (row.ref !== undefined) {
const entry = asset(row.ref);
entry.shows += 1;
touch(entry, row.at);
}
}
else if (row.eventType === "feedback") {
bump(row.at, "feedback");
if (row.ref !== undefined && row.signal !== undefined) {
const entry = asset(row.ref);
entry[row.signal] += 1;
touch(entry, row.at);
}
}
}
for (const select of input.selects) {
const entry = asset(select.ref);
entry.selects += 1;
touch(entry, select.ts);
}
const toQueryCount = (query, entry) => ({
query,
count: entry.count,
avgResults: mean(entry.results),
lastAt: entry.lastAt,
});
return {
totals: {
searches,
shows,
curates,
selects: input.selects.length,
zeroResultSearches,
distinctAssets: assets.size,
distinctQueries: queries.size,
},
selectRate: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
searchMedianMs: median(searchMs),
daily: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
topAssets: [...assets.values()]
.sort((a, b) => b.shows - a.shows || b.searchHits - a.searchHits || b.selects - a.selects || a.ref.localeCompare(b.ref))
.slice(0, input.top),
topQueries: [...queries.entries()]
.map(([query, entry]) => toQueryCount(query, entry))
.sort(byCountThenKey((item) => item.query))
.slice(0, input.top),
zeroResultQueries: [...zeroQueries.entries()]
.map(([query, entry]) => ({ query, count: entry.count, avgResults: 0, lastAt: entry.lastAt }))
.sort(byCountThenKey((item) => item.query))
.slice(0, input.top),
bySource,
};
}
function buildFeedback(top, rows) {
const totals = { positive: 0, negative: 0 };
const byAsset = new Map();
const byTag = {};
const negatives = [];
for (const row of rows) {
if (row.eventType !== "feedback" || row.signal === undefined)
continue;
totals[row.signal] += 1;
if (row.ref !== undefined) {
const entry = byAsset.get(row.ref) ?? { positive: 0, negative: 0, lastAt: row.at };
entry[row.signal] += 1;
if (row.at > entry.lastAt)
entry.lastAt = row.at;
byAsset.set(row.ref, entry);
}
for (const tag of row.tags ?? []) {
byTag[tag] ??= { positive: 0, negative: 0 };
byTag[tag][row.signal] += 1;
}
if (row.signal === "negative" && row.ref !== undefined) {
negatives.push({
id: row.id,
ref: row.ref,
at: row.at,
...(row.reason !== undefined ? { reason: row.reason } : {}),
...(row.tags ? { tags: row.tags } : {}),
});
}
}
const assetRows = [...byAsset.entries()].map(([ref, entry]) => ({
ref,
positive: entry.positive,
negative: entry.negative,
valence: computeValenceScore(entry).valence,
lastAt: entry.lastAt,
}));
return {
totals,
byAsset: assetRows
.sort((a, b) => b.positive + b.negative - (a.positive + a.negative) || a.ref.localeCompare(b.ref))
.slice(0, top),
byTag: Object.fromEntries(Object.entries(byTag).sort(([a], [b]) => a.localeCompare(b))),
recentNegative: negatives
.sort((a, b) => b.at.localeCompare(a.at) || b.id - a.id)
.slice(0, top)
.map(({ id: _id, ...rest }) => rest),
};
}
function buildUtility(input) {
const histogram = Array.from({ length: UTILITY_BUCKETS }, (_, index) => ({
bucket: `${(index / UTILITY_BUCKETS).toFixed(1)}-${((index + 1) / UTILITY_BUCKETS).toFixed(1)}`,
count: 0,
}));
const scored = [];
let neverUsed = 0;
for (const entry of input.utility ?? []) {
if (!entry.score) {
neverUsed += 1;
continue;
}
const clamped = Math.min(1, Math.max(0, entry.score.utility));
const bucket = histogram[Math.min(UTILITY_BUCKETS - 1, Math.floor(clamped * UTILITY_BUCKETS))];
if (bucket)
bucket.count += 1;
scored.push({
ref: entry.ref,
utility: entry.score.utility,
showCount: entry.score.showCount,
searchCount: entry.score.searchCount,
selectRate: entry.score.selectRate,
...(entry.score.lastUsedAt !== undefined ? { lastUsedAt: entry.score.lastUsedAt } : {}),
});
}
return {
count: scored.length,
histogram,
lowest: [...scored].sort((a, b) => a.utility - b.utility || a.ref.localeCompare(b.ref)).slice(0, input.top),
highest: [...scored].sort((a, b) => b.utility - a.utility || a.ref.localeCompare(b.ref)).slice(0, input.top),
neverUsed,
};
}
function taskDurationMs(row) {
try {
return decodeTaskHistoryMetadata(row.metadata_json).durationMs;
}
catch {
return undefined;
}
}
function buildTasks(top, rows) {
const byTask = new Map();
let failed = 0;
for (const row of rows) {
const entry = byTask.get(row.task_id) ?? { runs: 0, failed: 0, durations: [] };
entry.runs += 1;
if (row.status === "failed") {
entry.failed += 1;
failed += 1;
}
const durationMs = taskDurationMs(row);
if (durationMs !== undefined)
entry.durations.push(durationMs);
byTask.set(row.task_id, entry);
}
const summaries = [...byTask.entries()].map(([taskId, entry]) => ({
taskId,
runs: entry.runs,
failed: entry.failed,
medianMs: median(entry.durations),
}));
return {
runs: rows.length,
failed,
// Same definition as `akm health`'s task fail rate: failed runs over every run in the window.
failRate: rows.length > 0 ? failed / rows.length : null,
byTask: summaries.sort((a, b) => b.runs - a.runs || a.taskId.localeCompare(b.taskId)).slice(0, top),
};
}
function buildIndex(top, runs) {
return {
runs: runs.length,
medianMs: median(runs.map((run) => run.totalMs)),
// Input is oldest first; reversing before the stable sort puts later rows first within one timestamp.
recent: [...runs]
.reverse()
.sort((a, b) => b.at.localeCompare(a.at))
.slice(0, top),
};
}
// ── Entry point ─────────────────────────────────────────────────────────────
export function buildMetricsResult(input) {
const rows = input.usage.map(toUsageRow);
const outcomes = input.outcomes.slice(0, input.top).map((row) => ({
ref: row.asset_ref,
outcomeScore: row.outcome_score,
retrievalCount: row.retrieval_count,
negativeFeedbackCount: row.negative_feedback_count,
acceptedChangeCount: row.accepted_change_count,
}));
return {
schemaVersion: 1,
window: input.window,
filters: { source: input.source },
usage: buildUsage(input, rows),
feedback: buildFeedback(input.top, rows),
utility: buildUtility(input),
outcomes: { lowestOutcome: outcomes },
llm: input.llm,
index: buildIndex(input.top, input.indexRuns),
tasks: buildTasks(input.top, input.tasks),
proposals: input.proposals,
workflows: input.workflows,
...(input.includeRows ? { rows: { usage: rows, llm: input.llmRows } } : {}),
notes: input.notes,
};
}