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

263 lines (262 loc) • 13.1 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/. /** * Pure recognition/placement layer for the built-in akm asset types — akm * 0.9.0 chunk-3 (the taxonomy-cutover leaf). * * This module is the SINGLE SOURCE OF TRUTH for the `AssetSpec` filesystem * surface (`stashDir`/`isRelevantFile`/`toCanonicalName`/`toAssetPath`), the * per-type stash-subdir map, and the derive/resolve helpers built on them. * Chunk-3 deleted the mutable taxonomy globals that used to expose this data * as ambient registries; the surface is now reached through the small typed * accessors below (`stashDirFor`/`stashDirNames`/`placementTypes`/ * `assetPathForName`/`placementSpecFor`), which the `akm` bundle adapter * (`adapter/adapters/akm-adapter.ts`) consumes for placement. * * ── Cycle-safety ── * * This leaf imports only Node builtins and the `core/recognition-util` pure * sink, so it is NOT an import-cycle (SCC) participant. Depending on it from * the `akm` adapter keeps the adapter a leaf. Renderer NAMES and action * builders for the built-in types live in the static `type-presentation.ts` * table (`TYPE_PRESENTATION`); they are intentionally absent here — placement * is a filesystem concern, presentation is a rendering concern. */ import fs from "node:fs"; import path from "node:path"; import { toPosix } from "../common.js"; import { SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js"; const workflowSpec = { isRelevantFile: (fileName) => WORKFLOW_EXTENSIONS.includes(path.extname(fileName).toLowerCase()), toCanonicalName: (typeRoot, filePath) => { const rel = toPosix(path.relative(typeRoot, filePath)); for (const ext of WORKFLOW_EXTENSIONS) { if (rel.toLowerCase().endsWith(ext)) return rel.slice(0, -ext.length); } return rel; }, toAssetPath: (typeRoot, name) => { // Explicit extension wins (accepts refs like "release/ship.yaml"). const lower = name.toLowerCase(); for (const ext of WORKFLOW_EXTENSIONS) { if (lower.endsWith(ext)) return path.join(typeRoot, name); } // Probe in canonical extension priority order and fall back // to the markdown path so error messages keep naming the canonical file. for (const ext of WORKFLOW_EXTENSIONS) { const candidate = path.join(typeRoot, `${name}${ext}`); if (fs.existsSync(candidate)) return candidate; } return path.join(typeRoot, `${name}.md`); }, }; const markdownSpec = { isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".md", toCanonicalName: (typeRoot, filePath) => { const rel = toPosix(path.relative(typeRoot, filePath)); // Strip .md extension from canonical names. return rel.endsWith(".md") ? rel.slice(0, -3) : rel; }, toAssetPath: (typeRoot, name) => { // Accept both with and without .md extension const withExt = name.endsWith(".md") ? name : `${name}.md`; return path.join(typeRoot, withExt); }, }; const scriptSpec = { isRelevantFile: (fileName) => SCRIPT_EXTENSIONS.has(path.extname(fileName).toLowerCase()), toCanonicalName: (typeRoot, filePath) => toPosix(path.relative(typeRoot, filePath)), toAssetPath: (typeRoot, name) => path.join(typeRoot, name), }; const BUILTIN_PLACEMENT_SPECS = { skill: { stashDir: "skills", isRelevantFile: (fileName) => fileName === "SKILL.md", toCanonicalName: (typeRoot, filePath) => { const relDir = toPosix(path.dirname(path.relative(typeRoot, filePath))); if (!relDir || relDir === ".") return undefined; return relDir; }, toAssetPath: (typeRoot, name) => path.join(typeRoot, name, "SKILL.md"), }, command: { stashDir: "commands", ...markdownSpec }, agent: { stashDir: "agents", ...markdownSpec }, knowledge: { stashDir: "knowledge", ...markdownSpec }, // R-045 / Q-18 second half (owner ruling 11, EXECUTE NOW) — `instruction` as // a real stash-resident type, mirroring `knowledge`'s plain markdown spec. // This is distinct from the ADAPTER-OWNED instruction docs emitted by // format-family adapters (root CLAUDE.md/AGENTS.md via `tool-dir-shared.ts`): // those carry `document.ownsPresentation === true` and are routed straight // to their adapter's own projection by `rendererForIndexedEntry` // (`src/commands/read/show.ts`), which checks `ownsPresentation` BEFORE // ever consulting a type/renderer mapping. A stash-resident `instructions/` // asset never sets that marker, so it always renders via the `knowledge-md` // renderer (`TYPE_PRESENTATION.instruction`, `src/core/type-presentation.ts`) // like any other placement type — no collision with the adapter-owned path. instruction: { stashDir: "instructions", ...markdownSpec }, workflow: { stashDir: "workflows", ...workflowSpec }, script: { stashDir: "scripts", ...scriptSpec }, memory: { stashDir: "memories", ...markdownSpec }, // Environment assets — whole `.env` files sourced/injected wholesale. Replaced // the deprecated `vault` type (removed in 0.9.0). Only key NAMES are surfaced // as metadata; values and comment text are never read for indexing (comments // routinely contain commented-out credentials). env: { stashDir: "env", isRelevantFile: (fileName) => fileName === ".env" || fileName.endsWith(".env"), toCanonicalName: (typeRoot, filePath) => { const rel = toPosix(path.relative(typeRoot, filePath)); const fileName = path.basename(rel); // Treat ".env" as the "default" env; "<name>.env" → "<name>" if (fileName === ".env") { const dir = path.dirname(rel); return dir === "." || dir === "" ? "default" : `${dir}/default`; } const stripped = rel.endsWith(".env") ? rel.slice(0, -4) : rel; return stripped; }, toAssetPath: (typeRoot, name) => { if (name === "default") return path.join(typeRoot, ".env"); return path.join(typeRoot, name.endsWith(".env") ? name : `${name}.env`); }, }, // Secrets — a single sensitive value used on its own for authentication (a // PEM key, API token, TLS cert). Unlike `env` (a group of related .env // configuration), the ENTIRE file is the one secret value — there is no safe // region to parse, so only the filename is ever surfaced as metadata. A // secret is any regular file under `secrets/` except `.lock`/`.sensitive` // sidecars; the canonical name preserves the natural filename. secret: { stashDir: "secrets", isRelevantFile: (fileName) => !fileName.endsWith(".lock") && !fileName.endsWith(".sensitive"), toCanonicalName: (typeRoot, filePath) => toPosix(path.relative(typeRoot, filePath)), toAssetPath: (typeRoot, name) => path.join(typeRoot, name), }, // v1 spec §13 — `lesson` asset type. Required frontmatter fields are // `description` and `when_to_use`; lint enforces both. lesson: { stashDir: "lessons", ...markdownSpec }, // Scheduled tasks. A task file pairs a cron-style schedule with a target // (workflow ref, prompt, or command). Stored as pure YAML under // <stash>/tasks/<id>.yml. task: { stashDir: "tasks", isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".yml", toCanonicalName: (typeRoot, filePath) => { const rel = toPosix(path.relative(typeRoot, filePath)); return rel.toLowerCase().endsWith(".yml") ? rel.slice(0, -4) : rel; }, toAssetPath: (typeRoot, name) => { const withExt = name.toLowerCase().endsWith(".yml") ? name : `${name}.yml`; return path.join(typeRoot, withExt); }, }, // #561 — agent sessions indexed as a first-class searchable asset type. // Generated markdown written by the `extract` pass to // `sessions/<harness>/<session-id>.md`. session: { stashDir: "sessions", ...markdownSpec }, // Durable stash-level semantic knowledge — facts about the user, team, or // project. A plain markdown spec; see docs/architecture/specs/fact-asset-type.md. fact: { stashDir: "facts", ...markdownSpec }, }; const _placementKeysSubsetOfKnownTypes = true; void _placementKeysSubsetOfKnownTypes; const PLACEMENT_SPECS = { ...BUILTIN_PLACEMENT_SPECS }; /** Live placement spec for a type, or `undefined` for an unknown type. */ export function placementSpecFor(type) { return PLACEMENT_SPECS[type]; } /** Every registered placement spec (for whole-registry sweeps). */ export function placementSpecList() { return Object.values(PLACEMENT_SPECS); } /** Every registered placement type key (the set of types that have a stash subdir). */ export function placementTypes() { return Object.keys(PLACEMENT_SPECS); } /** The stash subdir a type places into, or `undefined` for an unknown type. */ export function stashDirFor(type) { return PLACEMENT_SPECS[type]?.stashDir; } /** * Reverse of {@link stashDirFor}: the placement type owning a stash subdir, or * `undefined` when no registered type places into it. The type→subdir map is a * bijection over the built-in types (each type has a distinct subdir), so this * is the well-defined inverse used to project a path-based conceptId onto the * asset-type metadata required by native adapters. */ export function typeForStashDir(stashDir) { for (const [type, spec] of Object.entries(PLACEMENT_SPECS)) { if (spec.stashDir === stashDir) return type; } return undefined; } /** All stash subdir names across the registered types. */ export function stashDirNames() { return Object.values(PLACEMENT_SPECS).map((spec) => spec.stashDir); } /** * Mutate the placement registry to add/replace a type's spec. Used by the * custom-asset-type registration surface (extension types); built-ins are * baked into {@link PLACEMENT_SPECS} above. */ export function registerAssetSpec(type, spec) { PLACEMENT_SPECS[type] = spec; } /** Remove a previously-registered type's spec. */ export function deregisterAssetSpec(type) { delete PLACEMENT_SPECS[type]; } export function isRelevantAssetFile(assetType, fileName) { return PLACEMENT_SPECS[assetType]?.isRelevantFile(fileName) ?? false; } export function deriveCanonicalAssetName(assetType, typeRoot, filePath) { return PLACEMENT_SPECS[assetType]?.toCanonicalName(typeRoot, filePath); } export function deriveCanonicalAssetNameFromStashRoot(assetType, stashRoot, filePath) { const relPath = toPosix(path.relative(stashRoot, filePath)); const segments = relPath.split("/").filter(Boolean); const firstSegment = segments[0]; // When the first segment matches the canonical type dir (e.g. "agents"), // use it as the type root so canonical names are relative to it. // Otherwise fall back to stashRoot — this preserves the full relative path // as the canonical name, which is correct for installed stashes that live // under custom directories (e.g. "tools/agents/svelte-file-editor"). const typeRoot = firstSegment !== undefined && firstSegment === stashDirFor(assetType) ? path.join(stashRoot, firstSegment) : stashRoot; return deriveCanonicalAssetName(assetType, typeRoot, filePath); } export function assetPathForName(assetType, typeRoot, name) { const spec = PLACEMENT_SPECS[assetType]; if (!spec) throw new Error(`Unknown asset type: "${assetType}"`); return spec.toAssetPath(typeRoot, name); } /** * Every physical path spelling that could own `name`, closed-form (#857). * `toAssetPath` is a function, so it can only pick ONE spelling — but `env`'s * "default" alias is genuinely dual-owned: both `<dir>/.env` and * `<dir>/default.env` derive the same canonical name (`toCanonicalName` * above), so a physical-owner lookup must consider both without reading * either file. Every other placement type has exactly one inverse spelling — * including `memory`: `<name>.derived.md` is a separate item that owns * `<name>.derived`, never a second spelling of `<name>`. */ export function assetPathCandidatesForName(assetType, typeRoot, name) { const primary = assetPathForName(assetType, typeRoot, name); if (assetType !== "env") return [primary]; const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined; if (base === undefined) return [primary]; const dotForm = path.join(typeRoot, base, ".env"); const namedForm = path.join(typeRoot, base, "default.env"); return [...new Set([primary, dotForm, namedForm])]; }