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

370 lines (369 loc) • 17.9 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/. /** * Improve profile + per-process (WI-9.6) discriminated schemas. Extracted * verbatim from the former `config-schema.ts` monolith — no behavior change. */ import { z } from "zod"; import { IMPROVE_PROCESS_NAMES } from "../engine-semantics.js"; import { engineName, LlmInvocationOverridesSchema, nonEmptyString, positiveInt } from "./primitives.js"; // ── Improve profile / process ────────────────────────────────────────────── // // WI-9.6 (§4.2/§10.2): each of the improve processes (reflect, distill, // consolidate, memoryInference, extract, validation, triage, // proactiveMaintenance — graphExtraction had one too, until the LLM // entity-graph it ran was retired in 0.9.17-alpha.9) gets its OWN schema // below — a shared base (engine, model, llm, enabled, timeoutMs) extended // with only the fields meaningful on that process — replacing the prior // single ImproveProcessConfigSchema reused via `.optional()` for all of them // (which accepted, and silently ignored, any field on any process). Field→process assignment is derived from each // field's original "only meaningful on X" doc comment, cross-checked against // its actual runtime consumers and the built-in strategy assets // (src/assets/improve-strategies/*.json). // // `ImproveProcessConfigSchema` ITSELF STAYS (unnarrowed — the union of every // field across every process): it backs (a) the wide `ImproveProcessConfig` // TS type that generic dynamic-process-name code needs (getImproveProcessConfig, // ResolvedImproveProcess.config, agent/runner.ts's cast — the process name // isn't known at the type level there, so the type must be a supertype every // process's narrow output is assignable to), and (b) two existing tests that // parse fields from multiple processes against it directly with no process // context (tests/integration/extract-command.test.ts, // tests/config-triage-process.test.ts). Real per-config validation for // `processes.<name>` uses the NARROW per-process schemas via // {@link ImproveProfileProcessesSchema} below — setting a field on the wrong // process there is now rejected (the intended §4.2 improvement). const IMPROVE_PROCESS_BASE_FIELDS = { engine: engineName.optional(), model: nonEmptyString.optional(), llm: LlmInvocationOverridesSchema.optional(), enabled: z.boolean().optional(), timeoutMs: z.union([positiveInt, z.null()]).optional(), }; /** Reflect/distill/consolidate: process-level asset-type filter (see improve-strategies.ts shouldSkipRef/DEFAULT_ALLOWED_TYPES). */ const allowedTypesField = z.array(z.string().min(1)).optional(); /** * Reflect only (R12): conceptId prefixes to exclude, matched after stripping * an optional `bundle//` from both the ref and each prefix (see * improve-strategies.ts shouldSkipRef). `allowedTypes` is type-only and can't * exclude a subset of one type, e.g. raw wiki-ingest snapshots indexed as * `knowledge/wikis/articles/raw/*`. distill/consolidate are memory-only and * never read this field. */ const excludeRefPrefixesField = z.array(z.string().min(1)).optional(); /** * Consolidate process: hard cap on memories processed per pass. * Reflect/distill: max refs processed (same as profile-level `limit`). * proactiveMaintenance: fallback when `maxPerRun` is absent. */ const processLimitField = positiveInt.optional(); /** * The distill and reflect LLM-as-judge quality gates. Default ON; set * `enabled: false` on the process to opt out. A judge error, timeout or * unparsable reply never passes: distill sends the lesson to review and * reflect refuses the edit. `engine`, `model`, `timeoutMs` and `llm` give the * gate a judge of its own (#1011), resolved over the process's own settings; * with none set, the gate judges with the generator's runner. */ const qualityGateField = z .object({ enabled: z.boolean().optional(), engine: engineName.optional(), model: nonEmptyString.optional(), timeoutMs: z.union([positiveInt, z.null()]).optional(), llm: LlmInvocationOverridesSchema.passthrough().optional(), }) .passthrough() .optional(); /** * WS-3b: CLS (Complementary Learning System) interleaving (step 9). * The distill prompt includes the lessons, knowledge notes and skills the library already holds near the * memory, so the writer answers NONE for a rule one of them states and does not overwrite a prior * generalization. Default ON; `enabled: false` turns it off. Only meaningful on the `distill` process. */ const clsField = z .object({ enabled: z.boolean().optional(), // Number of adjacent lessons/knowledge to include in prompts (default 3). adjacentCount: z.number().int().min(1).optional(), }) .passthrough() .optional(); /** * WS-3b: Distill→source fidelity check (step 10). After a distill proposal, * check it against its cited source memories; a contradiction flag forces * human review. Default OFF. Distill process only. */ const fidelityCheckField = z.object({ enabled: z.boolean().optional() }).passthrough().optional(); /** * #639 — semantic value-floor filter for the `reflect` process. When * enabled, proposals classified as "low-value" by the deterministic noise * gate are deferred. DEFAULT OFF (absent / { enabled: false } = pre-#639 * byte-identical behaviour). Reflect process only. */ const lowValueFilterField = z.object({ enabled: z.boolean().optional() }).passthrough().optional(); /** * The wording lists of reflect's pre-judge defect filter (`findReflectDefect`). * Each list is optional: one that is set replaces that rule's default list, and * an empty one turns the rule off. Phrases (`placeholders`, `metaCommentary`) * match as whole words in any case; `frontmatterKeys` are exact key names. * Reflect process only. */ const defectFilterField = z .object({ placeholders: z.array(nonEmptyString).optional(), metaCommentary: z.array(nonEmptyString).optional(), frontmatterKeys: z.array(nonEmptyString).optional(), }) .passthrough() .optional(); /** * #626 — extract process: pre-LLM heuristic triage gate. When enabled, a * deterministic scorer decides BEFORE the extraction LLM call whether a * session carries enough signal to be worth extracting; low-signal sessions * are skipped at zero LLM cost. Default OFF. `minScore` is the minimum total * heuristic score to PASS (default 2). Extract process only — NOTE: unrelated * to the `triage` PROCESS (`processes.triage`); this is a nested object under * `processes.extract.triage` that happens to share the name. */ const extractTriageGateField = z .object({ enabled: z.boolean().optional(), minScore: z.number().min(0).optional(), }) .passthrough() .optional(); // Unknown keys pass through here like every other nested surface (the loader // names them once); `extraParams` remains the arbitrary provider-parameter // escape hatch. const triageJudgmentLlmOverridesField = LlmInvocationOverridesSchema.passthrough(); const triageJudgmentObjectField = z .object({ enabled: z.boolean().optional(), engine: engineName.optional(), model: nonEmptyString.optional(), timeoutMs: z.union([positiveInt, z.null()]).optional(), llm: triageJudgmentLlmOverridesField.optional(), }) .passthrough(); /** Triage process: explicit LLM-as-judge enablement and execution overrides. */ const triageJudgmentField = z .union([z.boolean(), triageJudgmentObjectField]) .transform((value) => typeof value === "boolean" ? { enabled: value } : { ...value, enabled: value.enabled ?? true }) .optional(); /** * WS-3b: Anti-collapse guard (step 8), consolidate process only: a small * random (non-similarity-driven) fraction of the pool is mixed into the * clustered order so consolidation is not purely rich-get-richer. Default ON * (opt out via `enabled: false`); `randomClusterFraction` defaults to 0.05. * The retired merge guards (`maxGeneration`, `lexicalDiversityCheck`, * `mergeInformationFloor`, `minSpecificityRetention`) never refused a merge * and are tolerated as unknown keys. */ const antiCollapseField = z .object({ enabled: z.boolean().optional(), randomClusterFraction: z.number().min(0).max(1).optional(), }) .passthrough() .optional(); const REFLECT_PROCESS_FIELDS = { allowedTypes: allowedTypesField, excludeRefPrefixes: excludeRefPrefixesField, limit: processLimitField, qualityGate: qualityGateField, lowValueFilter: lowValueFilterField, defectFilter: defectFilterField, }; const DISTILL_PROCESS_FIELDS = { allowedTypes: allowedTypesField, limit: processLimitField, qualityGate: qualityGateField, // Skip distill entirely when reflect produced zero planned refs. requirePlannedRefs: z.boolean().optional(), cls: clsField, fidelityCheck: fidelityCheckField, }; const CONSOLIDATE_PROCESS_FIELDS = { allowedTypes: allowedTypesField, limit: processLimitField, // Minimum eligible-memory pool size below which the consolidation pass skips // entirely (emits `pool_below_min_size`). 0 disables the guard. Default 500. minPoolSize: z.number().int().min(0).optional(), maxChunkSize: z.number().int().min(1).max(50).optional(), // Fallback p90 wall-clock time per consolidation chunk in seconds, used for // cold-start budget estimation when no telemetry history exists. The actual // p90 is derived from observed run durations once sufficient history // accumulates; this value is only used on the very first run. Default 30s. p90ChunkSecondsDefault: z.number().finite().positive().optional(), antiCollapse: antiCollapseField, }; const MEMORY_INFERENCE_PROCESS_FIELDS = { // Minimum pending memory count to run the pass. minPendingCount: z.number().int().min(0).optional(), cls: clsField, }; const EXTRACT_PROCESS_FIELDS = { defaultSince: z.string().min(1).optional(), maxTotalChars: positiveInt.optional(), // Minimum raw session size (pre-filter inputCount) below which the extract // LLM call is skipped (#595/#596). 0 disables the gate. Absent = default 10 // (skip only truly empty sessions). minContentChars: z.number().int().min(0).optional(), triage: extractTriageGateField, // Minimum number of new (unseen, in-window) candidate sessions below which // the extract pass skips entirely (emits an `improve_skipped` event with // `reason: "below_min_new_sessions"`). 0 disables the guard. Default 0 // (disabled) so existing behaviour is preserved; only opted-in profiles set it. minNewSessions: z.number().int().min(0).optional(), // Cap on NEW sessions processed (LLM-called) per run; the rest roll to the // next run (still unseen). 0 disables. Absent = default 25. maxSessionsPerRun: z.number().int().min(0).optional(), // #561 — index agent sessions as a searchable `session` asset. Absent = // on-when-an-LLM-is-available (fail-open when offline). COST: when on, each // processed session makes a SECOND LLM call (the session summary) on top of // the extraction call — i.e. ~2 LLM calls/session. Set to false to halve // per-session extract cost at the price of unsearchable sessions. // (Unchanged/skip sessions still cost zero — the content-hash ledger gates // both calls upstream.) indexSessions: z.boolean().optional(), // #561 — minimum session duration in minutes for session indexing. 0 // disables the gate. Absent = default 5. minSessionDuration: z.number().min(0).optional(), }; const TRIAGE_PROCESS_FIELDS = { applyMode: z.enum(["queue", "promote"]).optional(), maxAcceptsPerRun: positiveInt.optional(), judgment: triageJudgmentField, }; /** proactiveMaintenance process fields (Layer 2). */ const PROACTIVE_MAINTENANCE_PROCESS_FIELDS = { // Staleness gate + rotation cooldown in days (default 30). dueDays: z.number().int().min(0).optional(), // Top-N bound per run (default 25). Alias for `limit`; `maxPerRun` wins // when both are set. maxPerRun: positiveInt.optional(), limit: processLimitField, }; /** distill/consolidate are memory-only and never read `excludeRefPrefixes` (reflect only, R12). */ function rejectExcludeRefPrefixesOutsideReflect(value, ctx) { if ("excludeRefPrefixes" in value) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["excludeRefPrefixes"], message: "excludeRefPrefixes is only valid on the reflect process", }); } } export const ImproveProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...REFLECT_PROCESS_FIELDS, ...DISTILL_PROCESS_FIELDS, ...CONSOLIDATE_PROCESS_FIELDS, ...MEMORY_INFERENCE_PROCESS_FIELDS, ...EXTRACT_PROCESS_FIELDS, ...TRIAGE_PROCESS_FIELDS, ...PROACTIVE_MAINTENANCE_PROCESS_FIELDS, }) .passthrough(); /** `processes.reflect` — narrow per-process schema (WI-9.6). */ export const ReflectProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...REFLECT_PROCESS_FIELDS }) .passthrough(); /** `processes.distill` — narrow per-process schema (WI-9.6). */ export const DistillProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...DISTILL_PROCESS_FIELDS }) .passthrough() .superRefine(rejectExcludeRefPrefixesOutsideReflect); /** `processes.consolidate` — narrow per-process schema (WI-9.6). */ export const ConsolidateProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...CONSOLIDATE_PROCESS_FIELDS }) .passthrough() .superRefine(rejectExcludeRefPrefixesOutsideReflect); /** `processes.memoryInference` — narrow per-process schema (WI-9.6). */ export const MemoryInferenceProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...MEMORY_INFERENCE_PROCESS_FIELDS }) .passthrough(); /** `processes.extract` — narrow per-process schema (WI-9.6). */ export const ExtractProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...EXTRACT_PROCESS_FIELDS }) .passthrough(); /** `processes.validation` — narrow per-process schema (WI-9.6); no extra fields beyond the shared base. */ export const ValidationProcessConfigSchema = z.object({ ...IMPROVE_PROCESS_BASE_FIELDS }).passthrough(); /** `processes.triage` — narrow per-process schema (WI-9.6). */ export const TriageProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...TRIAGE_PROCESS_FIELDS }) .passthrough(); /** `processes.proactiveMaintenance` — narrow per-process schema (WI-9.6). */ export const ProactiveMaintenanceProcessConfigSchema = z .object({ ...IMPROVE_PROCESS_BASE_FIELDS, ...PROACTIVE_MAINTENANCE_PROCESS_FIELDS }) .passthrough(); /** * Process names that once had a dedicated schema and are now gone. Setting * one, even with `enabled: true`, is tolerated rather than rejected by the * "unknown enabled process" check below — an old config must keep loading * (AGENTS.md "Reading persisted data"). `graphExtraction`: the LLM * entity-graph extraction it ran was retired in 0.9.17-alpha.9. */ const RETIRED_PROCESS_NAMES = new Set(["graphExtraction"]); const ImproveProfileProcessesSchema = z .object({ reflect: ReflectProcessConfigSchema.optional(), distill: DistillProcessConfigSchema.optional(), consolidate: ConsolidateProcessConfigSchema.optional(), memoryInference: MemoryInferenceProcessConfigSchema.optional(), extract: ExtractProcessConfigSchema.optional(), validation: ValidationProcessConfigSchema.optional(), triage: TriageProcessConfigSchema.optional(), proactiveMaintenance: ProactiveMaintenanceProcessConfigSchema.optional(), }) .passthrough() .superRefine((val, ctx) => { // 0.8.0 removed the duplicated `feedbackDistillation` process key — it was // a thin wrapper around `processes.distill.enabled`. Keep the migration // hint so a stale config gets an actionable message rather than silently // doing nothing. Other unknown process keys remain preserved for // cross-version compatibility, but an enabled one is rejected below because // this version cannot execute it. if ("feedbackDistillation" in val) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "feedbackDistillation was removed in 0.8.0 — use processes.distill.enabled instead. " + "It now controls both the orchestration gate and the LLM-call gate.", }); } for (const [name, process] of Object.entries(val)) { if (!IMPROVE_PROCESS_NAMES.includes(name) && !RETIRED_PROCESS_NAMES.has(name) && process !== null && typeof process === "object" && process.enabled === true) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [name], message: `Unknown enabled improve process "${name}"`, }); } } }); export const ImproveProfileConfigSchema = z .object({ engine: engineName.optional(), model: nonEmptyString.optional(), timeoutMs: z.union([positiveInt, z.null()]).optional(), llm: LlmInvocationOverridesSchema.optional(), description: z.string().min(1).optional(), processes: ImproveProfileProcessesSchema.optional(), limit: positiveInt.optional(), sync: z .object({ enabled: z.boolean().optional(), push: z.boolean().optional(), message: z.string().min(1).optional(), }) .passthrough() .optional(), }) .passthrough();