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

354 lines (353 loc) • 17.7 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 { getParsedInvocation } from "../../cli/invocation.js"; import { getStringArg } from "../../cli/parse-args.js"; import { defineJsonCommand, output, parseAllFlagValues } from "../../cli/shared.js"; import { UsageError } from "../../core/errors.js"; import { appendEvent } from "../../core/events.js"; import { resolveUsageEventSource } from "../../indexer/usage/usage-events.js"; import { buildMemoryFrontmatter, parseDuration, readMemoryContent, runAutoHeuristics, runLlmEnrich, synthesizeMemoryDescription, } from "../remember.js"; import { assertFlatAssetName, inferAssetName, resolveSupersedesForWrite, resolveSupersedesWriteTarget, resolveXrefsForWrite, writeMarkdownAsset, } from "./knowledge.js"; import { akmSearch } from "./search.js"; // ── Helper: similar memory search ──────────────────────────────────────────── /** * Best-effort top-3 similar memory search for `--show-similar`. * Scoped to memory: type; excludes the just-written ref. */ async function fetchSimilarMemories(query, excludeRef, eventSource) { try { const result = await akmSearch({ query, type: "memory", limit: 4, eventSource, attributionProjection: "brief", }); return (result.hits ?? []) .filter((h) => "ref" in h && h.ref !== excludeRef) .slice(0, 3) .map((h) => ({ ref: h.ref, ...(h.name ? { title: h.name } : {}) })); } catch { return []; } } /** The flag `remember` takes for its destination, as its errors spell it. */ const TARGET_FLAG = "--bundle"; /** * `--target` was renamed to `--bundle` on `remember` in 0.9 (S8). citty is * non-strict, so the retired spelling is silently absorbed rather than * rejected — the memory then lands in the default bundle instead of the one * the caller named, with exit 0 and no error. Reject it explicitly instead. */ function rejectRetiredTargetFlag() { if (!getParsedInvocation().hasFlag("--target")) return; throw new UsageError("`akm remember --target` was renamed to `--bundle` in 0.9. Use `--bundle <name>` instead.", "INVALID_FLAG_VALUE"); } // ── Command definition ──────────────────────────────────────────────────────── export const rememberCommand = defineJsonCommand({ meta: { name: "remember", description: "Record a memory in the default bundle", }, args: { content: { type: "positional", description: "Memory content. Omit to read markdown from stdin.", required: false, }, name: { type: "string", description: "Memory name (flat, no '/'; defaults to a slug from the content). Use --path for a subdirectory.", }, path: { type: "string", description: "Relative subdirectory under memories/ to place the memory in (e.g. 'personal/projects'). The filename still comes from --name or the content slug.", }, force: { type: "boolean", description: "Overwrite an existing memory with the same name", default: false, }, description: { type: "string", description: "Short description written to frontmatter (persisted as the memory's description field)", }, tag: { type: "string", description: "Tag to add to the memory (repeatable: --tag foo --tag bar)", }, expires: { type: "string", description: "Expiry duration shorthand — e.g. 30d, 12h, 5m (minutes), 3M (months). Resolved to an ISO date.", }, source: { type: "string", description: "Source reference (URL, asset ref, file path, or any free-form string)", }, xref: { type: "string", description: "Cross-reference ref recorded in the memory's `xrefs:` frontmatter (repeatable: --xref knowledge/auth-flow --xref memories/vpn-note). Each ref must resolve in the write target or a configured source; an unresolvable ref aborts the write.", }, supersedes: { type: "string", description: "Ref of an existing asset this memory corrects (repeatable: --supersedes memories/projectA/old-note). Writes the correction with an xref to the old asset AND demotes the old asset (`beliefState: superseded` + `supersededBy`, a metadata-only edit) so ranking prefers the correction and `--belief current` hides the stale version. An unresolvable or self-referencing ref aborts the write; a ref outside the write target and working bundle still writes the correction but skips the demotion (reported as applied: false).", }, auto: { type: "boolean", description: "Apply heuristic tagging (code, subjective, source, observed_at) from the body", default: false, }, enrich: { type: "boolean", description: "Call the configured LLM to propose tags and description (requires LLM config)", default: false, }, bundle: { type: "string", description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working bundle.", }, user: { type: "string", description: "Scope this memory to a user id (persisted as `scope_user` frontmatter)", }, agent: { type: "string", description: "Scope this memory to an agent id (persisted as `scope_agent` frontmatter)", }, run: { type: "string", description: "Scope this memory to a run id (persisted as `scope_run` frontmatter)", }, channel: { type: "string", description: "Scope this memory to a channel name (persisted as `scope_channel` frontmatter)", }, "show-similar": { type: "boolean", // R-062: canonical spelling is kebab-case, matching every other // multi-word flag in the CLI (--fail-on-flagged, --auto-fix, …). // `--showSimilar` (the pre-rename spelling) is kept as an explicit, // documented alias rather than a silent citty auto-alias — citty // registers BOTH the camelCase and kebab-case spelling of any // declared flag name automatically, so this is a rename, not a // breaking change: both spellings already worked, and both keep // working. alias: "showSimilar", description: "Return top-3 similar existing memories in output (opt-in). Alias: --showSimilar.", }, }, async run({ args }) { rejectRetiredTargetFlag(); const body = readMemoryContent(args.content); const eventSource = resolveUsageEventSource(); // `--name` is a flat name; subdirectory placement is `--path`'s job. assertFlatAssetName(args.name); // Determine if the user has requested any structured metadata mode. // Collect all --tag occurrences directly from process.argv because citty // only exposes the last value for repeated string flags. const rawTags = parseAllFlagValues("--tag"); // Collect and validate --xref occurrences (repeatable, same argv pattern // as --tag). Validation happens BEFORE any write: an unresolvable ref is // input validation (UsageError → exit 2) and must leave the stash // untouched. Refs resolvable only in a configured extra stash source are // accepted (cross-stash provenance). const rawSupersedes = parseAllFlagValues("--supersedes"); const writeTarget = resolveSupersedesWriteTarget(rawSupersedes, args.bundle, TARGET_FLAG); const xrefs = resolveXrefsForWrite(parseAllFlagValues("--xref"), writeTarget, TARGET_FLAG); // Collect and validate --supersedes occurrences (repeatable). Same // before-any-write validation contract: an unresolvable ref exits 2 with // nothing written AND nothing demoted (no partial correction). The // superseded refs fold into the new memory's xrefs automatically // (correction provenance per the back-linking conventions); the demotion // itself runs inside writeMarkdownAsset, ordered before the git boundary // commit. const supersedes = resolveSupersedesForWrite(rawSupersedes, writeTarget, TARGET_FLAG); for (const s of supersedes) { if (!xrefs.includes(s.ref)) xrefs.push(s.ref); } // Collect scope flags. Scope alone counts as structured metadata so we // emit frontmatter, but it does NOT trigger the "tags required" check — // memory + scope (no tags) is a valid combination for multi-tenant use. const scopeFields = {}; for (const k of ["user", "agent", "run", "channel"]) { const v = getStringArg(args, k); if (v) scopeFields[k] = v; } const hasScope = Object.keys(scopeFields).length > 0; const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description; // --xref counts as structured metadata (it must land in frontmatter) but, // like scope, does NOT trigger the tags-required check — provenance // without tags is a valid write. // --enrich is structured (it must reach the Mode-3 dispatch below, not the // zero-flag hot path) but, like --auto, never tag-requiring: enrichment is // fail-soft and a zero-tag outcome still writes. const hasStructuredArgs = hasTagRequiringArgs || hasScope || args.auto || args.enrich || xrefs.length > 0; if (!hasStructuredArgs) { // Phase 1B / Rec 7: even the zero-flag hot-path emits // `captureMode: hot` + `beliefState: asserted` so user-supplied // memories outrank background-derived ones during ranking. // #834: `description` is synthesized deterministically (see synthesizeMemoryDescription) so the memory is indexable. const frontmatterBlock = buildMemoryFrontmatter({ description: synthesizeMemoryDescription(body), captureMode: "hot", beliefState: "asserted", }); const contentWithFrontmatter = `${frontmatterBlock}\n${body}`; // Derive the asset slug from the body (not the frontmatter block); // otherwise inferAssetName would key off the leading `---` delimiter. const result = await writeMarkdownAsset({ type: "memory", content: contentWithFrontmatter, name: args.name, fallbackPrefix: "memory", preferredName: inferAssetName(body, "memory"), force: args.force, target: writeTarget, targetFlag: TARGET_FLAG, path: args.path, supersedes, }); appendEvent({ eventType: "remember", ref: result.ref, metadata: { path: result.path, force: args.force === true }, }); if (args.showSimilar) { const similar = await fetchSimilarMemories(body.slice(0, 500), result.ref, eventSource); output("remember", { ok: true, ...result, similar }); } else { output("remember", { ok: true, ...result }); } return; } // ── Accumulate metadata from all three modes ────────────────────────── // Start with CLI args (Mode 1: always) const tags = [...rawTags]; // --description is persisted as-is; LLM enrichment may fill it if absent. let description = args.description || undefined; let source = args.source; let observed_at; let expires; let subjective; let executionNotices; // Resolve --expires to an ISO date string if (args.expires) { const durationMs = parseDuration(args.expires); const expiresDate = new Date(Date.now() + durationMs); expires = expiresDate.toISOString().slice(0, 10); } // Mode 2: --auto heuristics if (args.auto) { const auto = runAutoHeuristics(body); for (const t of auto.tags) { if (!tags.includes(t)) tags.push(t); } if (!source && auto.source) source = auto.source; if (!observed_at && auto.observed_at) observed_at = auto.observed_at; if (!subjective && auto.subjective) subjective = auto.subjective; } // Mode 3: --enrich LLM (fail-soft) if (args.enrich) { const enriched = await runLlmEnrich(body); for (const t of enriched.tags) { if (!tags.includes(t)) tags.push(t); } if (!description && enriched.description) description = enriched.description; if (!observed_at && enriched.observed_at) observed_at = enriched.observed_at; executionNotices = enriched.notices; } // #834: no --description and no --enrich-derived one — synthesize deterministically (see zero-flag path above). if (!description) description = synthesizeMemoryDescription(body); // ── Required-field check (before any write) ─────────────────────────── // Tags remain required when the user explicitly asked for tag-bearing // metadata (--tag / --enrich / --description / --source / --expires). // `--auto` alone is allowed even when its heuristics derive zero tags. // Scope-only writes (`akm remember "..." --user u1`) also skip this // check — scope is independent metadata and a memory with only scope is // valid. const missing = []; if (hasTagRequiringArgs && tags.length === 0) missing.push("tags"); if (missing.length > 0) { throw new UsageError(`Memory is missing required frontmatter field(s): ${missing.join(", ")}. ` + "Provide them via --tag <value>, --auto (heuristics), or --enrich (LLM)."); } // ── Build frontmatter and write ─────────────────────────────────────── // Phase 1B / Rec 7: the hot-path CLI write always marks the memory as // `captureMode: hot` and `beliefState: asserted`. Ranking applies a // hot-capture boost so user-supplied memories outrank otherwise-equal // background-derived ones. const frontmatterBlock = buildMemoryFrontmatter({ description, tags, source, xrefs, observed_at, expires, subjective, captureMode: "hot", beliefState: "asserted", ...(hasScope ? { scope: scopeFields } : {}), }); const contentWithFrontmatter = `${frontmatterBlock}\n${body}`; // Derive the asset slug from the body, exactly like the hot path above: // `contentWithFrontmatter` starts with the `---` fence, which // inferAssetName would slugify to "" and fall back to a random // memory-<epoch>-<rand> name. const result = await writeMarkdownAsset({ type: "memory", content: contentWithFrontmatter, name: args.name, fallbackPrefix: "memory", preferredName: inferAssetName(body, "memory"), force: args.force, target: writeTarget, targetFlag: TARGET_FLAG, path: args.path, supersedes, }); appendEvent({ eventType: "remember", ref: result.ref, metadata: { path: result.path, force: args.force === true, tagCount: tags.length, enriched: args.enrich === true, auto: args.auto === true, ...(executionNotices && executionNotices.length > 0 ? { notices: executionNotices } : {}), ...(hasScope ? { scope: scopeFields } : {}), }, }); if (args.showSimilar) { const similar = await fetchSimilarMemories((body ?? args.content ?? "").slice(0, 500), result.ref, eventSource); output("remember", { ok: true, ...result, similar, ...(executionNotices && executionNotices.length > 0 ? { notices: executionNotices } : {}), }); } else { output("remember", { ok: true, ...result, ...(executionNotices && executionNotices.length > 0 ? { notices: executionNotices } : {}), }); } }, });