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
428 lines (427 loc) • 23.8 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/.
import fs from "node:fs";
import { defineJsonCommand, output, parseAllFlagValues } from "../cli/shared.js";
import { makeBundleRef, parseBundleRef } from "../core/asset/asset-ref.js";
import { assembleAsset } from "../core/asset/asset-serialize.js";
import { parseFrontmatter, parseFrontmatterBlock } from "../core/asset/frontmatter.js";
import { conceptIdFromTypeName, parseRefInput } from "../core/asset/resolve-ref.js";
import { isWithin, writeFileAtomic } from "../core/common.js";
import { FEEDBACK_FAILURE_MODES, loadConfig } from "../core/config/config.js";
import { NotFoundError, UsageError } from "../core/errors.js";
import { appendEvent } from "../core/events.js";
import { resolveMutationTarget } from "../core/mutation-target.js";
import { isPathAbsent } from "../core/path-access.js";
import { getDbPath } from "../core/paths.js";
import { withStateDb } from "../core/state-db.js";
import { warn } from "../core/warn.js";
import { withWriteTargetMutation } from "../core/write-source.js";
import { resolveSourceEntries } from "../indexer/search/search-source.js";
import { countFeedbackSignals, insertUsageEvent, resolveUsageEventSource } from "../indexer/usage/usage-events.js";
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../storage/repositories/index-entries-repository.js";
import { applyFeedbackToUtilityScore } from "../storage/repositories/index-utility-repository.js";
// ── Tag validation ────────────────────────────────────────────────────────────
const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
function validateFeedbackTags(raw) {
const seen = new Set();
const out = [];
for (const tag of raw) {
const parts = tag.split(":");
if (parts.length < 2 || parts[0] === "" || parts.slice(1).join("") === "") {
throw new UsageError(`Invalid tag "${tag}". Tags must be in key:value format where key matches [a-z_][a-z0-9_]* and value is non-empty.`, "INVALID_FLAG_VALUE");
}
const key = parts[0];
if (!TAG_KEY_RE.test(key)) {
throw new UsageError(`Invalid tag key "${key}" in "${tag}". Key must match [a-z_][a-z0-9_]*.`, "INVALID_FLAG_VALUE");
}
if (seen.has(tag))
continue;
seen.add(tag);
out.push(tag);
}
return out;
}
// ── Lesson strength helper ────────────────────────────────────────────────────
/**
* Phase 7A: append a feedback ref to a lesson's `lessonStrength[]`
* frontmatter array. Returns `{ strength }` (post-update count) on success,
* or `null` when the lesson cannot be located. Idempotent: if the ref is
* already credited, no write occurs.
*
* The function looks up the lesson's file via the indexer DB so the write
* targets the canonical on-disk location. Frontmatter is rewritten in
* place (no asset-spec round-trip) because we're modifying a single key on
* an existing asset — the same pattern memory-inference uses for
* `inferenceProcessed`.
*/
function appendLessonStrength(refInput, feedbackRef) {
// Canonical conceptId (`lessons/<name>`, D-R3): `findEntryIdByRef` keys on
// the stored `item_ref`.
const conceptId = conceptIdFromTypeName(refInput.type, refInput.name);
const config = loadConfig();
let filePath;
let bundleId;
const db = openExistingDatabase();
try {
const entryId = findEntryIdByRef(db, makeBundleRef(refInput.origin, conceptId));
if (entryId !== undefined) {
const itemRef = getItemRefById(db, entryId);
const parsedItemRef = itemRef ? parseBundleRef(itemRef) : undefined;
filePath = getEntryFilePathById(db, entryId) ?? undefined;
bundleId = parsedItemRef?.bundle;
}
}
finally {
closeDatabase(db);
}
const requestedRef = makeBundleRef(refInput.origin, conceptId);
if (!filePath || !bundleId || !fs.existsSync(filePath)) {
warn(`[feedback] --applied-to: lesson ${requestedRef} is not in the index or is missing on disk.`);
return null;
}
const resolved = resolveMutationTarget(config, { ...refInput, origin: bundleId });
if (!isWithin(filePath, resolved.target.source.path)) {
throw new UsageError(`Resolved lesson ${requestedRef} is outside bundle "${bundleId}".`);
}
fs.lstatSync(filePath);
const initialUpdate = buildLessonStrengthUpdate(fs.readFileSync(filePath), feedbackRef);
if (!initialUpdate.nextBytes) {
return { ref: makeBundleRef(bundleId, conceptId), strength: initialUpdate.strength };
}
let strength = initialUpdate.strength;
let mutationStarted = false;
try {
withWriteTargetMutation(resolved.target, [filePath], {
purpose: "feedback-lesson-credit",
message: `Update ${makeBundleRef(bundleId, conceptId)}`,
}, () => {
const stat = fs.lstatSync(filePath);
const update = buildLessonStrengthUpdate(fs.readFileSync(filePath), feedbackRef);
strength = update.strength;
if (!update.nextBytes)
return;
mutationStarted = true;
// Preserve the existing file's permission bits (markdown assets are typically 0o644).
writeFileAtomic(filePath, update.nextBytes, stat.mode & 0o777);
});
}
catch (err) {
if (mutationStarted)
throw err;
warn(`[feedback] --applied-to: failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
return { ref: makeBundleRef(bundleId, conceptId), strength };
}
function buildLessonStrengthUpdate(rawBytes, feedbackRef) {
const raw = rawBytes.toString("utf8");
const parsed = parseFrontmatter(raw);
const data = { ...parsed.data };
const existing = data.lessonStrength;
const strengthList = Array.isArray(existing)
? existing.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim())
: typeof existing === "string" && existing.trim().length > 0
? [existing.trim()]
: [];
if (strengthList.includes(feedbackRef)) {
return { strength: strengthList.length };
}
strengthList.push(feedbackRef);
data.lessonStrength = strengthList;
const block = parseFrontmatterBlock(raw);
const body = block?.content ?? raw;
const next = assembleAsset(data, body);
const nextBytes = Buffer.from(next);
return rawBytes.equals(nextBytes) ? { strength: strengthList.length } : { strength: strengthList.length, nextBytes };
}
/**
* Persist the feedback usage-event (state.db) and immediately fold it into the
* entry's MemRL utility score (index.db). Chunk-8 WI-8.3: usage_events lives in
* state.db; entries + utility_scores stay in `indexDb`. BOTH positive and
* negative signals apply the EMA utility update unconditionally and
* immediately when the source is user-attributed — no `akm index` run is
* required for either signal to affect search ranking. Uses the bounded-step
* EMA policy (F-5 / #386, arXiv:2601.03192).
*
* The update is intentionally SKIPPED for non-`user` event sources (`improve`,
* `task`, `audit`, `unknown`) — an anti-self-reinforcement guard that stops an
* agent or automated pipeline from boosting its own picks. This is by design
* and must not be removed; R-034 only asks that the skip be surfaced to the
* caller rather than passing silently. Best-effort: a utility-update failure
* never fails the feedback record.
*/
function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadataStr) {
let utilityResult;
let rankingUpdateApplied = false;
let rankingUpdateSkippedReason;
const eventSource = resolveUsageEventSource();
withStateDb((stateDb) => {
insertUsageEvent(stateDb, {
event_type: "feedback",
entry_ref: durableEntryRef,
entry_id: entryId,
signal,
metadata: metadataStr,
source: eventSource,
});
if (eventSource !== "user") {
rankingUpdateSkippedReason =
`feedback source is "${eventSource}", not "user" — ranking updates only apply to user-attributed ` +
"feedback (anti-self-reinforcement guard; set AKM_EVENT_SOURCE=user to record as user demand).";
return;
}
try {
const { pos, neg } = countFeedbackSignals(stateDb, entryId);
utilityResult = applyFeedbackToUtilityScore(indexDb, entryId, pos, neg);
rankingUpdateApplied = true;
}
catch (err) {
// best-effort — feedback recording succeeds even if utility update fails
rankingUpdateSkippedReason = `utility update failed: ${err instanceof Error ? err.message : String(err)}`;
}
});
return { utilityResult, rankingUpdateApplied, rankingUpdateSkippedReason };
}
// ── Command definition ────────────────────────────────────────────────────────
export const feedbackCommand = defineJsonCommand({
meta: {
name: "feedback",
description: "Record positive or negative feedback for any indexed bundle asset.\n\n" +
'`akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags\n' +
"the asset for review: the next improve run proposes a fix based on your reason, so be\n" +
"specific. `--positive` records that an asset helped (it raises its ranking) and does not\n" +
"trigger a rewrite.\n\n" +
"Both signals adjust the asset's usefulness score right away, in the same\n" +
"process: positive feedback raises it, negative lowers it, and recent\n" +
"feedback counts for more than old feedback. No reindex is needed — the new\n" +
"score affects ranking starting with the very next `akm search`.",
},
args: {
// Optional in citty so run() is invoked even when omitted; we re-validate
// and throw a structured UsageError below so exit code is 2 (USAGE) rather
// than citty's default 0 (help banner).
ref: { type: "positional", description: "Asset ref ([bundle//]conceptId, e.g. lessons/deploy)", required: false },
positive: {
type: "boolean",
description: "Record that the asset helped (raises its ranking immediately; does not trigger a rewrite)",
default: false,
},
negative: {
type: "boolean",
description: "Flag the asset for review: the next improve run proposes a fix from --reason (also lowers its ranking immediately, no reindex needed).",
default: false,
},
reason: {
type: "string",
description: "What is wrong with the asset's content and what should change; the next improve run proposes a fix from it, so be specific (required for negative feedback by default). Not for akm command errors.",
},
"failure-mode": {
type: "string",
description: "Structured failure-mode taxonomy for negative feedback. " +
`Accepted values: ${FEEDBACK_FAILURE_MODES.join(", ")}. ` +
"Stored alongside --reason in event metadata for aggregation by the distill pipeline.",
},
tag: {
type: "string",
description: "Tag to attach to the feedback (repeatable, e.g. --tag slice:train --tag team:platform)",
},
"applied-to": {
type: "string",
description: "Credit a lesson that helped resolve this task. Accepts a `lessons/<name>` ref. " +
"When combined with --positive, appends this feedback ref to the target lesson's " +
"`lessonStrength[]` frontmatter array (dedup, idempotent). A non-lesson target or a " +
"missing --positive produces a warning rather than silently doing nothing.",
},
},
async run({ args }) {
const ref = (args.ref ?? "").trim();
if (!ref) {
throw new UsageError("Asset ref is required. Usage: akm feedback <ref> --positive|--negative", "MISSING_REQUIRED_ARGUMENT", "Pass a ref like `skills/deploy` and either --positive or --negative.");
}
const parsedRef = parseBundleRef(ref);
if (args.positive && args.negative) {
throw new UsageError("Specify either --positive or --negative, not both.");
}
if (!args.positive && !args.negative) {
throw new UsageError("Specify --positive or --negative.");
}
const signal = args.positive ? "positive" : "negative";
const reason = args.reason;
// F-3 / #384: Validate --failure-mode against the curated enum.
const failureMode = args["failure-mode"]?.trim() || undefined;
if (failureMode) {
if (args.positive) {
throw new UsageError("--failure-mode is only valid for negative feedback.", "INVALID_FLAG_VALUE", "Remove --failure-mode or switch to --negative.");
}
const cfg = loadConfig();
const allowedModes = cfg.feedback?.allowedFailureModes ?? FEEDBACK_FAILURE_MODES;
if (allowedModes.length > 0 && !allowedModes.includes(failureMode)) {
throw new UsageError(`Invalid --failure-mode "${failureMode}". Accepted values: ${allowedModes.join(", ")}.`, "INVALID_FLAG_VALUE", `Use one of: ${allowedModes.join(", ")}`);
}
}
if (args.negative === true && !reason?.trim()) {
// F-3 / #384: Default requireReason is now true. Load config to allow
// operators to opt out via feedback.requireReason: false in akm.json.
const cfg = loadConfig();
const requireReason = cfg.feedback?.requireReason ?? true; // Default: true (F-3 / #384)
if (requireReason) {
throw new UsageError("Negative feedback requires --reason: the next improve run proposes a fix from it, so say what is wrong and what should change. " +
"Use --failure-mode for a curated taxonomy or --reason for free text. " +
"Set feedback.requireReason: false in akm.json to downgrade to a warning.", "MISSING_REQUIRED_ARGUMENT", `Hint: akm feedback ${ref} --negative --reason "<what is wrong and what should change>" [--failure-mode incorrect|outdated|dangerous|incomplete|redundant]`);
}
else {
warn("Warning: negative feedback without --reason gives the next improve run nothing to base a fix on.");
}
}
const rawTags = parseAllFlagValues("--tag");
const validatedTags = validateFeedbackTags(rawTags);
const metadataObj = {
signal,
...(reason?.trim() ? { reason: reason.trim() } : {}),
...(failureMode ? { failureMode } : {}),
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
};
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
// Feedback only needs the index to exist, not to be current. A stale index
// is fine — the ref lookup works against any populated DB. We do NOT call
// ensureIndex here: it either blocks (3+ min inline reindex) or spawns a
// background process that holds the writer lock, causing the feedback write
// to spin-wait for the full reindex duration. If the DB is absent we give a
// clear error below rather than silently triggering a rebuild.
// "Run 'akm index' first" is only true advice for an index that was never
// built. Told to someone whose index exists but is unreadable it is a lie
// that sends them to rebuild a file they may not have permission to touch
// (#791), so that case falls through to `openExistingDatabase` below, which
// names the path, errno, mode/owner and uid instead.
if (isPathAbsent(getDbPath())) {
throw new UsageError("Index not found. Run 'akm index' first to build the index before recording feedback.", "MISSING_REQUIRED_ARGUMENT", "akm index");
}
// Feedback writes exactly 2 rows (usage_events + utility_score). SQLite
// WAL mode + busy_timeout=30s handles concurrent access with an ongoing
// `akm improve` run without needing the application-level writer lock.
// The lock was originally needed to prevent feedback from racing a
// background reindex it spawned — now that ensureIndex is removed, holding
// the lock only causes feedback to block for the full improve run duration.
let utilityResult;
let rankingUpdateApplied = false;
let rankingUpdateSkippedReason;
let durableRef = ref;
const db = openExistingDatabase();
try {
const config = loadConfig();
const sources = resolveSourceEntries(undefined, config);
const requestedSource = parsedRef.bundle ? resolveSourcesForOrigin(parsedRef.bundle, sources)[0] : undefined;
if (parsedRef.bundle && !requestedSource) {
throw new UsageError(`Source "${parsedRef.bundle}" is not configured.`, "INVALID_FLAG_VALUE");
}
const lookupRef = makeBundleRef(parsedRef.bundle, parsedRef.conceptId);
const entryId = findEntryIdByRef(db, lookupRef, parsedRef.bundle);
if (entryId === undefined) {
// NotFoundError (exit 1), not UsageError (exit 2): the flags parsed
// fine, the asset just isn't there. The documented exit-code table
// reserves 1 for "requested resource missing", and scripts branch on it.
throw new NotFoundError(`Ref "${ref}" is not in the index. ` +
"Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.", "ASSET_NOT_FOUND");
}
// Persist the feedback signal into usage_events. Both positive and
// negative signals apply the EMA utility update immediately — no
// `akm index` run is required for either signal to affect ranking in
// search results (see recordFeedbackUsage / applyFeedbackToUtilityScore).
// WI-8.5b: the `feedback` / `improve_review_needed` events key on the
// resolved entry's fully-qualified item_ref — the SAME durable key the
// usage_events row carries and the SAME spelling the signal-delta
// correlation reads (buildLatestFeedbackTsMap, collapsed to [item_ref]).
const itemRef = getItemRefById(db, entryId);
if (!itemRef)
throw new UsageError(`Indexed ref "${ref}" has no durable item ref.`, "INVALID_PROPOSAL");
durableRef = itemRef;
const recordResult = recordFeedbackUsage(db, entryId, itemRef, signal, metadataStr);
utilityResult = recordResult.utilityResult;
rankingUpdateApplied = recordResult.rankingUpdateApplied;
rankingUpdateSkippedReason = recordResult.rankingUpdateSkippedReason;
}
finally {
closeDatabase(db);
}
appendEvent({
eventType: "feedback",
ref: durableRef,
metadata: metadataObj,
});
// F-5 / #386: When a high-utility asset crosses below the review threshold,
// auto-create a review-needed escalation proposal so a human can confirm
// whether the negative feedback is valid before the asset falls out of
// the improve loop. Best-effort — failure is logged but does not fail the
// feedback command.
// Emit a structured event rather than a proposal so the review-needed
// signal doesn't risk an accidental asset overwrite if the proposal is
// accepted.
if (utilityResult?.crossedReviewThreshold) {
try {
appendEvent({
eventType: "improve_review_needed",
ref: durableRef,
metadata: {
previousUtility: utilityResult.previousUtility,
nextUtility: utilityResult.nextUtility,
reason: reason?.trim() ?? null,
failureMode: failureMode ?? null,
},
});
}
catch (escalationErr) {
warn(`[feedback] Could not emit review-needed event for ${ref}: ${escalationErr instanceof Error ? escalationErr.message : String(escalationErr)}`);
}
}
// Phase 7A / Advantage D4b: --applied-to credits a lesson. When the
// target is a `lessons/<name>` ref and the signal is positive, append
// the feedback ref to the target lesson's `lessonStrength[]`
// frontmatter array (dedup, idempotent). Non-lesson targets are REJECTED
// with a loud warning (R-033b) rather than silently doing nothing.
// Failures here are warnings — feedback recording is the primary
// contract and must not regress on lesson-write errors.
const appliedToRaw = args["applied-to"]?.trim();
let appliedToResult = null;
if (appliedToRaw && signal === "positive") {
let parsedApplied;
try {
parsedApplied = parseRefInput(appliedToRaw);
}
catch (err) {
warn(`[feedback] --applied-to failed for ${appliedToRaw}: ${err instanceof Error ? err.message : String(err)}`);
}
if (parsedApplied) {
if (parsedApplied.type === "lesson") {
const updated = appendLessonStrength(parsedApplied, durableRef);
if (updated) {
appliedToResult = { lessonRef: updated.ref, strength: updated.strength };
}
}
else {
warn(`[feedback] --applied-to ${appliedToRaw} was ignored: it resolves to a "${parsedApplied.type}" asset, ` +
"not a lesson. Only `lessons/<name>` refs can be credited via --applied-to.");
}
}
}
else if (appliedToRaw && signal !== "positive") {
warn("[feedback] --applied-to is ignored without --positive; lesson credit is only recorded on positive signals.");
}
output("feedback", {
ok: true,
ref,
signal,
reason: reason?.trim() ?? null,
failureMode: failureMode ?? null,
tags: validatedTags,
rankingUpdate: rankingUpdateApplied
? { applied: true }
: { applied: false, reason: rankingUpdateSkippedReason ?? "unknown" },
...(appliedToResult
? { appliedTo: { ref: appliedToResult.lessonRef, lessonStrength: appliedToResult.strength } }
: {}),
});
},
});