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

526 lines (525 loc) • 23.8 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/. /** * Memory inference pass for `akm index` (#201). * * Detects memories pending inference, asks the configured LLM to compress each * into one higher-signal derived memory, and writes the result back as a new * memory file with frontmatter `inferred: true` + a `source:` backref to the * parent memory. * * Pending predicate (see {@link isPendingMemory}): * - File lives under `<stashRoot>/memories/` and ends in `.md`. * - Frontmatter does NOT have `inferenceProcessed: true` (parent already split). * - Frontmatter does NOT have `inferred: true` (this is itself a child fact). * * Idempotency: after a successful split the parent's frontmatter is rewritten * with `inferenceProcessed: true`. A subsequent `akm index` therefore skips * the parent without re-running the LLM. * * Disabling — two orthogonal gates: * 1. The selected strategy sets `processes.memoryInference.enabled = false` * blocks the pass at the feature-flag layer (no network call may ever * issue). Historically the v1 spec §14 gate, superseded by the 0.8.0 * profile shape. * 2. `index.memory.llm = false` (or no resolvable LLM profile) opts the * pass out at the per-pass layer (#208). * A pass runs iff both layers allow it. Existing inferred children are * NEVER deleted — the user keeps what was already produced. * * Locked v1 contract: * - LLM access is exclusively via the frozen runner returned by * `resolveIndexPassExecution("memory", config)`. * - All child memory writes go through `writeAssetToSource` in * `src/core/write-source.ts`. The parent's frontmatter rewrite is an * explicit narrow exception — see {@link markParentProcessed}. */ import fs from "node:fs"; import path from "node:path"; import { detectAdapterId } from "../../core/adapter/detect-adapter.js"; import { assembleAsset } from "../../core/asset/asset-serialize.js"; import { parseFrontmatter, parseFrontmatterBlock } from "../../core/asset/frontmatter.js"; import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js"; import { bestEffort } from "../../core/best-effort.js"; import { todayIso } from "../../core/common.js"; import { concurrentMap } from "../../core/concurrent.js"; import { ConfigError } from "../../core/errors.js"; import { warn } from "../../core/warn.js"; import { beginWriteProvenance, recordWrittenPath } from "../../core/write-provenance.js"; import { writeAssetToSource } from "../../core/write-source.js"; import { runnerLlmConnection } from "../../integrations/agent/runner.js"; import { assertRunnerCredentials } from "../../integrations/agent/runner-dispatch.js"; import { isProcessEnabled } from "../../llm/feature-gate.js"; import { resolveIndexPassExecution } from "../../llm/index-passes.js"; import * as memoryInfer from "../../llm/memory-infer.js"; import { computeBodyHash, getLlmCacheEntry } from "../../storage/repositories/index-llm-cache-repository.js"; import { withLlmCache } from "../db/llm-cache.js"; import { walkMarkdownFiles } from "../walk/walker.js"; /** * Frontmatter keys this pass cares about. Constants so a future rename only * needs to touch one site. */ const FM_INFERRED = "inferred"; const FM_INFERENCE_PROCESSED = "inferenceProcessed"; const FM_SOURCE = "source"; const FM_CAPTURE_MODE = "captureMode"; function validateDerivedMemoryDraft(raw) { if (!raw || typeof raw !== "object") return undefined; const parsed = raw; const title = typeof parsed.title === "string" ? parsed.title : ""; const description = typeof parsed.description === "string" ? parsed.description : ""; const content = typeof parsed.content === "string" ? parsed.content : ""; const tags = Array.isArray(parsed.tags) ? parsed.tags.filter((tag) => typeof tag === "string") : []; const searchHints = Array.isArray(parsed.searchHints) ? parsed.searchHints.filter((hint) => typeof hint === "string") : []; return title && description && content && tags.length > 0 && searchHints.length > 0 ? { title, description, tags, searchHints, content } : undefined; } function planPendingMemoryRecord(record, ctx) { if (ctx.signal?.aborted) return { kind: "aborted", record }; if (fs.existsSync(derivedChildPath(record))) return { kind: "existing-child", record }; if (ctx.db && !ctx.reEnrich) { const cached = bestEffort(() => { const entry = getLlmCacheEntry(ctx.db, record.filePath, computeBodyHash(record.body), "memory-inference-v2"); return entry ? validateDerivedMemoryDraft(JSON.parse(entry.resultJson)) : undefined; }, "memory inference cache read corrupt — fall through to recompute"); if (cached) return { kind: "cache-hit", record, derived: cached }; } return { kind: "model", record }; } function memoryExecutionForContext(ctx, config) { if (Object.hasOwn(ctx, "llmRunner")) { return Object.freeze({ runner: ctx.llmRunner ?? undefined, notices: Object.freeze([]) }); } return resolveIndexPassExecution("memory", config); } async function inferPendingMemoryRecord(plan, ctx) { const { record } = plan; const { config, featureConfig, signal, db, reEnrich, llmRunner, inferTelemetry, compressMemoryToDerivedMemory, onNotices, onConfigFailure, } = ctx; if (signal?.aborted || plan.kind === "aborted") return { aborted: true }; // Existing children are complete, but the parent may be unmarked after a // crash between the child write and parent update (or an external write). if (fs.existsSync(derivedChildPath(record))) { markParentProcessed(record); return { skipped: false, splitParent: false, written: 0, fromCache: false, retryAttempts: 0, childExists: true, precheck: true, }; } // The child can disappear after the read-only classification pass (for // example, an external cleanup racing this index run). The plan is not proof // that the child still exists at the mutation boundary: leave the parent // pending so a later run can classify and preflight the now-real model work. if (plan.kind === "existing-child") { return { skipped: true, fromCache: false, retryAttempts: 0 }; } let fromCache = plan.kind === "cache-hit"; let retryAttempts = 0; const onRetryAttempt = () => { retryAttempts += 1; }; const infer = () => compressMemoryToDerivedMemory(llmRunner, record.body, signal, featureConfig, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices); let derived = plan.kind === "cache-hit" ? plan.derived : undefined; try { if (plan.kind === "model") { derived = db ? await withLlmCache(db, record.filePath, record.body, reEnrich ?? false, infer, validateDerivedMemoryDraft, undefined, "memory-inference-v2", { onCacheHit: () => { fromCache = true; }, }) : await compressMemoryToDerivedMemory(llmRunner, record.body, signal, config, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices); } } catch (error) { if (error instanceof ConfigError) { onConfigFailure(error); return undefined; } throw error; } if (!derived) return { skipped: true, fromCache, retryAttempts }; const writeOutcome = await writeDerivedMemory(record, derived); if (writeOutcome.written > 0) { markParentProcessed(record); return { skipped: false, splitParent: true, written: writeOutcome.written, fromCache, retryAttempts }; } // A child that appeared mid-flight is complete and can safely mark its // parent. A genuine write failure remains unmarked for a later retry. if (writeOutcome.childExists) markParentProcessed(record); return { skipped: false, splitParent: false, written: 0, fromCache, retryAttempts, childExists: true, precheck: false, }; } /** * Top-level entry point. Returns a no-op result when the pass is disabled. * * Two orthogonal gates: * * 1. **Feature gate** — the selected strategy's `processes.memoryInference.enabled` * (defaults to `true`). When `false`, no network call may issue regardless * of per-pass settings. * 2. **Per-pass gate** — `resolveIndexPassExecution("memory", config)` reads * the index pass selection. Without a runner, the indexer simply skips * this pass for the current run. * * Both must allow the call for the pass to run. Either set to `false` * short-circuits to a no-op result. */ export async function runMemoryInferencePass(ctx) { // R78: owns the write-provenance journal end-to-end so it closes on every // exit path, including a throw out of the body below — an unclosed journal // would keep reporting every later write in this process as this call's own. const provenance = beginWriteProvenance(); try { return await runMemoryInferencePassBody(ctx, provenance); } finally { provenance.end(); } } async function runMemoryInferencePassBody(ctx, provenance) { const { config, sources, signal, db, reEnrich, onProgress, options = {} } = ctx; const invocationOwnsRunner = Object.hasOwn(ctx, "llmRunner"); const compressMemoryToDerivedMemory = options.compressMemoryToDerivedMemory ?? memoryInfer.compressMemoryToDerivedMemory; const result = { considered: 0, cacheHits: 0, retryAttempts: 0, splitParents: 0, writtenFacts: 0, skippedNoFacts: 0, skippedChildExists: 0, skippedAborted: 0, unaccounted: 0, htmlErrorCount: 0, writtenPaths: [], }; // Mutable sink threaded into compressMemoryToDerivedMemory so the per-call // HTML-error categorization (which is otherwise swallowed inside the feature // gate) bubbles up into the pass result. const inferTelemetry = {}; const noticesByKey = new Map(); const onNotices = (notices) => { for (const notice of notices) noticesByKey.set(JSON.stringify(notice), notice); }; const completeResult = () => { if (noticesByKey.size > 0) result.notices = Object.freeze([...noticesByKey.values()]); // Non-destructive read — the wrapper's `finally` owns closing the journal. result.writtenPaths = provenance.writtenPaths(); return result; }; // Gate 1 — feature gate via isProcessEnabled, which reads the 0.8.0 path // (selected strategy's processes.memoryInference.enabled). Defaults to // enabled when the key is absent. if (!invocationOwnsRunner && !isProcessEnabled("index", "memory_inference", config)) return completeResult(); // Gate 2 — per-pass opt-out (#208). Returns the resolved llm config or // `undefined` when the pass should not run. const execution = memoryExecutionForContext(ctx, config); onNotices(execution.notices); const llmRunner = execution.runner; if (!llmRunner) return completeResult(); const featureConfig = invocationOwnsRunner ? { ...config, index: { ...config.index, memory: { ...config.index?.memory, enabled: true } } } : config; // The pass only writes to the primary (working) stash. Read-only caches // (git, npm, website) are deliberately untouched — writing inferred // children there would be clobbered by the next sync(). const primary = sources[0]; if (!primary || (primary.adapterId ?? detectAdapterId(primary.path)) !== "akm") return completeResult(); const pending = collectPendingMemories(primary.path).filter((record) => !options.candidateRefs || options.candidateRefs.has(record.ref)); result.considered = pending.length; if (pending.length === 0) return completeResult(); // Classify the whole batch without mutation. Required credentials are // materialized only when at least one record will actually dispatch; this // keeps aborted, existing-child, and validated-cache-only batches available // offline while preserving all-or-nothing preflight for mixed batches. const plans = pending.map((record) => planPendingMemoryRecord(record, { signal, db, reEnrich })); if (plans.some((plan) => plan.kind === "model")) assertRunnerCredentials(llmRunner); let processed = 0; const total = pending.length; onProgress?.({ processed, total, writtenFacts: 0, skippedNoFacts: 0 }); let configFailure; const perRecordResults = await concurrentMap(plans, (plan) => inferPendingMemoryRecord(plan, { config, featureConfig, signal, db, reEnrich, llmRunner, inferTelemetry, compressMemoryToDerivedMemory, onNotices, onConfigFailure: (error) => { configFailure ??= error; }, }), // Caller-set connection concurrency or 1: `resolveLlmEngineUse` does // not forward `engines.<name>.concurrency`, so config cannot raise this. runnerLlmConnection(llmRunner)?.concurrency ?? 1); if (configFailure) throw configFailure; for (let i = 0; i < perRecordResults.length; i++) { const res = perRecordResults[i]; if (!res) continue; if ("aborted" in res && res.aborted) { result.skippedAborted += 1; processed++; onProgress?.({ processed, total, writtenFacts: result.writtenFacts, skippedNoFacts: result.skippedNoFacts, currentRef: pending[i]?.ref, }); continue; } if (res.fromCache) { result.cacheHits += 1; } if ("retryAttempts" in res) { result.retryAttempts += res.retryAttempts; } if (res.skipped) { result.skippedNoFacts += 1; // Intentionally NOT marked processed — a transient LLM failure should // be retried on the next index run. } else if (res.splitParent) { result.splitParents += 1; result.writtenFacts += res.written; } else if ("childExists" in res && res.childExists) { // Derived child already on disk. Track separately so this category is // observable in health output and stops bleeding into the // freshAttempts denominator. Pre-check skips (#588) are the routine // self-healing path — no LLM attempt was consumed and the parent has // been marked processed — so only the rare post-LLM case (mid-flight // race or write failure) warrants a per-ref warning. result.skippedChildExists += 1; if (!res.precheck) { warn(`memory inference: derived child for ${pending[i]?.ref ?? "<unknown>"} already existed or write failed; counted as skippedChildExists`); } } else { // The per-record state machine should cover every outcome. A hit here // means a new code path slipped past the categorisation — surface it // loudly so health metrics stay honest and we get a signal to fix. result.unaccounted += 1; warn(`memory inference: unaccounted per-record outcome for ${pending[i]?.ref ?? "<unknown>"}`); } processed++; onProgress?.({ processed, total, writtenFacts: result.writtenFacts, skippedNoFacts: result.skippedNoFacts, currentRef: pending[i]?.ref, }); } result.htmlErrorCount = inferTelemetry.htmlErrorCount ?? 0; return completeResult(); } // ── Pending detection ─────────────────────────────────────────────────────── /** * Walk `<stashRoot>/memories/` (recursively) and return every memory that * still needs inference. The directory may not exist on a fresh stash; that * is treated as "no pending memories" rather than an error. */ export function collectPendingMemories(stashRoot) { const memoriesDir = path.join(stashRoot, "memories"); if (!fs.existsSync(memoriesDir)) return []; const out = []; const walked = walkMarkdownFiles(memoriesDir); if (!walked.complete) { warn(`memory inference: directory scan under ${memoriesDir} is incomplete — some memories may be missing`); } for (const filePath of walked.files) { let raw; try { raw = fs.readFileSync(filePath, "utf8"); } catch (err) { warn(`memory inference: failed to read candidate memory ${filePath}: ${err instanceof Error ? err.message : String(err)}`); continue; } const parsed = parseFrontmatter(raw); if (!isPendingMemory(parsed.data, filePath)) continue; const relName = toMemoryName(memoriesDir, filePath); if (!relName) continue; out.push({ filePath, stashRoot, ref: conceptIdFromTypeName("memory", relName), data: parsed.data, body: parsed.content, name: relName, }); } return out; } /** * Predicate: true when the parsed frontmatter indicates the memory has not * yet been split AND is not itself an inferred child. * * Also guards against `.derived` files whose `inferred:` frontmatter key has * been dropped by a manual edit or schema-repair rewrite. The file name suffix * is structural and immutable; frontmatter flags are mutable. A file whose * path contains `.derived` is always treated as a derived child regardless of * its frontmatter state — this prevents `<name>.derived.derived.md` chains. * * @param frontmatter - Parsed YAML frontmatter from the memory file. * @param filePath - Optional absolute path to the memory file. When * supplied, the name-based guard is applied. * * Exported for direct unit testing — keeping the predicate in one place * avoids drift between the walker, tests, and any future consumers. */ export function isPendingMemory(frontmatter, filePath) { // Name-based guard: a `.derived` suffix in the path means this file is a // derived child regardless of what its frontmatter currently says. if (filePath !== undefined) { const base = path.basename(filePath, ".md"); if (base.endsWith(".derived")) return false; } if (frontmatter[FM_INFERRED] === true) return false; if (frontmatter[FM_INFERENCE_PROCESSED] === true) return false; return true; } function toMemoryName(memoriesDir, filePath) { const rel = path.relative(memoriesDir, filePath); if (!rel || rel.startsWith("..")) return undefined; // Strip the `.md` extension; preserve any nested subdirectory layout the // user has organised under memories/. return rel.replace(/\\/g, "/").replace(/\.md$/i, ""); } /** * Absolute path of the derived child for a parent memory. Single source of * truth for the `<parent>.derived.md` naming convention — used both by the * pre-LLM existence check (#588) and the write path. */ function derivedChildPath(parent) { return path.join(parent.stashRoot, "memories", `${parent.name}.derived.md`); } async function writeDerivedMemory(parent, derived) { const writeTarget = { kind: "filesystem", name: "stash", path: parent.stashRoot, adapterId: "akm", }; const writeConfig = { type: "filesystem", name: "stash", path: parent.stashRoot, writable: true, }; const childName = `${parent.name}.derived`; const childRefStr = `memories/${childName}`; if (fs.existsSync(derivedChildPath(parent))) { // The derived child appeared on disk after the caller's pre-check (#588) // — a rare mid-flight race. Report `childExists` so the caller marks the // parent processed (#550) instead of re-queueing it forever. return { written: 0, childExists: true }; } try { const content = renderDerivedMemory(parent, derived); const childRef = parseRefInput(childRefStr); await writeAssetToSource(writeTarget, writeConfig, childRef, content); return { written: 1, childExists: false }; } catch (err) { warn(`memory inference: failed to write derived memory ${childName}: ${err instanceof Error ? err.message : String(err)}`); // A genuine write failure — the parent must remain pending so it is // retried on the next run. `childExists: false` keeps it from being // marked processed. return { written: 0, childExists: false }; } } function renderDerivedMemory(parent, derived) { const fm = { [FM_INFERRED]: true, [FM_CAPTURE_MODE]: "background", // The `source:` backref and internal candidate identity use the same current // `memories/<name>` conceptId spelling. [FM_SOURCE]: conceptIdFromTypeName("memory", parent.name), description: derived.description, tags: derived.tags, searchHints: derived.searchHints, title: derived.title, derivedFrom: parent.name, updated: todayIso(), }; return assembleAsset(fm, `# ${derived.title.trim()}\n\n${derived.content.trim()}\n`); } function markParentProcessed(parent) { // Frontmatter-only rewrite of an existing asset: not a new asset write, // so writeAssetToSource isn't a fit here (it would round-trip the body // through the asset-spec rendering layer instead of preserving the // user's original markdown bytes verbatim). The narrow exception is // documented in v1 spec §10 step 5 and CLAUDE.md write-source rules. let raw; try { raw = fs.readFileSync(parent.filePath, "utf8"); } catch (err) { warn(`memory inference: failed to re-read parent ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`); return; } const block = parseFrontmatterBlock(raw); if (!block) { // Cannot safely rewrite malformed frontmatter — skip marking so the memory // is retried on the next run once the frontmatter is repaired. Writing with // `body = raw` would wrap the entire file (including the bad frontmatter) // in a new block, producing a duplicate-frontmatter corruption. warn(`memory inference: skipping markParentProcessed for ${parent.filePath} — could not parse frontmatter block`); return; } const updatedFm = { ...parent.data, [FM_INFERENCE_PROCESSED]: true }; const next = assembleAsset(updatedFm, block.content); try { fs.writeFileSync(parent.filePath, next, "utf8"); // #652: the parent's `inference_processed` stamp is a real asset mutation // (the documented writeAssetToSource exception above) — journal it. recordWrittenPath(parent.filePath); } catch (err) { warn(`memory inference: failed to mark parent processed ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`); } }