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

150 lines (149 loc) • 6.36 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 { isReadOnlyFilesystemError } from "../../core/system-error.js"; import { error } from "../../core/warn.js"; /** * Convert a raw `EventRow` from the database to the public `EventEnvelope` * interface used throughout the events module. */ export function eventRowToEnvelope(row) { let metadata; try { const parsed = JSON.parse(row.metadata_json); // Only attach metadata when the JSON blob is non-empty so downstream // consumers that check `envelope.metadata !== undefined` keep working. if (Object.keys(parsed).length > 0) { metadata = parsed; } } catch { // Corrupt JSON in the DB — treat as no metadata. } return { schemaVersion: 1, id: row.id, ts: row.ts, eventType: row.event_type, ...(row.ref !== null ? { ref: row.ref } : {}), ...(metadata !== undefined ? { metadata } : {}), }; } /** * Insert a single event. Returns the auto-assigned monotonic rowid, which * callers can store as a "sinceId" cursor for future `readEventsSince` calls. * * Best-effort: mirrors the behaviour of the old `appendEvent` — errors are * caught and logged to stderr rather than propagated so observability never * breaks mutation. */ export function insertEvent(db, input) { try { return insertEventStrict(db, input); } catch (err) { if (isReadOnlyFilesystemError(err)) return undefined; const message = err instanceof Error ? err.message : String(err); error(`akm: state.db event insert failed (${message})`); return undefined; } } /** Insert an event without best-effort error swallowing for transaction-coupled mutations. */ export function insertEventStrict(db, input) { const result = db .prepare(`INSERT INTO events (event_type, ts, ref, metadata_json) VALUES (?, ?, ?, ?) RETURNING id`) .get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata ?? {})); if (!result) throw new Error(`Failed to persist ${input.eventType} event.`); return result.id; } /** Strict idempotent event insert for durable mutation journals. */ export function insertEventOnce(db, input) { const rows = db .prepare("SELECT id, metadata_json FROM events WHERE event_type = ? AND ref IS ? ORDER BY id") .all(input.eventType, input.ref ?? null); for (const row of rows) { try { const metadata = JSON.parse(row.metadata_json); if (metadata[input.idempotencyMetadataKey ?? "proposalTransactionId"] === input.idempotencyKey) return row.id; } catch { // Corrupt unrelated event metadata cannot satisfy this idempotency key. } } const result = db .prepare(`INSERT INTO events (event_type, ts, ref, metadata_json) VALUES (?, ?, ?, ?) RETURNING id`) .get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata)); if (!result) throw new Error(`Failed to persist ${input.eventType} event.`); return result.id; } /** * Read events from the database matching the filter. Returns events in * ascending id order so consumers can process them in emission order — * true even when `options.limit` selects the most-recent page (see * {@link ReadStateEventsOptions.limit}). * * The returned `nextId` is the maximum id seen (or `sinceId` when no rows * match), suitable as the next `sinceId` cursor value. This is unaffected by * `limit`: it is always the id of the last row actually returned, which — * because a `limit` page is the MOST RECENT matching rows — is the same * value an unbounded read of the same filter would have produced. */ export function readStateEvents(db, options = {}) { const conditions = []; const params = []; if (options.sinceId !== undefined && options.sinceId > 0) { conditions.push("id > ?"); params.push(options.sinceId); } if (options.since) { conditions.push("ts >= ?"); params.push(options.since); } if (options.type) { conditions.push("event_type = ?"); params.push(options.type); } if (options.ref) { conditions.push("ref = ?"); params.push(options.ref); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const hasLimit = options.limit !== undefined && options.limit > 0; const orderAndLimit = hasLimit ? "ORDER BY id DESC LIMIT ?" : "ORDER BY id ASC"; const queryParams = hasLimit ? [...params, options.limit] : params; const rows = db .prepare(`SELECT id, event_type, ts, ref, metadata_json FROM events ${where} ${orderAndLimit}`) .all(...queryParams); // A DESC-ordered LIMIT page comes back newest-first; reverse it so the // returned array is always ascending by id, matching the unbounded path. const events = (hasLimit ? rows.slice().reverse() : rows).map(eventRowToEnvelope); const nextId = events.length > 0 ? events[events.length - 1].id : (options.sinceId ?? 0); return { events, nextId }; } /** * Delete events older than `retentionDays` (default: 90). Safe to call from * a maintenance cron; uses a single DELETE with an index-covered ts predicate. * * Returns the number of rows actually deleted so callers can emit an * `events_purged` observability event. A non-positive or non-finite * `retentionDays` is treated as "disabled" and returns 0 without scanning. */ export function purgeOldEvents(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 events WHERE ts < ?").run(cutoff); // bun:sqlite's run() returns { changes, lastInsertRowid }. `changes` may be // a number or bigint depending on the underlying lib; coerce to number for // the metadata payload. const changes = result.changes ?? 0; return typeof changes === "bigint" ? Number(changes) : changes; }