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

1,045 lines (1,044 loc) • 46.6 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/. import fs from "node:fs"; import path from "node:path"; import { assembleAsset } from "../../../core/asset/asset-serialize.js"; import { mutateFrontmatter, parseFrontmatter } from "../../../core/asset/frontmatter.js"; import { MEMORY_ARCHIVE_REL } from "../../../core/asset/memory-archive.js"; import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js"; import { asNonEmptyString, groupBy, stringArray, toPosix } from "../../../core/common.js"; import { DERIVED_SUFFIX } from "../../../core/recognition-util.js"; import { warn } from "../../../core/warn.js"; import { recordWrittenPath } from "../../../core/write-provenance.js"; import { walkMarkdownFiles } from "../../../indexer/walk/walker.js"; import { checkGitPathSafety, isGitBackedStash } from "../../../sources/providers/git-stash.js"; import { contentHash } from "../content-hash.js"; import { isDerivedMemory, memoryIdentityRef, parseMemoryName, resolveParentRef } from "./derived-ref.js"; export function analyzeMemoryCleanup(stashDir, options = {}) { const records = collectDerivedMemories(stashDir, options.parentRef); const byRef = new Map(records.map((record) => [record.ref, record])); const byParent = groupBy(records, (record) => record.parentRef); const planned = new Map(); const contradictionCandidates = []; const beliefTransitions = new Map(); const planPrune = (record, reason, survivorRef) => { const existing = planned.get(record.ref); if (existing) return existing; const next = { ref: record.ref, parentRef: record.parentRef, reason, ...(survivorRef ? { survivorRef } : {}), filePath: record.filePath, }; planned.set(record.ref, next); return next; }; const planBeliefTransition = (record, toState, reason, currentBeliefRefs = []) => { const normalizedRefs = [...new Set(currentBeliefRefs)].sort(); const metadataChanged = !sameStringArray(record.currentBeliefRefs, normalizedRefs) || (toState === "contradicted" ? !sameStringArray(record.contradictedBy, normalizedRefs) : record.contradictedBy.length > 0); if (record.beliefState === toState && !metadataChanged) return; const existing = beliefTransitions.get(record.ref); if (existing) return existing; const next = { ref: record.ref, parentRef: record.parentRef, fromState: record.beliefState, toState, reason, ...(normalizedRefs[0] ? { relatedRef: normalizedRefs[0] } : {}), ...(normalizedRefs.length > 0 ? { relatedRefs: normalizedRefs, currentBeliefRefs: normalizedRefs } : {}), }; beliefTransitions.set(record.ref, next); return next; }; for (const record of records) { const supersededTarget = firstExistingRef(record.supersededBy, byRef, record.ref); if (supersededTarget) { planPrune(record, "superseded-derived", supersededTarget); continue; } if (record.obsolete) { planPrune(record, "obsolete-derived"); } } const excludedRefs = new Set(planned.keys()); for (const family of byParent.values()) { const activeFamily = family.filter((record) => !excludedRefs.has(record.ref)); const resolution = resolveFamilyContradictions(activeFamily); for (const candidate of resolution.contradictionCandidates) { contradictionCandidates.push(candidate); } for (const transition of resolution.transitions) { const record = byRef.get(transition.ref); if (!record) continue; planBeliefTransition(record, transition.toState, transition.reason, transition.currentBeliefRefs ?? []); } } const excludedForDuplicateDetection = new Set([ ...planned.keys(), ...contradictionCandidates.map((candidate) => candidate.ref), ]); for (const family of byParent.values()) { const active = family.filter((record) => !excludedForDuplicateDetection.has(record.ref)); const byFingerprint = groupBy(active, (record) => record.fingerprint); for (const duplicates of byFingerprint.values()) { if (duplicates.length < 2) continue; const [survivor, ...rest] = sortRecordsForSurvival(duplicates); if (survivor === undefined) continue; for (const duplicate of rest) { planPrune(duplicate, "duplicate-derived", survivor.ref); } } } const consolidationCandidates = []; const excludedForConsolidation = new Set([ ...planned.keys(), ...contradictionCandidates.map((candidate) => candidate.ref), ]); for (const [parentRef, family] of byParent.entries()) { const active = family.filter((record) => !excludedForConsolidation.has(record.ref)); if (active.length < 2) continue; const bySignal = groupBy(active.filter((record) => record.signalKey !== undefined), (record) => record.signalKey); for (const [signal, signalRecords] of bySignal.entries()) { if (signalRecords.length < 2) continue; const ordered = sortRecordsForSurvival(signalRecords); consolidationCandidates.push({ parentRef, signal, refs: ordered.map((record) => record.ref), suggestedSurvivorRef: ordered[0].ref, }); } } const RELATIVE_DATE_RE = /\b(yesterday|last week|last month|last year|\d+ days? ago|\d+ weeks? ago|\d+ months? ago)\b/gi; const relativeDateCandidates = []; for (const record of records) { const matches = record.body.match(RELATIVE_DATE_RE); if (matches && matches.length > 0) { relativeDateCandidates.push({ ref: record.ref, filePath: record.filePath, matches: [...new Set(matches.map((m) => m.toLowerCase()))], }); } } return { analyzedDerived: records.length, pruneCandidates: [...planned.values()] .map(({ filePath: _filePath, ...candidate }) => candidate) .sort(compareCandidates), contradictionCandidates: contradictionCandidates.sort(compareContradictionCandidates), beliefStateTransitions: [...beliefTransitions.values()].sort(compareBeliefTransitions), consolidationCandidates: consolidationCandidates.sort(compareConsolidationCandidates), relativeDateCandidates, }; } export function applyMemoryCleanup(stashDir, plan) { const records = collectDerivedMemories(stashDir); const fileByRef = new Map(records.map((record) => [record.ref, record.filePath])); const archived = []; const appliedBeliefTransitions = []; const warnings = []; for (const transition of plan.beliefStateTransitions) { const filePath = fileByRef.get(transition.ref); if (!filePath) continue; try { persistBeliefStateTransition(filePath, transition); appliedBeliefTransitions.push(transition); } catch (error) { warnings.push(formatApplyWarning("belief-transition", transition.ref, error)); } } let transitionLogPath; if (appliedBeliefTransitions.length > 0) { try { transitionLogPath = appendBeliefStateTransitionLog(stashDir, appliedBeliefTransitions); } catch (error) { warnings.push(formatApplyWarning("transition-log", "memory-cleanup", error)); } } for (const candidate of plan.pruneCandidates) { const filePath = fileByRef.get(candidate.ref); if (!filePath) continue; try { archived.push(archiveCleanupCandidate(stashDir, candidate, filePath)); } catch (error) { warnings.push(formatApplyWarning("archive", candidate.ref, error)); } } // M-5 / #396: Resolve relative dates for flagged candidates. // Anchor: use the file's `createdAt` frontmatter field, or fall back to the // file's mtime. Graphiti arXiv:2501.13956, HeidelTime (Strötgen & Gertz 2010). let relativeDatesResolved = 0; for (const candidate of plan.relativeDateCandidates) { try { const raw = fs.readFileSync(candidate.filePath, "utf8"); const fm = parseFrontmatter(raw); const createdAtStr = fm.data.createdAt; let referenceDate; if (createdAtStr) { const parsed = new Date(createdAtStr); referenceDate = Number.isNaN(parsed.getTime()) ? new Date(fs.statSync(candidate.filePath).mtimeMs) : parsed; } else { referenceDate = new Date(fs.statSync(candidate.filePath).mtimeMs); } const resolvedBody = resolveRelativeDates(fm.content ?? "", referenceDate); if (resolvedBody === (fm.content ?? "")) continue; // no change const newContent = assembleAsset(fm.data, resolvedBody); fs.writeFileSync(candidate.filePath, newContent, "utf8"); // #652: relative-date resolution rewrites the asset in place. recordWrittenPath(candidate.filePath); relativeDatesResolved++; } catch (error) { warnings.push(formatApplyWarning("relative-date-resolve", candidate.ref, error)); } } archived.sort((a, b) => a.ref.localeCompare(b.ref)); appliedBeliefTransitions.sort(compareBeliefTransitions); return { archived, beliefStateTransitions: appliedBeliefTransitions, ...(transitionLogPath ? { transitionLogPath: path.relative(stashDir, transitionLogPath).replace(/\\/g, "/") } : {}), ...(transitionLogPath ? { transitionLogEntries: appliedBeliefTransitions.length } : {}), ...(relativeDatesResolved > 0 ? { relativeDatesResolved } : {}), ...(warnings.length > 0 ? { warnings } : {}), }; } function formatApplyWarning(stage, ref, error) { const detail = error instanceof Error ? error.message : String(error); return `${stage} failed for ${ref}: ${detail}`; } /** * M-5 / #396: Resolve relative date expressions to absolute ISO dates. * * Uses `referenceDate` as the anchor point (Graphiti arXiv:2501.13956, * HeidelTime Strötgen & Gertz 2010 — document creation time as reference). * Replaces patterns like "yesterday", "3 days ago", "last week" with their * ISO 8601 date string (YYYY-MM-DD). * * Returns the rewritten string; returns the original if no matches. */ function resolveRelativeDates(text, referenceDate) { const RELATIVE_DATE_RE = /\b(yesterday|last week|last month|last year|\d+ days? ago|\d+ weeks? ago|\d+ months? ago)\b/gi; return text.replace(RELATIVE_DATE_RE, (match) => { const lower = match.toLowerCase().trim(); const d = new Date(referenceDate); if (lower === "yesterday") { d.setDate(d.getDate() - 1); } else if (lower === "last week") { d.setDate(d.getDate() - 7); } else if (lower === "last month") { d.setMonth(d.getMonth() - 1); } else if (lower === "last year") { d.setFullYear(d.getFullYear() - 1); } else { const numMatch = lower.match(/^(\d+)\s+(day|week|month)s?\s+ago$/); if (numMatch) { const n = Number.parseInt(numMatch[1] ?? "0", 10); const unit = numMatch[2] ?? "day"; if (unit === "day") d.setDate(d.getDate() - n); else if (unit === "week") d.setDate(d.getDate() - n * 7); else if (unit === "month") d.setMonth(d.getMonth() - n); } else { return match; // unrecognized pattern — leave as-is } } return d.toISOString().slice(0, 10); // YYYY-MM-DD }); } function resolveFamilyContradictions(family) { if (family.length === 0) return { contradictionCandidates: [], transitions: [] }; const familyRefSet = new Set(family.map((record) => record.ref)); const edges = new Map(); let edgeCount = 0; for (const record of family) { const targets = [ ...new Set(record.contradictedBy.filter((ref) => ref !== record.ref && familyRefSet.has(ref))), ].sort(); edges.set(record.ref, targets); edgeCount += targets.length; } if (edgeCount === 0) { return { contradictionCandidates: [], transitions: family .filter((record) => { // `deprecated` is a frozen historical state — never refresh it to active. // (`superseded` is intentionally still refreshable to preserve pre-Phase-1A behavior.) if (isFrozenHistoricalBeliefState(record.beliefState)) return false; // `active` and `asserted` are both "current/believed" states. Only emit a // refresh if there is something to clear (contradictions / currentBeliefRefs) // or the state is something else entirely (e.g. lingering `contradicted`). if (isActiveLikeBeliefState(record.beliefState)) { return record.contradictedBy.length > 0 || record.currentBeliefRefs.length > 0; } return true; }) .map((record) => ({ ref: record.ref, parentRef: record.parentRef, fromState: record.beliefState, // Preserve `asserted` authority; otherwise refresh to plain `active`. toState: record.beliefState === "asserted" ? "asserted" : "active", reason: "belief-refresh", })), }; } const { components, componentIndexByRef } = stronglyConnectedComponents(family.map((record) => record.ref), edges); const outgoingComponents = new Map(); for (let index = 0; index < components.length; index += 1) { outgoingComponents.set(index, new Set()); } for (const [ref, targets] of edges.entries()) { const fromIndex = componentIndexByRef.get(ref); if (fromIndex === undefined) continue; for (const target of targets) { const toIndex = componentIndexByRef.get(target); if (toIndex === undefined || toIndex === fromIndex) continue; outgoingComponents.get(fromIndex)?.add(toIndex); } } const sinkComponents = new Set(); for (const [index, outgoing] of outgoingComponents.entries()) { if (outgoing.size === 0) sinkComponents.add(index); } const reachableSinkRefsMemo = new Map(); const reachableSinkRefsForComponent = (index) => { const memoized = reachableSinkRefsMemo.get(index); if (memoized) return memoized; const outgoing = outgoingComponents.get(index); if (!outgoing || outgoing.size === 0) { const refs = [...components[index]].sort(); reachableSinkRefsMemo.set(index, refs); return refs; } const refs = new Set(); for (const nextIndex of outgoing) { for (const ref of reachableSinkRefsForComponent(nextIndex)) refs.add(ref); } const resolved = [...refs].sort(); reachableSinkRefsMemo.set(index, resolved); return resolved; }; const contradictionCandidates = []; const transitions = []; for (const record of family) { const componentIndex = componentIndexByRef.get(record.ref); if (componentIndex === undefined) continue; const isCurrentComponent = sinkComponents.has(componentIndex); const currentRefs = reachableSinkRefsForComponent(componentIndex); if (!isCurrentComponent) { contradictionCandidates.push({ ref: record.ref, parentRef: record.parentRef, reason: "contradicted-derived", contradictedByRef: currentRefs[0], contradictedByRefs: currentRefs, currentBeliefRefs: currentRefs, }); if (record.beliefState !== "contradicted" || !sameStringArray(record.contradictedBy, currentRefs) || !sameStringArray(record.currentBeliefRefs, currentRefs)) { transitions.push({ ref: record.ref, parentRef: record.parentRef, fromState: record.beliefState, toState: "contradicted", reason: "contradicted-derived", relatedRef: currentRefs[0], relatedRefs: currentRefs, currentBeliefRefs: currentRefs, }); } continue; } const componentRefs = [...components[componentIndex]].sort(); const peerCurrentRefs = componentRefs.filter((ref) => ref !== record.ref); // `deprecated` is a frozen historical state — never refresh to active. // (`superseded` is intentionally still refreshable to preserve pre-Phase-1A behavior.) if (isFrozenHistoricalBeliefState(record.beliefState)) { continue; } // For `active` / `asserted` records, only refresh when something changes. // For everything else (e.g. lingering `contradicted`) always refresh. const isActiveLike = isActiveLikeBeliefState(record.beliefState); const needsRefresh = !isActiveLike || record.contradictedBy.length > 0 || !sameStringArray(record.currentBeliefRefs, peerCurrentRefs); if (needsRefresh) { transitions.push({ ref: record.ref, parentRef: record.parentRef, fromState: record.beliefState, // Preserve `asserted` authority; otherwise refresh to plain `active`. toState: record.beliefState === "asserted" ? "asserted" : "active", reason: "belief-refresh", ...(peerCurrentRefs[0] ? { relatedRef: peerCurrentRefs[0], relatedRefs: peerCurrentRefs } : {}), ...(peerCurrentRefs.length > 0 ? { currentBeliefRefs: peerCurrentRefs } : {}), }); } } return { contradictionCandidates: contradictionCandidates.sort(compareContradictionCandidates), transitions: transitions.sort(compareBeliefTransitions), }; } function stronglyConnectedComponents(refs, edges) { let index = 0; const indices = new Map(); const lowLinks = new Map(); const stack = []; const onStack = new Set(); const components = []; const visit = (ref) => { indices.set(ref, index); lowLinks.set(ref, index); index += 1; stack.push(ref); onStack.add(ref); for (const target of edges.get(ref) ?? []) { if (!indices.has(target)) { visit(target); lowLinks.set(ref, Math.min(lowLinks.get(ref) ?? 0, lowLinks.get(target) ?? 0)); } else if (onStack.has(target)) { lowLinks.set(ref, Math.min(lowLinks.get(ref) ?? 0, indices.get(target) ?? 0)); } } if ((lowLinks.get(ref) ?? -1) !== (indices.get(ref) ?? -2)) return; const component = []; while (stack.length > 0) { const member = stack.pop(); onStack.delete(member); component.push(member); if (member === ref) break; } components.push(component.sort()); }; for (const ref of refs) { if (!indices.has(ref)) visit(ref); } const componentIndexByRef = new Map(); for (let componentIndex = 0; componentIndex < components.length; componentIndex += 1) { for (const ref of components[componentIndex]) { componentIndexByRef.set(ref, componentIndex); } } return { components, componentIndexByRef }; } /** * The `.derived` twin of a non-derived memory file, if one exists on disk: * `<name>.derived.md` beside it, the naming convention * `indexer/passes/memory-inference.ts`'s `derivedChildPath` writes. * `undefined` for a knowledge or lesson ref (no such twin exists), or for a * memory that is already itself `.derived` (it has no further twin). * * Used by `akm proposal accept` (alpha.9) to take a retired or promoted * memory's derived child along when it archives the memory. */ export function derivedTwinPath(filePath, refType) { if (refType !== "memory" || filePath.endsWith(`${DERIVED_SUFFIX}.md`)) return undefined; const twin = `${filePath.slice(0, -3)}${DERIVED_SUFFIX}.md`; return fs.existsSync(twin) ? twin : undefined; } /** * True for a retire-proposal-caused archive (alpha.9: the consolidate pair * pass, or O1's promotion retirement) — distinguished from a memory-cleanup * family-prune candidate by carrying a `proposalId`. The two paths differ in * how `previousBeliefState` is derived (below) and in which extra tombstone * fields apply. */ function isRetireCandidate(candidate) { return candidate.proposalId !== undefined; } /** * The tombstone's `previousBeliefState`. Memory cleanup's own family-prune * candidates keep their original reason-based inference (unchanged, so * existing behavior is not disturbed by this generalization). A * retire-proposal candidate has no such reason vocabulary to infer from, so * it reads the asset's ACTUAL frontmatter `beliefState` instead — more * correct, and available because every retire path already has the file on * disk right before the move. */ function resolvePreviousBeliefState(candidate, filePath) { if (!isRetireCandidate(candidate)) return priorBeliefStateForArchive(candidate); try { return resolveBeliefState(parseFrontmatter(fs.readFileSync(filePath, "utf8")).data); } catch { return "active"; } } /** * Move `filePath` into the recoverable cleanup archive * (`.akm/memory-cleanup/archive/<stamp>-<ref>/`) with a `cleanup.md` * tombstone, journaling both ends (`recordWrittenPath`) so a LATER sync * commits the move — `akm sync`, or the batched auto-sync an `akm improve` * run does at its own end (`docs/architecture/improvement.md`, "Auto-sync"). * This call does not itself commit anything: a standalone `akm proposal * accept` (the only way a retire proposal is ever accepted — triage never * auto-accepts one) leaves the move journaled but uncommitted until * something later reads that journal, unless the write target's `kind` is * `"git"`, in which case the caller's own `commitWriteTargetBoundary` commits * (and maybe pushes) immediately as part of the SAME accept. * * Generalized in alpha.9 to cover any memory, knowledge or lesson file in a * writable bundle — not only `.derived` memories — so `akm proposal accept` * can archive a consolidate pair-pass `retire` proposal's target, or (O1) an * accepted promotion's source memory, through the same one encoding memory * cleanup already used (D27: never two coexisting encodings). A * retire-proposal candidate (one carrying `proposalId`) additionally stamps * `proposalId`, `successorRefs` and `retiredAt` on the tombstone. */ export function archiveCleanupCandidate(stashDir, candidate, filePath) { const archivedAt = new Date().toISOString(); const previousBeliefState = resolvePreviousBeliefState(candidate, filePath); const originalPath = path.relative(stashDir, filePath).replace(/\\/g, "/"); const archiveDir = createArchiveDir(stashDir, candidate.ref, archivedAt); const archivedPath = path.join(archiveDir, originalPath); fs.mkdirSync(path.dirname(archivedPath), { recursive: true }); const retiring = isRetireCandidate(candidate); const archiveRef = path.relative(stashDir, archivedPath).replace(/\\/g, "/"); const auditPath = path.join(archiveDir, "cleanup.md"); const auditRef = path.relative(stashDir, auditPath).replace(/\\/g, "/"); const auditAsset = assembleAsset({ schemaVersion: 1, kind: "memory-cleanup-archive", archivedAt, beliefState: "archived", previousBeliefState, ref: candidate.ref, ...(candidate.parentRef ? { parentRef: candidate.parentRef } : {}), reason: candidate.reason, ...(candidate.survivorRef ? { survivorRef: candidate.survivorRef } : {}), originalPath, archivedPath: archiveRef, ...(retiring ? { proposalId: candidate.proposalId, retiredAt: archivedAt } : {}), ...(retiring && candidate.successorRefs && candidate.successorRefs.length > 0 ? { successorRefs: candidate.successorRefs } : {}), }, "Archived derived memory for recoverable cleanup.\n"); // 4c (third review round): write the tombstone BEFORE moving the file — a // crash in between used to leave a file already at archivedPath with no // cleanup.md to explain it (unrecoverable: revert refuses on a missing // tombstone, and nothing else knows this archive dir exists). Reordered, // a crash here instead leaves, at worst, a tombstone describing a move // that has not happened yet, with the file still at its original // location — the ordinary "nothing archived yet" state every caller // already handles. fs.writeFileSync(auditPath, auditAsset, "utf8"); recordWrittenPath(auditPath); fs.renameSync(filePath, archivedPath); // #652: an archive is a delete + a create. BOTH ends are journaled so the // sync stages the removal of the original alongside the archived copy. recordWrittenPath(filePath); recordWrittenPath(archivedPath); return { ref: candidate.ref, ...(candidate.parentRef ? { parentRef: candidate.parentRef } : {}), reason: candidate.reason, beliefState: "archived", previousBeliefState, ...(candidate.survivorRef ? { survivorRef: candidate.survivorRef } : {}), originalPath, archivedPath: archiveRef, auditPath: auditRef, archivedAt, ...(retiring ? { proposalId: candidate.proposalId, retiredAt: archivedAt } : {}), ...(retiring && candidate.successorRefs && candidate.successorRefs.length > 0 ? { successorRefs: candidate.successorRefs } : {}), }; } /** * How long a retirement's archived bytes stay on disk after `retiredAt` * before the purge sweep deletes them. Git history keeps the bytes (D27; * plan §5.4 "Purge"). */ export const RETIRE_GRACE_DAYS = 30; const RETIRE_GRACE_MS = RETIRE_GRACE_DAYS * 24 * 60 * 60 * 1000; /** The one file every archive dir keeps forever — never deleted by the purge sweep. */ const TOMBSTONE_FILENAME = "cleanup.md"; const EMPTY_ARCHIVE_PURGE_RESULT = { purgedDirs: 0, purgedFiles: 0 }; /** * The purge sweep (0.9.17-alpha.9 plan §5.4, §8 step 8): deterministic, no * LLM, run once at improve-run start. Deletes the archived asset bytes — * never `cleanup.md` — of every retirement whose tombstone `retiredAt` is * more than {@link RETIRE_GRACE_DAYS} old AND whose archived files are all * git-tracked and clean at the time of the sweep (see below). Git history * keeps the bytes (D27); the tombstone, and ref resolution through it * (`core/asset/memory-archive.ts`), are unaffected — only the tombstone's * own `originalPath` file(s) are removed. * * Git-backed bundles only: a bundle with no `.git` of its own has no history * to fall back on, so its archive is left untouched (`akm health` reports * its size instead — see `health/archive-usage.ts`). Every deleted path is * journaled (`recordWrittenPath`) so the end-of-run sync commits the * removal, the same way it commits the archive move itself (#652). * * `.git` presence is necessary but NOT sufficient: `proposal accept` only * commits for a `kind: "git"` write target (`core/write-source.ts` * `commitWriteTargetBoundary`), and improve's own auto-sync stages only the * paths its own run wrote. A filesystem-kind bundle that merely happens to * have a `.git` directory (e.g. the owner committing by hand, or an old * repo that was never configured as a git source) can carry retirements * that were archived but never committed — deleting those would lose the * only surviving copy. So every archived file under a directory past grace * is checked against `git ls-files` (tracked), `git status --porcelain * -uall` (clean), and `git ls-files -v` (verifiable — an assume-unchanged * or skip-worktree file hides its own edits from `git status`, so it is * never trusted as clean either) — each computed ONCE per sweep, not per * directory; a directory with even one untracked, modified, or * unverifiable file (tombstone included) is left whole for a later sweep. * If any of those three git calls itself fails (a broken submodule can fail * `git status` while `git ls-files` still succeeds, or git can be missing * from `PATH` entirely), the whole sweep purges nothing and warns once — * an empty result from a FAILED check is never treated the same as a * verified-empty one. * * A memory-cleanup family-prune archive (not a retire proposal's) carries no * `retiredAt` in its tombstone at all, so it is never a candidate here — * this sweep only ever touches retirements, never that older archive class. */ export function purgeGracedArchive(stashDir, now = new Date()) { if (!isGitBackedStash(stashDir)) return EMPTY_ARCHIVE_PURGE_RESULT; const archiveRoot = path.join(stashDir, MEMORY_ARCHIVE_REL); let entries; try { entries = fs.readdirSync(archiveRoot, { withFileTypes: true }); } catch { return EMPTY_ARCHIVE_PURGE_RESULT; // no archive yet } const cutoffMs = now.getTime() - RETIRE_GRACE_MS; // One git inspection per sweep, not per directory. All three sets are // repo-relative POSIX paths, matched below against each archived file's // own repo-relative path — a file is safe to delete only if it is in // `tracked`, NOT in `dirty`, and NOT in `unverifiable`. // // Each of the three git calls can itself fail independently — a broken // submodule can make `git status` exit nonzero while `git ls-files` // succeeds, or vice versa (round-3 review, probes G8/G9). `[]` from a // failed call is indistinguishable from a genuinely empty result once it // is in a Set, so this checks `ok` FIRST: any failure purges nothing this // sweep rather than silently trusting whichever check happened to // succeed — a `dirty`/`unverifiable` set that came back empty ONLY // because the call failed must never read as "nothing to protect". // G10: assume-unchanged / skip-worktree files never show up as dirty even // when genuinely modified — `checkGitPathSafety` treats them the same as // "not tracked" below, so such a file (and its whole retirement) is left // for a later sweep. const gitSafety = checkGitPathSafety(stashDir, MEMORY_ARCHIVE_REL); if (!gitSafety.ok) { warn(`[improve] archive purge: skipped this sweep — could not determine the archive's git state at ${stashDir} ` + "(git status/ls-files failed); nothing was purged."); return EMPTY_ARCHIVE_PURGE_RESULT; } let purgedDirs = 0; let purgedFiles = 0; for (const entry of entries) { // `Dirent.isDirectory()` reflects `lstat`, so it is false for a symlink // even when the symlink points at a directory — a symlinked // `archive/<name>` is skipped here, never followed (N1). Everything // below only ever joins path components onto `archiveRoot` through // `entry.name`/`readdirSync` results, so a purge can never reach // outside `.akm/memory-cleanup/archive/`. if (!entry.isDirectory()) continue; const dir = path.join(archiveRoot, entry.name); let data; try { data = parseFrontmatter(fs.readFileSync(path.join(dir, TOMBSTONE_FILENAME), "utf8")).data; } catch { continue; // not a tombstone dir, or unreadable — never guess } const retiredAt = data.retiredAt; if (typeof retiredAt !== "string") continue; // family-prune archive, not a retirement — out of scope const retiredMs = Date.parse(retiredAt); if (!Number.isFinite(retiredMs) || retiredMs >= cutoffMs) continue; // "more than" the grace period — exactly at it is not enough const allFiles = listFilesRecursive(dir); // tombstone included — the whole entry must be a clean, committed unit const isSafeToPurge = allFiles.every((filePath) => gitSafety.isSafe(toPosix(path.relative(stashDir, filePath)))); if (!isSafeToPurge) continue; // untracked, modified, or unverifiable entry — skip the whole directory this sweep (B1, G10) let children; try { children = fs.readdirSync(dir); } catch { continue; } let purgedAnyInThisDir = false; for (const child of children) { if (child === TOMBSTONE_FILENAME) continue; const childPath = path.join(dir, child); // The archived original path may be nested (e.g. `memories/sub/foo.md` // under this dir) — the journal (like git) tracks FILES, so every leaf // under childPath is recorded individually, not the directory itself. const filesUnderChild = listFilesRecursive(childPath); try { fs.rmSync(childPath, { recursive: true, force: true }); for (const filePath of filesUnderChild) recordWrittenPath(filePath); purgedFiles += filesUnderChild.length; purgedAnyInThisDir = purgedAnyInThisDir || filesUnderChild.length > 0; } catch { // Best-effort: a locked or already-gone entry is skipped, not fatal to the run. } } if (purgedAnyInThisDir) purgedDirs++; } return { purgedDirs, purgedFiles }; } /** Every file under `target` (itself included if it's a file), for individual journaling before a recursive delete. */ function listFilesRecursive(target) { let stat; try { stat = fs.lstatSync(target); } catch { return []; } if (!stat.isDirectory()) return stat.isFile() ? [target] : []; let children; try { children = fs.readdirSync(target); } catch { return []; } return children.flatMap((child) => listFilesRecursive(path.join(target, child))); } function persistBeliefStateTransition(filePath, transition) { mutateFrontmatter(filePath, (parsed) => { const nextFrontmatter = { ...parsed.data, beliefState: transition.toState, }; const currentBeliefRefs = [...new Set(transition.currentBeliefRefs ?? [])].sort(); if (transition.toState === "contradicted") { nextFrontmatter.contradictedBy = [...currentBeliefRefs]; } else { delete nextFrontmatter.contradictedBy; if (parsed.data.supersededBy !== undefined && refArray(parsed.data.supersededBy).length === 0) { delete nextFrontmatter.supersededBy; } } if (currentBeliefRefs.length > 0) nextFrontmatter.currentBeliefRefs = [...currentBeliefRefs]; else delete nextFrontmatter.currentBeliefRefs; return nextFrontmatter; }); } function appendBeliefStateTransitionLog(stashDir, transitions) { const logDir = path.join(stashDir, ".akm", "memory-cleanup"); fs.mkdirSync(logDir, { recursive: true }); const logPath = path.join(logDir, "belief-transitions.jsonl"); const appliedAt = new Date().toISOString(); const lines = transitions .map((transition) => JSON.stringify({ appliedAt, ref: transition.ref, parentRef: transition.parentRef, fromState: transition.fromState, toState: transition.toState, reason: transition.reason, ...(transition.relatedRef ? { relatedRef: transition.relatedRef } : {}), ...(transition.relatedRefs ? { relatedRefs: transition.relatedRefs } : {}), ...(transition.currentBeliefRefs ? { currentBeliefRefs: transition.currentBeliefRefs } : {}), })) .join("\n"); fs.appendFileSync(logPath, `${lines}\n`, "utf8"); recordWrittenPath(logPath); return logPath; } function priorBeliefStateForArchive(candidate) { if (candidate.reason === "superseded-derived") return "superseded"; return "active"; } function createArchiveDir(stashDir, ref, archivedAt) { const baseName = `${archivedAt.replace(/[:.]/g, "-")}-${sanitizeRef(ref)}`; const root = path.join(stashDir, MEMORY_ARCHIVE_REL); fs.mkdirSync(root, { recursive: true }); let attempt = 0; while (true) { const candidate = path.join(root, attempt === 0 ? baseName : `${baseName}-${attempt}`); if (!fs.existsSync(candidate)) { fs.mkdirSync(candidate, { recursive: true }); return candidate; } attempt += 1; } } function sanitizeRef(ref) { return ref.replace(/[^a-z0-9._-]+/gi, "-"); } function collectDerivedMemories(stashDir, parentRefFilter) { const memoriesDir = path.join(stashDir, "memories"); if (!fs.existsSync(memoriesDir)) return []; const records = []; const walked = walkMarkdownFiles(memoriesDir); if (!walked.complete) { warn(`memory improve: directory scan under ${memoriesDir} is incomplete — some derived memories may be missing`); } for (const filePath of walked.files) { const name = toMemoryName(memoriesDir, filePath); if (!name) continue; let raw; try { raw = fs.readFileSync(filePath, "utf8"); } catch { continue; } const parsed = parseFrontmatter(raw); const parentRef = resolveParentRef(name, parsed.data); if (!parentRef) continue; if (parentRefFilter && parentRef !== parentRefFilter) continue; if (!isDerivedMemory(name, parsed.data)) continue; const title = asNonEmptyString(parsed.data.title) ?? extractHeading(parsed.content) ?? ""; const description = asNonEmptyString(parsed.data.description) ?? ""; const tags = stringArray(parsed.data.tags); const searchHints = stringArray(parsed.data.searchHints); const body = parsed.content.trim(); const signalKey = normalizeSignal(firstNonEmpty([title, description, searchHints[0]])); records.push({ ref: `memory:${name}`, name, filePath, parentRef, title, description, tags, searchHints, body, // parentRef is the 0.9.0 `memories/<name>` conceptId (Group-C item 2), so // the canonical-child test compares the minted conceptId of the suffix- // stripped name against it rather than slicing a fixed `memory:` prefix. canonicalName: name.endsWith(DERIVED_SUFFIX) && parentRef === conceptIdFromTypeName("memory", name.slice(0, -DERIVED_SUFFIX.length)), signalScore: computeSignalScore(title, description, tags, searchHints, body), fingerprint: buildFingerprint(title, description, tags, searchHints, body), ...(signalKey ? { signalKey } : {}), supersededBy: refArray(parsed.data.supersededBy), contradictedBy: refArray(parsed.data.contradictedBy), currentBeliefRefs: refArray(parsed.data.currentBeliefRefs), obsolete: parsed.data.obsolete === true || parsed.data.retracted === true, beliefState: resolveBeliefState(parsed.data), }); } return records.sort(compareRecords); } /** * `active` and `asserted` are both "currently believed" states. `asserted` carries * stronger user-explicit authority (set by the hot-path `akm remember`) but for * state-machine purposes (contradiction resolution, refresh logic) they are * equivalent. */ function isActiveLikeBeliefState(state) { return state === "active" || state === "asserted"; } /** * `deprecated` is a frozen historical state introduced in Phase 1A. Once * recorded, the contradiction-resolution pass must not refresh it back to * active. (`contradicted` is also historical but it *is* updated by the * contradiction resolver, so it is treated separately.) * * Note: `superseded` is deliberately NOT included here. Pre-Phase-1A, * `superseded` records were refreshed to `active` by the belief-refresh * pass (the old guard was `record.beliefState !== "active"`). In practice * most `superseded` records are pruned earlier via `supersededBy` metadata * in `analyzeMemoryCleanup`, so they never reach belief refresh — but a * record marked `beliefState: superseded` without `supersededBy` metadata * was previously refreshable. Preserving that behavior here avoids a * surprise regression; only `deprecated` is the new frozen state. */ function isFrozenHistoricalBeliefState(state) { return state === "deprecated"; } function resolveBeliefState(frontmatter) { const explicit = asNonEmptyString(frontmatter.beliefState); if (explicit === "active" || explicit === "asserted" || explicit === "deprecated" || explicit === "superseded" || explicit === "contradicted") { return explicit; } return "active"; } // Belief-edge refs (contradictedBy / supersededBy / currentBeliefRefs) are the // IDENTITY channel: they are compared against a derived memory's own // `memory:<name>` ref (resolveFamilyContradictions' familyRefSet, // firstExistingRef's byRef map), so they are NORMALIZED to `memory:<name>` here. // On disk they arrive in either spelling — `memory:<name>` from pre-0.9.0 // writes, or the `[<bundle>//]memories/<name>` conceptId that // `writeSupersededEdge` persists today — and // `parseMemoryName` accepts both. Reading only the first spelling silently // dropped every edge the current write path produces. function refArray(value) { if (typeof value === "string") { const name = parseMemoryName(value); return name === undefined ? [] : [memoryIdentityRef(name)]; } if (!Array.isArray(value)) return []; const refs = new Set(); for (const item of value) { if (typeof item !== "string") continue; const name = parseMemoryName(item); if (name !== undefined) refs.add(memoryIdentityRef(name)); } return [...refs].sort(); } function buildFingerprint(title, description, tags, searchHints, body) { return contentHash(JSON.stringify({ title: normalizeSignal(title), description: normalizeSignal(description), tags: normalizeList(tags), searchHints: normalizeList(searchHints), body: normalizeBody(body), })); } function normalizeBody(value) { return value .replace(/^#+\s+/gm, "") .replace(/[`*_>#-]+/g, " ") .toLowerCase() .replace(/\s+/g, " ") .trim(); } function normalizeSignal(value) { if (!value) return undefined; const normalized = value.toLowerCase().replace(/\s+/g, " ").trim(); return normalized.length > 0 ? normalized : undefined; } function normalizeList(values) { return [ ...new Set(values.map((value) => normalizeSignal(value)).filter((value) => value !== undefined)), ].sort(); } function computeSignalScore(title, description, tags, searchHints, body) { return [title, description, body].join("\n").trim().length + tags.length * 25 + searchHints.length * 10; } function sortRecordsForSurvival(records) { return [...records].sort((a, b) => { if (a.canonicalName !== b.canonicalName) return a.canonicalName ? -1 : 1; if (a.signalScore !== b.signalScore) return b.signalScore - a.signalScore; return compareRecords(a, b); }); } function compareRecords(a, b) { return a.ref.localeCompare(b.ref); } function compareCandidates(a, b) { return a.ref.localeCompare(b.ref); } function compareContradictionCandidates(a, b) { return a.ref.localeCompare(b.ref); } function compareBeliefTransitions(a, b) { return a.ref.localeCompare(b.ref); } function compareConsolidationCandidates(a, b) { return a.parentRef.localeCompare(b.parentRef) || a.signal.localeCompare(b.signal); } function firstExistingRef(refs, byRef, selfRef) { for (const ref of refs) { if (ref === selfRef) continue; if (byRef.has(ref)) return ref; } return undefined; } function sameStringArray(a, b) { if (a.length !== b.length) return false; for (let index = 0; index < a.length; index += 1) { if (a[index] !== b[index]) return false; } return true; } function extractHeading(content) { for (const line of content.split(/\r?\n/)) { const match = line.match(/^#\s+(.+)$/); if (match?.[1]) return match[1].trim(); } return undefined; } function firstNonEmpty(values) { for (const value of values) { if (value && value.trim().length > 0) return value; } return undefined; } function toMemoryName(memoriesDir, filePath) { const rel = path.relative(memoriesDir, filePath); if (!rel || rel.startsWith("..")) return undefined; return rel.replace(/\\/g, "/").replace(/\.md$/i, ""); }