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

368 lines (367 loc) • 17.3 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/. /** * 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; } }