diff --git a/packages/core/src/okf/index.ts b/packages/core/src/okf/index.ts index 0216864..f5f9c16 100644 --- a/packages/core/src/okf/index.ts +++ b/packages/core/src/okf/index.ts @@ -1,7 +1,7 @@ export * from "./types.js"; export { parseDoc, serializeDoc, hasNonEmptyType } from "./frontmatter.js"; export { Bundle, BundleError, replaceSection } from "./bundle.js"; -export { regenerateIndex, regenerateIndexChain } from "./indexer.js"; +export { regenerateIndex, regenerateIndexChain, pruneEmptyDirs } from "./indexer.js"; export { appendLog, readLog } from "./logger.js"; export { searchBundle, listTypes, type SearchOptions } from "./search.js"; export { validateBundle } from "./validate.js"; diff --git a/packages/core/src/okf/indexer.ts b/packages/core/src/okf/indexer.ts index 42da85e..91be9a7 100644 --- a/packages/core/src/okf/indexer.ts +++ b/packages/core/src/okf/indexer.ts @@ -59,6 +59,16 @@ export async function regenerateIndexChain(bundle: Bundle, dir: string): Promise let current = bundle.resolve(dir); // If given a file path, start from its directory. if (current.endsWith(".md")) current = path.dirname(current); + // The directory may have been pruned away — start from the nearest ancestor + // that still exists (the root always exists). + while (current !== bundle.root) { + try { + await fs.access(current); + break; + } catch { + current = path.dirname(current); + } + } while (true) { await regenerateIndex(bundle, bundle.toBundlePath(current)); if (current === bundle.root) break; @@ -66,6 +76,39 @@ export async function regenerateIndexChain(bundle: Bundle, dir: string): Promise } } +/** + * Remove directories whose only content is the auto-generated index.md + * (issue #10: agents move/merge concepts and leave undeletable husks — + * delete_concept refuses reserved filenames by design, so cleanup must be + * deterministic). Bottom-up over the whole bundle; a directory emptied by + * pruning its children is pruned too. The root and dot-directories + * (.traces etc.) are never touched. Returns removed bundle paths. + */ +export async function pruneEmptyDirs(bundle: Bundle): Promise { + const removed: string[] = []; + + async function visit(absDir: string): Promise { + const entries = await fs.readdir(absDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith(".")) { + await visit(path.join(absDir, entry.name)); + } + } + if (absDir === bundle.root) return; + // Re-read: children may have been pruned during the recursion above. + const remaining = await fs.readdir(absDir); + const onlyIndex = + remaining.length === 0 || (remaining.length === 1 && remaining[0] === "index.md"); + if (onlyIndex) { + await fs.rm(absDir, { recursive: true, force: true }); + removed.push(bundle.toBundlePath(absDir)); + } + } + + await visit(bundle.root); + return removed; +} + function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/packages/core/src/okf/knowledge-base.ts b/packages/core/src/okf/knowledge-base.ts index 8d161f0..ce923c5 100644 --- a/packages/core/src/okf/knowledge-base.ts +++ b/packages/core/src/okf/knowledge-base.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { simpleGit, type SimpleGit } from "simple-git"; import { Bundle } from "./bundle.js"; -import { regenerateIndexChain } from "./indexer.js"; +import { pruneEmptyDirs, regenerateIndexChain } from "./indexer.js"; import { appendLog, readLog } from "./logger.js"; import { searchBundle, listTypes, type SearchOptions } from "./search.js"; import { validateBundle } from "./validate.js"; @@ -120,6 +120,10 @@ export class KnowledgeBase { action: LogAction, logSummary: string ): Promise { + // Sweep husks first (dirs holding only their auto-generated index.md) so + // the reindex below never resurrects a pruned directory. Whole-bundle: + // cheap at this scale, and it also heals husks from before this feature. + await pruneEmptyDirs(this.bundle); await regenerateIndexChain(this.bundle, path.posix.dirname(conceptPath)); const linked = `[${conceptPath.split("/").pop()}](${conceptPath})`; await appendLog(this.bundle, action, logSummary || `${action} of ${linked}.`); diff --git a/packages/core/test/okf.test.ts b/packages/core/test/okf.test.ts index ce1ecc2..670c90d 100644 --- a/packages/core/test/okf.test.ts +++ b/packages/core/test/okf.test.ts @@ -270,3 +270,45 @@ describe("mutation serialization", () => { expect(concepts.length).toBe(8); }); }); + +describe("empty directory pruning (#10)", () => { + it("prunes a directory left holding only its index.md after the last concept is deleted", async () => { + await kb.writeConcept("/family/children/kid.md", { type: "Person", title: "Kid" }, "x", "add"); + await kb.writeConcept("/family/parent.md", { type: "Person", title: "Parent" }, "x", "add"); + await kb.deleteConcept("/family/children/kid.md", "moved elsewhere"); + + // /family/children held only index.md → gone; /family still has parent.md → kept. + await expect(fs.access(path.join(root, "family/children"))).rejects.toThrow(); + await expect(fs.access(path.join(root, "family/parent.md"))).resolves.toBeUndefined(); + // Parent index no longer lists the pruned subdirectory. + const familyIndex = await fs.readFile(path.join(root, "family/index.md"), "utf-8"); + expect(familyIndex).not.toContain("children"); + }); + + it("prunes emptied ancestor chains but never the root", async () => { + await kb.writeConcept("/a/b/c/deep.md", { type: "T", title: "Deep" }, "x", "add"); + await kb.writeConcept("/other/keep.md", { type: "T", title: "Keep" }, "x", "add"); + await kb.deleteConcept("/a/b/c/deep.md", "gone"); + + for (const dir of ["a/b/c", "a/b", "a"]) { + await expect(fs.access(path.join(root, dir))).rejects.toThrow(); + } + await expect(fs.access(root)).resolves.toBeUndefined(); + const rootIndex = await fs.readFile(path.join(root, "index.md"), "utf-8"); + expect(rootIndex).not.toContain("[a](a/)"); + expect(rootIndex).toContain("other"); + }); + + it("heals pre-existing husks on the next unrelated mutation and spares dot-dirs", async () => { + // Simulate an old husk + a .traces dir behind the KB's back. + await fs.mkdir(path.join(root, "husk"), { recursive: true }); + await fs.writeFile(path.join(root, "husk/index.md"), "# Husk\n"); + await fs.mkdir(path.join(root, ".traces"), { recursive: true }); + await fs.writeFile(path.join(root, ".traces/t.json"), "{}"); + + await kb.writeConcept("/fresh.md", { type: "T", title: "Fresh" }, "x", "add"); + + await expect(fs.access(path.join(root, "husk"))).rejects.toThrow(); + await expect(fs.access(path.join(root, ".traces/t.json"))).resolves.toBeUndefined(); + }); +});