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

455 lines (454 loc) • 21.6 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/. /** * `akm search` — entry point. * * Spec §6.1: search consults the local FTS5 index. There is one query path * because there is one data store. Provider fan-out is gone. * * The orchestration here is thin: build the FTS query, optionally interleave * a registry search behind `--from registry|all`, and log a usage event. * Provider `search()` methods do not exist. */ import { getSources, loadConfig } from "../../core/config/config.js"; import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js"; import { appendEvent } from "../../core/events.js"; import { redactCredentialPatterns } from "../../core/redaction.js"; import { withStateDbTelemetry } from "../../core/state-db.js"; import { resolveReadSources } from "../../indexer/read-preflight.js"; import { searchLocal } from "../../indexer/search/db-search.js"; import { getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js"; import { insertUsageEvent } from "../../indexer/usage/usage-events.js"; import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js"; import { getEntryIdByFilePath, getItemRefById } from "../../storage/repositories/index-entries-repository.js"; import { searchRegistry } from "./registry-search.js"; const DEFAULT_LIMIT = 20; function duplicateConceptWarnings(hits, defaultBundle) { const ownersByConcept = new Map(); for (const hit of hits) { const displayRef = hit.ref.split("#", 1)[0] ?? hit.ref; const boundary = displayRef.indexOf("//"); const conceptId = boundary >= 0 ? displayRef.slice(boundary + 2) : displayRef; const owner = hit.origin ?? defaultBundle ?? "working-bundle"; const owners = ownersByConcept.get(conceptId) ?? []; if (!owners.includes(owner)) owners.push(owner); ownersByConcept.set(conceptId, owners); } return [...ownersByConcept] .filter(([, owners]) => owners.length > 1) .sort(([left], [right]) => left.localeCompare(right)) .map(([conceptId, owners]) => `Multiple bundles provide "${conceptId}": ${owners.join(", ")}. ` + "Unqualified refs resolve by configured bundle priority; use a bundle-qualified ref to select explicitly."); } export async function akmSearch(input) { const t0 = Date.now(); const query = input.query.trim(); const searchType = input.type ?? "any"; const limit = normalizeLimit(input.limit); const parsedSource = parseSearchSource(input.source ?? "local"); const config = loadConfig(); // Named-source filter: when --from is not a standard enum value, treat it // as a named source (a `bundles` key). Validated early (before // resolveSourceEntries, which can throw STASH_DIR_NOT_FOUND) so that a bad // --from name always produces INVALID_SOURCE_VALUE regardless of stash state. let namedSourceName; let source; if (parsedSource !== "local" && parsedSource !== "registry" && parsedSource !== "all") { namedSourceName = parsedSource; assertNamedSourceExists(config, namedSourceName); source = "local"; } else { source = parsedSource; } // A pure `--from registry` search needs no local stash at all (this is the // one path folded in from the retired `akm registry search`, which never // touched local source/stash resolution either) — only resolve local // sources when local hits are actually needed, or a named source narrows // to a local bundle. Without this, `resolveReadSources` (which can throw // STASH_DIR_NOT_FOUND via `resolveStashDir()`) would make registry-only // search fail on a machine with no stash ever configured. const needsLocalSources = source !== "registry" || namedSourceName !== undefined; let allSources = needsLocalSources ? resolveReadSources(undefined, config).sources : []; // When a named source was requested, narrow the sources list to just that entry. // `resolveSourceEntries` sets `registryId` to `entry.name` for each config source. if (namedSourceName !== undefined) { const ns = namedSourceName; allSources = allSources.filter((s) => s.registryId === ns || s.path === ns); // allSources may still be empty if the configured source dir doesn't exist on // disk (resolveSourceEntries skips non-existent dirs). Fall through to the // zero-sources guard below which emits a friendly warning. } if (needsLocalSources && allSources.length === 0) { // stashDir: "" is a safe sentinel here — the response carries zero hits // and a warning, so no downstream code will try to use the empty path. const response = { schemaVersion: 1, bundleDir: "", source, hits: [], warnings: ["No bundles configured. Run `akm bundle create` to create your working bundle."], timing: { totalMs: Date.now() - t0 }, }; maybeLogSearchEvent(input, query, response); return response; } // Primary stash directory — used for DB path lookups and as the default // stash root. Empty when a pure registry search skipped local resolution // entirely (safe: registry-only responses never read `stashDir`). const stashDir = allSources[0]?.path ?? ""; // Expose the filtered source list to downstream search calls. const sources = allSources; const filters = normalizeScopeFilters(input.filters); const includeProposed = input.includeProposed === true; const belief = input.belief ?? "all"; const localResult = source === "registry" ? undefined : await searchLocal({ query, searchType, limit, stashDir, sources, config, filters, includeProposed, beliefFilter: belief, // When `--from <name>` narrowed the source list above, propagate // that intent down to the database layer so FTS/vector hits from // sources outside the narrowed set are filtered out post-ranking. // Without this, the index (which spans every configured source) // would leak hits from sources the caller did not request. restrictToSources: namedSourceName !== undefined, includeExcludedTypes: input.includeSessions === true, }); const registryResult = source === "local" ? undefined : await searchRegistry(query, { limit, includeAssets: input.assets === true, registries: config.registries }); if (source === "local") { const localHits = localResult?.hits ?? []; const warnings = [...(localResult?.warnings ?? []), ...duplicateConceptWarnings(localHits, config.defaultBundle)]; const hasResults = localHits.length > 0; const response = { schemaVersion: 1, bundleDir: stashDir, source, hits: localHits, tip: hasResults ? undefined : localResult?.tip, warnings: warnings.length ? warnings : undefined, searchMode: localResult?.mode ?? "keyword", timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs }, }; maybeLogSearchEvent(input, query, response, usageSearchMode(localResult?.mode)); return response; } const registryHits = (registryResult?.hits ?? []).map((hit) => { const installRef = hit.installRef; // Hit-level `warnings` are forwarded when the provider surfaced any. return { type: "registry", name: hit.title, id: hit.id, description: hit.description, action: `akm bundle add ${installRef} -> then search again`, score: hit.score, registryName: hit.registryName, ...(hit.warnings && hit.warnings.length > 0 ? { warnings: hit.warnings } : {}), }; }); if (source === "registry") { const slicedRegistryHits = registryHits.slice(0, limit); const hasResults = slicedRegistryHits.length > 0; const response = { schemaVersion: 1, bundleDir: stashDir, source, hits: [], registryHits: slicedRegistryHits, tip: hasResults ? undefined : "No matching registry entries were found.", warnings: registryResult?.warnings.length ? registryResult.warnings : undefined, timing: { totalMs: Date.now() - t0 }, }; maybeLogSearchEvent(input, query, response); return response; } // source === "all" const allStashHits = (localResult?.hits ?? []).slice(0, limit); const warnings = [ ...(localResult?.warnings ?? []), ...duplicateConceptWarnings(allStashHits, config.defaultBundle), ...(registryResult?.warnings ?? []), ]; const hasResults = allStashHits.length > 0 || registryHits.length > 0; const response = { schemaVersion: 1, bundleDir: stashDir, source, hits: allStashHits, registryHits, tip: hasResults ? undefined : "No matching stash assets or registry entries were found.", warnings: warnings.length ? warnings : undefined, searchMode: localResult?.mode ?? "keyword", timing: { totalMs: Date.now() - t0 }, }; maybeLogSearchEvent(input, query, response, usageSearchMode(localResult?.mode)); return response; } /** Usage telemetry retains its historical semantic|keyword vocabulary. */ function usageSearchMode(mode) { return mode === "semantic" ? "semantic" : "keyword"; } function maybeLogSearchEvent(input, query, response, mode) { if (input.skipLogging) return; logSearchEvent(query, response, mode, input.eventSource, input.attributionProjection); } /** * Resolve entry IDs by file_path lookup (exact match, not LIKE). */ function resolveEntryIds(db, hits) { const results = []; for (const hit of hits) { try { const entryId = getEntryIdByFilePath(db, hit.path); if (entryId !== undefined) { // F4c: persist the DURABLE fully-qualified `bundle//conceptId` spelling, // derived from the resolved entry row's `item_ref` (D-R3: durable keys // come from the resolved item, never raw input). const itemRef = getItemRefById(db, entryId); if (itemRef !== null) { results.push({ entryId, ref: itemRef, hit }); } } } catch { /* skip unresolvable */ } } return results; } /** * Fire-and-forget: log a search event to the usage_events table. * Never blocks the caller; errors are silently ignored. * * Result count semantics: * - `stashHitCount`: number of local stash hits (response.hits, source-only * entries). Always 0 for registry-only searches. * - `registryHitCount`: number of registry hits (response.registryHits). * Only non-zero when source is "registry" or "all". * - `resultCount`: total across both pools so telemetry reflects the actual * number of results the user saw, regardless of source mode. * * Per-entry events are recorded only for stash hits because registry hits * have no local entry_id to reference. */ function logSearchEvent(rawQuery, response, mode = "keyword", 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); // Emit a structured event to events.jsonl so workflow-trace consumers // detect akm search invocations without relying on stdout scraping. const stashHits = response.hits.filter((h) => h.type !== "registry"); // D8: include registry hit refs so a show following a registry-only search generates a select event const registryHitRefs = (response.registryHits ?? []).map((h) => `registry:${h.id}`); const allResultRefs = [...stashHits.map((h) => h.ref), ...registryHitRefs]; appendEvent({ eventType: "search", metadata: { query, hitCount: stashHits.length, resultRefs: allResultRefs, mode }, }); try { // Short busy timeout: telemetry must never stall the search result behind // a background reindex holding the index.db write lock (30s default wait). // Under contention these usage hints are skipped, not waited for. withIndexDb((db) => { const resolved = resolveEntryIds(db, stashHits.slice(0, 50)); // usage_events telemetry now writes to state.db (Chunk-8 WI-8.3); // entry_id/entry_ref are resolved from index.db above and carried across. const stashHitCount = response.hits.length; const registryHitCount = Array.isArray(response.registryHits) ? response.registryHits.length : 0; withStateDbTelemetry((stateDb) => { for (const { entryId, ref, hit } of resolved) { insertUsageEvent(stateDb, { event_type: "search", query, entry_id: entryId, entry_ref: ref, metadata: usageEventAttributionMetadata(getSearchHitAttribution(hit), ref, attributionProjection), source: eventSource, }); } // Count registry hits separately so registry-only searches record a // non-zero resultCount. response.hits is always [] when source="registry". insertUsageEvent(stateDb, { event_type: "search", query, metadata: JSON.stringify({ resultCount: stashHitCount + registryHitCount, stashHitCount, registryHitCount, resolvedCount: resolved.length, mode, }), source: eventSource, }); }, TELEMETRY_BUSY_TIMEOUT_MS); // No live utility_scores write here (#862): a search result is an // impression, not a signal that the asset was useful. Retrieval // counts are recorded above via insertUsageEvent (search_count) and // rolled into utility_scores by the offline `recomputeUtilityScores` // pass (`akm index`). }, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS }); } catch (err) { rethrowIfTestIsolationError(err); /* fire-and-forget */ } } // ── Helpers ────────────────────────────────────────────────────────────────── /** * Validate a named `--from` against the bundle-derived source list (0.9.0 * spec §10.1: a named source is a `bundles` key, matched via its derived * source entry's `name`, or an exact path). Throws INVALID_SOURCE_VALUE with * the known names before any stash access can fail differently. */ function assertNamedSourceExists(config, namedSourceName) { const configSources = getSources(config); const foundInConfig = configSources.find((source) => source.name === namedSourceName || source.path === namedSourceName); if (foundInConfig?.enabled === false) { throw new UsageError(`Source "${namedSourceName}" is disabled.`, "INVALID_SOURCE_VALUE"); } if (!foundInConfig) { const validNames = configSources .filter((source) => source.enabled !== false) .map((source) => source.name) .filter((name) => Boolean(name)); const hint = validNames.length > 0 ? `Known source names: ${validNames.join(", ")}` : "No named sources are configured. Run `akm bundle list` to see installed bundles."; throw new UsageError(`Unknown source name: "${namedSourceName}". ${hint}`, "INVALID_SOURCE_VALUE"); } } function normalizeLimit(limit) { if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) { return DEFAULT_LIMIT; } return Math.min(Math.floor(limit), 200); } /** * Parse the `--from` flag value. * * Accepts: * - `local` (default) — search the local stash index only * - `registry` — search remote registries only * - `all` — search local and registries * - Any named configured bundle — filters local results to that bundle only. * The named-source path is detected and resolved * inside `akmSearch`; this function returns the raw name so the caller can * pass it through to `akmSearch` which accepts `SearchSource | string`. * * Unknown values that are not a known enum AND not a named source will still * produce an error inside `akmSearch` when the config lookup finds nothing. * This allows the CLI to accept named sources without requiring config access * at parse time. */ export function parseSearchSource(source) { if (source === "local" || source === "registry" || source === "all") return source; if (typeof source === "undefined") return "local"; // 0.9.0 (S8): `--source stash`/`--source both` renamed to `--from // local`/`--from all`. Reject the retired values explicitly — otherwise // they fall through to the named-source lookup below and surface as a // misleading "no source named stash/both" error instead of naming the // rename. if (source === "stash") { throw new UsageError('"stash" was renamed to "local" in 0.9. Use `--from local` instead.', "INVALID_FLAG_VALUE"); } if (source === "both") { throw new UsageError('"both" was renamed to "all" in 0.9. Use `--from all` instead.', "INVALID_FLAG_VALUE"); } // Pass through unknown strings — they may be valid named sources. // `akmSearch` will validate against config.sources and throw a UsageError // with a helpful message if the name isn't found. return source; } export function parseBeliefFilterMode(value) { if (value === undefined || value === "all") return "all"; if (value === "current" || value === "historical") return value; throw new UsageError(`Invalid value for --belief: ${String(value)}. Expected one of: all|current|historical`, "INVALID_FLAG_VALUE"); } /** * Strip empty / non-string values from a scope filter object. Returns * `undefined` when nothing meaningful remains, so callers don't pay for an * empty-filter post-walk in `searchLocal`. */ function normalizeScopeFilters(raw) { if (!raw) return undefined; const out = {}; for (const key of ["user", "agent", "run", "channel"]) { const value = raw[key]; if (typeof value === "string" && value.trim()) { out[key] = value.trim(); } } return Object.keys(out).length > 0 ? out : undefined; } /** * Parse repeated `--filter k=v` argv tokens into a * `StashEntryScope`. Throws a {@link UsageError} for malformed tokens * (missing `=`, unknown key) so callers don't see ambiguous misses. * * Used by both `akm search --filter` and `akm show --filter` — the two * commands share one spelling for the scope-narrowing axis. */ export function parseScopeFilterFlags(values, flagName = "--filter") { if (values.length === 0) return undefined; const out = {}; for (const raw of values) { const eq = raw.indexOf("="); if (eq <= 0) { throw new UsageError(`Invalid ${flagName} value "${raw}". Expected key=value (e.g. user=alice).`); } const key = raw.slice(0, eq).trim(); const value = raw.slice(eq + 1).trim(); if (key !== "user" && key !== "agent" && key !== "run" && key !== "channel") { throw new UsageError(`Unknown scope key "${key}" in ${flagName}. Valid keys: user, agent, run, channel.`); } if (!value) { throw new UsageError(`${flagName} ${key}=… requires a non-empty value.`); } out[key] = value; } return Object.keys(out).length > 0 ? out : undefined; } /** * Returns true iff `entry.scope` (when present) satisfies every key in * `filters`. A missing `entry.scope` only matches when `filters` is empty or * undefined. * * Filter semantics: * - No filter passed → all entries match. * - `filters.user = "alice"` → entry must have `scope.user === "alice"`. * - Multiple keys → AND-joined; every supplied key must match. */ export function entryMatchesScopeFilters(scope, filters) { if (!filters) return true; for (const key of ["user", "agent", "run", "channel"]) { const expected = filters[key]; if (expected === undefined) continue; if (!scope || scope[key] !== expected) return false; } return true; }