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
143 lines (142 loc) • 6.53 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/.
/**
* Usage event helpers for telemetry and utility-based re-ranking.
*
* Schema (created by ensureUsageEventsSchema):
* id, event_type, query, entry_id (nullable), entry_ref, signal, metadata, source, created_at
*/
import { rethrowIfTestIsolationError } from "../../core/errors.js";
const USAGE_EVENT_SOURCES = new Set(["user", "improve", "task", "audit", "unknown"]);
/**
* Resolve subprocess provenance without treating an invalid value as user
* demand.
*
* `fallback` (spec docs/plans/specs/p1b-model-extraction.md §5.2, F-1) is what
* an unset/empty ambient value resolves to — it defaults to `"user"`, which
* reproduces every pre-P1b call site byte-for-byte (P-07). A caller that
* already knows the invocation's provenance (the task runner, threading its
* `ExecutionProvenanceContext`) passes its resolved value as the fallback
* instead, so a recognized ambient `AKM_EVENT_SOURCE` still wins everywhere it
* won before, and only the *default* changes.
*/
export function resolveUsageEventSource(env = process.env, fallback = "user") {
const raw = env.AKM_EVENT_SOURCE;
if (raw === undefined || raw === "")
return fallback;
return USAGE_EVENT_SOURCES.has(raw) ? raw : "unknown";
}
// ── Schema ──────────────────────────────────────────────────────────────────
export function ensureUsageEventsSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
query TEXT,
entry_id INTEGER,
entry_ref TEXT,
signal TEXT,
metadata TEXT,
source TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_usage_events_entry ON usage_events(entry_id);
CREATE INDEX IF NOT EXISTS idx_usage_events_type ON usage_events(event_type);
CREATE INDEX IF NOT EXISTS idx_usage_events_ref ON usage_events(entry_ref);
CREATE INDEX IF NOT EXISTS idx_usage_events_source ON usage_events(source);
`);
}
// ── Insert ───────────────────────────────────────────────────────────────────
/**
* Insert a usage event into the database. Fire-and-forget: errors are
* silently caught so callers are never blocked or disrupted.
*/
export function insertUsageEvent(db, event) {
try {
db.prepare(`INSERT INTO usage_events (event_type, query, entry_id, entry_ref, signal, metadata, source)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(event.event_type, event.query ?? null, event.entry_id ?? null, event.entry_ref ?? null, event.signal ?? null, event.metadata ?? null, event.source ?? "unknown");
}
catch (error) {
rethrowIfTestIsolationError(error);
/* fire-and-forget: silently ignore errors */
}
}
// ── Query ────────────────────────────────────────────────────────────────────
/**
* Aggregate positive/negative feedback counts for a single entry.
*
* Lifted verbatim from `akm feedback` (feedback-cli.ts) where the same
* SUM(CASE …) query was hand-rolled inline. Returns plain numbers (NULL SUMs
* over an empty set are coalesced to 0) so the result fully materialises before
* any owning connection closes.
*/
export function countFeedbackSignals(db, entryId) {
const counts = db
.prepare(`SELECT
SUM(CASE WHEN signal = 'positive' THEN 1 ELSE 0 END) AS pos,
SUM(CASE WHEN signal = 'negative' THEN 1 ELSE 0 END) AS neg
FROM usage_events
WHERE event_type = 'feedback' AND entry_id = ? AND source = 'user'`)
.get(entryId);
return { pos: counts?.pos ?? 0, neg: counts?.neg ?? 0 };
}
/**
* Count usage events of a given `event_type`.
*
* Lifted verbatim from `akm improve` (improve.ts) where the show-event count
* was hand-rolled inline to drive the warning that the retrieval scope matches
* only search-retrieved assets.
*/
export function countUsageEventsByType(db, eventType) {
return db.prepare("SELECT COUNT(*) AS cnt FROM usage_events WHERE event_type = ?").get(eventType)
.cnt;
}
/**
* Durable refs a user-attributed `search`, `curate` or `show` returned, or a
* user `feedback` named, at or after `sinceIso`. Machine traffic (`improve`,
* `task`, `audit`, `unknown`) is not demand, as in {@link countFeedbackSignals}.
*/
export function listUsedEntryRefs(db, sinceIso) {
const rows = db
.prepare(`SELECT DISTINCT entry_ref FROM usage_events
WHERE event_type IN ('search', 'curate', 'show', 'feedback')
AND source = 'user'
AND entry_ref IS NOT NULL
AND julianday(created_at) >= julianday(?)`)
.all(sinceIso);
return rows.map((row) => row.entry_ref);
}
/**
* The distinct query texts of user `search` and `curate` events that returned
* `conceptId` (any bundle prefix), most recent first.
*/
export function listRetrievalQueries(db, conceptId) {
const rows = db
.prepare(`SELECT query, MAX(created_at) AS last_at FROM usage_events
WHERE event_type IN ('search', 'curate')
AND source = 'user'
AND query IS NOT NULL AND trim(query) != ''
AND instr(entry_ref, '//') > 0
AND substr(entry_ref, instr(entry_ref, '//') + 2) = ?
GROUP BY query
ORDER BY last_at DESC`)
.all(conceptId);
return rows.map((row) => row.query);
}
/** Usage events older than this many days are purged on every `akm index`. */
export const USAGE_EVENT_RETENTION_DAYS = 90;
/**
* Delete usage events older than the given number of days.
*/
export function purgeOldUsageEvents(db, retentionDays) {
if (!Number.isFinite(retentionDays) || retentionDays <= 0)
return;
try {
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
db.prepare("DELETE FROM usage_events WHERE created_at < ?").run(cutoff);
}
catch {
/* Table may not exist yet */
}
}