@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user