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

120 lines (119 loc) • 5.72 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/. /** * Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the * row reflects the most recent run. The `content_hash` persisted here is what * the NEXT run compares against (#602): a byte-identical session is skipped, a * changed session is re-processed, and a NULL-backfill row becomes hash-stable * after its one reprocess. `session_ended_at` is still written for * telemetry/forensics but is no longer the skip authority. */ export function upsertExtractedSession(db, input) { const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt) ? new Date(input.sessionEndedAt).toISOString() : null; db.prepare(` INSERT OR REPLACE INTO extract_sessions_seen (harness, session_id, processed_at, session_ended_at, outcome, candidate_count, proposal_count, rationale, source_run, metadata_json, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}), input.contentHash); } /** * Fetch a single session's last extract record, or `undefined` when the * session has never been processed. */ export function getExtractedSession(db, harness, sessionId) { // bun:sqlite returns null (not undefined) when no row matches — normalize so // callers can rely on `if (!row)` and `toBeUndefined()` equivalently. const row = db .prepare("SELECT * FROM extract_sessions_seen WHERE harness = ? AND session_id = ?") .get(harness, sessionId); return row ?? undefined; } /** * Bulk-fetch session-extract status for a list of sessionIds in one harness. * Returns a Map keyed by sessionId so callers can do O(1) lookups while * iterating the discovery list. */ export function getExtractedSessionsMap(db, harness, sessionIds) { const out = new Map(); if (sessionIds.length === 0) return out; // SQLite has a ~999 param ceiling; chunk if a caller ever exceeds that. const CHUNK = 500; for (let i = 0; i < sessionIds.length; i += CHUNK) { const chunk = sessionIds.slice(i, i + CHUNK); const placeholders = chunk.map(() => "?").join(","); const rows = db .prepare(`SELECT * FROM extract_sessions_seen WHERE harness = ? AND session_id IN (${placeholders})`) .all(harness, ...chunk); for (const row of rows) out.set(row.session_id, row); } return out; } /** * The most recent extract-run time for a harness — `MAX(processed_at)` across * its ledger rows, as ms epoch — or `null` when the harness has never been * extracted. Used to default the discovery window to "since the last run" so an * intermittently-online host that was off for days still rediscovers sessions * that ended during the gap (the content-hash ledger keeps the widened window * free of redundant LLM cost). */ export function getLastExtractRunAt(db, harness) { const row = db .prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?") .get(harness); if (!row?.last) return null; const ms = Date.parse(row.last); return Number.isFinite(ms) ? ms : null; } /** * #914: outcome counts from the `extract_sessions_seen` ledger since * `sinceIso`, grouped by harness/outcome/skipReason/engine. This is what lets * `akm health`'s `session-extraction` check derive its verdict from what * standalone `akm proposal extract` actually recorded — the only write path * the hook-driven SessionEnd extraction uses (`improve_runs` is never * populated by it). `skipReason`/`engine` are read from `metadata_json` via * `json_extract` and come back `null` both for a non-skip outcome and for a * row a release before #912/#913 wrote (no key at that path) — callers must * treat a `null` reason as "skipped, reason unknown", not as evidence of a * specific cause. */ export function getExtractOutcomeCountsSince(db, sinceIso) { return db .prepare(`SELECT harness, outcome, json_extract(metadata_json, '$.skipReason') AS skipReason, json_extract(metadata_json, '$.engine') AS engine, COUNT(*) AS count FROM extract_sessions_seen WHERE processed_at >= ? GROUP BY harness, outcome, skipReason, engine`) .all(sinceIso); } /** * Decide whether a session should be skipped because the extractor has already * processed BYTE-IDENTICAL content (#602). The skip authority is the content * hash, NOT `session_ended_at` — this is clock-independent, so it is immune to * the clock-skew / out-of-order-endedAt problems that caused the Jun 11-12 * double-extract + over-throttle incident. * * Rules: * - no prior row → `false` (never seen → process; AC3). * - prior.content_hash == null → `false` (legacy / hash-less row → process * exactly once to backfill the hash, then it becomes hash-stable; AC4). * - hashes equal → `true` (unchanged content → skip; AC1). * - hashes differ → `false` (changed content → re-process; AC2). */ export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) { if (!prior) return false; if (prior.content_hash == null) return false; return prior.content_hash === currentContentHash; }