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
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/.
/**
* 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)}`);
}
}