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
269 lines (268 loc) • 12.6 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/.
export const IMPROVE_LEDGER_OUTCOMES = [
"proposed",
"accepted",
"rejected",
"quality_rejected",
"review_needed",
"expired",
"unchanged",
"failed",
"judged_no_action",
];
const MS_PER_DAY = 86_400_000;
const DETAIL_MAX_CHARS = 500;
/**
* Post-rejection windows by source: reflect 14 d, distill 30 d, every other
* source 7 d — the constants the rejection backoff always used.
*/
export const LEDGER_REJECTION_WINDOW_DAYS = Object.freeze({
reflect: 14,
distill: 30,
});
export const LEDGER_DEFAULT_REJECTION_WINDOW_DAYS = 7;
/** A proposal nobody reviewed inside the retention window: short grace, not a rejection backoff. */
export const LEDGER_EXPIRED_GRACE_DAYS = 1;
/** Revisit cadence for a ref the stage looked at and had nothing to do (or is still pending). */
export const LEDGER_REVISIT_CADENCE_DAYS = 7;
/**
* The consolidate pair pass's own ledger source (alpha.9): kept apart from
* the promote pass's `consolidate` rows so the two candidate-selection
* cadences never collide on the same `(stash, ref, source)` key. Its
* eligibility is entirely content-driven (`content_hash` above, compared by
* `selectInitiators` in `src/commands/improve/consolidate/pair-pass.ts`) —
* {@link windowDays} below gives it no `next_eligible_at` timer at all, so a
* row never "expires" on its own; only a content change makes the ref
* eligible again.
*/
export const PAIR_PASS_LEDGER_SOURCE = "consolidate-pair";
/**
* The consolidate promote pass's ledger source: one row per source memory,
* keyed by the memory (`memories/<name>`), not by the knowledge ref the
* promotion would create.
*/
export const CONSOLIDATE_LEDGER_SOURCE = "consolidate";
/**
* Whether a decision on `(source, outcome)` leaves the ref's next attempt to
* its content instead of a clock (#998). An accepted or rejected consolidate
* promotion is a verdict on that memory's text; asking the model about the
* same text again can only reproduce the proposal (accepted used to be
* eligible at once, rejected after 7 days), so the memory waits for an edit.
* The clock stays for a row with no recorded hash — one decided before the
* hash was recorded — see {@link nextEligibleAt} and {@link isContentDrivenRow}.
*/
export function isContentDrivenDecision(source, outcome) {
return source === CONSOLIDATE_LEDGER_SOURCE && (outcome === "accepted" || outcome === "rejected");
}
/**
* Whether this row is held by its content hash: a decided consolidate
* promotion that recorded the body it was decided against. Such a row has no
* `next_eligible_at`; the caller compares `contentHash` with the asset's
* current body hash (the pair pass does the same in `selectInitiators`).
*/
export function isContentDrivenRow(row) {
return row.contentHash !== null && isContentDrivenDecision(row.source, row.outcome);
}
/**
* Outcomes whose window a fresh signal on the asset (new feedback, a content
* change) cannot lift. Every other window is a revisit cadence that a signal
* newer than `last_attempt_at` lifts.
*/
export const LEDGER_HARD_OUTCOMES = new Set([
"rejected",
"quality_rejected",
"expired",
]);
function windowDays(source, outcome) {
// The pair pass's own eligibility never reads next_eligible_at (it compares
// content_hash instead — selectInitiators in pair-pass.ts) — recording a
// window here would be a number nothing enforces, so every row it writes
// stays "eligible now" regardless of outcome.
if (source === PAIR_PASS_LEDGER_SOURCE)
return null;
switch (outcome) {
case "rejected":
case "quality_rejected":
return LEDGER_REJECTION_WINDOW_DAYS[source] ?? LEDGER_DEFAULT_REJECTION_WINDOW_DAYS;
case "expired":
return LEDGER_EXPIRED_GRACE_DAYS;
case "unchanged":
case "judged_no_action":
case "proposed":
case "review_needed":
return LEDGER_REVISIT_CADENCE_DAYS;
case "accepted":
case "failed":
return null;
}
}
/**
* The single cadence function: when a `(source, outcome)` recorded at
* `fromIso` becomes eligible again, or `null` for "immediately". A decision
* that {@link isContentDrivenDecision} holds by content, recorded together
* with the body hash it was decided against, starts no clock at all: `null`
* here means the hash is the whole test, not that the ref is free to retry.
*/
export function nextEligibleAt(source, outcome, fromIso, contentHash) {
const from = Date.parse(fromIso);
if (!Number.isFinite(from))
return null;
if (contentHash && isContentDrivenDecision(source, outcome))
return null;
const days = windowDays(source, outcome);
return days === null ? null : new Date(from + days * MS_PER_DAY).toISOString();
}
/**
* Whether the ledger blocks another attempt on this row at `nowIso`.
* `signalSinceIso` is the newest signal on the asset (feedback, content
* change); a signal newer than the last attempt lifts a soft window but never
* a hard one ({@link LEDGER_HARD_OUTCOMES}).
*/
export function isLedgerBlocked(row, nowIso, signalSinceIso) {
if (!row?.nextEligibleAt)
return false;
if (row.nextEligibleAt <= nowIso)
return false;
if (LEDGER_HARD_OUTCOMES.has(row.outcome))
return true;
return !(signalSinceIso !== undefined && signalSinceIso > row.lastAttemptAt);
}
function toRow(row) {
return {
stashDir: row.stash_dir,
ref: row.ref,
source: row.source,
lastAttemptAt: row.last_attempt_at,
// Tolerate an outcome a newer release may add: it still carries a window.
outcome: row.outcome,
nextEligibleAt: row.next_eligible_at,
proposalId: row.proposal_id,
detail: row.detail,
contentHash: row.content_hash,
};
}
function trimDetail(detail) {
if (detail === undefined)
return null;
const trimmed = detail.trim();
if (trimmed.length === 0)
return null;
return trimmed.length > DETAIL_MAX_CHARS ? `${trimmed.slice(0, DETAIL_MAX_CHARS - 1)}…` : trimmed;
}
/** Record an attempt on `(stashDir, ref, source)`: upsert the row and its cadence. */
export function recordImproveLedger(db, input) {
const row = {
stashDir: input.stashDir,
ref: input.ref,
source: input.source,
lastAttemptAt: input.at,
outcome: input.outcome,
nextEligibleAt: nextEligibleAt(input.source, input.outcome, input.at, input.contentHash),
proposalId: input.proposalId ?? null,
detail: trimDetail(input.detail),
contentHash: input.contentHash ?? null,
};
db.prepare(`INSERT INTO improve_ledger
(stash_dir, ref, source, last_attempt_at, outcome, next_eligible_at, proposal_id, detail, content_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stash_dir, ref, source) DO UPDATE SET
last_attempt_at = excluded.last_attempt_at,
outcome = excluded.outcome,
next_eligible_at = excluded.next_eligible_at,
proposal_id = excluded.proposal_id,
detail = excluded.detail,
content_hash = excluded.content_hash`).run(row.stashDir, row.ref, row.source, row.lastAttemptAt, row.outcome, row.nextEligibleAt, row.proposalId, row.detail, row.contentHash);
return row;
}
/**
* Record a decision (accept / reject / expire / revert) on the proposal a row
* was minted for. The row is found by `proposal_id` — the proposal's ref may
* differ from the ledger key (a distill proposal for `lessons/x` is keyed by
* its input `memories/x`) — and `last_attempt_at` is kept: the window starts
* at the decision, the attempt happened when it happened. A proposal no row
* knows (minted before the ledger existed, or whose row a later `judged_no_action`
* overwrote) gets a row keyed by `input.ref`.
*/
export function recordImproveLedgerDecision(db, input) {
const hash = isContentDrivenDecision(input.source, input.outcome) ? input.contentHash : undefined;
const changes = db
.prepare(`UPDATE improve_ledger
SET outcome = ?, next_eligible_at = ?, detail = ?, content_hash = COALESCE(?, content_hash)
WHERE stash_dir = ? AND proposal_id = ?`)
.run(input.outcome, nextEligibleAt(input.source, input.outcome, input.at, hash), trimDetail(input.detail), hash ?? null, input.stashDir, input.proposalId).changes;
if (Number(changes) > 0)
return;
recordImproveLedger(db, {
stashDir: input.stashDir,
ref: input.ref,
source: input.source,
outcome: input.outcome,
at: input.at,
proposalId: input.proposalId,
...(input.detail !== undefined ? { detail: input.detail } : {}),
...(hash !== undefined ? { contentHash: hash } : {}),
});
}
/**
* A rejected proposal was reopened (`akm proposal reopen`): put the rows
* {@link recordImproveLedgerDecision} found by `proposal_id` back to what the
* mint wrote — `proposed`, on the revisit cadence from the reopen, and no
* `content_hash` (a mint records none) — so the rejection's hard window stops
* reporting (and blocking) a proposal that is pending again.
* `last_attempt_at` is kept, as a decision keeps it. A proposal with no such
* row (its mint wrote none, or a later attempt took the key over) changes
* nothing.
*/
export function reopenImproveLedgerDecision(db, input) {
db.prepare(`UPDATE improve_ledger
SET outcome = 'proposed', next_eligible_at = ?, detail = ?, content_hash = NULL
WHERE stash_dir = ? AND proposal_id = ?`).run(nextEligibleAt(input.source, "proposed", input.at), trimDetail(input.detail), input.stashDir, input.proposalId);
}
/**
* The same reopen for a proposal whose mint deliberately wrote no ledger row (a
* retire proposal — see `createRetireProposal`): the row its rejection created
* is dropped, returning the ledger to what the mint left. Found by
* `proposal_id`, like {@link recordImproveLedgerDecision}.
*/
export function forgetImproveLedgerDecision(db, stashDir, proposalId) {
db.prepare("DELETE FROM improve_ledger WHERE stash_dir = ? AND proposal_id = ?").run(stashDir, proposalId);
}
/**
* Should-fix 6 (second review round): a read-only or dry-run open never
* migrates, so it can land on a state.db from before migration 029 added
* `content_hash` — reading it there threw "no such column", which
* `loadRetrievalScope`'s own catch then reported as "usage history
* unreadable", making the WHOLE scope `undefined` (every asset eligible) on
* every read-only/dry-run call against an as-yet-unmigrated database. A
* per-connection cache, since a real `Database` handle's schema does not
* change mid-lifetime and this is checked on every ledger read.
*/
const hasContentHashColumnCache = new WeakMap();
function hasContentHashColumn(db) {
const cached = hasContentHashColumnCache.get(db);
if (cached !== undefined)
return cached;
const has = db.prepare("PRAGMA table_info(improve_ledger)").all().some((c) => c.name === "content_hash");
hasContentHashColumnCache.set(db, has);
return has;
}
export function getImproveLedgerRow(db, stashDir, ref, source) {
const withHash = hasContentHashColumn(db);
const row = db
.prepare(`SELECT stash_dir, ref, source, last_attempt_at, outcome, next_eligible_at, proposal_id, detail${withHash ? ", content_hash" : ""}
FROM improve_ledger WHERE stash_dir = ? AND ref = ? AND source = ?`)
.get(stashDir, ref, source);
return row ? toRow(withHash ? row : { ...row, content_hash: null }) : undefined;
}
/** Every row for one stash, optionally narrowed to `sources`. */
export function listImproveLedgerRows(db, stashDir, sources) {
const withHash = hasContentHashColumn(db);
const sourceFilter = sources && sources.length > 0 ? ` AND source IN (${sources.map(() => "?").join(", ")})` : "";
const rows = db
.prepare(`SELECT stash_dir, ref, source, last_attempt_at, outcome, next_eligible_at, proposal_id, detail${withHash ? ", content_hash" : ""}
FROM improve_ledger WHERE stash_dir = ?${sourceFilter} ORDER BY ref ASC, source ASC`)
.all(stashDir, ...(sources && sources.length > 0 ? sources : []));
return rows.map((row) => toRow(withHash ? row : { ...row, content_hash: null }));
}