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