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
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 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),
});
}