AkurAI Build
Menu

popagent

public

Latest change 59f3c89df7ac29e072aafa31e6ad04dc601a750d - Add bounded repository brief service by AkurAI Build

import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";
import ts from "typescript";
import { agentWorkspaces, type AgentWorkspaceStore } from "./agent-workspaces";

const MAX_QUERY_CHARACTERS = 2_000;
const MAX_CANDIDATES = 8;
const MAX_CHANGED_FILES = 20;
const MAX_SYMBOLS_PER_FILE = 12;
const SOURCE_EXTENSIONS: Record<string, true> = { ".ts": true, ".tsx": true, ".js": true, ".jsx": true, ".mjs": true, ".cjs": true };
const EXCLUDED_PATH_SEGMENTS: Record<string, true> = { node_modules: true, ".git": true, dist: true, build: true, coverage: true, ".env": true };
const STOP_WORDS: Record<string, true> = {
  about: true, after: true, agent: true, also: true, before: true, between: true, change: true, code: true, could: true, does: true,
  from: true, have: true, into: true, make: true, more: true, need: true, should: true, that: true, their: true, this: true, with: true,
};

type BriefSymbol = { name: string; kind: "class" | "const" | "function" | "interface" | "type"; line: number };
type BriefCandidate = { path: string; score: number; symbols: BriefSymbol[] };
export type RepositoryBrief = {
  repository: { clean: boolean; changedFiles: string[] };
  candidates: BriefCandidate[];
  tests: string[];
  readyForImplementation: boolean;
  gaps: string[];
};

function queryTerms(query: string): string[] {
  const terms = query.toLowerCase().match(/[a-z][a-z0-9]*/g) ?? [];
  return [...new Set(terms.filter((term) => term.length > 2 && !STOP_WORDS[term]))];
}

function isSourcePath(path: string): boolean {
  return Boolean(SOURCE_EXTENSIONS[extname(path)])
    && !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path)
    && !path.split("/").some((segment) => EXCLUDED_PATH_SEGMENTS[segment]);
}

function scorePath(path: string, terms: string[]): number {
  const normalized = path.toLowerCase();
  const name = basename(path, extname(path)).toLowerCase();
  return terms.reduce((score, term) => {
    if (name === term) return score + 60;
    if (name.includes(term)) return score + 30;
    if (normalized.includes(term)) return score + 12;
    return score;
  }, 0);
}

function exportedSymbols(path: string, source: string): BriefSymbol[] {
  const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, false);
  const symbols: BriefSymbol[] = [];
  for (const statement of file.statements) {
    if (!ts.canHaveModifiers(statement) || !ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
    const line = file.getLineAndCharacterOfPosition(statement.getStart(file)).line + 1;
    if (ts.isFunctionDeclaration(statement) && statement.name) symbols.push({ name: statement.name.text, kind: "function", line });
    if (ts.isClassDeclaration(statement) && statement.name) symbols.push({ name: statement.name.text, kind: "class", line });
    if (ts.isInterfaceDeclaration(statement)) symbols.push({ name: statement.name.text, kind: "interface", line });
    if (ts.isTypeAliasDeclaration(statement)) symbols.push({ name: statement.name.text, kind: "type", line });
    if (ts.isVariableStatement(statement)) {
      for (const declaration of statement.declarationList.declarations) {
        if (ts.isIdentifier(declaration.name)) symbols.push({ name: declaration.name.text, kind: "const", line });
      }
    }
    if (symbols.length >= MAX_SYMBOLS_PER_FILE) break;
  }
  return symbols;
}

async function git(path: string, args: string[]): Promise<string> {
  const process = Bun.spawn(["git", "-C", path, ...args], { stdout: "pipe", stderr: "pipe" });
  if (await process.exited !== 0) throw new Error((await new Response(process.stderr).text()).trim() || "Git inspection failed");
  return new Response(process.stdout).text();
}

function changedPaths(status: string): string[] {
  return status.split("\n")
    .filter(Boolean)
    .map((line) => line.slice(3).replace(/^.* -> /, ""))
    .filter((path) => !path.split("/").some((segment) => EXCLUDED_PATH_SEGMENTS[segment]))
    .slice(0, MAX_CHANGED_FILES);
}

function testCommand(files: string[], candidate: string): string | undefined {
  const stem = candidate.replace(/\.(?:[cm]?[jt]sx?)$/, "");
  const test = files.find((file) => file === `${stem}.test.ts` || file === `${stem}.test.tsx` || file === `${stem}.spec.ts`);
  return test ? `bun test ${test}` : undefined;
}

export class RepositoryBriefService {
  constructor(private readonly workspaces: Pick<AgentWorkspaceStore, "resolveRepository"> = agentWorkspaces) {}

  async build(workspaceId: string, query: string): Promise<RepositoryBrief> {
    const request = query.trim();
    if (!request || request.length > MAX_QUERY_CHARACTERS) throw new Error("Repository brief query must be 1–2000 characters");
    const { path } = await this.workspaces.resolveRepository(workspaceId);
    const [status, tracked] = await Promise.all([
      git(path, ["status", "--porcelain=v1"]),
      git(path, ["ls-files", "--cached", "--others", "--exclude-standard"]),
    ]);
    const files = tracked.split("\n").filter(Boolean).filter((file) => !file.split("/").some((segment) => EXCLUDED_PATH_SEGMENTS[segment]));
    const terms = queryTerms(request);
    const ranked = files
      .filter(isSourcePath)
      .map((file) => ({ path: file, score: scorePath(file, terms) }))
      .filter((candidate) => candidate.score > 0)
      .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path))
      .slice(0, MAX_CANDIDATES);
    const candidates = await Promise.all(ranked.map(async (candidate) => {
      const source = await readFile(`${path}/${candidate.path}`, "utf8");
      return { ...candidate, symbols: exportedSymbols(candidate.path, source) };
    }));
    const tests = [...new Set(candidates.map((candidate) => testCommand(files, candidate.path)).filter((command): command is string => Boolean(command)))];
    const highestScore = candidates[0]?.score ?? 0;
    return {
      repository: { clean: status.length === 0, changedFiles: changedPaths(status) },
      candidates,
      tests,
      readyForImplementation: highestScore >= 30 && candidates.length > 0,
      gaps: candidates.length ? [] : ["No tracked source path matched the request terms."],
    };
  }
}

export const repositoryBrief = new RepositoryBriefService();