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

242 lines (241 loc) • 10.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/. import { DEFAULT_CONFIG } from "../../core/config/config.js"; import { formatRegistryCredentialWarning, formatRegistryError, formatRegistryLabel, hasRegistryUrlCredentials, redactCredentialBearingUrls, } from "../../core/registry-url.js"; import { warn } from "../../core/warn.js"; import { resolveRegistryProviderFactory } from "../../registry/factory.js"; // ── Eagerly import providers to trigger self-registration ─────────────────── import "../../registry/providers/index.js"; // ── Public API ────────────────────────────────────────────────────────────── export async function searchRegistry(query, options) { const trimmed = query.trim(); if (!trimmed) { return { query: "", hits: [], warnings: [] }; } const limit = clampLimit(options?.limit); // resolveRegistries() already filters by enabled; explicit registries are filtered here const raw = options?.registries ?? resolveRegistries(); const warnings = []; const entries = (options?.registries ? raw.filter((r) => r.enabled !== false) : raw).filter((entry) => { if (!hasRegistryUrlCredentials(entry.url)) return true; warnings.push(formatRegistryCredentialWarning(entry)); return false; }); // Resolve and search all providers concurrently const results = await Promise.allSettled(entries.map((entry) => { const provider = createProvider(entry, warnings); if (!provider) return Promise.resolve(null); return provider.search({ query: trimmed, limit, includeAssets: options?.includeAssets }); })); // Merge results grouped by provider // Each provider batch is normalized to [0, 1] before merging so that raw // scores from different providers (e.g. static-index can exceed 1.85 while // skills-sh uses installs-relative scoring) are comparable in the merged list. const allHits = []; const allAssetHits = []; for (let i = 0; i < results.length; i++) { const result = results[i]; if (result.status === "rejected") { warnings.push(`Registry ${formatRegistryLabel(entries[i])}: ${formatRegistryError(result.reason)}`); continue; } const value = result.value; if (!value) continue; const registryLabel = formatRegistryLabel(entries[i]); let dropped = 0; const validHits = []; for (const hit of value.hits) { if (isCompleteHit(hit)) { validHits.push(hit); } else { dropped++; } } // Normalize scores within this provider's batch before merging normalizeScores(validHits); for (const hit of validHits) { allHits.push(hit); } if (value.assetHits) { const validAssetHits = []; for (const hit of value.assetHits) { if (isCompleteAssetHit(hit)) { validAssetHits.push(hit); } else { dropped++; } } normalizeScores(validAssetHits); for (const hit of validAssetHits) { allAssetHits.push(hit); } } if (dropped > 0) { warnings.push(`Registry ${registryLabel} returned ${dropped} incomplete hit(s); dropped from response.`); } if (value.warnings) warnings.push(...value.warnings.map(redactCredentialBearingUrls)); } // Sort merged hits by normalized score descending, apply limit allHits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); const limitedHits = allHits.slice(0, limit); allAssetHits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); const limitedAssetHits = allAssetHits.slice(0, limit); return { query: trimmed, hits: limitedHits, warnings, assetHits: limitedAssetHits.length > 0 ? limitedAssetHits : undefined, }; } // ── Registry resolution ───────────────────────────────────────────────────── /** * Resolve the list of enabled registries. * * Priority: * 1. AKM_REGISTRY_URL env var (CI override, comma-separated) * 2. config.registries (filtered by enabled !== false) * 3. Default registries from DEFAULT_CONFIG * * AKM_REGISTRY_URL syntax (comma-separated): * - Bare URL: `https://example.com/index.json` → defaults to provider "static-index" * - Typed URL: `skills-sh::https://skills.sh/api` → explicit provider type * Format: `<provider-type>::<url>` */ export function resolveRegistries(configRegistries) { // Allow env var override (comma-separated URLs) — CI escape hatch const envUrls = process.env.AKM_REGISTRY_URL?.trim(); if (envUrls) { const entries = []; for (const raw of envUrls.split(",")) { const trimmed = raw.trim(); if (!trimmed) continue; // Parse optional `<provider-type>::<url>` prefix let provider; let url; const colonColonIdx = trimmed.indexOf("::"); if (colonColonIdx !== -1 && !trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { // Only treat as `provider::url` if the prefix doesn't look like a URL scheme itself provider = trimmed.slice(0, colonColonIdx).trim(); url = trimmed.slice(colonColonIdx + 2).trim(); if (!provider) { warn('[akm] Ignoring AKM_REGISTRY_URL entry: empty provider type before "::".'); continue; } } else { url = trimmed; } if (!url.startsWith("http://") && !url.startsWith("https://")) { warn("[akm] Ignoring AKM_REGISTRY_URL entry: URL must start with http:// or https://."); continue; } if (hasRegistryUrlCredentials(url)) { warn(`[akm] ${formatRegistryCredentialWarning({ url })}`); continue; } entries.push(provider ? { url, provider } : { url }); } return entries; } const registries = configRegistries ?? DEFAULT_CONFIG.registries ?? []; return registries.filter((entry) => { if (entry.enabled === false) return false; if (!hasRegistryUrlCredentials(entry.url)) return true; warn(`[akm] ${formatRegistryCredentialWarning(entry)}`); return false; }); } // ── Provider resolution ───────────────────────────────────────────────────── function createProvider(entry, warnings) { const providerType = entry.provider ?? "static-index"; const factory = resolveRegistryProviderFactory(providerType); if (!factory) { const label = formatRegistryLabel(entry); warnings.push(`Registry ${label}: unknown provider type "${providerType}"`); return null; } return factory(entry); } // ── Utilities ─────────────────────────────────────────────────────────────── /** * Normalize the `score` field of a batch of hits in-place to [0, 1]. * * Different registry providers use incompatible score scales * (static-index can exceed 1.85; skills-sh uses installs-relative values * in [0, 1]). Normalizing each provider's batch independently before merging * makes the merged sort order meaningful. * * When all scores are identical (or absent), scores are left unchanged so * relative ordering within the batch is preserved (all-same is effectively * already normalized). */ function normalizeScores(hits) { if (hits.length === 0) return; const rawScores = hits.map((h) => h.score ?? 0); const max = Math.max(...rawScores); if (max <= 0) return; // all zero or negative — leave as-is const min = Math.min(...rawScores); const range = max - min; for (let i = 0; i < hits.length; i++) { const raw = rawScores[i]; // Min-max normalize: [0, 1]. When all scores are equal (range === 0), // fall back to dividing by max so the value stays in [0, 1]. hits[i].score = range > 0 ? (raw - min) / range : raw / max; } } function clampLimit(limit) { if (!limit || !Number.isFinite(limit)) return 20; return Math.min(100, Math.max(1, Math.trunc(limit))); } // A complete hit must have the fields downstream consumers (CLI rendering, // `akm add`) rely on. Providers that return partial records would otherwise // surface as `{}` in the JSON output. function isCompleteHit(hit) { if (!hit || typeof hit !== "object") return false; return (typeof hit.source === "string" && typeof hit.id === "string" && hit.id.length > 0 && typeof hit.title === "string" && hit.title.length > 0 && typeof hit.ref === "string" && hit.ref.length > 0 && typeof hit.installRef === "string" && hit.installRef.length > 0); } function isCompleteAssetHit(hit) { if (!hit || typeof hit !== "object") return false; if (hit.type !== "registry-asset" || typeof hit.assetType !== "string" || hit.assetType.length === 0 || typeof hit.assetName !== "string" || hit.assetName.length === 0 || typeof hit.action !== "string") { return false; } // `stash` is required by the consumer (output shaping + asset-action display); // rejecting incomplete stashes here keeps malformed objects out of the JSON // output. Flagged in PR #168 review (#9). const stash = hit.stash; if (!stash || typeof stash !== "object") return false; if (typeof stash.id !== "string" || stash.id.length === 0) return false; if (typeof stash.name !== "string" || stash.name.length === 0) return false; return true; }