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
172 lines (171 loc) • 9.45 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/.
/**
* Per-directory document drain — akm 0.9.0 Chunk 5, milestone F4a M-core-2 (the
* engine swap). Replaces the live indexer's per-dir flat-walk matcher-pass
* `IndexDocument` stream with the `akm` adapter's `recognize` `IndexDocument`
* stream, reconstructing the durable `IndexDocument` via {@link
* indexDocumentToStashEntry} (proven lossless by the shadow-parity gate).
*
* Two behaviors the adapter fold does NOT carry, restored here at the drain
* layer (spec §14.2 "drain the full document stream"):
*
* - **Broken-workflow drop (item 3).** The live path dropped a broken workflow
* via the renderer contributor's throw → metadata-pass skip-with-warning; the
* `akm` adapter's synchronous `foldRecognizedMetadata` SWALLOWS the parse
* error, so a broken workflow would otherwise silently index. We run the
* shared source-IR compiler on drained workflow docs and DROP
* the entry with the same `Skipped workflow …` warning
* ({@link buildMetadataSkipWarning}), so the workflow-skip summary counts it.
*
* `doc.hash` (= sha256 of the file content) is surfaced per recognized file so
* the persist layer can populate the `content_hash` column (item 2). It is keyed
* by the file's absolute path rather than by the entry object.
*
* Pure of DB/global state; a new leaf (nothing imports it back), so it joins no
* import cycle.
*/
import path from "node:path";
import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js";
import { compareCodePoints } from "../../core/common.js";
import { canonicalizeWorkflowName } from "../../core/recognition-util.js";
import { compileWorkflowSource } from "../../workflows/compile.js";
import { resolveWorkflowSourceDomains, workflowNameForSourcePath } from "../../workflows/source-files.js";
import { buildMetadataSkipWarning } from "../passes/metadata.js";
import { buildFileContext } from "../walk/file-context.js";
import { indexDocumentToStashEntry } from "./doc-to-entry.js";
/** The markdown-workflow renderer name the `akm` adapter carries on `documentJson.renderer`. */
const WORKFLOW_MD_RENDERER = "workflow-md";
/**
* Drain one directory's recognized documents into durable entries.
*
* `fileContexts` are the dir's walked files (the drain no longer pre-filters —
* adapter-owned filtering, owner ruling 2026-07-21). `adapter.recognize` returns
* `null` for a file it abstains on (no matcher claims it, an OKF reserved file,
* or an AKM sensitive/infra file) — silently skipped, the same contract the
* legacy flat-walk pass's "no matcher claims the file" case had.
*/
export function drainDirDocuments(adapter, component, fileContexts) {
const entries = [];
const warnings = [];
const hashByFile = new Map();
const conceptIdByFile = new Map();
const rejectedPaths = new Set();
const rejectedConceptIds = new Set();
// A full directory drain may contain both peer workflow formats for one
// canonical ref. Ownership arbitration must happen *before* recognition:
// otherwise both documents reach the persistence fold and SQLite's final
// row is determined by the walk/readdir order. Resolve exactly the paths
// this drain owns, so a full scan retains only the deterministic `.md`
// winner while a targeted one-file reindex deliberately keeps its written
// source and therefore marks an existing peer row stale for read fallback.
const workflowOwnerPathByCanonicalName = new Map(resolveWorkflowSourceDomains(component.root, adapter.id, fileContexts.map((file) => file.absPath))
.filter((resolution) => resolution.source !== undefined)
.map((resolution) => [resolution.canonicalName, path.resolve(resolution.source.path)]));
const invalidWorkflowOwnerNames = new Set();
const orderedFileContexts = [...fileContexts].sort((left, right) => {
const leftName = workflowNameForSourcePath(component.root, adapter.id, left.absPath);
const rightName = workflowNameForSourcePath(component.root, adapter.id, right.absPath);
const leftOwner = leftName !== undefined &&
workflowOwnerPathByCanonicalName.get(canonicalizeWorkflowName(leftName)) === path.resolve(left.absPath);
const rightOwner = rightName !== undefined &&
workflowOwnerPathByCanonicalName.get(canonicalizeWorkflowName(rightName)) === path.resolve(right.absPath);
if (leftOwner !== rightOwner)
return leftOwner ? -1 : 1;
return compareCodePoints(left.absPath, right.absPath);
});
for (const file of orderedFileContexts) {
if (rejectedPaths.has(file.absPath))
continue;
const workflowName = workflowNameForSourcePath(component.root, adapter.id, file.absPath);
if (workflowName !== undefined) {
const canonicalName = canonicalizeWorkflowName(workflowName);
const ownerPath = workflowOwnerPathByCanonicalName.get(canonicalName);
if (ownerPath !== undefined &&
ownerPath !== path.resolve(file.absPath) &&
!invalidWorkflowOwnerNames.has(canonicalName)) {
continue;
}
}
const doc = adapter.recognize(component, file);
if (doc === null)
continue;
if (!doc.conceptId) {
warnings.push(`Skipped ${file.absPath}: adapter "${adapter.id}" returned no conceptId.`);
continue;
}
const entry = indexDocumentToStashEntry(doc);
// Workflow docs: drop-with-warning if broken; otherwise cache a lossless
// runtime projection when the current executor can represent the source.
const dropWarning = handleWorkflowDoc(doc, file, component.root);
if (dropWarning !== null) {
warnings.push(dropWarning);
if (workflowName !== undefined)
invalidWorkflowOwnerNames.add(canonicalizeWorkflowName(workflowName));
continue;
}
if (doc.hash !== undefined)
hashByFile.set(file.absPath, doc.hash);
conceptIdByFile.set(file.absPath, doc.conceptId);
entries.push(entry);
}
return { entries, warnings, hashByFile, conceptIdByFile, rejectedPaths, rejectedConceptIds };
}
/**
* `(stashRoot, files) → StashFile` drop-in for the deleted flat-walk matcher
* pass (F4a M-core-3): builds a FileContext per file and drains them through the
* `akm` adapter's `recognize`. The recognize engine is the proven-equal
* replacement for the old matcher-pass metadata assembly (shadow-parity gate), so
* callers that only need the recognized entries (`manifest`'s no-index fallback,
* the `registry` static-index builder, and the metadata unit tests) get identical
* entries — plus the D-R6 reserved-file exclusion and the AKM sensitive/infra
* abstention the adapter now enforces itself. Provenance is not persisted by these
* callers, so the synthetic component id is immaterial.
*/
export function recognizeStashEntries(stashRoot, files) {
const component = { id: stashRoot, adapter: "akm", root: stashRoot, writable: false };
// No pre-filter: the `akm` adapter's `recognize` claims/abstains per file
// (owner ruling 2026-07-21 — adapter-owned filtering).
const contexts = files.map((file) => buildFileContext(stashRoot, file));
const drained = drainDirDocuments(akmAdapter, component, contexts);
return drained.warnings.length > 0
? { entries: drained.entries, warnings: drained.warnings }
: { entries: drained.entries };
}
/**
* If `doc` is a workflow, compile it through source IR: return a
* `Skipped workflow …` drop warning when it is broken, or return `null` when
* it compiles. Non-workflow docs return `null` immediately.
*/
function handleWorkflowDoc(doc, file, workspaceRoot) {
if (doc.type !== "workflow" ||
(doc.adapterId !== "akm" && doc.adapterId !== "akm-workflow") ||
(docRenderer(doc) !== WORKFLOW_MD_RENDERER && doc.adapterId !== "akm-workflow")) {
return null;
}
const result = compileWorkflowSource(file.content(), { path: file.relPath, workspaceRoot });
if (!result.ok)
return workflowDropWarning(file, result.errors);
return null;
}
/** The winning renderer name the `akm` adapter carries on `documentJson.renderer`, or `undefined`. */
function docRenderer(doc) {
const dj = doc.documentJson;
if (dj !== null && typeof dj === "object" && "renderer" in dj) {
const renderer = dj.renderer;
return typeof renderer === "string" ? renderer : undefined;
}
return undefined;
}
/**
* Build the `Skipped workflow <path>:\n…` warning byte-for-byte the way the live
* pipeline did: the workflow parser's `path:line — message` summary wrapped in
* the `Workflow has errors:` prefix (the string `loadDocument`/`loadProgram`
* threw), then {@link buildMetadataSkipWarning}'s workflow branch. `startsWith
* "Skipped workflow "` so `isWorkflowSkipWarning` counts it for the summary.
*/
function workflowDropWarning(file, errors) {
const summary = errors.map((e) => `${file.relPath}:${e.line} — ${e.message}`).join("\n");
return buildMetadataSkipWarning(file.absPath, "workflow", `Workflow has errors:\n${summary}`);
}