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
98 lines (97 loc) • 4.15 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/.
/**
* The memory-cleanup archive as a REF-RESOLUTION surface (#884).
*
* `analyzeMemoryCleanup`'s prune (`commands/improve/memory/memory-improve.ts`
* `#archiveMemory`) does not delete a memory: it `rename`s the file under
* `.akm/memory-cleanup/archive/<stamp>-<ref>/<originalPath>` and writes a
* sibling `cleanup.md` audit asset carrying `ref` / `originalPath` /
* `archivedPath`. The bytes and the identity both survive — but the ref stops
* resolving at its ORIGINAL location, so every inbound belief edge
* (`contradictedBy` / `supersededBy`) pointing at the pruned memory becomes a
* `missing-ref` the moment #882 made those channels validatable.
*
* That is the #884 defect, and the archive already holds everything needed to
* fix it: the audit record IS a tombstone. This module reads those tombstones
* so ref resolution can answer "archived" instead of "missing". Resolution
* stays non-destructive — pruning never rewrites an unrelated memory's
* frontmatter, and the contradiction an edge records is preserved rather than
* erased (the concern #884 raised against a bare edge-scrub).
*
* A ref whose target has NO tombstone and no file is genuinely dangling — it
* was removed by something other than prune (a hand `git rm`, an older
* release). Those stay reported; clearing them mutates user data and so is
* gated behind `akm lint --prune-dangling-edges`.
*/
import fs from "node:fs";
import path from "node:path";
import { parseFrontmatter } from "./frontmatter.js";
/** Stash-relative root the prune path archives into. Must match `memory-improve.ts#createArchiveDir`. */
export const MEMORY_ARCHIVE_REL = ".akm/memory-cleanup/archive";
/** Filename of the per-archive audit asset written alongside the archived memory. */
const AUDIT_FILENAME = "cleanup.md";
/**
* Cache keyed by stash root. A lint sweep resolves thousands of refs against a
* directory that only the prune path ever writes, so the scan runs once per
* root instead of once per missing ref.
*/
const cache = new Map();
/** @internal Drop the memoized scans — process-global state needs a reset seam for tests (#785). */
export function resetMemoryArchiveCache() {
cache.clear();
}
/**
* Every stash-relative `originalPath` archived under `root`, i.e. the set of
* paths that USED to hold a memory and now hold a tombstone instead.
*
* Unreadable or malformed audit records are skipped rather than thrown: a
* corrupt tombstone must degrade to "this ref is missing" (the pre-#884
* answer), never break the whole lint sweep.
*/
export function archivedOriginalPaths(root) {
const cached = cache.get(root);
if (cached !== undefined)
return cached;
const paths = new Set();
const archiveRoot = path.join(root, MEMORY_ARCHIVE_REL);
let entries;
try {
entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
}
catch {
cache.set(root, paths); // no archive dir — nothing was ever pruned here
return paths;
}
for (const entry of entries) {
if (!entry.isDirectory())
continue;
let raw;
try {
raw = fs.readFileSync(path.join(archiveRoot, entry.name, AUDIT_FILENAME), "utf8");
}
catch {
continue;
}
let originalPath;
try {
originalPath = parseFrontmatter(raw).data.originalPath;
}
catch {
continue;
}
if (typeof originalPath === "string" && originalPath.trim().length > 0) {
paths.add(originalPath.trim().replace(/\\/g, "/"));
}
}
cache.set(root, paths);
return paths;
}
/**
* True when `relPath` (stash-relative, POSIX) names a memory that prune
* archived — the ref resolves to a tombstone rather than to nothing.
*/
export function isArchivedRelPath(relPath, root) {
return archivedOriginalPaths(root).has(relPath.replace(/\\/g, "/"));
}