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

363 lines (362 loc) • 19.5 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/. import catchup from "../../assets/improve-strategies/catchup.json" with { type: "json" }; import consolidate from "../../assets/improve-strategies/consolidate.json" with { type: "json" }; import defaultStrategy from "../../assets/improve-strategies/default.json" with { type: "json" }; import proactiveMaintenance from "../../assets/improve-strategies/proactive-maintenance.json" with { type: "json" }; import quick from "../../assets/improve-strategies/quick.json" with { type: "json" }; import reflectDistill from "../../assets/improve-strategies/reflect-distill.json" with { type: "json" }; import thorough from "../../assets/improve-strategies/thorough.json" with { type: "json" }; import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js"; import { ImproveProfileConfigSchema } from "../../core/config/config-schema.js"; import { deepMergeConfig } from "../../core/config/deep-merge.js"; import { BUILTIN_IMPROVE_STRATEGY_NAMES, IMPROVE_ENGINE_PROCESSES, IMPROVE_PROCESS_NAMES, } from "../../core/config/engine-semantics.js"; import { ConfigError } from "../../core/errors.js"; import { describeLlmCredentialAvailability, } from "../../integrations/agent/engine-resolution.js"; import { runnerLlmConnection } from "../../integrations/agent/runner.js"; import { applyAutonomyGate } from "./autonomy-gate.js"; import { resolveImproveExecution } from "./execution.js"; import { stripBundle } from "./ledger.js"; export const DEFAULT_ALLOWED_TYPES = { reflect: ["agent", "command", "knowledge", "lesson", "memory", "skill", "workflow"], distill: ["memory"], consolidate: ["memory"], }; /** Resolve process enablement from the selected strategy, the sole improve authority. */ export function resolveProcessEnabled(processName, strategy) { const processes = strategy.processes; return processes?.[processName]?.enabled === true; } /** An `excludeRefPrefixes` entry as a bare conceptId without a trailing `/` (else `startsWith(".../raw//")` never matches). */ function stripBundlePrefix(value) { return stripBundle(value).replace(/\/+$/, ""); } export function shouldSkipRef(ref, processName, strategy) { const process = strategy.processes?.[processName]; if (process?.enabled === false) return { skip: true, reason: "process-disabled" }; const parsed = parseRefInput(ref); const allowed = process?.allowedTypes ?? DEFAULT_ALLOWED_TYPES[processName]; if (!allowed.includes(parsed.type)) return { skip: true, reason: "type-filter" }; // R12: reflect only — raw wiki-ingest snapshots are type `knowledge`, so // allowedTypes alone can't exclude them (distill/consolidate are memory-only). if (processName === "reflect") { const excludePrefixes = strategy.processes?.reflect?.excludeRefPrefixes; if (excludePrefixes && excludePrefixes.length > 0) { const conceptId = conceptIdFromTypeName(parsed.type, parsed.name); const excluded = excludePrefixes.some((prefix) => { const stripped = stripBundlePrefix(prefix); return conceptId === stripped || conceptId.startsWith(`${stripped}/`); }); if (excluded) return { skip: true, reason: "exclude-filter" }; } } return { skip: false, reason: "" }; } const REF_SCOPED_PROCESSES = new Set(["reflect", "distill", "consolidate"]); /** How many refs a ref-scoped process would act on (`undefined` for any other process). */ export function eligibleRefCount(refs, process, strategy) { if (!REF_SCOPED_PROCESSES.has(process)) return undefined; const name = process; return refs.filter((entry) => !shouldSkipRef(entry.ref, name, strategy).skip).length; } export function isStrategyFilteredForAllPasses(ref, strategy) { return shouldSkipRef(ref, "reflect", strategy).skip && shouldSkipRef(ref, "distill", strategy).skip; } const BUILTIN_STRATEGIES = { default: defaultStrategy, quick, thorough, consolidate, catchup, "reflect-distill": reflectDistill, "proactive-maintenance": proactiveMaintenance, }; if (BUILTIN_IMPROVE_STRATEGY_NAMES.some((name) => !(name in BUILTIN_STRATEGIES))) { throw new Error("Built-in improve strategy names are out of sync with their assets"); } export function resolveImproveStrategy(name, config) { const selectedName = name ?? config.defaults?.improveStrategy ?? "default"; const userStrategies = config.improve?.strategies ?? {}; // graph-refresh named a specific, common retirement — refuse it // unconditionally, even when `improve.strategies["graph-refresh"]` still // has an override block from when it customized the (now-deleted) built-in // strategy of the same name. That override is a partial patch (e.g. just // `processes.graphExtraction.mode`), not a full strategy definition — // falling through to the generic "resolve as a user strategy" path below // would silently merge it onto `default` and run a full, unplanned improve // pass instead of refusing. `akm migrate apply` drops the leftover block // (src/core/config/config.ts, normalizeConfigFile) since it can never // apply again. if (selectedName === "graph-refresh") { const hasLeftoverOverride = Boolean(userStrategies["graph-refresh"]); throw new ConfigError(`Improve strategy "graph-refresh" was retired in 0.9.17-alpha.9 along with the LLM entity-graph extraction it ran.` + (hasLeftoverOverride ? ' Your config still has a leftover `improve.strategies["graph-refresh"]` override for it — `akm migrate apply` removes it.' : ""), "UNKNOWN_IMPROVE_STRATEGY", // Override CONFIG_HINTS.UNKNOWN_IMPROVE_STRATEGY (src/core/errors.ts): // its "listed strategy names" phrase does not apply here (this message // names no strategies), and its "define it under improve.strategies" // suggestion is actively wrong — that is exactly the leftover-override // shape this refusal exists to reject, not a way around it. "Choose a different strategy. `graph-refresh` cannot be redefined under `improve.strategies` — it always refuses."); } if (!(selectedName in BUILTIN_STRATEGIES) && !userStrategies[selectedName]) { const valid = [...new Set([...Object.keys(BUILTIN_STRATEGIES), ...Object.keys(userStrategies)])].sort(); throw new ConfigError(`Improve strategy "${selectedName}" not found. Valid strategies: ${valid.join(", ")}.`, "UNKNOWN_IMPROVE_STRATEGY"); } const selectedStrategy = BUILTIN_STRATEGIES[selectedName] ?? {}; const baseStrategy = deepMergeConfig(BUILTIN_STRATEGIES.default ?? {}, selectedStrategy); const resolved = deepMergeConfig(baseStrategy, (userStrategies[selectedName] ?? {})); return { name: selectedName, config: ImproveProfileConfigSchema.parse(resolved) }; } /** * The processes that make their own model calls, so the plan resolves a runner * for each: every engine process but triage, whose engine is its judgment's. */ export const MODEL_CALLING_PROCESSES = new Set(IMPROVE_ENGINE_PROCESSES.filter((name) => name !== "triage")); /** * Project a resolved improve plan into one routing row per * {@link IMPROVE_PROCESS_NAMES} name, plus a `"triage.judgment"` * pseudo-row when the strategy configures a judgment engine. Pure — no I/O, * no re-resolution. Shared by `improve --dry-run`'s `plan.processes` (#947) * and `akm health`'s post-run reporting (#944); health's own * `active-improve-strategy` check keeps its existing `engines` shape. */ export function projectResolvedProcessRouting(plan) { const unavailableByProcess = new Map(plan.engineUnavailable.map((item) => [item.process, item])); const rows = []; for (const processName of IMPROVE_PROCESS_NAMES) { const process = plan.processes[processName]; const unavailable = unavailableByProcess.get(processName); rows.push({ process: processName, enabled: process.enabled, ...(process.runner ? { engine: process.runner.engine, model: runnerLlmConnection(process.runner)?.model, engineKind: process.runner.kind, } : // #800/#957 round 3 — a credential-unavailable process never carries a // runner, but its structurally resolved engine/model is still worth // showing in the routing table (dry-run preview, health probe). unavailable?.engine ? { engine: unavailable.engine, ...(unavailable.model ? { model: unavailable.model } : {}), engineKind: "llm", } : {}), notices: process.notices ?? [], ...(unavailable ? { unavailable: { configKey: unavailable.configKey, reason: unavailable.reason } } : {}), }); } if (plan.strategy.config.processes?.triage?.judgment?.enabled === true) { const judgmentUnavailable = unavailableByProcess.get("triage.judgment"); rows.push({ process: "triage.judgment", enabled: plan.triageJudgment !== null, ...(plan.triageJudgment ? { engine: plan.triageJudgment.engine, engineKind: plan.triageJudgment.kind, ...(plan.triageJudgment.kind === "llm" ? { model: plan.triageJudgment.connection.model } : {}), } : {}), notices: plan.triageJudgmentNotices ?? [], ...(judgmentUnavailable ? { unavailable: { configKey: judgmentUnavailable.configKey, reason: judgmentUnavailable.reason } } : {}), }); } return rows; } export function cloneAndFreeze(value) { const clone = structuredClone(value); const freeze = (item) => { if (typeof item !== "object" || item === null || Object.isFrozen(item)) return; for (const child of Object.values(item)) freeze(child); Object.freeze(item); }; freeze(clone); return clone; } /** Resolve every enabled process structurally before improve emits signals or performs I/O. */ export function resolveImprovePlan(name, config, options = {}) { const selected = resolveImproveStrategy(name, config); // D8 — gate autonomy BEFORE the plan is built, so every downstream consumer // (process enablement, LLM preflight, triage judgment) sees one already-safe // strategy rather than each having to re-ask whether autonomy is allowed. const { config: gatedStrategyConfig, gated } = applyAutonomyGate(selected.config, config); const strategy = { name: selected.name, config: gatedStrategyConfig }; return { ...buildImprovePlan(strategy, config, options), autonomyGated: gated }; } /** Describe why a resolved-but-uncredentialed runner belongs in `engineUnavailable` (#957). */ function credentialUnavailableReason(engineName, status) { return `requires a credential that is not available in this process's environment (engine "${engineName}": ${status.reason})`; } /** * Whether the credential akm reads for `runner` is reachable in `env`: an LLM * engine's own, or an SDK engine's LLM fallback. An agent CLI reads its own. */ function runnerCredentialStatus(runner, env) { if (runner.kind === "llm") return describeLlmCredentialAvailability(runner, env); if (runner.kind === "sdk") { return describeLlmCredentialAvailability({ credential: runner.fallbackCredential, apiKeyFile: runner.fallbackApiKeyFile, apiKeySecretRef: runner.fallbackApiKeySecretRef, }, env); } return { available: true }; } function buildImprovePlan(strategy, config, options) { const env = options.env ?? process.env; const processes = {}; const engineUnavailable = []; // #957 round 2 — distinguishes "credential unavailable" (a working engine // whose secret isn't in this environment) from "no engine selected at all" // among the pushes below, so `allowAllDisabled` can bypass the ConfigError // only for the former. The latter is the guard's original, pre-#957 // condition and must keep hard-aborting for every caller, including a // health probe with `allowAllDisabled` set — a totally unconfigured // install is not the credential-in-the-wrong-environment case this option // exists for. let anyEngineNotConfigured = false; for (const processName of IMPROVE_PROCESS_NAMES) { const sourceProcessConfig = strategy.config.processes?.[processName] ?? {}; const enabled = sourceProcessConfig.enabled === true; let runner = null; let notices = []; if (!MODEL_CALLING_PROCESSES.has(processName) || !enabled) { processes[processName] = Object.freeze({ enabled, config: cloneAndFreeze(sourceProcessConfig), runner }); continue; } // Validation itself is structural and always runs. Only its optional repair // step needs a model, so disabling repair must not create an LLM preflight. const skipsRepairEngine = processName === "validation" && options.repairValidationFailures === false; // #957: a resolved-but-uncredentialed runner is folded into the same // "no engine" branch below — set here so the shared push/continue block // can tell the two apart in its reason text. let credentialUnavailableMessage; // #800/#957 round 3 — the structurally resolved engine/model/contextLength // for the credential-unavailable case only, so a dry-run preview can read // what resolution decided (e.g. the consolidation chunk-size estimate) // without the runner ever carrying a credential-less connection forward. let credentialUnavailableRouting; if (!skipsRepairEngine) { const resolved = resolveImproveExecution({ config, profile: strategy.config, process: sourceProcessConfig, processName, }); runner = resolved?.runner ?? null; notices = resolved?.notices ?? []; if (runner) { const credentialStatus = runnerCredentialStatus(runner, env); if (!credentialStatus.available) { const connection = runnerLlmConnection(runner); credentialUnavailableMessage = credentialUnavailableReason(runner.engine, credentialStatus); credentialUnavailableRouting = { engine: runner.engine, ...(connection?.model !== undefined ? { model: connection.model } : {}), ...(connection?.contextLength !== undefined ? { contextLength: connection.contextLength } : {}), }; runner = null; notices = []; } } } if (!runner && !skipsRepairEngine) { const configKey = `improve.strategies.${strategy.name}.processes.${processName}.engine`; if (!credentialUnavailableMessage) anyEngineNotConfigured = true; engineUnavailable.push({ process: processName, configKey, reason: credentialUnavailableMessage ?? `requires an engine that is not configured. Set defaults.llmEngine or ${configKey}`, ...credentialUnavailableRouting, }); processes[processName] = Object.freeze({ enabled: false, config: cloneAndFreeze({ ...sourceProcessConfig, enabled: false }), runner: null, }); continue; } if (runner) runner = cloneAndFreeze(runner); processes[processName] = Object.freeze({ enabled, config: cloneAndFreeze(sourceProcessConfig), runner, ...(notices.length > 0 ? { notices: cloneAndFreeze(notices) } : {}), }); } if (engineUnavailable.length > 0 && !Object.values(processes).some((process) => process.enabled) && (!options.allowAllDisabled || anyEngineNotConfigured)) { const names = engineUnavailable.map((item) => `"${item.process}"`).join(", "); throw new ConfigError(`No improve process can run: ${names} ${engineUnavailable.length === 1 ? "requires" : "require"} an engine that is not configured. Set defaults.llmEngine, or the per-process engine key named for each.`, "LLM_NOT_CONFIGURED"); } const triage = strategy.config.processes?.triage; const judgmentEnabled = triage?.judgment?.enabled === true; const triageJudgmentResolution = processes.triage.enabled && judgmentEnabled ? resolveImproveExecution({ config, profile: strategy.config, process: triage, current: triage.judgment, processName: "triage-judgment", }) : null; let triageJudgment = triageJudgmentResolution?.runner ?? null; if (processes.triage.enabled && judgmentEnabled && !triageJudgment) { throw new ConfigError(`Enabled improve triage judgment requires an engine. Set defaults.llmEngine or improve.strategies.${strategy.name}.processes.triage.judgment.engine.`, "LLM_NOT_CONFIGURED"); } // #957: same credential-unavailable treatment as the main per-process loop // above — a triage judgment engine that resolves structurally but whose // credential (or, for an SDK judgment, its LLM fallback credential) is // unavailable in this process's environment is folded into // `engineUnavailable` under the reserved `"triage.judgment"` process name // instead of silently reaching dispatch with a doomed credential. if (triageJudgment) { const credentialStatus = runnerCredentialStatus(triageJudgment, env); if (!credentialStatus.available) { engineUnavailable.push({ process: "triage.judgment", configKey: `improve.strategies.${strategy.name}.processes.triage.judgment.engine`, reason: credentialUnavailableReason(triageJudgment.engine, credentialStatus), }); triageJudgment = null; } } const frozenProcesses = Object.freeze(processes); const frozenStrategy = Object.freeze({ name: strategy.name, config: cloneAndFreeze({ ...strategy.config, processes: Object.fromEntries(Object.entries(frozenProcesses).map(([name, process]) => [name, process.config])), }), }); return Object.freeze({ config: cloneAndFreeze(config), strategy: frozenStrategy, processes: frozenProcesses, triageJudgment: triageJudgment ? cloneAndFreeze(triageJudgment) : null, ...(triageJudgmentResolution?.notices.length ? { triageJudgmentNotices: cloneAndFreeze(triageJudgmentResolution.notices) } : {}), engineUnavailable: Object.freeze(engineUnavailable), }); }