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
309 lines (306 loc) • 13.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-specific helpers for `akm remember`.
*
* Extracted from `src/cli.ts` so the domain logic (frontmatter assembly,
* heuristic derivation, LLM enrichment) is testable in isolation and the
* CLI entry point stays focused on argument parsing + output routing.
*/
import { serializeFrontmatter } from "../core/asset/asset-serialize.js";
import { DESCRIPTION_MAX_CHARS } from "../core/authoring-rules.js";
import { toErrorMessage, tryReadStdinText } from "../core/common.js";
import { loadConfig } from "../core/config/config.js";
import { ConfigError, UsageError } from "../core/errors.js";
import { parseEmbeddedJsonResponse } from "../core/parse.js";
import { DURATION_UNITS, parseDuration as parseDurationSpec } from "../core/time.js";
import { warn } from "../core/warn.js";
import { SCOPE_KEYS } from "../indexer/passes/metadata.js";
import { callStructured } from "../llm/structured-call.js";
import { withLlmStage } from "../llm/usage-telemetry.js";
import { resolveImproveExecution } from "./improve/execution.js";
/**
* Parse a shorthand duration string to a number of milliseconds.
* Supports the CLI-wide canonical grammar: `30d` (days), `12h` (hours),
* `5m` (minutes), `3M` (months, approximated as 30d).
*/
export function parseDuration(s) {
// Canonical CLI unit grammar: `m` = minutes, `M` = months. Not lower-cased,
// so case distinguishes the two (`5m` = 5 minutes, `5M` = 5 months). See
// core/time.ts DURATION_UNITS.
const ms = parseDurationSpec(s.trim(), DURATION_UNITS);
if (ms === null) {
throw new UsageError(`Invalid --expires format "${s}". Use shorthand like 30d, 12h, 5m, or 3M.`, "INVALID_FLAG_VALUE");
}
return ms;
}
/**
* Build a YAML frontmatter block from memory metadata.
*
* Uses `yaml.stringify` so values containing newlines, colons, or other
* YAML metacharacters are safely quoted. The previous implementation
* interpolated user input directly into `key: value` lines, which let a
* `description` containing `\n` + `tags: [x]` inject additional keys into
* the frontmatter — that is no longer possible here.
*
* Only includes fields that are present (non-empty).
*/
export function buildMemoryFrontmatter(fields) {
const obj = {};
if (fields.description?.trim())
obj.description = fields.description;
if (fields.tags && fields.tags.length > 0)
obj.tags = fields.tags;
if (fields.source?.trim())
obj.source = fields.source;
if (fields.xrefs && fields.xrefs.length > 0)
obj.xrefs = fields.xrefs;
if (fields.observed_at?.trim())
obj.observed_at = fields.observed_at;
if (fields.expires?.trim())
obj.expires = fields.expires;
if (fields.subjective)
obj.subjective = true;
if (fields.captureMode === "hot" || fields.captureMode === "background") {
obj.captureMode = fields.captureMode;
}
if (typeof fields.beliefState === "string" && fields.beliefState.trim()) {
obj.beliefState = fields.beliefState.trim();
}
// Scope keys are emitted as flat top-level keys (`scope_user`, …) so the
// existing one-level frontmatter parser can read them without nesting.
// A scope object with no populated values is dropped.
if (fields.scope) {
for (const key of SCOPE_KEYS) {
const value = fields.scope[key];
if (typeof value === "string" && value.trim()) {
obj[`scope_${key}`] = value.trim();
}
}
}
// No fields populated → emit a bare delimiter pair so callers don't
// produce `---\n{}\n---` (the YAML serializer's empty-object form).
if (Object.keys(obj).length === 0)
return "---\n---";
const serialized = serializeFrontmatter(obj);
return `---\n${serialized}\n---`;
}
/**
* Read memory content from the positional arg or stdin.
* Throws {@link UsageError} if neither is populated.
*/
export function readMemoryContent(contentArg) {
const content = contentArg ?? tryReadStdinText();
if (!content?.trim()) {
throw new UsageError("Memory content is required. Pass quoted text or pipe markdown into stdin.");
}
return content;
}
/**
* Split `text` into sentence-shaped chunks on `.`/`!`/`?`, swallowing any
* immediately-trailing closing quotes/brackets/repeated terminators into the
* same sentence (so `Alice said, "hi there."` ends the sentence at the
* closing quote, not the period). A terminator ends a sentence only when
* whitespace or the end of the text follows, so `192.168.0.203`, `0.9.12`,
* `example.com` and `notes.md` stay whole.
*
* Ported from akm-eval's memory backend (`splitIntoSentences` /
* `firstSentencesCapped` in akm-eval/src/memory/backends/akm.ts), which
* independently arrived at this exact synthesis rule after measuring that
* akm indexes only frontmatter/heading text, never body prose — the same gap
* this fixes at the source.
*/
function splitIntoSentences(text) {
const sentences = [];
let start = 0;
let i = 0;
while (i < text.length) {
const ch = text.charAt(i);
if (ch === "." || ch === "!" || ch === "?") {
let end = i + 1;
while (end < text.length && /["'”’)\]!?.]/.test(text.charAt(end)))
end += 1;
if (end < text.length && !/\s/.test(text.charAt(end))) {
i = end;
continue;
}
sentences.push(text.slice(start, end));
while (end < text.length && /\s/.test(text.charAt(end)))
end += 1;
start = end;
i = end;
continue;
}
i += 1;
}
if (start < text.length)
sentences.push(text.slice(start));
return sentences;
}
/**
* Deterministically synthesize a `description` from a memory body when the
* caller didn't supply one (#834): `akm remember`'s hot-capture path used to
* write memories with no `description:` and no `tags:`, and akm's indexer
* covers only synthesized frontmatter/headings — never body prose — so those
* memories were retrievable only by whatever words survived into the
* auto-generated filename. This closes that gap at write time.
*
* Skips a leading markdown heading line (if any) so the description reads as
* prose rather than repeating the title, then accumulates whole sentences
* from the body until the next one would exceed `capChars`, hard-truncating
* only if the very first sentence alone is over the cap. Pure, deterministic,
* no LLM call — `akm remember` must stay a fast local write.
*/
export function synthesizeMemoryDescription(body, capChars = DESCRIPTION_MAX_CHARS) {
const withoutHeading = body.replace(/^\s*#{1,6}\s+.*(?:\r?\n)?/, "");
const trimmed = withoutHeading.trim() || body.trim();
if (!trimmed)
return "";
let out = "";
for (const raw of splitIntoSentences(trimmed)) {
const sentence = raw.trim();
if (!sentence)
continue;
const candidate = out ? `${out} ${sentence}` : sentence;
if (candidate.length > capChars) {
if (!out)
return `${candidate.slice(0, Math.max(0, capChars - 1)).trimEnd()}…`;
break;
}
out = candidate;
}
return out;
}
/**
* Run heuristic analysis on memory body text. Returns derived metadata
* fields without modifying any files. Pure TS, zero network, zero latency.
*/
export function runAutoHeuristics(body) {
const tags = [];
// Fenced code block present → tag "code"
if (/^```/m.test(body)) {
tags.push("code");
}
// First-person pronoun → subjective
const subjective = /\b(I|we|my|our)\b/.test(body) ? true : undefined;
// First URL-shaped token → source
const urlMatch = body.match(/https?:\/\/[^\s)>'"]+/);
const source = urlMatch ? urlMatch[0] : undefined;
// ISO date token or obvious relative date phrase → observed_at
const observed_at = detectObservedAt(body);
return { tags, source, observed_at, subjective };
}
const RELATIVE_DATE_OFFSETS = {
today: () => { },
yesterday: (d) => d.setDate(d.getDate() - 1),
"last week": (d) => d.setDate(d.getDate() - 7),
"last month": (d) => d.setMonth(d.getMonth() - 1),
};
function detectObservedAt(body) {
const isoMatch = body.match(/\b(\d{4}-\d{2}-\d{2})\b/);
if (isoMatch)
return isoMatch[1];
const relMatch = body.match(/\b(today|yesterday|last\s+week|last\s+month)\b/i);
if (!relMatch)
return undefined;
// Normalise the matched phrase: lowercase, collapse internal whitespace,
// so "last week" matches the lookup table key.
const phrase = relMatch[1].toLowerCase().replace(/\s+/g, " ");
const offset = RELATIVE_DATE_OFFSETS[phrase];
if (!offset)
return undefined;
const d = new Date();
offset(d);
return d.toISOString().slice(0, 10);
}
/** Hard timeout for the `--enrich` LLM call. Write-path must not block on a misbehaving endpoint. */
const LLM_ENRICH_TIMEOUT_MS = 10_000;
/**
* Attempt LLM enrichment of memory metadata. Returns merged metadata
* fields on success. Provider, timeout, and parse failures return an empty
* result and emit a warning. Invalid configuration remains a hard pre-write
* error and preserves its original {@link ConfigError}.
*/
export async function runLlmEnrich(body) {
const config = loadConfig();
const resolved = resolveImproveExecution({ config, processName: "remember-enrich" });
if (!resolved) {
warn("Warning: --enrich requires an engine to be configured. Run `akm setup` to configure one.");
return { tags: [] };
}
const runner = resolved.runner;
const noticesByKey = new Map(resolved.notices.map((notice) => [JSON.stringify(notice), notice]));
const noticeFields = () => noticesByKey.size > 0 ? { notices: Object.freeze([...noticesByKey.values()]) } : {};
const prompt = `You are a memory tagger for a developer knowledge base.
Given the memory text below, return ONLY a JSON object with these fields:
- "tags": array of 1-5 short lowercase keyword tags
- "description": one-sentence summary (optional)
- "observed_at": ISO date (YYYY-MM-DD) if the text references a specific date (optional)
Memory text:
${body.slice(0, 2000)}
Return ONLY the JSON object, no prose, no markdown fences.`;
try {
let timeoutHandle;
const result = await (async () => {
try {
return await Promise.race([
withLlmStage("remember", () => callStructured({
feature: "remember_enrich",
runner,
messages: [
{ role: "system", content: "Return only valid JSON. No prose." },
{ role: "user", content: prompt },
],
request: { maxTokens: 256, temperature: 0.1 },
onNotices: (value) => {
for (const notice of value)
noticesByKey.set(JSON.stringify(notice), notice);
},
parse: (raw) => raw ?? "",
onError: () => "",
fallback: "",
})),
new Promise((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error("LLM enrichment timed out")), LLM_ENRICH_TIMEOUT_MS);
}),
]);
}
finally {
if (timeoutHandle !== undefined) {
clearTimeout(timeoutHandle);
}
}
})();
const parsed = parseEmbeddedJsonResponse(result);
if (!parsed) {
warn("Warning: --enrich received invalid JSON from the LLM. Writing memory without enrichment.");
return { tags: [], ...noticeFields() };
}
const tags = Array.isArray(parsed.tags)
? parsed.tags.filter((t) => typeof t === "string" && t.trim().length > 0)
: [];
const description = typeof parsed.description === "string" && parsed.description.trim() ? parsed.description.trim() : undefined;
const observed_at = typeof parsed.observed_at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.observed_at.trim())
? parsed.observed_at.trim()
: undefined;
return { tags, description, observed_at, ...noticeFields() };
}
catch (err) {
if (err instanceof ConfigError)
throw err;
warn(`Warning: --enrich failed (${toErrorMessage(err)}). Writing memory without enrichment.`);
return { tags: [], ...noticeFields() };
}
}
// R-061(c): `resolveRememberContentArg` / `wasRememberFlagValueConsumedAsContent`
// used to live here — a heuristic guarding against citty consuming a global
// flag's value (`--format`/`--detail`) as the `content` positional. That guard
// predated `rememberCommand` declaring `GLOBAL_OUTPUT_ARGS` (via
// `defineJsonCommand`): once the leaf command itself declares `format`/
// `detail` args, citty's parser consumes their space-separated values as the
// flag's own value and never assigns them to `content` in the first place, so
// the heuristic no longer has anything to guard against. Verified: `akm
// remember "yaml" --format yaml` and `akm remember "brief" --detail brief`
// both write the literal content unchanged. Deleted rather than kept as
// defense-in-depth per the cleanup program's dead-code policy.