kthulu/codebase/@packages/agent-core/src/context-builder.ts
2026-03-06 16:43:05 -08:00

193 lines
5.1 KiB
TypeScript

import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative } from 'node:path';
const IGNORED_DIRS = new Set(['node_modules', 'dist', '.git', '.next', '__pycache__']);
const MAX_OUTPUT_CHARS = 8000;
const README_EXCERPT_CHARS = 500;
const MAX_DEPS_SHOWN = 10;
interface DirectoryEntry {
name: string;
isDir: boolean;
children?: DirectoryEntry[];
}
async function readDirTree(dir: string, depth: number, maxDepth: number): Promise<DirectoryEntry[]> {
if (depth > maxDepth) return [];
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return [];
}
const result: DirectoryEntry[] = [];
for (const name of entries.sort()) {
if (IGNORED_DIRS.has(name) || name.startsWith('.')) continue;
const fullPath = join(dir, name);
let isDir = false;
try {
const s = await stat(fullPath);
isDir = s.isDirectory();
} catch {
continue;
}
const entry: DirectoryEntry = { name, isDir };
if (isDir && depth < maxDepth) {
entry.children = await readDirTree(fullPath, depth + 1, maxDepth);
}
result.push(entry);
}
return result;
}
function formatDirTree(entries: DirectoryEntry[], indent = 0): string {
const lines: string[] = [];
const prefix = ' '.repeat(indent);
for (const entry of entries) {
if (entry.isDir) {
lines.push(`${prefix}${entry.name}/`);
if (entry.children && entry.children.length > 0) {
lines.push(formatDirTree(entry.children, indent + 1));
}
} else {
lines.push(`${prefix}${entry.name}`);
}
}
return lines.filter(Boolean).join('\n');
}
interface PackageJson {
name?: string;
description?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}
interface TsConfig {
compilerOptions?: {
target?: string;
paths?: Record<string, string[]>;
};
}
async function tryReadJson<T>(filePath: string): Promise<T | null> {
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch {
return null;
}
}
async function tryReadText(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, 'utf-8');
} catch {
return null;
}
}
function truncate(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.slice(0, maxChars) + '\n[...truncated]';
}
/**
* Builds a structured project context summary (~2000 tokens) for an AI coding agent.
* Scans package.json, tsconfig.json, CLAUDE.md, and README.md alongside the directory tree.
*/
export async function buildProjectContext(projectDir: string): Promise<string> {
const sections: string[] = [];
// --- package.json ---
const pkg = await tryReadJson<PackageJson>(join(projectDir, 'package.json'));
const projectName = pkg?.name ?? (relative(process.cwd(), projectDir) || projectDir);
const description = pkg?.description ?? '';
sections.push(`## Project: ${projectName}`);
if (description) {
sections.push(description);
}
sections.push('');
// --- Tech stack from package.json ---
if (pkg) {
const allDeps: Record<string, string> = {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies,
};
const depEntries = Object.entries(allDeps).slice(0, MAX_DEPS_SHOWN);
if (depEntries.length > 0) {
sections.push('### Key Dependencies');
for (const [name, version] of depEntries) {
sections.push(`- ${name}: ${version}`);
}
sections.push('');
}
}
// --- tsconfig.json ---
const tsconfig = await tryReadJson<TsConfig>(join(projectDir, 'tsconfig.json'));
if (tsconfig?.compilerOptions) {
const { target, paths } = tsconfig.compilerOptions;
const techLines: string[] = [];
if (target) techLines.push(`- TypeScript target: ${target}`);
if (paths && Object.keys(paths).length > 0) {
techLines.push(`- Path aliases: ${Object.keys(paths).join(', ')}`);
}
if (techLines.length > 0) {
sections.push('### Tech Stack');
sections.push(...techLines);
sections.push('');
}
}
// --- Directory tree (depth 3) ---
const tree = await readDirTree(projectDir, 0, 3);
const treeText = formatDirTree(tree);
if (treeText) {
sections.push('### Structure');
sections.push(treeText);
sections.push('');
}
// --- CLAUDE.md ---
const claudeRoot = await tryReadText(join(projectDir, 'CLAUDE.md'));
const claudeDotDir = await tryReadText(join(projectDir, '.claude', 'CLAUDE.md'));
let claudeContent = claudeRoot ?? claudeDotDir;
if (claudeContent) {
sections.push('### Project Instructions');
sections.push(claudeContent.trim());
sections.push('');
}
// --- README.md ---
const readme = await tryReadText(join(projectDir, 'README.md'));
if (readme) {
const excerpt = readme.slice(0, README_EXCERPT_CHARS);
sections.push('### README');
sections.push(excerpt + (readme.length > README_EXCERPT_CHARS ? '\n[...truncated]' : ''));
sections.push('');
}
const combined = sections.join('\n');
return truncate(combined, MAX_OUTPUT_CHARS);
}