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

251 lines (250 loc) • 11.7 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/. /** * 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, }); }