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
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/.
/**
* 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;
}