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/workspace/walk.ts4 * Description: Filesystem walking, .gitignore/.khaelorignore parsing, and glob matching for grep/glob tools.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import * as fsp from "node:fs/promises";11import * as path from "node:path";1213/** One file discovered by the walker. */14export interface WalkedFile {15 /** Absolute path. */16 path: string;17 mtimeMs: number;18}1920export interface WalkOptions {21 /**22 * Built-in directory ignores applied in addition to ignore files.23 * Default: node_modules, .git, dist (TOOL_PROTOCOL §6.2).24 */25 builtinIgnores?: string[];26 /** Safety cap on the number of files returned. Default 50000. */27 maxFiles?: number;28 /** Abort signal — the walk stops early when aborted. */29 signal?: AbortSignal;30}3132const DEFAULT_BUILTIN_IGNORES = ["node_modules", ".git", "dist"];33const DEFAULT_MAX_FILES = 50_000;3435// ─────────────────────────── glob translation ───────────────────────────3637function escapeRegExpChar(ch: string): string {38 return /[.+^${}()|\\]/.test(ch) ? `\\${ch}` : ch;39}4041/**42 * Translate a gitignore-style glob into a RegExp over a `/`-separated43 * relative path. Supports `**`, `*`, `?`, `[...]`. A pattern without a44 * slash matches at any depth (matchBase); a leading `/` anchors it.45 */46export function globToRegExp(pattern: string): RegExp {47 let glob = pattern;48 const dirOnly = glob.endsWith("/");49 if (dirOnly) glob = glob.slice(0, -1);50 if (glob.startsWith("/")) {51 glob = glob.slice(1);52 } else if (!glob.includes("/")) {53 // No slash → match the basename at any depth.54 glob = `**/${glob}`;55 }5657 let out = "";58 let i = 0;59 while (i < glob.length) {60 const ch = glob[i] as string;61 if (ch === "*") {62 const isDouble = glob[i + 1] === "*";63 if (isDouble) {64 const next = glob[i + 2];65 if (next === "/") {66 // `**/` — zero or more whole segments.67 out += "(?:[^/]+/)*";68 i += 3;69 } else {70 out += ".*";71 i += 2;72 }73 } else {74 out += "[^/]*";75 i += 1;76 }77 } else if (ch === "?") {78 out += "[^/]";79 i += 1;80 } else if (ch === "[") {81 const close = glob.indexOf("]", i + 1);82 if (close === -1) {83 out += "\\[";84 i += 1;85 } else {86 let cls = glob.slice(i + 1, close);87 if (cls.startsWith("!")) cls = `^${cls.slice(1)}`;88 out += `[${cls}]`;89 i = close + 1;90 }91 } else {92 out += escapeRegExpChar(ch);93 i += 1;94 }95 }96 return new RegExp(`^${out}$`);97}9899// ─────────────────────────── ignore matching ───────────────────────────100101interface IgnoreRule {102 negated: boolean;103 dirOnly: boolean;104 regex: RegExp;105}106107/** Ordered ignore rules; last matching rule wins (gitignore semantics, simple parse). */108export class IgnoreMatcher {109 private readonly rules: IgnoreRule[] = [];110111 addPattern(raw: string): void {112 let line = raw.replace(/\r$/, "");113 if (line.trim().length === 0) return;114 if (line.startsWith("#")) return;115 let negated = false;116 if (line.startsWith("!")) {117 negated = true;118 line = line.slice(1);119 }120 line = line.trim();121 if (line.length === 0) return;122 const dirOnly = line.endsWith("/");123 this.rules.push({ negated, dirOnly, regex: globToRegExp(line) });124 }125126 addFileContent(content: string): void {127 for (const line of content.split("\n")) this.addPattern(line);128 }129130 /** Is `relPath` (posix separators, no leading slash) ignored? */131 ignores(relPath: string, isDirectory: boolean): boolean {132 let ignored = false;133 for (const rule of this.rules) {134 if (rule.dirOnly && !isDirectory) continue;135 if (rule.regex.test(relPath)) ignored = !rule.negated;136 }137 return ignored;138 }139}140141async function readIfExists(file: string): Promise<string | undefined> {142 try {143 return await fsp.readFile(file, "utf8");144 } catch {145 return undefined;146 }147}148149/** Build the ignore matcher for a walk root: built-ins + .gitignore + .khaelorignore. */150export async function loadIgnoreMatcher(151 root: string,152 builtinIgnores: string[] = DEFAULT_BUILTIN_IGNORES,153): Promise<IgnoreMatcher> {154 const matcher = new IgnoreMatcher();155 for (const name of builtinIgnores) matcher.addPattern(`${name}/`);156 const gitignore = await readIfExists(path.join(root, ".gitignore"));157 if (gitignore !== undefined) matcher.addFileContent(gitignore);158 const khaelorignore = await readIfExists(path.join(root, ".khaelorignore"));159 if (khaelorignore !== undefined) matcher.addFileContent(khaelorignore);160 return matcher;161}162163/**164 * Recursively list files under `root`, honoring .gitignore/.khaelorignore165 * (root-level, simple parse) plus built-in noise directories. Hidden files166 * and directories (dot-prefixed) are skipped; symlinks are not followed.167 */168export async function walkFiles(root: string, options: WalkOptions = {}): Promise<WalkedFile[]> {169 const absRoot = path.resolve(root);170 const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;171 const matcher = await loadIgnoreMatcher(absRoot, options.builtinIgnores);172 const out: WalkedFile[] = [];173 const stack: string[] = [absRoot];174175 while (stack.length > 0) {176 if (options.signal?.aborted === true) break;177 const dir = stack.pop() as string;178 let entries;179 try {180 entries = await fsp.readdir(dir, { withFileTypes: true });181 } catch {182 continue;183 }184 for (const entry of entries) {185 if (out.length >= maxFiles) return out;186 if (entry.name.startsWith(".")) continue;187 const abs = path.join(dir, entry.name);188 const rel = path.relative(absRoot, abs).split(path.sep).join("/");189 if (entry.isDirectory()) {190 if (!matcher.ignores(rel, true)) stack.push(abs);191 } else if (entry.isFile()) {192 if (matcher.ignores(rel, false)) continue;193 try {194 const stat = await fsp.stat(abs);195 out.push({ path: abs, mtimeMs: stat.mtimeMs });196 } catch {197 // File vanished mid-walk.198 }199 }200 // Symlinks and special files are skipped.201 }202 }203 return out;204}205206/** A directory listing for the read tool (TOOL_PROTOCOL §2.2 — 2 levels, hidden counts). */207export interface DirectoryListing {208 lines: string[];209 hiddenCount: number;210 truncated: boolean;211}212213const LISTING_MAX_LINES = 200;214const OPAQUE_DIRS = new Set(["node_modules", ".git", "dist"]);215216/** List a directory two levels deep: entries sorted directories-first, hidden entries counted. */217export async function listDirectory(dir: string, depth = 2): Promise<DirectoryListing> {218 const lines: string[] = [];219 let hiddenCount = 0;220 let truncated = false;221222 async function visit(current: string, level: number): Promise<void> {223 let entries;224 try {225 entries = await fsp.readdir(current, { withFileTypes: true });226 } catch {227 return;228 }229 entries.sort((a, b) => {230 if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;231 return a.name.localeCompare(b.name);232 });233 for (const entry of entries) {234 if (entry.name.startsWith(".")) {235 hiddenCount += 1;236 continue;237 }238 if (lines.length >= LISTING_MAX_LINES) {239 truncated = true;240 return;241 }242 const indent = " ".repeat(level);243 if (entry.isDirectory()) {244 lines.push(`${indent}${entry.name}/`);245 if (level + 1 < depth && !OPAQUE_DIRS.has(entry.name)) {246 await visit(path.join(current, entry.name), level + 1);247 }248 } else {249 lines.push(`${indent}${entry.name}`);250 }251 }252 }253254 await visit(path.resolve(dir), 0);255 return { lines, hiddenCount, truncated };256}257