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
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/.
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;
}