Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/okf/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/okf/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,56 @@ 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;
current = path.dirname(current);
}
}

/**
* 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<string[]> {
const removed: string[] = [];

async function visit(absDir: string): Promise<void> {
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);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/okf/knowledge-base.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -120,6 +120,10 @@ export class KnowledgeBase {
action: LogAction,
logSummary: string
): Promise<void> {
// 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}.`);
Expand Down
42 changes: 42 additions & 0 deletions packages/core/test/okf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading