Menu
popagent
publicLatest 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();