SPB Git

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%
14.5 KB · 454 lines typescript
Raw Blame History
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