import { tool, text, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { rm, writeFile, readdir, readFile, stat, mkdir, rename, copyFile, appendFile as fsAppendFile, unlink } from "fs/promises";
import { watch as fsWatch, type FSWatcher } from "fs";
import { join, resolve, dirname, relative, sep } from "path";
import { tmpdir } from "os";
import type { ToolContext } from "./context";
import { validatePath } from "./helpers";
import { rankFuzzyMatches } from "../fuzzySearch";
import { savePersistedState } from "../stateManager";
import { generateId } from "../backgroundCommands";
// ── N.8: File watcher registry ────────────────────────────────────────────────
interface FileWatchEntry {
id: string;
watchPath: string;
type: "file" | "directory";
recursive: boolean;
status: "watching" | "stopped";
startTime: number;
events: Array<{ eventType: string; filename: string | null; time: string }>;
watcher: FSWatcher;
}
const fileWatchers = new Map<string, FileWatchEntry>();
const WATCHER_EVENT_LIMIT = 50;
/**
* Atomic write: write to a temp file then rename into place.
* POSIX rename() is atomic — a crash mid-write leaves the original intact.
* N.1: replaces bare writeFile() calls throughout save_file and apply_patch.
*/
async function atomicWrite(filePath: string, content: string): Promise<void> {
const tmp = `${filePath}.__tmp`;
try {
await writeFile(tmp, content, "utf-8");
await rename(tmp, filePath);
} catch (e) {
await unlink(tmp).catch(() => {});
throw e;
}
}
export function createFileTools(ctx: ToolContext): Tool[] {
const tools: Tool[] = [];
// ─── Navigation ─────────────────────────────────────────────────────────────
tools.push(tool({
name: "change_directory",
description: text`
Change the current working directory.
Returns the new current working directory.
`,
parameters: { directory: z.string() },
implementation: async ({ directory }) => {
// Use resolve() not validatePath() — the model must be able to navigate
// to parent dirs ("..") and absolute paths, not just workspace subdirs.
const newPath = resolve(ctx.cwd, directory);
// Enforce protectedPaths even though we allow leaving the workspace.
for (const blocked of ctx.protectedPaths) {
if (newPath === blocked || newPath.startsWith(blocked + sep)) {
return { error: `Access Denied: '${newPath}' is within a protected path ('${blocked}').` };
}
}
const stats = await stat(newPath);
if (!stats.isDirectory()) throw new Error(`Path is not a directory: ${newPath}`);
const previous = ctx.cwd;
ctx.cwd = newPath;
ctx.fullState.currentWorkingDirectory = newPath;
await savePersistedState(ctx.fullState);
return { previous_directory: previous, current_directory: ctx.cwd };
},
}));
tools.push(tool({
name: "list_directory",
description: "List the files and directories in the current working directory or a specified subdirectory.",
parameters: {
path: z.string().optional().describe("The path to the directory to list. Defaults to current working directory."),
},
implementation: async ({ path }) => {
const targetPath = path ? validatePath(ctx.cwd, path, ctx.protectedPaths) : ctx.cwd;
return { files: await readdir(targetPath) };
},
}));
// ─── Read ────────────────────────────────────────────────────────────────────
tools.push(tool({
name: "read_file",
description: "Read the content of a file in the current working directory.",
parameters: { file_name: z.string() },
implementation: async ({ file_name }) => {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
const stats = await stat(filePath);
if (stats.size > 10_000_000) return { error: "File too large (>10MB)" };
const buffer = await readFile(filePath);
const checkBuffer = buffer.subarray(0, Math.min(buffer.length, 1024));
if (checkBuffer.includes(0)) return { error: "File appears to be binary and cannot be read as text." };
const content = buffer.toString("utf-8");
const lineCount = content.split("\n").length;
const tokenEstimate = Math.round(content.length / 4);
// N.2: append a size hint so the model can decide whether to use read_file_range
return { content, _meta: { lines: lineCount, approx_tokens: tokenEstimate } };
},
}));
tools.push(tool({
name: "read_file_range",
description: text`
Read a specific range of lines from a file.
Returns the content with line numbers for easy reference.
Line numbers are 1-indexed (line 1 is the first line).
`,
parameters: {
file_name: z.string(),
start_line: z.number().int().min(1).describe("Starting line number (1-indexed)"),
end_line: z.number().int().min(1).describe("Ending line number (1-indexed, inclusive)"),
},
implementation: async ({ file_name, start_line, end_line }) => {
try {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
const content = await readFile(filePath, "utf-8");
const lines = content.split("\n");
if (start_line > lines.length) return { error: `Start line ${start_line} is beyond the end of the file (${lines.length} lines)` };
const actualEndLine = Math.min(end_line, lines.length);
const selectedLines = lines.slice(start_line - 1, actualEndLine);
const numberedContent = selectedLines.map((line, idx) => `${start_line + idx}: ${line}`).join("\n");
return { file_name, start_line, end_line: actualEndLine, line_count: selectedLines.length, content_with_line_numbers: numberedContent };
} catch (e) {
return { error: `Failed to read file range: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ─── Write / Create ──────────────────────────────────────────────────────────
tools.push(tool({
name: "save_file",
description: text`
Save content to a specified file in the current working directory.
This tool returns the full path to the saved file. You should then
output this full path to the user.
`,
parameters: {
file_name: z.string().optional(),
content: z.string().optional(),
files: z.array(z.object({ file_name: z.string(), content: z.string() })).optional().describe("For saving multiple files at once."),
},
implementation: async ({ file_name, content, files }) => {
const filesToSave = Array.isArray(files) ? [...files] : [];
if (file_name && content) filesToSave.push({ file_name, content });
if (filesToSave.length === 0) return { error: "Must provide either file_name and content, or a files array." };
const savedPaths: string[] = [];
const errors: string[] = [];
for (const file of filesToSave) {
if (!file.file_name?.trim()) { errors.push("Filename cannot be empty"); continue; }
if (/[\*\?<>|"]/.test(file.file_name)) { errors.push(`Filename ${file.file_name} contains invalid characters (*, ?, <, >, |, ")`); continue; }
try {
const filePath = validatePath(ctx.cwd, file.file_name, ctx.protectedPaths);
await mkdir(dirname(filePath), { recursive: true });
await atomicWrite(filePath, file.content); // N.1: atomic write
savedPaths.push(filePath);
} catch (e) {
errors.push(`Failed to save ${file.file_name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
// M.3: track recently written files (capped at 10) for session resume context
if (savedPaths.length > 0) {
const recent = ctx.fullState.recentFiles ?? [];
const relative_ = savedPaths.map(p => {
try { return relative(ctx.cwd, p); } catch { return p; }
});
ctx.fullState.recentFiles = [...new Set([...relative_, ...recent])].slice(0, 10);
savePersistedState(ctx.fullState).catch(() => {}); // fire-and-forget
}
if (errors.length > 0 && savedPaths.length === 0) return { error: errors.join("\n") };
return { success: true, paths: savedPaths, errors: errors.length > 0 ? errors : undefined };
},
}));
tools.push(tool({
name: "append_file",
description: text`
Append content to the end of a file.
If the file doesn't exist, it will be created.
Useful for adding logs, entries, or building files incrementally.
`,
parameters: {
file_name: z.string(),
content: z.string().describe("The text content to append to the file"),
},
implementation: async ({ file_name, content }) => {
try {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
await mkdir(dirname(filePath), { recursive: true });
await fsAppendFile(filePath, content, "utf-8");
return { success: true, message: `Content appended to ${file_name}` };
} catch (e) {
return { error: `Failed to append to file: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
tools.push(tool({
name: "insert_at_line",
description: text`
Insert content at a specific line number in a file.
The line_number is 1-indexed (line 1 is the first line).
Existing content at that line and below will be pushed down.
`,
parameters: {
file_name: z.string(),
line_number: z.number().int().min(1).describe("The line number to insert at (1-indexed)"),
content_to_insert: z.string().describe("The text content to insert at the specified line"),
},
implementation: async ({ file_name, line_number, content_to_insert }) => {
try {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
let content = "";
try { content = await readFile(filePath, "utf-8"); }
catch { if (line_number !== 1) return { error: `File '${file_name}' does not exist. Can only insert at line 1 in a new file.` }; }
const lines = content.split("\n");
const insertIndex = Math.min(line_number - 1, lines.length);
lines.splice(insertIndex, 0, content_to_insert);
await writeFile(filePath, lines.join("\n"), "utf-8");
return { success: true, message: `Inserted ${content_to_insert.split("\n").length} line(s) at line ${line_number} in ${file_name}`, new_line_count: lines.length };
} catch (e) {
return { error: `Failed to insert text: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ─── Edit ────────────────────────────────────────────────────────────────────
tools.push(tool({
name: "replace_text_in_file",
description: text`
Replace a specific string in a file with a new string.
Useful for making small edits without rewriting the entire file.
Ensure 'old_string' matches exactly (including whitespace) or the replace will fail.
`,
parameters: {
file_name: z.string(),
old_string: z.string().describe("The exact text to replace. Must be unique in the file."),
new_string: z.string().describe("The text to insert in place of old_string."),
},
implementation: async ({ file_name, old_string, new_string }) => {
try {
if (!old_string) return { error: "old_string cannot be empty" };
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
const content = await readFile(filePath, "utf-8");
if (!content.includes(old_string)) return { error: "Could not find the exact 'old_string' in the file. Please check whitespace and indentation." };
const occurrenceCount = content.split(old_string).length - 1;
if (occurrenceCount > 1) return { error: `Found ${occurrenceCount} occurrences of 'old_string'. Please provide more context (surrounding lines) in 'old_string' to make it unique.` };
await writeFile(filePath, content.replace(old_string, new_string), "utf-8");
return { success: true, message: `Successfully replaced text in ${file_name}` };
} catch (e) {
return { error: `Failed to replace text: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
tools.push(tool({
name: "multi_replace_text",
description: text`
Replace multiple scattered text blocks in a single file safely.
Each replacement must specify start_line, end_line, old_string, and new_string.
The old_string must perfectly match what is currently in the file.
`,
parameters: {
file_name: z.string(),
replacements: z.array(z.object({
start_line: z.number().int().min(1),
end_line: z.number().int().min(1),
old_string: z.string(),
new_string: z.string(),
})).describe("Array of replacements to make in the file"),
},
implementation: async ({ file_name, replacements }) => {
try {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
const content = await readFile(filePath, "utf-8");
const lines = content.split("\n");
const errors: string[] = [];
const sortedReplacements = [...replacements].sort((a, b) => b.start_line - a.start_line);
for (const rep of sortedReplacements) {
if (rep.start_line > rep.end_line) { errors.push(`Invalid range: start_line ${rep.start_line} > end_line ${rep.end_line}`); continue; }
const chunk = lines.slice(rep.start_line - 1, rep.end_line).join("\n");
if (!chunk.includes(rep.old_string)) { errors.push(`Could not find old_string between lines ${rep.start_line}-${rep.end_line}`); continue; }
const newChunkLines = chunk.replace(rep.old_string, rep.new_string).split("\n");
lines.splice(rep.start_line - 1, rep.end_line - rep.start_line + 1, ...newChunkLines);
}
if (errors.length > 0) return { error: "Replacements failed:\n" + errors.join("\n") };
await writeFile(filePath, lines.join("\n"), "utf-8");
return { success: true, message: `Applied ${replacements.length} replacements to ${file_name}` };
} catch (e) {
return { error: `Failed to multi-replace: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
tools.push(tool({
name: "delete_lines_in_file",
description: text`
Delete a specific line or range of lines from a file.
Line numbers are 1-indexed (line 1 is the first line).
If end_line is omitted, only start_line will be deleted.
`,
parameters: {
file_name: z.string(),
start_line: z.number().int().min(1),
end_line: z.number().int().min(1).optional(),
},
implementation: async ({ file_name, start_line, end_line }) => {
try {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
let content: string;
try { content = await readFile(filePath, "utf-8"); }
catch { return { error: `File '${file_name}' does not exist.` }; }
const lines = content.split("\n");
const actualEndLine = end_line ?? start_line;
if (start_line > lines.length) return { error: `Start line ${start_line} is beyond the end of the file (${lines.length} lines)` };
const deleteCount = Math.min(actualEndLine - start_line + 1, lines.length - start_line + 1);
if (deleteCount <= 0) return { error: "Invalid line range. End line must be >= Start line." };
lines.splice(start_line - 1, deleteCount);
await writeFile(filePath, lines.join("\n"), "utf-8");
return { success: true, message: `Deleted ${deleteCount} line(s) (lines ${start_line}-${actualEndLine}) from ${file_name}`, new_line_count: lines.length };
} catch (e) {
return { error: `Failed to delete lines: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ─── Delete / Move / Copy ────────────────────────────────────────────────────
tools.push(tool({
name: "delete_path",
description: "Delete a file or directory in the current working directory. Be careful!",
parameters: { path: z.string() },
implementation: async ({ path }) => {
const targetPath = validatePath(ctx.cwd, path, ctx.protectedPaths);
await rm(targetPath, { recursive: true, force: true });
return { success: true, path: targetPath };
},
}));
tools.push(tool({
name: "delete_files_by_pattern",
description: "Delete multiple files in the current directory that match a regex pattern.",
parameters: {
pattern: z.string().describe("Regex pattern to match filenames (e.g., '^auto_gen_.*\\.txt$')"),
},
implementation: async ({ pattern }) => {
try {
if (pattern.length > 100) return { error: "Pattern too complex (max 100 characters)" };
const regex = new RegExp(pattern);
const start = Date.now();
regex.test("safe_test_string_for_redos_check_1234567890_safe_test_string_for_redos_check_1234567890");
if (Date.now() - start > 100) return { error: "Pattern is too complex or slow (ReDoS protection)." };
const files = await readdir(ctx.cwd);
const deleted: string[] = [];
for (const file of files) {
if (regex.test(file)) {
await rm(join(ctx.cwd, file), { force: true });
deleted.push(file);
}
}
return { deleted_count: deleted.length, deleted_files: deleted };
} catch (e) {
return { error: `Failed to delete files: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
tools.push(tool({
name: "move_file",
description: "Move or rename a file or directory.",
parameters: { source: z.string(), destination: z.string() },
implementation: async ({ source, destination }) => {
const sourcePath = validatePath(ctx.cwd, source, ctx.protectedPaths);
const destPath = validatePath(ctx.cwd, destination, ctx.protectedPaths);
await rename(sourcePath, destPath);
return { success: true, from: sourcePath, to: destPath };
},
}));
tools.push(tool({
name: "copy_file",
description: "Copy a file to a new location.",
parameters: { source: z.string(), destination: z.string() },
implementation: async ({ source, destination }) => {
const sourcePath = validatePath(ctx.cwd, source, ctx.protectedPaths);
const destPath = validatePath(ctx.cwd, destination, ctx.protectedPaths);
await copyFile(sourcePath, destPath);
return { success: true, from: sourcePath, to: destPath };
},
}));
tools.push(tool({
name: "make_directory",
description: "Create a new directory in the current working directory.",
parameters: { directory_name: z.string() },
implementation: async ({ directory_name }) => {
const dirPath = validatePath(ctx.cwd, directory_name, ctx.protectedPaths);
await mkdir(dirPath, { recursive: true });
return { success: true, path: dirPath };
},
}));
// ─── Search / Find ───────────────────────────────────────────────────────────
tools.push(tool({
name: "search_directory",
description: text`
Search an entire directory for a regex pattern (like grep -n).
Returns structured matches: file path, line number, the matching line,
and optional context lines above/below each match.
`,
parameters: {
directory_path: z.string().optional().describe("Directory to search. Defaults to workspace root."),
pattern: z.string().describe("Regex pattern or string to search for"),
use_regex: z.boolean().optional().default(false),
case_sensitive: z.boolean().optional().default(false).describe("Whether the search is case-sensitive. Default: false."),
context_lines: z.number().int().min(0).max(10).optional().default(2).describe("Number of lines of context to include above and below each match (0–10, default 2)."),
max_matches: z.number().int().min(1).max(500).optional().default(100).describe("Maximum number of matches to return (1–500, default 100). Lower this to stay within context limits on large repos."),
exclude: z.array(z.string()).optional().describe("Additional directory or filename patterns to skip (e.g. ['dist', 'coverage', '*.min.js']). node_modules and .git are always excluded."),
},
implementation: async ({ directory_path, pattern, use_regex, case_sensitive = false, context_lines = 2, max_matches = 100, exclude = [] }, toolCtx) => {
try {
const targetDir = directory_path ? validatePath(ctx.cwd, directory_path, ctx.protectedPaths) : ctx.cwd;
const flags = case_sensitive ? "g" : "gi";
const regex = new RegExp(use_regex ? pattern : pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
// Build exclusion set from always-excluded dirs + user-provided patterns
const ALWAYS_EXCLUDE = new Set(["node_modules", ".git"]);
const shouldExclude = (name: string, fullPath: string): boolean => {
if (ALWAYS_EXCLUDE.has(name) || name.startsWith(".")) return true;
for (const pat of exclude) {
if (pat.includes("*")) {
const escaped = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
if (new RegExp(`^${escaped}$`, "i").test(name)) return true;
} else if (name === pat || fullPath.includes(`/${pat}/`) || fullPath.endsWith(`/${pat}`)) {
return true;
}
}
return false;
};
// Collect all candidate file paths first, then search in parallel
// with bounded concurrency (8 simultaneous reads) for large trees.
const CONCURRENCY = 8;
const filePaths: string[] = [];
toolCtx?.status?.("Collecting files…");
async function collectFiles(dir: string): Promise<void> {
const entries = await readdir(dir);
await Promise.all(entries.map(async (entry) => {
const fullPath = join(dir, entry);
if (shouldExclude(entry, fullPath)) return;
try {
const st = await stat(fullPath);
if (st.isDirectory()) await collectFiles(fullPath);
else if (st.isFile() && st.size <= 2_000_000) filePaths.push(fullPath);
} catch { /* skip inaccessible entries */ }
}));
}
await collectFiles(targetDir);
toolCtx?.status?.(`Searching ${filePaths.length} file(s) for "${pattern}"…`);
type Match = { file: string; line: number; match: string; context: string[] };
const results: Match[] = [];
let searchedCount = 0;
// Process files in parallel with bounded concurrency
for (let i = 0; i < filePaths.length && results.length < max_matches; i += CONCURRENCY) {
const batch = filePaths.slice(i, i + CONCURRENCY);
if (i > 0 && i % (CONCURRENCY * 4) === 0) {
toolCtx?.status?.(`Searched ${searchedCount}/${filePaths.length} files — ${results.length} match(es) so far…`);
}
await Promise.all(batch.map(async (fullPath) => {
if (results.length >= max_matches) return;
try {
const content = await readFile(fullPath, "utf-8");
if (content.includes("\0")) return; // skip binary
const lines = content.split("\n");
const relPath = relative(ctx.cwd, fullPath);
// Track covered line ranges to deduplicate overlapping context windows
const coveredUpTo: number[] = []; // per-match end line (0-indexed)
for (let j = 0; j < lines.length && results.length < max_matches; j++) {
if (!lines[j].match(regex)) continue;
// Skip if this match line is already inside a prior match's context window
const alreadyCovered = coveredUpTo.some(endLine => j <= endLine);
if (alreadyCovered) continue;
const ctxStart = Math.max(0, j - context_lines);
const ctxEnd = Math.min(lines.length - 1, j + context_lines);
coveredUpTo.push(ctxEnd);
const contextWindow: string[] = [];
for (let k = ctxStart; k <= ctxEnd; k++) {
const prefix = k === j ? `>${k + 1}:` : ` ${k + 1}:`;
contextWindow.push(`${prefix} ${lines[k]}`);
}
results.push({ file: relPath, line: j + 1, match: lines[j].trim(), context: contextWindow });
}
searchedCount++;
} catch { /* skip unreadable files */ }
}));
}
if (results.length === 0) return { matches: [], message: `No matches found. Searched ${searchedCount} files.` };
return {
matches: results,
message: `Found ${results.length} match(es) across ${searchedCount} file(s) searched.${results.length >= max_matches ? ` (limit of ${max_matches} reached — use max_matches or a narrower pattern/directory to get more)` : ""}`,
};
} catch (e) {
return { error: `Search failed: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
tools.push(tool({
name: "search_in_file",
description: text`
Search for a pattern within a single file (grep-like functionality).
Returns matching lines with their line numbers.
The pattern can be a simple substring or a regex pattern.
`,
parameters: {
file_name: z.string(),
pattern: z.string().describe("Search pattern (substring or regex)"),
case_sensitive: z.boolean().optional().default(false),
use_regex: z.boolean().optional().default(false),
},
implementation: async ({ file_name, pattern, case_sensitive = false, use_regex = false }) => {
try {
const filePath = validatePath(ctx.cwd, file_name, ctx.protectedPaths);
const content = await readFile(filePath, "utf-8");
const lines = content.split("\n");
const matches: Array<{ line_number: number; content: string }> = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
let searchPattern = pattern;
if (!case_sensitive) { line = line.toLowerCase(); searchPattern = pattern.toLowerCase(); }
let isMatch = false;
if (use_regex) {
try { isMatch = new RegExp(searchPattern).test(line); }
catch (e) { return { error: `Invalid regex pattern: ${e instanceof Error ? e.message : String(e)}` }; }
} else {
isMatch = line.includes(searchPattern);
}
if (isMatch) matches.push({ line_number: i + 1, content: lines[i] });
}
return {
file_name, pattern, match_count: matches.length, matches: matches.slice(0, 100),
note: matches.length > 100 ? `Showing first 100 of ${matches.length} matches` : undefined,
};
} catch (e) {
return { error: `Failed to search file: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
tools.push(tool({
name: "find_files",
description: "Find files recursively in the current directory matching a name pattern.",
parameters: {
pattern: z.string().describe("Substring to match in filename (case-insensitive)"),
max_depth: z.number().optional().describe("Maximum depth to search (default: 5)"),
},
implementation: async ({ pattern, max_depth }) => {
const depthLimit = max_depth ?? 5;
const foundFiles: string[] = [];
const lowerPattern = pattern.toLowerCase();
async function scan(dir: string, currentDepth: number) {
if (currentDepth > depthLimit) return;
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (["node_modules", ".git", "dist", ".lmstudio"].includes(entry.name)) continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) await scan(fullPath, currentDepth + 1);
else if (entry.isFile() && entry.name.toLowerCase().includes(lowerPattern)) foundFiles.push(fullPath);
}
} catch { /* ignore access errors */ }
}
await scan(ctx.cwd, 0);
return { found_files: foundFiles.slice(0, 100), count: foundFiles.length };
},
}));
tools.push(tool({
name: "fuzzy_find_local_files",
description: "Fuzzy find local files by path/name similarity using Levenshtein scoring.",
parameters: {
query: z.string().describe("Search query to match against file names/paths."),
path: z.string().optional().describe("Sub-directory to search in (default: current directory)."),
max_results: z.number().int().min(1).max(20).optional().describe("Max results to return (default: 5)."),
},
implementation: async ({ query, path = ".", max_results = 5 }) => {
try {
const targetDir = validatePath(ctx.cwd, path, ctx.protectedPaths);
const entries = await readdir(targetDir, { recursive: true, withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => {
const fullPath = join((entry as any).parentPath ?? (entry as any).path, entry.name);
return relative(targetDir, fullPath).replace(/\\/g, "/");
});
const ranked = rankFuzzyMatches(query, files, max_results);
return { query, path: targetDir, results: ranked.map(item => ({ path: item.value, score: item.score })) };
} catch (error) {
return { error: `Fuzzy file search failed: ${error instanceof Error ? error.message : String(error)}` };
}
},
}));
tools.push(tool({
name: "get_file_metadata",
description: "Get metadata (size, dates) for a specific file.",
parameters: { path: z.string() },
implementation: async ({ path }) => {
try {
const targetPath = validatePath(ctx.cwd, path, ctx.protectedPaths);
const stats = await stat(targetPath);
return {
path: targetPath, size: stats.size, created: stats.birthtime, modified: stats.mtime,
is_directory: stats.isDirectory(), is_file: stats.isFile(),
};
} catch (error) {
return { error: `Failed to get metadata: ${error instanceof Error ? error.message : String(error)}` };
}
},
}));
// ── apply_patch (Phase L) ────────────────────────────────────────────────────
tools.push(tool({
name: "apply_patch",
description: "Apply a unified diff patch to files in the workspace. Accepts standard unified diff format (output of `git diff` or `diff -u`). Requires git to be installed. Use dry_run: true to verify the patch applies cleanly before committing.",
parameters: {
patch: z.string().describe("Unified diff content to apply."),
dry_run: z.boolean().optional().default(false).describe("If true, checks whether the patch applies cleanly without making any changes."),
},
implementation: async ({ patch, dry_run = false }) => {
const tmpFile = join(tmpdir(), `toolbox-patch-${Date.now()}.diff`);
try {
await writeFile(tmpFile, patch, "utf-8");
const { simpleGit } = await import("simple-git");
const git = simpleGit(ctx.cwd);
const args = ["apply"];
if (dry_run) args.push("--check");
args.push("--", tmpFile);
await git.raw(args);
return {
success: true,
message: dry_run ? "Patch applies cleanly (dry run — no changes made)." : "Patch applied successfully.",
};
} catch (e) {
return { error: `Patch ${dry_run ? "check" : "apply"} failed: ${e instanceof Error ? e.message : String(e)}` };
} finally {
await unlink(tmpFile).catch(() => {});
}
},
}));
// ── N.15: edit_file_with_diff ─────────────────────────────────────────────
tools.push(tool({
name: "edit_file_with_diff",
description: "Apply a unified diff to a specific file. Validates the patch applies cleanly before making any changes — if validation fails, the file is left untouched. Dramatically reduces token usage for large files: send a 10-line diff instead of a full 500-line rewrite. The diff must target a single file; for multi-file patches use apply_patch.",
parameters: {
file_path: z.string().describe("Workspace-relative path to the file to edit."),
unified_diff: z.string().describe("Unified diff in standard format. Context lines must match the current file exactly. If --- / +++ headers are omitted, they are added automatically."),
},
implementation: async ({ file_path, unified_diff }) => {
const fpath = validatePath(ctx.cwd, file_path, ctx.protectedPaths);
// Normalise: ensure the diff has --- / +++ headers git apply expects
let diff = unified_diff.trimStart();
if (!diff.startsWith("---")) {
diff = `--- a/${file_path}\n+++ b/${file_path}\n${diff}`;
}
const tmpFile = join(tmpdir(), `toolbox-diff-${Date.now()}.patch`);
try {
await writeFile(tmpFile, diff, "utf-8");
const { simpleGit } = await import("simple-git");
const git = simpleGit(ctx.cwd);
// Validate first — abort if it won't apply cleanly
try {
await git.raw(["apply", "--check", "--", tmpFile]);
} catch (checkErr) {
const msg = checkErr instanceof Error ? checkErr.message : String(checkErr);
return {
error: `Diff does not apply cleanly to '${file_path}': ${msg.trim()}`,
hint: "Ensure context lines in the diff match the current file content exactly. Re-read the file with read_file to get the current content.",
};
}
await git.raw(["apply", "--", tmpFile]);
// Read back the result to confirm and return stats
const newContent = await readFile(fpath, "utf-8");
const lines = newContent.split("\n").length;
return { success: true, file: file_path, lines_after_edit: lines, message: `Diff applied successfully to '${file_path}'.` };
} catch (e) {
return { error: `edit_file_with_diff failed: ${e instanceof Error ? e.message : String(e)}` };
} finally {
await unlink(tmpFile).catch(() => {});
}
},
}));
// ── N.8: watch_file / watch_directory / stop_watch / list_watches ────────
function startWatcher(watchPath: string, type: "file" | "directory", recursive: boolean): string {
const id = generateId();
const watcher = fsWatch(watchPath, { recursive }, (eventType, filename) => {
const entry = fileWatchers.get(id);
if (!entry || entry.status !== "watching") return;
entry.events.push({ eventType, filename: filename ? String(filename) : null, time: new Date().toISOString() });
if (entry.events.length > WATCHER_EVENT_LIMIT) entry.events.shift();
});
watcher.on("error", () => {
const entry = fileWatchers.get(id);
if (entry) entry.status = "stopped";
});
fileWatchers.set(id, { id, watchPath, type, recursive, status: "watching", startTime: Date.now(), events: [], watcher });
return id;
}
tools.push(tool({
name: "watch_file",
description: "Start watching a file for changes. Returns a watcher ID. Use list_watches to poll for change events, and stop_watch to cancel. Events are buffered (last 50).",
parameters: {
file_path: z.string().describe("Path to the file to watch."),
},
implementation: async ({ file_path }) => {
const fpath = validatePath(ctx.cwd, file_path, ctx.protectedPaths);
try { await stat(fpath); } catch { return { error: `File not found: ${file_path}` }; }
const id = startWatcher(fpath, "file", false);
return { success: true, watcher_id: id, watching: fpath, message: `Watching file. Use list_watches to poll for events.` };
},
}));
tools.push(tool({
name: "watch_directory",
description: "Start watching a directory for file system changes. Returns a watcher ID. Use list_watches to poll for change events, and stop_watch to cancel.",
parameters: {
directory_path: z.string().describe("Directory to watch."),
recursive: z.boolean().optional().default(false).describe("If true, watch all subdirectories recursively (default: false)."),
},
implementation: async ({ directory_path, recursive = false }) => {
const dpath = validatePath(ctx.cwd, directory_path, ctx.protectedPaths);
try {
const s = await stat(dpath);
if (!s.isDirectory()) return { error: `Not a directory: ${directory_path}` };
} catch { return { error: `Directory not found: ${directory_path}` }; }
const id = startWatcher(dpath, "directory", recursive);
return { success: true, watcher_id: id, watching: dpath, recursive, message: `Watching directory. Use list_watches to poll for events.` };
},
}));
tools.push(tool({
name: "stop_watch",
description: "Stop a file or directory watcher by its ID.",
parameters: {
watcher_id: z.string().describe("Watcher ID returned by watch_file or watch_directory."),
},
implementation: async ({ watcher_id }) => {
const entry = fileWatchers.get(watcher_id);
if (!entry) return { error: `No watcher found with ID '${watcher_id}'.` };
entry.watcher.close();
entry.status = "stopped";
return { success: true, watcher_id, message: `Watcher stopped.` };
},
}));
tools.push(tool({
name: "list_watches",
description: "List all active and recently stopped file/directory watchers, including buffered change events.",
parameters: {
watcher_id: z.string().optional().describe("If provided, return details for a specific watcher only."),
},
implementation: async ({ watcher_id }) => {
const entries = watcher_id
? fileWatchers.has(watcher_id) ? [fileWatchers.get(watcher_id)!] : []
: Array.from(fileWatchers.values());
if (watcher_id && entries.length === 0) return { error: `No watcher found with ID '${watcher_id}'.` };
return {
watchers: entries.map(e => ({
id: e.id,
path: e.watchPath,
type: e.type,
recursive: e.recursive,
status: e.status,
running_for_seconds: Math.floor((Date.now() - e.startTime) / 1000),
event_count: e.events.length,
recent_events: e.events.slice(-10),
})),
};
},
}));
return tools;
}