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

420 lines (419 loc) • 18 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/. /** * The first-class `llm-wiki` adapter — akm 0.9.0 chunk-4 (DEV-7 restore). * * The `wiki` ASSET-TYPE dies in chunk 4 (plan §11 Chunk 4 / §7.4), but the LLM * Wiki structure stays first-class as its OWN adapter. This relocates the * native wiki semantics from the retired `src/wiki/` module into a * `BundleAdapter` implementing `docs/architecture/specs/akm-0.9.0-bundle-adapter-spec.md` §7 * (llm-wiki row), §6 (wiki-page row), §0.2 (the `wiki` asset-type is retired; * the adapter is first-class), §1.2 (probe = schema.md + pages/), §9 (links). * * Conformance oracle (authored, DO NOT modify): fixture * `tests/fixtures/bundles/llm-wiki/` + goldens * `tests/fixtures/format-family-goldens/llm-wiki/{recognition,placement,renderer,lint}.json`. * The adapter is built to MATCH those goldens (driven by * `tests/core/adapter/llm-wiki-adapter.test.ts`). * * ── Structure (spec §7, wiki.ts canonical-content contract) ── * * - `schema.md` / `index.md` / `log.md` at the wiki ROOT are RESERVED * (recognized, never indexed as concepts) — analogous to OKF's index.md/log.md. * - `raw/<slug>.md` are immutable ingested sources → the adapter's own `type` * = `wiki-source` (RESOLVED, reading A: recognition golden). First-class * addressable + searchable; lint tracks whether each is cited. * - `pages/**.md` are agent-authored pages → open `type` = frontmatter * `pageKind` (concept/entity/note/<custom>). None of concept/entity/note/ * wiki-source is in `KNOWN_TYPES`, so they present GENERICALLY (renderer * golden); the retired `wiki-md` renderer is deliberately unused. * - Anything else (root README, stray root `.md`, other dirs) is NOT a wiki * asset (`recognize` abstains) — pages live under `pages/`. * * ── Links / citations (spec §9) ── * * A page's `IndexDocument.links` = its resolved `xrefs:` frontmatter (the * `wiki:<name>/<conceptId>` form, stripped to the target conceptId) PLUS its * body bundle-relative markdown links, deduped, first-appearance order. Its * `sources:` frontmatter resolves to the cited raw conceptIds (citation edges), * carried on `documentJson` (adapter extras — plan §4.3) and consumed by the * uncited-raw lint check. * * ── validate (spec §6/§7, §9) — native wiki checks ONLY ── * * The native structural checks ported from the retired `src/wiki/` module's * `lintWiki`: `broken-xref` * (an xref target that does not resolve to an existing page), `uncited-raw` * (a `raw/` source not cited by any page's `sources:`), `missing-description` * (a page with no description), and `broken-source` (a page `sources:` entry * with no matching raw file). Broken xrefs are TOLERATED (warning, non-blocking, * spec §9). The shared `runBaseValidateChecks` (unquoted-colon / missing-updated * / stale-path / missing-ref) is NOT run: wiki files never carry an `updated` * field, so `missing-updated` would fire on every page/reserved file and * contradict the lint golden's clean result — the native checks are the wiki's * complete validation surface. The `orphan` issue CODE is APPROVED but the * recorded chunk-4 behavior (lint golden edgeCases) is to emit `broken-xref` * only (the actionable finding) and surface `orphan` after the xref is fixed — * so no `orphan` finding is emitted here. * * ── Cycle-safety ── * * Imported by the `adapters/index.ts` barrel, which is NOT test-only: `core/ * adapter/registry.ts:33` imports it in production for the frozen * `BUILTIN_ADAPTERS` list (`installations.ts#detectAdapterId`, * `provider-utils.ts#detectStashRoot`). This still can never gain an inbound * edge from a cycle participant, since neither the barrel nor the registry * import anything that imports back into this file. It value-imports only * pure leaves (`frontmatter`, `shared`) plus Node builtins + `yaml` (already a * runtime dep). Verified: `bun scripts/lint-import-cycles.ts` reports 0 cycle * participants with this file present. */ import fs from "node:fs"; import path from "node:path"; import { parse as parseYaml } from "yaml"; import { parseFrontmatter, parseFrontmatterBlock } from "../../asset/frontmatter.js"; import { toPosix } from "../../common.js"; import { hashContent, nonEmptyString } from "./shared.js"; /** A wiki is a single-component bundle; its one component is conventionally `main` (recognition golden). */ const WIKI_COMPONENT_ID = "main"; /** The adapter's own `type` for immutable ingested sources under `raw/` (recognition golden, reading A). */ const WIKI_SOURCE_TYPE = "wiki-source"; /** Default open `type` for a `pages/` file whose frontmatter carries no `pageKind`. */ const DEFAULT_PAGE_KIND = "note"; /** Reserved wiki files (case-insensitive) at the wiki ROOT — recognized, never indexed as concepts (spec §7). */ const RESERVED_ROOT_FILES = new Set(["schema.md", "index.md", "log.md"]); /** Content subdirectory holding immutable ingested sources. */ const RAW_SUBDIR = "raw"; /** Content subdirectory holding agent-authored pages. */ const PAGES_SUBDIR = "pages"; /** POSIX-normalize separators. */ /** conceptId = component-root-relative path minus `.md`. */ function conceptIdOf(relPath) { return toPosix(relPath).replace(/\.md$/i, ""); } /** * Parse a wiki page's frontmatter with a real YAML parser so list-valued keys * (`xrefs:`, `sources:`) round-trip (the project's hand-rolled `parseFrontmatter` * deliberately drops YAML lists — see `wiki.ts#parsePageFrontmatterYaml`). * Tolerant: malformed YAML / no frontmatter yields empty fields. */ function parseWikiFrontmatter(raw) { const out = { xrefs: [], sources: [] }; const block = parseFrontmatterBlock(raw); let data = {}; if (block) { try { const value = parseYaml(block.frontmatter); if (value && typeof value === "object" && !Array.isArray(value)) { data = value; } } catch { // malformed YAML — fall back to the lightweight parser (scalars only) try { data = parseFrontmatter(raw).data; } catch { data = {}; } } } out.description = nonEmptyString(data.description); out.pageKind = nonEmptyString(data.pageKind); out.wikiRole = nonEmptyString(data.wikiRole); if (Array.isArray(data.xrefs)) { out.xrefs = data.xrefs .filter((x) => typeof x === "string" && x.trim().length > 0) .map((x) => x.trim()); } if (Array.isArray(data.sources)) { out.sources = data.sources .filter((s) => typeof s === "string" && s.trim().length > 0) .map((s) => s.trim()); } return out; } /** True when `relPath` is a RESERVED wiki file at the component root (schema/index/log). */ function isReservedRootFile(relPath) { const posix = toPosix(relPath); if (posix.includes("/")) return false; // reserved files are special ONLY at the wiki root return RESERVED_ROOT_FILES.has(posix.toLowerCase()); } /** First path segment of a component-root-relative POSIX path (or the whole thing when flat). */ function firstSegment(relPath) { const posix = toPosix(relPath); const slash = posix.indexOf("/"); return slash < 0 ? posix : posix.slice(0, slash); } /** * Resolve one xref token (`wiki:<bundleId>/<conceptId>`) to a same-wiki target * conceptId, or `null` when it points at another wiki / is not a wiki xref. * `bundleId` is the bundle id (`c.id`); cross-wiki xrefs are left alone * (wiki.ts#lintWiki: "a cross-wiki link is a feature, not a defect"). */ function resolveXref(xref, bundleId) { const prefix = `wiki:${bundleId}/`; if (!xref.startsWith(prefix)) return null; const target = xref.slice(prefix.length).replace(/\.md$/i, ""); return target.length > 0 ? target : null; } /** * Resolve a page body's bundle-relative markdown links to component-root-relative * conceptIds. `/`-rooted links resolve from the component root; standard relative * links resolve against the linking page's own directory. External schemes, * anchors, non-`.md` targets, and links escaping the component root are dropped * (tolerant, §9). First-appearance order; duplicates collapse. */ function resolveBodyLinks(body, fileRelPath) { const dir = path.posix.dirname(toPosix(fileRelPath)); const linkRe = /\[[^\]]*\]\(([^)]+)\)/g; const out = []; const seen = new Set(); let match; // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop while ((match = linkRe.exec(body)) !== null) { let target = match[1].trim(); const wsIdx = target.search(/\s/); if (wsIdx >= 0) target = target.slice(0, wsIdx); const hashIdx = target.indexOf("#"); if (hashIdx >= 0) target = target.slice(0, hashIdx); const queryIdx = target.indexOf("?"); if (queryIdx >= 0) target = target.slice(0, queryIdx); if (!target) continue; if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue; // external scheme if (target.startsWith("//")) continue; // protocol-relative if (!target.toLowerCase().endsWith(".md")) continue; let resolved; if (target.startsWith("/")) { resolved = path.posix.normalize(target.slice(1)); } else { const base = dir === "." ? "" : dir; resolved = path.posix.normalize(path.posix.join(base, target)); } if (resolved.startsWith("../") || resolved === ".." || resolved.startsWith("/")) continue; const conceptId = resolved.replace(/\.md$/i, ""); if (!conceptId || seen.has(conceptId)) continue; seen.add(conceptId); out.push(conceptId); } return out; } /** Resolve a page's `xrefs` + body links into deduped target conceptIds (links = relationships, §9). */ function resolvePageLinks(fm, body, fileRelPath, bundleId) { const out = []; const seen = new Set(); const push = (conceptId) => { if (conceptId.length > 0 && !seen.has(conceptId)) { seen.add(conceptId); out.push(conceptId); } }; for (const xref of fm.xrefs) { const resolved = resolveXref(xref, bundleId); if (resolved !== null) push(resolved); } for (const link of resolveBodyLinks(body, fileRelPath)) push(link); return out; } /** Resolve a page's `sources:` entries (`raw/<slug>[.md]`) to raw conceptIds (citation edges). */ function resolveSources(fm) { const out = []; const seen = new Set(); for (const src of fm.sources) { const match = src.match(/^raw\/([^/\s]+?)(?:\.md)?$/i); if (!match) continue; const conceptId = `raw/${match[1]}`; if (!seen.has(conceptId)) { seen.add(conceptId); out.push(conceptId); } } return out; } function recognize(c, file) { if (file.ext !== ".md") return null; const relPath = toPosix(file.relPath); // Reserved root files (schema/index/log) are recognized but never indexed. if (isReservedRootFile(relPath)) return null; const head = firstSegment(relPath); const isRaw = head === RAW_SUBDIR && relPath.includes("/"); const isPage = head === PAGES_SUBDIR && relPath.includes("/"); // Only `raw/` sources and `pages/` pages are wiki assets. Everything else // (root README, stray root `.md`, other dirs) is abstained on. if (!isRaw && !isPage) return null; const conceptId = conceptIdOf(relPath); const lastSegment = conceptId.split("/").pop() ?? conceptId; const raw = file.content(); const fm = parseWikiFrontmatter(raw); const body = parseFrontmatter(raw).content; const doc = { ref: `${c.id}//${conceptId}`, bundle: c.id, component: WIKI_COMPONENT_ID, conceptId, path: file.absPath, hash: hashContent(raw), adapterId: "llm-wiki", ownsPresentation: true, type: isRaw ? WIKI_SOURCE_TYPE : (fm.pageKind ?? DEFAULT_PAGE_KIND), name: lastSegment, content: body, }; if (fm.description !== undefined) doc.description = fm.description; if (isPage) { const links = resolvePageLinks(fm, body, relPath, c.id); if (links.length > 0) doc.links = links; const sources = resolveSources(fm); // wiki page extras (adapter-owned; plan §4.3 — wikiRole/pageKind + citations). const extras = { pageKind: fm.pageKind ?? DEFAULT_PAGE_KIND, sources }; if (fm.wikiRole !== undefined) extras.wikiRole = fm.wikiRole; doc.documentJson = extras; } else { doc.documentJson = { wikiRole: fm.wikiRole ?? "source" }; } return doc; } async function validate(c, changes, ctx) { const items = []; for (const change of changes) { if (change.op === "delete") continue; const raw = change.after ?? (await ctx.readFile(change.path)); if (typeof raw !== "string") continue; const relPath = toPosix(change.path); if (isReservedRootFile(relPath)) continue; // reserved files are not concepts — no page checks const head = firstSegment(relPath); const isRaw = head === RAW_SUBDIR && relPath.includes("/"); const isPage = head === PAGES_SUBDIR && relPath.includes("/"); if (!isRaw && !isPage) continue; items.push({ relPath, conceptId: conceptIdOf(relPath), kind: isRaw ? "raw" : "page", fm: parseWikiFrontmatter(raw), body: parseFrontmatter(raw).content, }); } const existingConceptIds = new Set(items.map((i) => i.conceptId)); const citedRawConceptIds = new Set(); for (const item of items) { if (item.kind !== "page") continue; for (const src of resolveSources(item.fm)) citedRawConceptIds.add(src); } const diagnostics = []; for (const item of items) { if (item.kind === "raw") { // uncited-raw: a raw source not cited by any page's `sources:`. if (!citedRawConceptIds.has(item.conceptId)) { diagnostics.push({ file: item.relPath, issue: "uncited-raw", detail: `warning: raw source ${item.conceptId} is not cited by any page's sources: frontmatter (non-blocking).`, fixed: false, }); } continue; } // missing-description if (item.fm.description === undefined) { diagnostics.push({ file: item.relPath, issue: "missing-description", detail: "warning: page is missing a frontmatter `description` (non-blocking).", fixed: false, }); } // broken-xref: same-wiki xref targets that do not resolve to an existing page. const targets = new Set(); for (const xref of item.fm.xrefs) { const resolved = resolveXref(xref, c.id); if (resolved !== null) targets.add(resolved); } for (const link of resolveBodyLinks(item.body, item.relPath)) targets.add(link); for (const target of targets) { if (!existingConceptIds.has(target)) { diagnostics.push({ file: item.relPath, issue: "broken-xref", detail: `warning: cross-reference target not found: ${target} ` + "(non-blocking; cross-references must point at pages that actually exist — wiki schema hard rule).", fixed: false, }); } } // broken-source: `sources:` entries must resolve to an existing raw file. for (const source of resolveSources(item.fm)) { if (!existingConceptIds.has(source)) { diagnostics.push({ file: item.relPath, issue: "broken-source", detail: `warning: page references missing raw source ${source} (non-blocking).`, fixed: false, }); } } } return diagnostics; } export const llmWikiAdapter = { id: "llm-wiki", version: "0.9.0", extensions: [".md"], recognize, validate, readCandidates(c, conceptId) { const canonical = toPosix(conceptId).replace(/\.md$/i, ""); return [{ path: path.join(c.root, `${canonical}.md`), conceptId: canonical }]; }, /** `<c.root>/<conceptId>.md` — the conceptId already carries its `pages/`/`raw/` prefix (placement golden). */ placeNew(c, conceptId) { return path.join(c.root, `${conceptId}.md`); }, /** The wiki owns its whole root (schema/index/log at root plus pages/ + raw/); one component. */ directoryList(_c) { return ["."]; }, /** Install-time probe (§1.2): a root is an LLM Wiki when it has a root `schema.md` AND a `pages/` directory. */ looksLikeRoot(root) { try { if (!fs.existsSync(path.join(root, "schema.md"))) return false; return fs.statSync(path.join(root, PAGES_SUBDIR)).isDirectory(); } catch { return false; } }, }; // Exported pure helpers for the conformance test. export { resolveBodyLinks, resolvePageLinks, resolveSources, resolveXref };