spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/repository/map.ts4 * Description: Incremental filesystem map — bounded, gitignore-aware repo tree with compact rendering.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { Workspace } from "../workspace/index.js";1112/** One entry of the repository map. Paths are POSIX-relative to the workspace cwd. */13export interface RepoFileEntry {14 path: string;15 kind: "file" | "dir";16 size: number;17 mtimeMs: number;18 /** Files only — inferred from the extension. */19 language?: string;20}2122export interface RepositoryMapOptions {23 /** Directory recursion cap. Default 10. */24 maxDepth?: number;25 /** Exposure cap for `fileMap()` (the scan itself is bounded by depth + pruning). Default 4000. */26 maxEntries?: number;27 /** Extra directory names to prune, in addition to built-ins and .gitignore-derived names. */28 extraPruneNames?: string[];29 /** Hard ceiling for a full scan command. Default 30 000 ms. */30 scanTimeoutMs?: number;31 /**32 * Scanner flavor override (tests). "printf" needs GNU-compatible find33 * (GNU findutils, bfs); "stat" needs BSD stat (macOS). Auto-detected by default.34 */35 flavor?: ScanFlavor;36}3738export type ScanFlavor = "printf" | "stat";3940/** Module-local failure type — scanning is best-effort but a total failure is loud. */41export class RepositoryScanError extends Error {42 constructor(message: string) {43 super(message);44 this.name = "RepositoryScanError";45 }46}4748/** Directory names never descended into (plus .gitignore/.khaelorignore-derived names). */49const BUILTIN_PRUNE_NAMES = [50 ".git",51 "node_modules",52 ".hg",53 ".svn",54 "__pycache__",55 ".venv",56 ".cache",57] as const;5859/** File basenames excluded from the map. */60const PRUNE_FILE_NAMES = new Set([".DS_Store"]);6162const DEFAULT_MAX_DEPTH = 10;63const DEFAULT_MAX_ENTRIES = 4000;64const DEFAULT_SCAN_TIMEOUT_MS = 30_000;65const DEFAULT_RENDER_BYTES = 4096;6667/** Field separator embedded in scan output — ASCII unit separator, absent from sane paths. */68const US = "\u001f";6970const LANGUAGE_BY_EXTENSION: Record<string, string> = {71 ts: "typescript",72 tsx: "typescript",73 mts: "typescript",74 cts: "typescript",75 js: "javascript",76 jsx: "javascript",77 mjs: "javascript",78 cjs: "javascript",79 py: "python",80 rs: "rust",81 go: "go",82 rb: "ruby",83 java: "java",84 c: "c",85 h: "c",86 cpp: "cpp",87 cc: "cpp",88 hpp: "cpp",89 cs: "csharp",90 swift: "swift",91 kt: "kotlin",92 sh: "shell",93 bash: "shell",94 zsh: "shell",95 md: "markdown",96 json: "json",97 jsonc: "jsonc",98 yaml: "yaml",99 yml: "yaml",100 toml: "toml",101 html: "html",102 css: "css",103 scss: "scss",104 sql: "sql",105 php: "php",106 lua: "lua",107};108109/** Infer a language label from a file path's extension. */110export function languageOf(filePath: string): string | undefined {111 const base = filePath.slice(filePath.lastIndexOf("/") + 1);112 const dot = base.lastIndexOf(".");113 if (dot <= 0) return undefined;114 return LANGUAGE_BY_EXTENSION[base.slice(dot + 1).toLowerCase()];115}116117interface ScanRow {118 kind: "file" | "dir";119 size: number;120 mtimeMs: number;121 rel: string;122}123124function shellQuote(s: string): string {125 return `'${s.replaceAll("'", "'\\''")}'`;126}127128function depthOf(rel: string): number {129 return rel.length === 0 ? 0 : rel.split("/").length;130}131132function parentOf(rel: string): string {133 const idx = rel.lastIndexOf("/");134 return idx === -1 ? "" : rel.slice(0, idx);135}136137function baseNameOf(rel: string): string {138 return rel.slice(rel.lastIndexOf("/") + 1);139}140141/**142 * Parse simple ignore-file lines into prunable directory names. Only plain143 * names (optionally anchored `/name` or trailing `name/`) are honored; glob144 * and negation patterns are intentionally out of V1 scope.145 */146export function prunableNamesFromIgnore(content: string): string[] {147 const names: string[] = [];148 for (const rawLine of content.split("\n")) {149 let line = rawLine.trim();150 if (line.length === 0 || line.startsWith("#") || line.startsWith("!")) continue;151 if (line.endsWith("/")) line = line.slice(0, -1);152 if (line.startsWith("/")) line = line.slice(1);153 if (line.length === 0) continue;154 if (/[*?[\]/]/.test(line)) continue; // complex pattern — skip (approximation)155 names.push(line);156 }157 return names;158}159160/**161 * Bounded, incremental map of the project tree. Scans through the workspace162 * (`find`, plus BSD `stat` when `find -printf` is unavailable) so this module163 * never touches `node:fs` (ARCHITECTURE.md §2.1 rule 5). Refresh re-stats164 * directories only and re-lists just the directories whose mtime changed.165 */166export class RepositoryMap {167 private readonly workspace: Workspace;168 private readonly maxDepth: number;169 private readonly maxEntries: number;170 private readonly scanTimeoutMs: number;171 private readonly extraPruneNames: string[];172 private readonly forcedFlavor: ScanFlavor | undefined;173174 private flavor: ScanFlavor | undefined;175 private pruneNames: string[] = [...BUILTIN_PRUNE_NAMES];176 private readonly entriesByPath = new Map<string, RepoFileEntry>();177 private rootMtimeMs = 0;178 private built = false;179 private lastTruncated = false;180181 constructor(workspace: Workspace, options: RepositoryMapOptions = {}) {182 this.workspace = workspace;183 this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;184 this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;185 this.scanTimeoutMs = options.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS;186 this.extraPruneNames = options.extraPruneNames ?? [];187 this.forcedFlavor = options.flavor;188 }189190 isBuilt(): boolean {191 return this.built;192 }193194 /** Whether the last `fileMap()` exposure hit its entry cap. */195 wasTruncated(): boolean {196 return this.lastTruncated;197 }198199 totalEntries(): number {200 return this.entriesByPath.size;201 }202203 // ── scanning plumbing ──204205 private async detectFlavor(): Promise<ScanFlavor> {206 if (this.forcedFlavor !== undefined) return this.forcedFlavor;207 if (this.flavor !== undefined) return this.flavor;208 const probe = await this.workspace.exec({209 cmd: "find . -maxdepth 0 -printf 'ok\\n'",210 timeoutMs: 5000,211 });212 this.flavor = probe.exitCode === 0 && probe.stdout.startsWith("ok") ? "printf" : "stat";213 return this.flavor;214 }215216 private pruneExpr(): string {217 const names = [...new Set([...this.pruneNames, ...this.extraPruneNames])];218 const nameTests = names.map((n) => `-name ${shellQuote(n)}`).join(" -o ");219 return `\\( -type d \\( ${nameTests} \\) -prune \\)`;220 }221222 private scanCommand(223 flavor: ScanFlavor,224 startPath: string,225 depthArgs: string,226 typeExpr: string,227 ): string {228 const start = shellQuote(startPath);229 if (flavor === "printf") {230 const fmt = `%y${US}%s${US}%T@${US}%p\\n`;231 return `find ${start} ${depthArgs} ${this.pruneExpr()} -o \\( ${typeExpr} \\) -printf '${fmt}'`;232 }233 const fmt = `%HT${US}%z${US}%m${US}%N`;234 return `find ${start} ${depthArgs} ${this.pruneExpr()} -o \\( ${typeExpr} \\) -print0 | xargs -0 stat -f '${fmt}'`;235 }236237 private parseScan(stdout: string, flavor: ScanFlavor): ScanRow[] {238 const rows: ScanRow[] = [];239 for (const line of stdout.split("\n")) {240 if (line.length === 0) continue;241 const parts = line.split(US);242 if (parts.length < 4) continue;243 const kindField = parts[0] ?? "";244 let kind: "file" | "dir";245 if (flavor === "printf") {246 if (kindField === "f") kind = "file";247 else if (kindField === "d") kind = "dir";248 else continue;249 } else if (kindField === "Regular File") kind = "file";250 else if (kindField === "Directory") kind = "dir";251 else continue;252253 const size = Number.parseInt(parts[1] ?? "0", 10);254 const mtimeSeconds = Number.parseFloat(parts[2] ?? "0");255 let rel = parts.slice(3).join(US);256 if (rel.startsWith("./")) rel = rel.slice(2);257 if (kind === "file" && PRUNE_FILE_NAMES.has(baseNameOf(rel))) continue;258 rows.push({259 kind,260 size: Number.isFinite(size) ? size : 0,261 mtimeMs: Math.round(mtimeSeconds * 1000),262 rel,263 });264 }265 return rows;266 }267268 private async runScan(startPath: string, depthArgs: string, typeExpr: string): Promise<ScanRow[]> {269 const flavor = await this.detectFlavor();270 const cmd = this.scanCommand(flavor, startPath, depthArgs, typeExpr);271 const result = await this.workspace.exec({ cmd, timeoutMs: this.scanTimeoutMs });272 if (result.stdout.length === 0 && result.exitCode !== 0) {273 throw new RepositoryScanError(274 `repository scan failed (exit ${String(result.exitCode)}): ${result.stderr.trim().slice(0, 300)}`,275 );276 }277 return this.parseScan(result.stdout, flavor);278 }279280 private async loadIgnorePrunes(): Promise<void> {281 const names = new Set<string>(BUILTIN_PRUNE_NAMES);282 for (const file of [".gitignore", ".khaelorignore"]) {283 try {284 const content = await this.workspace.readFile(file);285 for (const name of prunableNamesFromIgnore(content)) names.add(name);286 } catch {287 // No ignore file — fine.288 }289 }290 this.pruneNames = [...names];291 }292293 // ── build and refresh ──294295 /** Full scan of the tree (bounded by depth and pruning). */296 async build(): Promise<void> {297 await this.loadIgnorePrunes();298 const rows = await this.runScan(".", `-maxdepth ${String(this.maxDepth)}`, "-type f -o -type d");299 this.entriesByPath.clear();300 for (const row of rows) {301 if (row.rel === "." || row.rel === "") {302 this.rootMtimeMs = row.mtimeMs;303 continue;304 }305 this.setEntry(row);306 }307 this.built = true;308 }309310 /**311 * Incremental refresh: one dirs-only scan detects created/deleted/changed312 * directories by mtime; only the children of changed directories are313 * re-listed. Files inside untouched directories are not re-stated.314 */315 async refresh(): Promise<void> {316 if (!this.built) {317 await this.build();318 return;319 }320 const dirRows = await this.runScan(".", `-maxdepth ${String(this.maxDepth)}`, "-type d");321 const newDirs = new Map<string, ScanRow>();322 let newRootMtime = this.rootMtimeMs;323 for (const row of dirRows) {324 if (row.rel === "." || row.rel === "") newRootMtime = row.mtimeMs;325 else newDirs.set(row.rel, row);326 }327328 const oldDirs = new Map<string, RepoFileEntry>();329 for (const entry of this.entriesByPath.values()) {330 if (entry.kind === "dir") oldDirs.set(entry.path, entry);331 }332333 // Deleted directories: drop the dir and its whole subtree.334 for (const [dirPath] of oldDirs) {335 if (!newDirs.has(dirPath)) {336 const prefix = `${dirPath}/`;337 for (const key of [...this.entriesByPath.keys()]) {338 if (key === dirPath || key.startsWith(prefix)) this.entriesByPath.delete(key);339 }340 }341 }342343 // Changed or new directories: re-list immediate children.344 const changedDirs: string[] = [];345 for (const [dirPath, row] of newDirs) {346 const before = oldDirs.get(dirPath);347 if (before === undefined || before.mtimeMs !== row.mtimeMs) changedDirs.push(dirPath);348 this.setEntry(row); // keep dir metadata current349 }350 if (newRootMtime !== this.rootMtimeMs) {351 changedDirs.push("");352 this.rootMtimeMs = newRootMtime;353 }354355 for (const dirPath of changedDirs) {356 if (depthOf(dirPath) >= this.maxDepth) continue;357 const start = dirPath === "" ? "." : `./${dirPath}`;358 const rows = await this.runScan(start, "-mindepth 1 -maxdepth 1", "-type f -o -type d");359 // Replace the immediate FILE children wholesale (dirs are authoritative above).360 for (const [key, entry] of [...this.entriesByPath.entries()]) {361 if (entry.kind === "file" && parentOf(key) === dirPath) this.entriesByPath.delete(key);362 }363 for (const row of rows) {364 if (row.kind === "file") this.setEntry(row);365 }366 }367 }368369 private setEntry(row: ScanRow): void {370 const entry: RepoFileEntry = {371 path: row.rel,372 kind: row.kind,373 size: row.size,374 mtimeMs: row.mtimeMs,375 };376 if (row.kind === "file") {377 const language = languageOf(row.rel);378 if (language !== undefined) entry.language = language;379 }380 this.entriesByPath.set(row.rel, entry);381 }382383 // ── exposure ──384385 /**386 * The bounded map (files and directories), sorted shallowest-first then by387 * path, capped at `maxEntries` (ARCHITECTURE.md `RepositoryIndex.fileMap`).388 */389 async fileMap(opts?: { maxEntries?: number }): Promise<RepoFileEntry[]> {390 if (!this.built) await this.build();391 const cap = opts?.maxEntries ?? this.maxEntries;392 const all = [...this.entriesByPath.values()].sort(393 (a, b) => depthOf(a.path) - depthOf(b.path) || a.path.localeCompare(b.path),394 );395 this.lastTruncated = all.length > cap;396 return all.slice(0, cap);397 }398399 /** File entries only (for fuzzy file finding). Builds on first use. */400 async files(): Promise<RepoFileEntry[]> {401 if (!this.built) await this.build();402 return [...this.entriesByPath.values()].filter((e) => e.kind === "file");403 }404405 /**406 * Compact textual repo map for context injection — an indented tree,407 * hard-capped at `maxBytes` with an explicit omission marker.408 */409 async render(maxBytes: number = DEFAULT_RENDER_BYTES): Promise<string> {410 if (!this.built) await this.build();411 const children = new Map<string, RepoFileEntry[]>();412 for (const entry of this.entriesByPath.values()) {413 const parent = parentOf(entry.path);414 const list = children.get(parent);415 if (list === undefined) children.set(parent, [entry]);416 else list.push(entry);417 }418 for (const list of children.values()) {419 list.sort((a, b) =>420 a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === "dir" ? -1 : 1,421 );422 }423424 const trailerReserve = 48;425 const lines: string[] = [];426 let bytes = 0;427 let emitted = 0;428 let exhausted = false;429430 const walk = (parent: string, indent: number): void => {431 if (exhausted) return;432 for (const entry of children.get(parent) ?? []) {433 const name = baseNameOf(entry.path) + (entry.kind === "dir" ? "/" : "");434 const line = `${" ".repeat(indent)}${name}`;435 const lineBytes = Buffer.byteLength(line, "utf8") + 1;436 if (bytes + lineBytes > maxBytes - trailerReserve) {437 exhausted = true;438 return;439 }440 lines.push(line);441 bytes += lineBytes;442 emitted += 1;443 if (entry.kind === "dir") walk(entry.path, indent + 2);444 if (exhausted) return;445 }446 };447 walk("", 0);448449 const omitted = this.entriesByPath.size - emitted;450 if (omitted > 0) lines.push(`… ${String(omitted)} more entries not shown`);451 return lines.join("\n");452 }453}454