fix(coding-agent): added fuzzy @file auto-read matching

Fixes #57
This commit is contained in:
can1357
2026-02-13 14:03:28 +01:00
parent 4c399f018a
commit 3ade89fd1f
2 changed files with 191 additions and 7 deletions
@@ -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<boolean> {
try {
await Bun.file(filePath).stat();
return true;
} catch {
return false;
}
}
async function listMentionCandidates(cwd: string): Promise<MentionCandidate[]> {
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<MentionCandidate[]>,
): Promise<string | null> {
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<MentionCandidate[]> | null = null;
const getMentionCandidates = (): Promise<MentionCandidate[]> => {
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
}
@@ -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<string> {
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);
});
});