AkurAI Build
Menu

popagent

public

Latest 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);
  }
}