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
251 lines (250 loc) • 11.7 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/.
/**
* Schema-repair pass for `akm improve`.
*
* Attempts to patch missing frontmatter fields (`description`, `when_to_use`)
* on assets that failed schema validation, using a single bounded in-tree LLM
* call per asset. Results are recorded as `schema_repair_invoked` events; a
* queued repair's `improve_ledger` row (written with the proposal) keeps the
* same asset from being re-queued inside the ledger's revisit window.
*
* This module is extracted from `improve.ts` to make the repair logic
* independently testable and to use the shared structured execution seam.
*/
import fs from "node:fs";
import path from "node:path";
import { assembleAsset } from "../../core/asset/asset-serialize.js";
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
import { parseRefInput } from "../../core/asset/resolve-ref.js";
import { authoringRulesForType } from "../../core/authoring-rules.js";
import { ConfigError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { parseEmbeddedJsonResponse } from "../../core/parse.js";
import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
import { info } from "../../core/warn.js";
import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
import { assertRunnerCredentials } from "../../integrations/agent/runner-dispatch.js";
import { callStructured } from "../../llm/structured-call.js";
import { isLedgerBlocked, ledgerKey, loadLedgerSnapshot } from "../improve/ledger.js";
import { createProposal } from "../proposal/repository.js";
/** Proposal source and improve-ledger source for this pass. */
const SCHEMA_REPAIR_SOURCE = "schema-repair";
async function classifySchemaRepairs(args) {
const { failures, startMs, budgetMs, stashDir, findFilePath, isLessonCandidateFn, repairs } = args;
const eligibleRepairs = [];
const pendingErrors = [];
const ledger = loadLedgerSnapshot(undefined, stashDir, [SCHEMA_REPAIR_SOURCE]);
const nowIso = new Date().toISOString();
for (const failure of failures) {
if (Date.now() - startMs >= budgetMs)
break;
if (isLedgerBlocked(ledger.get(ledgerKey(SCHEMA_REPAIR_SOURCE, failure.ref)), nowIso)) {
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
continue;
}
const filePath = await findFilePath(failure.ref, stashDir);
if (!filePath || path.extname(filePath).toLowerCase() !== ".md") {
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
continue;
}
try {
const raw = fs.readFileSync(filePath, "utf8");
const frontmatter = parseFrontmatter(raw);
const missingFields = [];
if (!frontmatter.data.description)
missingFields.push("description");
if (isLessonCandidateFn(failure.ref) && !frontmatter.data.when_to_use)
missingFields.push("when_to_use");
if (missingFields.length === 0) {
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
continue;
}
const fieldList = missingFields.join(" and ");
const standardsContext = resolveStandardsContext(failure.ref, stashDir);
const standardsSection = standardsContext.trim()
? `\n\nStandards to follow (the rulebook for this target):\n${standardsContext.trim()}`
: "";
const assetType = parseRefInput(failure.ref).type;
const authoringRules = authoringRulesForType(assetType);
const authoringRulesSection = authoringRules ? `\n\n${authoringRules}` : "";
eligibleRepairs.push({
failure,
frontmatter,
fieldList,
messages: [
{
role: "system",
content: "You generate concise asset frontmatter fields. Respond with a JSON object containing only the missing fields. No prose, no markdown fences.",
},
{
role: "user",
content: `Generate the missing frontmatter fields (${fieldList}) for this ${assetType} asset. Return ONLY valid JSON like {"description": "...", "when_to_use": "..."}${standardsSection}${authoringRulesSection}\n\n${(frontmatter.content ?? raw).slice(0, 2000)}`,
},
],
});
}
catch (error) {
pendingErrors.push({ ref: failure.ref, reason: failure.reason, outcome: "error", error: String(error) });
}
}
return { eligibleRepairs, pendingErrors };
}
// ── Main ─────────────────────────────────────────────────────────────────────
/**
* Run the schema-repair loop for a batch of validation failures.
* Returns a list of per-asset outcome records and the set of refs whose live
* files were repaired. Queued proposals never count as live repairs. Invalid
* symbolic credentials reject with {@link ConfigError} before any repair
* proposal or per-ref event is written.
*/
export async function runSchemaRepairPass(failures, options) {
const repairs = [];
const repairedRefs = new Set();
const { startMs, budgetMs, stashDir, findFilePath = defaultFindFilePath, isLessonCandidateFn = defaultIsLessonCandidate, chatFn, } = options;
const llmRunner = options.llmRunner ?? null;
if (!llmRunner)
throw new Error("runSchemaRepairPass requires a resolved runner");
if (!stashDir) {
throw new Error("runSchemaRepairPass requires stashDir so repairs route through the proposal queue");
}
// Classify the entire batch using read-only work before credential lookup or
// proposal/event persistence. A missing required credential therefore aborts
// the eligible batch atomically instead of leaving an earlier proposal behind.
const { eligibleRepairs, pendingErrors } = await classifySchemaRepairs({
failures,
startMs,
budgetMs,
stashDir,
findFilePath,
isLessonCandidateFn,
repairs,
});
if (eligibleRepairs.length === 0) {
for (const repair of pendingErrors) {
appendEvent({
eventType: "schema_repair_invoked",
ref: repair.ref,
metadata: { outcome: "error", reason: repair.reason, error: repair.error },
});
repairs.push(repair);
}
return { repairs, repairedRefs };
}
assertRunnerCredentials(llmRunner);
for (const repair of pendingErrors) {
appendEvent({
eventType: "schema_repair_invoked",
ref: repair.ref,
metadata: { outcome: "error", reason: repair.reason, error: repair.error },
});
repairs.push(repair);
}
for (const { failure, frontmatter: fm, fieldList, messages } of eligibleRepairs) {
let loweringNotices = [];
const noticeFields = () => loweringNotices.length > 0 ? { notices: loweringNotices } : {};
try {
info(`[improve] schema-repair ${failure.ref} (${fieldList})`);
const llmResponse = await callStructured({
feature: "schema_repair",
runner: llmRunner,
messages,
...(chatFn ? { request: { chat: chatFn } } : {}),
onNotices: (value) => {
loweringNotices = value;
},
parse: (rawResponse) => rawResponse ?? "",
onError: () => "",
fallback: "",
});
const parsed = parseEmbeddedJsonResponse(llmResponse.trim());
if (!parsed) {
repairs.push({
ref: failure.ref,
reason: failure.reason,
outcome: "error",
error: "LLM returned unparseable JSON for schema repair",
...noticeFields(),
});
continue;
}
const newFm = { ...fm.data };
if (parsed.description)
newFm.description = parsed.description;
if (parsed.when_to_use)
newFm.when_to_use = parsed.when_to_use;
const newContent = assembleAsset(newFm, fm.content);
// M-3 / #387: Route through proposal queue instead of writing directly to
// disk. This restores akm's safety invariant — the proposal queue is the
// only path to a committed asset write. LLM-generated `description` /
// `when_to_use` fields can be incorrect; routing through the queue makes
// them human-reviewable before they affect search ranking and curate hints.
// mem0 open gaps (arXiv:2504.19413) — any LLM write to a memory field
// should be human-reviewable.
const proposalResult = createProposal(stashDir, {
ref: failure.ref,
source: SCHEMA_REPAIR_SOURCE,
// Key the ledger row by the ref this pass reads it back under.
attemptedRefs: [failure.ref],
payload: {
content: newContent,
...(Object.keys(newFm).length > 0 ? { frontmatter: newFm } : {}),
},
});
info(`[improve] schema-repair queued: ${failure.ref} (proposal id: ${proposalResult.id})`);
appendEvent({
eventType: "schema_repair_invoked",
ref: failure.ref,
metadata: {
outcome: "queued",
reason: failure.reason,
proposalId: proposalResult.id,
...noticeFields(),
},
});
repairs.push({
ref: failure.ref,
reason: failure.reason,
outcome: "queued",
proposalId: proposalResult.id,
...noticeFields(),
});
}
catch (e) {
if (e instanceof ConfigError)
throw e;
appendEvent({
eventType: "schema_repair_invoked",
ref: failure.ref,
metadata: { outcome: "error", reason: failure.reason, error: String(e), ...noticeFields() },
});
repairs.push({
ref: failure.ref,
reason: failure.reason,
outcome: "error",
error: String(e),
...noticeFields(),
});
}
}
return { repairs, repairedRefs };
}
// ── Default seam implementations ─────────────────────────────────────────────
function defaultIsLessonCandidate(ref) {
try {
const parsed = parseRefInput(ref);
return parsed.type === "lesson";
}
catch {
return false;
}
}
async function defaultFindFilePath(ref, stashDir) {
return resolveAssetPath(ref, {
stashDir,
mode: "index-first",
directoryIndexNames: ["SKILL.md", "index.md", "README.md"],
preserveDirectNameFallback: true,
});
}