// @file mentions — let a prompt reference files on the terminal's machine by typing // `@path`. Used by BOTH surfaces: // • the local REPL (readline tab-completion + resolution at submit) // • the app composer, driven over the relay (a files_search palette; the SAME // resolution runs on the terminal when the prompt lands) // // The mention token stays INLINE in the prompt (so the model sees the reference in // context) and each referenced file's content is appended after it as a // … block — text inline, images as real attachments. This // mirrors Pi's own @file CLI-arg expander (cli/file-processor) but is a library, not // a process: it never exits on a bad path, and it is CWD-CONSTRAINED. // // SECURITY: resolution is a client-side text expansion that bypasses the permission // gate (unlike the Read tool). A remote driver is the account owner, but a // gate-bypassing arbitrary read (`@/etc/shadow`, `@../secrets`) is exactly what we // must not grant. So every token MUST resolve inside cwd — anything that escapes the // cwd subtree (absolute paths, `..`, symlink targets outside) is skipped, not read. // The same rule bounds the relay file-search so filenames outside the project never // leak to the controller. import { readFile, readdir, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; /** An image attachment, shaped for AgentSession.prompt()'s `images` option (Pi's ImageContent). */ export interface MentionImage { type: "image"; data: string; // base64 mimeType: string; } export interface ResolvedMentions { /** The prompt with each referenced file's content appended as a block. */ text: string; /** Image attachments to pass via prompt options.images. */ images: MentionImage[]; /** cwd-relative paths that were successfully attached. */ resolved: string[]; /** Raw tokens that couldn't be attached (missing / outside cwd / a dir / too big). */ skipped: string[]; } // Inline text stays reasonable; a giant file would blow the context and the relay. const MAX_TEXT_BYTES = 256 * 1024; // 256 KB per text file inlined const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image before base64 const IMAGE_EXT: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", }; const extOf = (name: string): string => { const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; }; // Trailing characters that are almost always sentence punctuation, not part of a // filename — trimmed from a token if the trimmed form resolves and the raw doesn't. const TRAIL_PUNCT = /[.,;:!?)\]}>]+$/; // A mention is `@` at start-of-string or after whitespace, then either a "quoted path" // (allows spaces) or a run of non-whitespace path characters. Capturing group 2 is the // path (quoted contents via group 3, else the bare run). const MENTION_RE = /(^|\s)@("([^"]+)"|[^\s@]+)/g; /** Pull the raw path tokens out of a prompt (order-preserving, de-duplicated). */ export function parseMentions(text: string): string[] { const out: string[] = []; const seen = new Set(); for (const m of text.matchAll(MENTION_RE)) { const raw = m[3] ?? m[2]; // quoted contents, else the bare run if (raw && !seen.has(raw)) { seen.add(raw); out.push(raw); } } return out; } // Resolve a raw token to an absolute path inside cwd, or null if it escapes / doesn't // exist. Follows the real (symlink-resolved) path and re-checks containment so a // symlink inside cwd pointing outside can't be used to read out. async function resolveInsideCwd(raw: string, cwd: string): Promise { // A relative path only — an absolute token is an escape attempt by definition. if (isAbsolute(raw)) return null; const abs = resolve(cwd, raw); const within = (p: string): boolean => p === cwd || p.startsWith(cwd + sep); if (!within(abs)) return null; // `..` climbed out try { const real = await realpath(abs); // realpath the cwd too, so a symlinked project root still matches. const realCwd = await realpath(cwd).catch(() => cwd); if (real !== realCwd && !real.startsWith(realCwd + sep)) return null; return real; } catch { return null; // doesn't exist } } // Try the token as-is, then progressively trimmed of trailing punctuation, returning // the first form that resolves to a readable file inside cwd. async function resolveToken(raw: string, cwd: string): Promise { const candidates = [raw]; const trimmed = raw.replace(TRAIL_PUNCT, ""); if (trimmed && trimmed !== raw) candidates.push(trimmed); for (const c of candidates) { const abs = await resolveInsideCwd(c, cwd); if (abs) return abs; } return null; } /** * Expand every `@path` mention in `text` into appended blocks (text) plus image * attachments. Unresolved mentions are left inline verbatim and reported in `skipped`. * The returned `text` equals the input when there are no resolvable mentions. */ export async function resolveMentions(text: string, cwd: string): Promise { const tokens = parseMentions(text); const images: MentionImage[] = []; const resolved: string[] = []; const skipped: string[] = []; const blocks: string[] = []; // resolveToken returns the symlink-resolved (real) absolute path, so relative paths // must be computed against the real cwd — otherwise a symlinked cwd (e.g. /var → // /private/var on macOS) yields a spurious `../../…` prefix. const realCwd = await realpath(cwd).catch(() => cwd); for (const raw of tokens) { const abs = await resolveToken(raw, cwd); if (!abs) { skipped.push(raw); continue; } let st; try { st = await stat(abs); } catch { skipped.push(raw); continue; } if (!st.isFile() || st.size === 0) { skipped.push(raw); continue; } const rel = relative(realCwd, abs) || basename(abs); const mime = IMAGE_EXT[extOf(abs)]; try { if (mime) { if (st.size > MAX_IMAGE_BYTES) { skipped.push(raw); continue; } const buf = await readFile(abs); images.push({ type: "image", data: buf.toString("base64"), mimeType: mime }); // A bare reference so the model ties the image to the path it saw inline. blocks.push(``); } else { if (st.size > MAX_TEXT_BYTES) { skipped.push(raw); continue; } const content = await readFile(abs, "utf-8"); blocks.push(`\n${content}\n`); } resolved.push(rel); } catch { skipped.push(raw); } } const out = blocks.length ? `${text}\n\n${blocks.join("\n")}` : text; return { text: out, images, resolved, skipped }; } // ── autocomplete ────────────────────────────────────────────────────────────────── export interface FileMatch { /** cwd-relative path (directories carry a trailing "/"). */ path: string; isDir: boolean; } // Directory entries we never surface as suggestions (noise / not project files). // Pruned from BOTH the single-level listing and the project-wide walk — .git and // node_modules are where a repo keeps its hundred thousand entries, and a walk that // descended into them would spend its whole budget before reaching real source. const IGNORE_DIRS = new Set([".git", "node_modules", ".DS_Store"]); // Bounds for the project-wide walk behind @-search. A source tree with vendor trees // pruned (see the .gitignore note below) is a few thousand entries; these caps exist // so a pathological tree can never turn one keystroke into a disk storm. Whichever // bound trips first ends the walk — the matches gathered up to that point are ranked // and returned, so the degrade is "fewer suggestions", never a hang. const WALK_MAX_ENTRIES = 20_000; const WALK_MAX_MS = 250; const WALK_MAX_POOL = 2_000; // ── .gitignore pruning for the walk ────────────────────────────── // IGNORE_DIRS alone can't keep the walk inside real source: a repo's heaviest trees // are often NOT named node_modules — ios/Pods here, android/build, dist, web-build, // .expo elsewhere. If the walk descended into those, a 20k-entry budget could die in // one vendor checkout before reaching the first source file (that exact starvation // is why `@en.json` found nothing while `@screens/…` worked). Every fuzzy finder // answers this the same way: honor .gitignore. So the walk reads one at EVERY level // (a nested .gitignore scopes its own subtree — that's what makes ios/.gitignore // prune Pods), bounded to the common grammar: comments, blank lines, `dir/`, // anchored `/path`, mid-slash paths, and `*`/`?`/`**` globs. Negations (`!…`) are // deliberately skipped — full git precedence order is not worth it here, and // over-ignoring only hides a suggestion (exact drill-down and resolution at submit // still reach anything). interface GitPattern { re: RegExp; dirOnly: boolean; anchored: boolean } interface GitScope { rel: string; pats: GitPattern[]; parent: GitScope | null } /** Common-grammar .gitignore glob → regex source. Doublestar spans levels at the * three git-defined positions (start of pattern, end, embedded between slashes); * star and question mark never cross a segment. */ function globToRegex(pat: string): string { let s = pat; if (s.startsWith("**/")) s = "\u0000" + s.slice(3); s = s.replace(/\/\*\*\//g, "/\u0000"); if (s.endsWith("/**")) s = s.slice(0, -2) + "\u0001"; s = s.replace(/\*\*/g, "*"); // any leftover ** behaves like * s = s.split("").map((ch) => { if (ch === "*") return "[^/]*"; if (ch === "?") return "[^/]"; if ("\\.+^${}()|[]".includes(ch)) return "\\" + ch; return ch; }).join(""); return s.replace(/\u0000/g, "(?:[^/]*/)*").replace(/\u0001/g, ".*"); } function compileGitignore(text: string): GitPattern[] { const out: GitPattern[] = []; for (const rawLine of text.split("\n")) { const line = rawLine.trim(); if (!line || line.startsWith("#") || line.startsWith("!")) continue; let pat = line; const dirOnly = pat.endsWith("/"); if (dirOnly) pat = pat.slice(0, -1); let anchored = pat.startsWith("/"); if (anchored) pat = pat.slice(1); if (!anchored && pat.includes("/")) anchored = true; // a mid-slash pattern is rooted, per git if (!pat || pat === "*") continue; // "*" would empty the sweep; treat as noise out.push({ re: new RegExp(`^${globToRegex(pat)}$`), dirOnly, anchored }); } return out; } async function readGitignore(dirAbs: string): Promise { try { return compileGitignore(await readFile(join(dirAbs, ".gitignore"), "utf-8")); } catch { return null; // none here — the common case } } /** Is `rel` (cwd-relative) ruled out by any scope in the chain? Each scope's * patterns are tested against the path relative to ITS dir; a non-anchored * pattern matches the entry's own name at any depth. */ function isIgnoredByGitignore(rel: string, isDir: boolean, scope: GitScope | null): boolean { for (let s = scope; s; s = s.parent) { const relToScope = s.rel ? rel.slice(s.rel.length + 1) : rel; const base = relToScope.slice(relToScope.lastIndexOf("/") + 1); for (const p of s.pats) { if (p.dirOnly && !isDir) continue; if (p.anchored ? p.re.test(relToScope) : p.re.test(base)) return true; } } return false; } /** * Depth-first walk of cwd's subtree, collecting every file and directory as a * cwd-relative FileMatch (dirs carry a trailing "/"). IGNORE_DIRS is pruned, * .gitignore rules prune vendor/build trees (see the block above), symlinks are * listed but NEVER followed (loop + escape), and dotfiles are hidden unless * `showHidden`. Bounded by the caps above — see the note on them. */ async function walkProject(cwd: string, showHidden: boolean): Promise { const pool: FileMatch[] = []; const deadline = Date.now() + WALK_MAX_MS; const rootPats = await readGitignore(cwd); const rootScope: GitScope | null = rootPats ? { rel: "", pats: rootPats, parent: null } : null; // An explicit stack rather than recursion: a deep tree can't overflow anything, // and the budget checks have one natural place (the loop head). Each pending dir // carries the gitignore scope chain in force beneath it, as an immutable // parent-linked list — no push/pop bookkeeping to drift out of sync. const pending: Array<{ abs: string; rel: string; scope: GitScope | null }> = [{ abs: cwd, rel: "", scope: rootScope }]; let visited = 0; while (pending.length > 0) { if (visited >= WALK_MAX_ENTRIES || pool.length >= WALK_MAX_POOL || Date.now() > deadline) break; const dir = pending.pop()!; let entries: import("node:fs").Dirent[]; try { entries = await readdir(dir.abs, { withFileTypes: true }); } catch { continue; // unreadable (permissions / vanished) — skip, like a listing would } // Sorted so the pool order — and therefore any rank tie — is deterministic. entries.sort((a, b) => a.name.localeCompare(b.name)); // A .gitignore in THIS dir scopes everything below it. const innerPats = await readGitignore(dir.abs); const scope: GitScope | null = innerPats ? { rel: dir.rel, pats: innerPats, parent: dir.scope } : dir.scope; for (const e of entries) { if (visited >= WALK_MAX_ENTRIES || pool.length >= WALK_MAX_POOL) break; visited++; if (IGNORE_DIRS.has(e.name)) continue; if (e.name.startsWith(".") && !showHidden) continue; const isDir = e.isDirectory(); const rel = dir.rel ? `${dir.rel}/${e.name}` : e.name; if (isIgnoredByGitignore(rel, isDir, scope)) continue; pool.push({ path: isDir ? `${rel}/` : rel, isDir }); // Real subdirectories only — a symlink to a dir is surfaced as a dir pick // (drill-down resolves it safely) but never walked. if (isDir && !e.isSymbolicLink()) pending.push({ abs: join(dir.abs, e.name), rel, scope }); } } return pool; } /** * List up to `limit` files/dirs inside cwd whose path matches `query` — the text the * user typed after `@`. Two tiers: * * 1. Drill-down (exact): the query's own directory, its children filtered by the * basename prefix. `@src/` lists src/; `@src/ut` names src/utils/ first. A * trailing "/" means "I'm browsing HERE" and returns only this tier. * 2. Project-wide (fuzzy): the rest of the subtree, at any depth, honoring * .gitignore so vendor/build trees never crowd out — or starve the budget * before — real source. A bare fragment (`@remo`) matches any file/dir NAME * containing it — wherever it lives — and a dir-qualified one (`@src/ut`) * matches whole paths carrying it. Skipped for a one-character fragment, where * a whole-tree sweep is all noise; the drill-down answer is the right one there. * * Case-insensitive. CWD-constrained: a query that escapes cwd returns nothing, and * the walk never leaves the subtree — so no path outside the project can leak. */ export async function searchFiles(query: string, cwd: string, limit = 50): Promise { const q = query ?? ""; if (isAbsolute(q)) return []; // Split into the directory to scan and the basename prefix to filter by. A trailing // slash means "list this dir", so the prefix is empty. const endsWithSlash = q.endsWith("/"); const dirPart = endsWithSlash ? q : dirname(q); const prefix = endsWithSlash ? "" : basename(q); const scanRel = dirPart === "." ? "" : dirPart; const scanAbs = resolve(cwd, scanRel); // Containment check (mirror resolveInsideCwd, sync form — no realpath needed for a listing). if (scanAbs !== cwd && !scanAbs.startsWith(cwd + sep)) return []; let entries: import("node:fs").Dirent[]; try { entries = await readdir(scanAbs, { withFileTypes: true }); } catch { entries = []; // the dir may not exist — tier 2 below can still search the tree } const pfx = prefix.toLowerCase(); const matches: FileMatch[] = []; for (const e of entries) { if (IGNORE_DIRS.has(e.name)) continue; if (pfx && !e.name.toLowerCase().startsWith(pfx)) continue; if (e.name.startsWith(".") && !pfx.startsWith(".")) continue; // hide dotfiles unless asked const isDir = e.isDirectory(); const rel = scanRel ? join(scanRel, e.name) : e.name; matches.push({ path: isDir ? `${rel}/` : rel, isDir }); if (matches.length >= limit) break; } // Directories first, then alphabetical — the natural drill-down order. matches.sort((a, b) => (a.isDir === b.isDir ? a.path.localeCompare(b.path) : a.isDir ? -1 : 1)); // Tier 2 — the project-wide reach. Off when browsing a dir outright ("src/"), when // tier 1 already filled the page, and for a 1-char bare fragment (all noise). A // dir-qualified fragment is exempt from the length gate: its match is a whole-path // substring, which is already precise at any length ("src/a" hits src/app.ts). const dirQualified = dirPart !== "."; if (!endsWithSlash && matches.length < limit && (prefix.length === 0 || prefix.length >= 2 || dirQualified)) { const pool = await walkProject(cwd, prefix.startsWith(".")); const ql = q.toLowerCase(); const seen = new Set(matches.map((m) => m.path)); const scored: Array<{ m: FileMatch; starts: boolean; depth: number }> = []; for (const m of pool) { if (seen.has(m.path)) continue; seen.add(m.path); const bare = m.path.endsWith("/") ? m.path.slice(0, -1) : m.path; const name = basename(bare).toLowerCase(); if (prefix) { // A dir-qualified query matches against the whole path ("src/ut" hits // src/util/cache.ts, and also another package's src/…); a bare fragment // matches the entry's own name, wherever it sits in the tree. const hit = dirQualified ? m.path.toLowerCase().includes(ql) : name.includes(pfx); if (!hit) continue; } scored.push({ m, starts: prefix ? name.startsWith(pfx) : false, depth: bare.split("/").length }); } // Relevance: a name that STARTS with the fragment outranks one that merely // carries it, then shallower over deeper, then alphabetical. Bare `@` (no // fragment) sorts purely shallow-first — a browsable, explorer-style listing. scored.sort((a, b) => a.starts !== b.starts ? (a.starts ? -1 : 1) : a.depth !== b.depth ? a.depth - b.depth : a.m.path.localeCompare(b.m.path), ); for (const s of scored) { if (matches.length >= limit) break; matches.push(s.m); } } return matches; } /** * A Node readline completer for `@`-mentions. Given the line up to the cursor, if it * ends in an `@token`, returns full-line completions (readline replaces the whole * line) so the mention drills into the cwd tree on Tab. Returns [[], line] when the * cursor isn't in a mention, leaving other completion untouched. */ export async function completeMention(line: string, cwd: string): Promise<[string[], string]> { // Find the last unquoted-ish `@token` that runs to the end of the line. const m = /(^|\s)@([^\s@]*)$/.exec(line); if (!m) return [[], line]; const token = m[2]; const tokenStart = m.index + m[1].length; // index of the '@' const head = line.slice(0, tokenStart); // everything before '@' const matches = await searchFiles(token, cwd, 100); // Rebuild each as a full line: head + "@" + path. A single dir match keeps the // trailing slash so the next Tab drills in. const hits = matches.map((mm) => `${head}@${mm.path}`); return [hits, line]; }