diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index 01bd56e6e..c046829fc 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -15,6 +15,7 @@ import { formatAge } from "../tools/render-utils"; import { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateStringToBytesFromStart } from "../tools/truncate"; import { formatDimensionNote, resizeImage } from "./image-resize"; import { detectSupportedImageMimeTypeFromFile } from "./mime"; +import { fuzzyMatch } from "./fuzzy"; /** Regex to match @filepath patterns in text */ const FILE_MENTION_REGEX = /@([^\s@]+)/g; @@ -22,6 +23,9 @@ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/; const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/; const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/; const DEFAULT_DIR_LIMIT = 500; +const MIN_FUZZY_QUERY_LENGTH = 5; +const MAX_RESOLUTION_CANDIDATES = 20_000; +const PATH_SEPARATOR_REGEX = /[\/._\-\s]+/g; // Avoid OOM when users @mention very large files. Above these limits we skip // auto-reading and only include the path in the message. @@ -41,6 +45,97 @@ function sanitizeMentionPath(rawPath: string): string | null { return cleaned.length > 0 ? cleaned : null; } +type MentionCandidate = { + path: string; + pathLower: string; + normalizedPath: string; +}; + +function normalizeMentionQuery(query: string): string { + return query.toLowerCase().replace(PATH_SEPARATOR_REGEX, ""); +} + +async function pathExists(filePath: string): Promise { + try { + await Bun.file(filePath).stat(); + return true; + } catch { + return false; + } +} + +async function listMentionCandidates(cwd: string): Promise { + let entries: string[]; + try { + entries = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd, dot: true, onlyFiles: false })); + } catch { + return []; + } + + entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + const candidates: MentionCandidate[] = []; + for (const entry of entries) { + if (candidates.length >= MAX_RESOLUTION_CANDIDATES) { + break; + } + + const pathLower = entry.toLowerCase(); + const normalizedPath = normalizeMentionQuery(entry); + if (normalizedPath.length === 0) { + continue; + } + candidates.push({ path: entry, pathLower, normalizedPath }); + } + + return candidates; +} + +async function resolveMentionPath( + filePath: string, + cwd: string, + getMentionCandidates: () => Promise, +): Promise { + const absolutePath = resolveReadPath(filePath, cwd); + if (await pathExists(absolutePath)) { + return filePath; + } + + const queryLower = filePath.toLowerCase(); + const candidates = await getMentionCandidates(); + const prefixMatches = candidates.filter(candidate => candidate.pathLower.startsWith(queryLower)); + if (prefixMatches.length === 1) { + return prefixMatches[0]?.path ?? null; + } + if (prefixMatches.length > 1) { + return null; + } + + const normalizedQuery = normalizeMentionQuery(filePath); + if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) { + return null; + } + + const scored = candidates + .map(candidate => ({ candidate, match: fuzzyMatch(normalizedQuery, candidate.normalizedPath) })) + .filter(entry => entry.match.matches) + .sort((a, b) => { + if (a.match.score !== b.match.score) { + return a.match.score - b.match.score; + } + return a.candidate.path.localeCompare(b.candidate.path); + }); + + if (scored.length === 0) { + return null; + } + + const best = scored[0]; + + return best?.candidate.path ?? null; +} + + function buildTextOutput(textContent: string): { output: string; lineCount: number } { const allLines = textContent.split("\n"); const totalFileLines = allLines.length; @@ -175,15 +270,23 @@ export async function generateFileMentionMessages( const autoResizeImages = options?.autoResizeImages ?? true; const files: FileMentionMessage["files"] = []; + let mentionCandidatesPromise: Promise | null = null; + const getMentionCandidates = (): Promise => { + mentionCandidatesPromise ??= listMentionCandidates(cwd); + return mentionCandidatesPromise; + }; for (const filePath of filePaths) { - const absolutePath = resolveReadPath(filePath, cwd); - + const resolvedPath = await resolveMentionPath(filePath, cwd, getMentionCandidates); + if (!resolvedPath) { + continue; + } + const absolutePath = resolveReadPath(resolvedPath, cwd); try { const stat = await Bun.file(absolutePath).stat(); if (stat.isDirectory()) { const { output, lineCount } = await buildDirectoryListing(absolutePath); - files.push({ path: filePath, content: output, lineCount }); + files.push({ path: resolvedPath, content: output, lineCount }); continue; } @@ -191,7 +294,7 @@ export async function generateFileMentionMessages( if (mimeType) { if (stat.size > MAX_AUTO_READ_IMAGE_BYTES) { files.push({ - path: filePath, + path: resolvedPath, content: `(skipped auto-read: too large, ${formatSize(stat.size)})`, byteSize: stat.size, skippedReason: "tooLarge", @@ -221,13 +324,13 @@ export async function generateFileMentionMessages( } } - files.push({ path: filePath, content: dimensionNote ?? "", image }); + files.push({ path: resolvedPath, content: dimensionNote ?? "", image }); continue; } if (stat.size > MAX_AUTO_READ_TEXT_BYTES) { files.push({ - path: filePath, + path: resolvedPath, content: `(skipped auto-read: too large, ${formatSize(stat.size)})`, byteSize: stat.size, skippedReason: "tooLarge", @@ -240,7 +343,7 @@ export async function generateFileMentionMessages( if (options?.useHashLines) { output = formatHashLines(output); } - files.push({ path: filePath, content: output, lineCount }); + files.push({ path: resolvedPath, content: output, lineCount }); } catch { // File doesn't exist or isn't readable - skip silently } diff --git a/packages/coding-agent/test/file-mentions.test.ts b/packages/coding-agent/test/file-mentions.test.ts new file mode 100644 index 000000000..9b3f2a506 --- /dev/null +++ b/packages/coding-agent/test/file-mentions.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { generateFileMentionMessages } from "@oh-my-pi/pi-coding-agent/utils/file-mentions"; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +async function createTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-file-mentions-")); + tempDirs.push(dir); + return dir; +} + +describe("generateFileMentionMessages path resolution", () => { + test("prefers exact path over fuzzy candidates", async () => { + const cwd = await createTempDir(); + await Bun.write(path.join(cwd, "httpserap"), "exact file"); + await fs.mkdir(path.join(cwd, "http_server_api_tests"), { recursive: true }); + await Bun.write(path.join(cwd, "http_server_api_tests", "spec.txt"), "spec"); + + const messages = await generateFileMentionMessages(["httpserap"], cwd); + expect(messages).toHaveLength(1); + const message = messages[0]; + if (!message || message.role !== "fileMention") { + throw new Error("expected file mention message"); + } + expect(message.files).toHaveLength(1); + expect(message.files[0]?.path).toBe("httpserap"); + expect(message.files[0]?.content).toContain("exact file"); + }); + + test("resolves unique prefix match", async () => { + const cwd = await createTempDir(); + await fs.mkdir(path.join(cwd, "docs"), { recursive: true }); + await Bun.write(path.join(cwd, "docs", "readme.md"), "hello"); + + const messages = await generateFileMentionMessages(["docs/rea"], cwd); + expect(messages).toHaveLength(1); + const message = messages[0]; + if (!message || message.role !== "fileMention") { + throw new Error("expected file mention message"); + } + expect(message.files[0]?.path).toBe("docs/readme.md"); + expect(message.files[0]?.content).toContain("hello"); + }); + + test("resolves fuzzy match for segmented names", async () => { + const cwd = await createTempDir(); + await fs.mkdir(path.join(cwd, "http_server_api_tests"), { recursive: true }); + await Bun.write(path.join(cwd, "http_server_api_tests", "case.ts"), "ok"); + + const messages = await generateFileMentionMessages(["httpserap"], cwd); + expect(messages).toHaveLength(1); + const message = messages[0]; + if (!message || message.role !== "fileMention") { + throw new Error("expected file mention message"); + } + expect(message.files[0]?.path).toBe("http_server_api_tests"); + expect(message.files[0]?.content).toContain("case.ts"); + }); + + test("returns no message for ambiguous or short fuzzy queries", async () => { + const cwd = await createTempDir(); + await Bun.write(path.join(cwd, "spec-alpha.txt"), "a"); + await Bun.write(path.join(cwd, "spec-beta.txt"), "b"); + await Bun.write(path.join(cwd, "alphabet.txt"), "c"); + + const ambiguous = await generateFileMentionMessages(["spec"], cwd); + expect(ambiguous).toHaveLength(0); + + const shortQuery = await generateFileMentionMessages(["ab"], cwd); + expect(shortQuery).toHaveLength(0); + }); +});