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
368 lines (367 loc) • 17.3 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/.
/**
* Curate logic for `akm curate`.
*
* Given a query (and optional type filter / source / limit), pick a small,
* high-signal set of stash + registry hits and enrich each with the data
* needed to act (ref, run, parameters, follow-up command).
*
* Curation is one search with the fused ranking, the top `limit` hits, and
* per-hit enrichment (preview, run and parameters, support refs from the
* hit's declared links). An optional reranker reorders the top fused
* candidates first.
*
* The exported `akmCurate()` API is the single entry point; tests can also
* drive `curateSearchResults` with a fixture search response.
*/
import { parseBundleRef } from "../../core/asset/asset-ref.js";
import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
import { loadConfig } from "../../core/config/config.js";
import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { nonTaskInput } from "../../core/non-task-input.js";
import { redactCredentialPatterns } from "../../core/redaction.js";
import { withStateDbTelemetry } from "../../core/state-db.js";
import { searchHitContent } from "../../indexer/search/db-search.js";
import { copySearchHitAttribution, getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
import { estimateTokenCount } from "../../llm/embedders/remote.js";
import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
import { rerankDocuments } from "../../llm/rerank-client.js";
import { truncateDescription } from "../../output/shapes/helpers.js";
import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
import { findEntryIdByRef, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
import { akmSearch, parseSearchSource } from "./search.js";
import { akmShowUnified } from "./show.js";
const DEFAULT_CURATE_LIMIT = 4;
const MAX_CURATE_SUPPORT_REFS = 2;
/** Fused candidates the reranker reorders when `search.curateRerank.topN` is unset. */
const DEFAULT_CURATE_RERANK_TOP_N = 30;
/** Characters of name, description and content sent to the reranker per candidate. */
const RERANK_DOCUMENT_CHARS = 2000;
/**
* Fire-and-forget: log a curate event to the usage_events table and events.jsonl.
* Never blocks the caller; errors are silently ignored.
*/
function logCurateEvent(rawQuery, result, eventSource = "user", attributionProjection = "full") {
// Credentials pasted into a query (e.g. by the Claude Code hook that
// curates every user prompt) must never reach state.db verbatim — see
// `redactCredentialPatterns`. Redacted once here so every persistence call
// below (events.metadata_json via appendEvent, usage_events.query via
// insertUsageEvent) gets the same scrubbed text.
const query = redactCredentialPatterns(rawQuery);
const itemRefs = result.items.map((item) => ("ref" in item ? item.ref : `registry:${item.id}`));
appendEvent({
eventType: "curate",
metadata: { query, itemCount: result.items.length, itemRefs },
});
try {
withIndexDb((db) => {
// Resolve each curated item's DURABLE fully-qualified `item_ref` (D-R3)
// + entry_id from index.db (`db`); the usage_events writes land in
// state.db (Chunk-8 WI-8.3).
const perItem = result.items
.filter((item) => "ref" in item && typeof item.ref === "string")
.flatMap((item) => {
const entryId = findEntryIdByRef(db, item.ref);
if (entryId === undefined)
return [];
const itemRef = getItemRefById(db, entryId);
return itemRef ? [{ entryRef: itemRef, entryId, item }] : [];
});
withStateDbTelemetry((stateDb) => {
insertUsageEvent(stateDb, {
event_type: "curate",
query,
metadata: JSON.stringify({
itemCount: result.items.length,
itemRefs,
}),
source: eventSource,
});
for (const { entryRef, entryId, item } of perItem) {
insertUsageEvent(stateDb, {
event_type: "curate",
query,
entry_ref: entryRef,
entry_id: entryId,
metadata: usageEventAttributionMetadata(getSearchHitAttribution(item), entryRef, attributionProjection),
source: eventSource,
});
}
}, TELEMETRY_BUSY_TIMEOUT_MS);
}, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
}
catch (err) {
rethrowIfTestIsolationError(err);
}
}
export async function akmCurate(options) {
const trimmedQuery = options.query.trim();
if (!trimmedQuery) {
throw new UsageError('A curation query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT");
}
const nonTask = nonTaskInput(trimmedQuery);
if (nonTask) {
const abstained = {
query: options.query,
summary: `Curate abstained: the input is ${nonTask}, not a task.`,
items: [],
tip: 'Nothing was selected on purpose. To curate for it, pass the task itself: akm curate "<what you are trying to do>".',
};
if (!options.skipLogging) {
logCurateEvent(options.query, abstained, options.eventSource, options.attributionProjection);
}
return abstained;
}
const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CURATE_LIMIT;
const source = options.source ?? parseSearchSource("local");
const searchResponse = options.searchResponse ??
(await akmSearch({
query: options.query,
type: options.type,
// An enabled reranker reorders the top fused candidates, not just the final `limit`.
limit: Math.max(limit, rerankTopN(loadConfig())),
source,
skipLogging: true,
}));
const result = await curateSearchResults(options.query, searchResponse, limit, options.type, options.eventSource);
if (!options.skipLogging) {
logCurateEvent(options.query, result, options.eventSource, options.attributionProjection);
}
return result;
}
export async function curateSearchResults(query, result, limit, selectedType, eventSource) {
const allStashHits = result.hits.filter((hit) => hit.type !== "registry");
const registryHits = result.registryHits ?? [];
// F3/R-018: `--type` NARROWS the candidate pool. The caller's search
// usually applied the filter already, but `curateSearchResults` is also
// driven directly (tests, `searchResponse` fixtures) with a
// `SearchResponse` that was never type-filtered.
const stashHits = selectedType && selectedType !== "any" ? allStashHits.filter((hit) => hit.type === selectedType) : allStashHits;
const selectedStashHits = (await maybeRerankCuratedStashHits(query, stashHits)).slice(0, limit);
// F4/R-019: respect `--limit` for registry fill instead of hard-capping it
// at a bare literal 2 — the remaining slots after stash hits ARE the cap.
const selectedRegistryHits = selectedStashHits.length >= limit ? [] : registryHits.slice(0, limit - selectedStashHits.length);
const selectedRefs = new Set(selectedStashHits.map((hit) => hit.ref));
const items = [
...(await Promise.all(selectedStashHits.map((hit) => enrichCuratedStashHit(query, hit, selectedRefs, eventSource)))),
...selectedRegistryHits.map((hit) => buildCuratedRegistryItem(query, hit)),
].slice(0, limit);
return {
query,
summary: buildCurateSummary(query, items),
items,
...(result.warnings?.length ? { warnings: result.warnings } : {}),
...(result.searchMode ? { searchMode: result.searchMode } : {}),
...(result.tip ? { tip: result.tip } : {}),
};
}
/**
* Pack a curate result's stash hits into a single token-budgeted blob:
* resolve each hit's content via the SAME path `akm show` uses
* (`akmShowUnified` — this also means a `ref#fragment` hit packs just the
* matched section), then greedily accumulate hits, in the ranking order
* `curateSearchResults` already produced, until the next hit would exceed
* `budgetTokens`.
*
* Registry hits are never packed — only `CuratedStashItem`s (locked
* contract, AGENTS.md: registry results stay separate/opt-in).
*
* Truncation policy: drop whole hits from the tail of the ranked list first.
* The only exception is a single high-rank hit that alone exceeds the
* budget — that one hit is truncated to fit rather than dropping everything.
*/
export async function packCuratedHits(result, budgetTokens) {
const stashItems = result.items.filter((item) => item.source === "local");
const packed = [];
let used = 0;
for (const item of stashItems) {
let shown;
try {
shown = await akmShowUnified({ ref: item.ref, skipLogging: true });
}
catch {
continue;
}
const content = shown.content ?? shown.template ?? shown.prompt ?? "";
const tokens = estimateTokenCount(content);
if (used + tokens <= budgetTokens) {
packed.push({ ref: item.ref, tokens, content });
used += tokens;
continue;
}
if (packed.length === 0) {
const remaining = budgetTokens - used;
if (remaining > 0) {
const truncated = content.slice(0, remaining * 4);
packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated });
used += estimateTokenCount(truncated);
}
}
break;
}
return { query: result.query, budget: budgetTokens, tokens: used, items: packed };
}
async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource) {
let shown;
try {
shown = await akmShowUnified({ ref: hit.ref, eventSource, skipLogging: true });
}
catch {
shown = undefined;
}
const description = shown?.description ?? hit.description;
const preview = buildCuratedPreview(shown, hit);
const supportRefs = buildCurateSupportRefs(shown?.links, selectedRefs, hit.ref);
const item = {
source: "local",
type: shown?.type ?? hit.type,
name: shown?.name ?? hit.name,
ref: hit.ref,
path: shown?.path ?? hit.path,
editable: shown?.editable ?? hit.editable ?? false,
...((shown?.editable ?? hit.editable ?? false) === false
? { editHint: shown?.editHint ?? hit.editHint ?? `This asset is read-only. Inspect it with: akm show ${hit.ref}` }
: {}),
...(description ? { description } : {}),
...(preview ? { preview } : {}),
...(shown?.keys?.length ? { keys: shown.keys } : {}),
...(shown?.parameters?.length ? { parameters: shown.parameters } : {}),
...(shown?.run ? { run: shown.run } : {}),
...(supportRefs.length > 0 ? { supportRefs } : {}),
followUp: `akm show ${hit.ref}`,
reason: buildCuratedReason(query, shown?.type ?? hit.type),
...(hit.score !== undefined ? { score: hit.score } : {}),
};
copySearchHitAttribution(hit, item, item.description);
return item;
}
function buildCuratedRegistryItem(query, hit) {
return {
source: "registry",
type: "registry",
name: hit.name,
id: hit.id,
...(hit.description ? { description: hit.description } : {}),
followUp: hit.action ?? `akm bundle add ${hit.id}`,
reason: `Useful external source to explore for ${query}.`,
...(hit.score !== undefined ? { score: hit.score } : {}),
};
}
function firstNonEmpty(values) {
return values.find((value) => typeof value === "string" && value.trim().length > 0);
}
function buildCuratedPreview(shown, hit) {
if (shown?.run)
return truncateDescription(`run ${shown.run}`, 160);
const payload = firstNonEmpty([shown?.template, shown?.prompt, shown?.content, hit.description])
?.replace(/\s+/g, " ")
.trim();
return payload ? truncateDescription(payload, 160) : undefined;
}
function buildCuratedReason(query, type) {
switch (type) {
case "script":
return `Strong runnable script match for "${query}".`;
case "command":
return `Strong reusable command/template match for "${query}".`;
case "knowledge":
return `Strong reference document match for "${query}".`;
case "skill":
return `Strong instructions/workflow match for "${query}".`;
case "agent":
return `Strong specialized agent prompt match for "${query}".`;
case "memory":
return `Strong saved context match for "${query}".`;
default:
return `Strong ${type} match for "${query}".`;
}
}
function buildCurateSummary(query, items) {
if (items.length === 0) {
return `No curated assets were selected for "${query}".`;
}
// F4b: emit the flipped conceptId ref for stash items (registry items have no
// ref — keep their `registry:<name>` label).
const labels = items.map((item) => ("ref" in item ? item.ref : `${item.type}:${item.name}`));
return `Selected ${items.length} curated result${items.length === 1 ? "" : "s"}: ${labels.join(", ")}.`;
}
/** How many fused candidates curate fetches for the reranker; 0 when reranking is off. */
function rerankTopN(config) {
if (!isLlmFeatureEnabled(config, "curate_rerank"))
return 0;
return config.search?.curateRerank?.topN ?? DEFAULT_CURATE_RERANK_TOP_N;
}
/**
* Optional cross-encoder rerank of the top fused candidates (#951). Disabled
* by default (`search.curateRerank.enabled`) and, when enabled, best-effort:
* any failure (misconfigured endpoint, network error, timeout, malformed
* response) keeps the fused order — a reranker outage must never turn into a
* curate failure. The top `topN` candidates (default
* {@link DEFAULT_CURATE_RERANK_TOP_N}) are sent as name, description and the
* start of the indexed content; the rest keep their fused order after them.
*/
async function maybeRerankCuratedStashHits(query, hits) {
if (hits.length <= 1)
return hits;
const config = loadConfig();
const rerankConfig = config.search?.curateRerank;
return tryLlmFeature("curate_rerank", config, async () => {
const head = hits.slice(0, rerankTopN(config));
const tail = hits.slice(head.length);
const ranked = await rerankDocuments(rerankConfig ?? {}, query, rerankDocumentTexts(head));
const rerankedHead = ranked
.map(({ index }) => head[index])
.filter((hit) => hit !== undefined);
return [...rerankedHead, ...tail];
}, hits, { timeoutMs: rerankConfig?.timeoutMs ?? null });
}
/**
* Name, description and the start of each hit's indexed content, capped for
* the reranker. The content is the index's safe projection (env and secret
* values never reach it), never the raw file.
*/
function rerankDocumentTexts(hits) {
return hits.map((hit) => [hit.name, hit.description, searchHitContent(hit)].filter(Boolean).join("\n").slice(0, RERANK_DOCUMENT_CHARS));
}
/**
* Up to {@link MAX_CURATE_SUPPORT_REFS} assets the hit's declared links (#935)
* name, not already selected: what the hit links to first, then what links to
* it, in the order `akm show` lists them. The LLM entity graph's `related`
* list no longer feeds them.
*/
function buildCurateSupportRefs(links, selectedRefs, ownerRef) {
const supportRefs = [];
for (const [part, direction] of [
["outgoing", "from"],
["incoming", "to"],
]) {
for (const [kind, group] of Object.entries(links?.[part] ?? {})) {
for (const ref of group.refs) {
if (ref === ownerRef || selectedRefs.has(ref) || supportRefs.some((existing) => existing.ref === ref))
continue;
const type = supportRefType(ref);
supportRefs.push({
ref,
...(type ? { type } : {}),
reason: `Declared link (${kind}) ${direction} this asset.`,
});
if (supportRefs.length >= MAX_CURATE_SUPPORT_REFS)
return supportRefs;
}
}
}
return supportRefs;
}
/** The asset type a ref's conceptId names (`memories/x` → `memory`), when it names one. */
function supportRefType(ref) {
try {
return typeNameFromConceptId(parseBundleRef(ref).conceptId)?.type;
}
catch {
return undefined;
}
}