Menu
popagent
publicLatest change ac1b32888da9db0a129d0e91ff5cb4dac22c12f0 - Add workspace documentation RAG by AkurAI Build
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import type { AgentWorkspace, DocumentationPage, DocumentationPageSummary } from "./api-types";
export const MAX_DOCUMENTATION_CHARACTERS = 1_000_000;
type WorkspaceResolver = {
resolveRepository(id: string): Promise<{ workspace: AgentWorkspace; path: string }>;
};
export class DocumentationConflictError extends Error {
constructor(readonly current: DocumentationPage) {
super("Documentation revision conflict");
}
}
function contained(root: string, candidate: string): boolean {
const path = relative(root, candidate);
return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
}
function documentationPath(path: string): string {
const normalized = path.trim().replaceAll("\\", "/").replace(/^\.\//, "");
if (
normalized === "README.md"
|| (normalized.startsWith("docs/") && normalized.endsWith(".md"))
) {
const parts = normalized.split("/");
if (parts.every((part) => part && part !== "." && part !== ".." && !part.startsWith("."))) return normalized;
}
throw new RangeError("Invalid documentation path");
}
function digest(content: string): string {
return new Bun.CryptoHasher("sha256").update(content).digest("hex");
}
function pageTitle(path: string, content: string): string {
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
if (heading) return heading.replace(/\s+#+$/, "");
return path.split("/").at(-1)!.replace(/\.md$/i, "").replaceAll(/[-_]+/g, " ");
}
export class DocumentationFiles {
constructor(private readonly workspaces: WorkspaceResolver) {}
async list(workspaceId: string): Promise<DocumentationPageSummary[]> {
const { path: root } = await this.workspaces.resolveRepository(workspaceId);
const paths: string[] = [];
if (await stat(resolve(root, "README.md")).then((item) => item.isFile()).catch(() => false)) {
paths.push("README.md");
}
const walk = async (directory: string, prefix: string): Promise<void> => {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.isSymbolicLink() || entry.name.startsWith(".")) continue;
const childPrefix = `${prefix}/${entry.name}`;
if (entry.isDirectory()) await walk(resolve(directory, entry.name), childPrefix);
else if (entry.isFile() && entry.name.endsWith(".md")) paths.push(childPrefix);
}
};
await walk(resolve(root, "docs"), "docs");
const pages = await Promise.all(paths.sort().map((path) => this.read(workspaceId, path)));
return pages.map(({ content: _content, ...summary }) => summary);
}
async read(workspaceId: string, inputPath: string): Promise<DocumentationPage> {
const path = documentationPath(inputPath);
const { path: repository } = await this.workspaces.resolveRepository(workspaceId);
const root = await realpath(repository);
const lexical = resolve(root, path);
const candidate = await realpath(lexical).catch(() => undefined);
const parent = await realpath(dirname(lexical)).catch(() => undefined);
const symbolic = await lstat(lexical).then((item) => item.isSymbolicLink()).catch(() => false);
if (!candidate || !contained(root, candidate) || parent !== dirname(lexical) || symbolic) {
throw new Error("Documentation page not found");
}
const info = await stat(candidate);
if (!info.isFile() || info.size > MAX_DOCUMENTATION_CHARACTERS * 4) {
throw new Error("Documentation page not found");
}
const content = await readFile(candidate, "utf8");
if (content.length > MAX_DOCUMENTATION_CHARACTERS) throw new RangeError("Documentation page is too large");
return {
path,
title: pageTitle(path, content),
content,
revision: digest(content),
size: info.size,
updatedAt: info.mtime.toISOString(),
};
}
async save(workspaceId: string, input: { path: string; content: string; revision?: string }): Promise<DocumentationPage> {
const path = documentationPath(input.path);
if (input.content.length > MAX_DOCUMENTATION_CHARACTERS) throw new RangeError("Documentation page is too large");
const { path: repository } = await this.workspaces.resolveRepository(workspaceId);
const root = await realpath(repository);
const target = resolve(root, path);
if (!contained(root, target)) throw new RangeError("Invalid documentation path");
const current = await this.read(workspaceId, path).catch((error) => {
if (error instanceof Error && error.message === "Documentation page not found") return undefined;
throw error;
});
if (current && input.revision !== current.revision) throw new DocumentationConflictError(current);
if (!current && input.revision) throw new Error("Documentation page not found");
const parent = resolve(root, dirname(path));
await mkdir(parent, { recursive: true });
const realParent = await realpath(parent);
if (!contained(root, realParent) || realParent !== parent) throw new RangeError("Invalid documentation path");
const temporary = resolve(realParent, `.${crypto.randomUUID()}.tmp`);
await writeFile(temporary, input.content, { mode: 0o644 });
await rename(temporary, target);
return this.read(workspaceId, path);
}
async move(workspaceId: string, inputPath: string, nextInputPath: string, revision: string): Promise<DocumentationPage> {
const current = await this.read(workspaceId, inputPath);
if (revision !== current.revision) throw new DocumentationConflictError(current);
const nextPath = documentationPath(nextInputPath);
if (nextPath === current.path) return current;
const { path: repository } = await this.workspaces.resolveRepository(workspaceId);
const root = await realpath(repository);
const source = await realpath(resolve(root, current.path));
if (!contained(root, source)) throw new RangeError("Invalid documentation path");
const target = resolve(root, nextPath);
if (await stat(target).then(() => true).catch(() => false)) throw new Error("Documentation page already exists");
const parent = resolve(root, dirname(nextPath));
await mkdir(parent, { recursive: true });
const realParent = await realpath(parent);
if (!contained(root, realParent) || realParent !== parent) throw new RangeError("Invalid documentation path");
await rename(source, target);
return this.read(workspaceId, nextPath);
}
async remove(workspaceId: string, inputPath: string, revision: string): Promise<void> {
const current = await this.read(workspaceId, inputPath);
if (revision !== current.revision) throw new DocumentationConflictError(current);
const { path: repository } = await this.workspaces.resolveRepository(workspaceId);
const root = await realpath(repository);
const candidate = await realpath(resolve(root, current.path));
if (!contained(root, candidate)) throw new RangeError("Invalid documentation path");
await rm(candidate);
}
}