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

305 lines (304 loc) • 13.4 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 { NotFoundError } from "../../core/errors.js"; import { formatRegistryCredentialWarning, formatRegistryError, formatRegistryLabel, hasRegistryUrlCredentials, } from "../../core/registry-url.js"; import { warnOnce } from "../../core/warn.js"; import { asString } from "../../integrations/github.js"; import { fetchCachedJson } from "../../storage/repositories/registry-index-cache-repository.js"; import { registerRegistryProvider } from "../factory.js"; import { fetchRegistryJson } from "../network.js"; import { buildInstallRef } from "../resolve.js"; // ── Constants ─────────────────────────────────────────────────────────────── /** Cache TTL in milliseconds (1 hour). */ const CACHE_TTL_MS = 60 * 60 * 1000; /** Index format versions this reader was written against; both use the same `stashes[]` wire format. */ const KNOWN_INDEX_VERSIONS = new Set([2, 3]); // ── Provider class ────────────────────────────────────────────────────────── class StaticIndexProvider { type = "static-index"; config; constructor(config) { this.config = config; } async search(options) { if (hasRegistryUrlCredentials(this.config.url)) { return { hits: [], warnings: [formatRegistryCredentialWarning(this.config)] }; } const warnings = []; const bundles = await this.loadBundles(warnings); const hits = scoreBundles(bundles, options.query, options.limit); let assetHits; if (options.includeAssets) { const scored = scoreAssets(bundles, options.query, options.limit); if (scored.length > 0) assetHits = scored; } return { hits, assetHits, warnings: warnings.length > 0 ? warnings : undefined }; } // ── Internals ─────────────────────────────────────────────────────────── async loadBundles(warnings) { const bundles = []; try { const index = await loadIndex(this.config); if (index) { if (!KNOWN_INDEX_VERSIONS.has(index.version ?? Number.NaN)) { warnOnce(`registry-index-version:${this.config.url}`, `Registry ${formatRegistryLabel(this.config)}: index version ${index.version ?? "(missing)"} is not one this akm was written against (2 or 3); reading the entries it can.`); } const regName = this.config.name; for (const stash of index.stashes) { if (stash.source === "git") { warnings.push(`Registry ${formatRegistryLabel(this.config)}: ignored ${stash.id} because registry-provided git transport refs are not installable`); continue; } bundles.push({ stash, registryName: regName }); } } } catch (err) { warnings.push(`Registry ${formatRegistryLabel(this.config)}: ${formatRegistryError(err)}`); } return bundles; } } // ── Self-register ─────────────────────────────────────────────────────────── registerRegistryProvider("static-index", (config) => new StaticIndexProvider(config)); // ── Index loading with cache ──────────────────────────────────────────────── async function loadIndex(entry) { return fetchCachedJson({ cacheKey: entry.url, ttlMs: CACHE_TTL_MS, // Both the fresh hit and the stale fallback parse identically; a corrupt // cache row lets JSON.parse throw out of the load. parseCache: (json) => parseRegistryIndex(JSON.parse(json)) ?? undefined, fetchFresh: async () => { // Registry indexes can grow large; 50 MB is the cap on what one is // allowed to send. const data = await fetchRegistryJson(entry.url, { timeoutMs: 10_000, maxBytes: 50 * 1024 * 1024 }); const index = parseRegistryIndex(data); if (!index) { throw new NotFoundError(`Registry index at ${formatRegistryLabel(entry)} has no stashes array`, "REGISTRY_RESPONSE_INVALID"); } return { value: index, cacheJson: JSON.stringify(index) }; }, }); } // ── Index parsing (exported for reuse) ────────────────────────────────────── /** * Read whatever the index carries: the only requirement is a `stashes` array. * Entries that lack an id, name, ref or known source are skipped; `version` * and `updatedAt` are carried through as published. */ export function parseRegistryIndex(data) { if (typeof data !== "object" || data === null || Array.isArray(data)) return null; const obj = data; if (!Array.isArray(obj.stashes)) return null; const stashes = obj.stashes.flatMap((raw) => { const stash = parseBundleEntry(raw); return stash ? [stash] : []; }); return { version: typeof obj.version === "number" ? obj.version : undefined, updatedAt: asString(obj.updatedAt), stashes, }; } // ── Stash entry parsing ─────────────────────────────────────────────────────── function parseBundleEntry(raw) { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; const obj = raw; const id = asString(obj.id); const name = asString(obj.name); const ref = asString(obj.ref); const source = asSource(obj.source); if (!id || !name || !ref || !source) return null; // Some published indexes include `curated`; it is intentionally ignored. return { id, name, ref, source, description: asString(obj.description), homepage: asString(obj.homepage), tags: asStringArray(obj.tags), assetTypes: asStringArray(obj.assetTypes), assets: parseAssets(obj.assets), author: asString(obj.author), license: asString(obj.license), latestVersion: asString(obj.latestVersion), }; } // ── Scoring ───────────────────────────────────────────────────────────────── function scoreBundles(stashes, query, limit) { const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); const scored = []; for (const { stash, registryName } of stashes) { const score = scoreStash(stash, tokens); if (score > 0) { scored.push({ stash, registryName, score }); } } scored.sort((a, b) => b.score - a.score); return scored.slice(0, limit).map(({ stash, registryName, score }) => toSearchHit(stash, score, registryName)); } function scoreStash(stash, tokens) { let score = 0; const nameLower = stash.name.toLowerCase(); const descLower = (stash.description ?? "").toLowerCase(); const tagsLower = (stash.tags ?? []).map((t) => t.toLowerCase()); for (const token of tokens) { // Exact name match is strongest signal if (nameLower === token) { score += 1.0; } else if (nameLower.includes(token)) { score += 0.6; } // Tag matches are high-signal (curated keywords) if (tagsLower.some((tag) => tag === token)) { score += 0.5; } else if (tagsLower.some((tag) => tag.includes(token))) { score += 0.25; } // Description substring if (descLower.includes(token)) { score += 0.2; } // Author match if (stash.author?.toLowerCase().includes(token)) { score += 0.15; } } // Normalize by token count so multi-word queries don't inflate scores return tokens.length > 0 ? score / tokens.length : 0; } function toSearchHit(stash, score, registryName) { const metadata = {}; if (stash.latestVersion) metadata.version = stash.latestVersion; if (stash.author) metadata.author = stash.author; if (stash.license) metadata.license = stash.license; if (stash.assetTypes?.length) metadata.assetTypes = stash.assetTypes.join(", "); return { source: stash.source, id: stash.id, title: stash.name, description: stash.description, ref: stash.ref, installRef: buildInstallRef(stash.source, stash.ref, "registry"), homepage: stash.homepage, score: Math.round(score * 1000) / 1000, metadata, registryName, }; } // ── Asset parsing ─────────────────────────────────────────────────────────── function parseAssets(raw) { if (!Array.isArray(raw)) return undefined; const parsed = raw.flatMap((item) => { const entry = parseAssetEntry(item); return entry ? [entry] : []; }); return parsed.length > 0 ? parsed : undefined; } function parseAssetEntry(raw) { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; const obj = raw; const type = asString(obj.type); const name = asString(obj.name); if (!type || !name) return null; return { type, name, description: asString(obj.description), tags: asStringArray(obj.tags), estimatedTokens: typeof obj.estimatedTokens === "number" ? obj.estimatedTokens : undefined, }; } // ── Asset-level scoring ───────────────────────────────────────────────────── function scoreAssets(stashes, query, limit) { const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); if (tokens.length === 0) return []; const scored = []; for (const { stash, registryName } of stashes) { if (!stash.assets || stash.assets.length === 0) continue; const installRef = buildInstallRef(stash.source, stash.ref, "registry"); for (const asset of stash.assets) { const score = scoreAsset(asset, tokens); if (score > 0) { scored.push({ hit: { type: "registry-asset", assetType: asset.type, assetName: asset.name, description: asset.description, estimatedTokens: asset.estimatedTokens, stash: { id: stash.id, name: stash.name }, registryName, action: `akm bundle add ${installRef}`, score: Math.round(score * 1000) / 1000, }, score, }); } } } scored.sort((a, b) => b.score - a.score); return scored.slice(0, limit).map(({ hit }) => hit); } function scoreAsset(asset, tokens) { let score = 0; const nameLower = asset.name.toLowerCase(); const descLower = (asset.description ?? "").toLowerCase(); const tagsLower = (asset.tags ?? []).map((t) => t.toLowerCase()); const typeLower = asset.type.toLowerCase(); for (const token of tokens) { if (nameLower === token) { score += 1.0; } else if (nameLower.includes(token)) { score += 0.6; } if (typeLower === token) { score += 0.4; } else if (typeLower.includes(token)) { score += 0.2; } if (tagsLower.some((tag) => tag === token)) { score += 0.5; } else if (tagsLower.some((tag) => tag.includes(token))) { score += 0.25; } if (descLower.includes(token)) { score += 0.2; } } return tokens.length > 0 ? score / tokens.length : 0; } // ── Utilities ─────────────────────────────────────────────────────────────── function asSource(value) { if (value === "npm" || value === "github" || value === "git" || value === "local") return value; return undefined; } function asStringArray(value) { if (!Array.isArray(value)) return undefined; const filtered = value.filter((v) => typeof v === "string"); return filtered.length > 0 ? filtered : undefined; }