/**
* File Operations — read, write, edit, multi-edit, notebook, list, search
*
* Extracted from local-tools.ts for single-responsibility.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
import { dirname, join } from "path";
import { execSync } from "child_process";
import { homedir } from "os";
import { backupFile } from "../file-history.js";
import { notifyFileChanged } from "../lsp-manager.js";
import { debugLog } from "../debug-log.js";
import { ToolResult } from "../../../shared/types.js";
export function resolvePath(p: string): string {
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
return p;
}
// ============================================================================
// READ FILE
// ============================================================================
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
const IMAGE_MEDIA_TYPES: Record<string, string> = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", webp: "image/webp",
};
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "aiff", "aac", "ogg", "flac", "m4a"]);
const AUDIO_MEDIA_TYPES: Record<string, string> = {
mp3: "audio/mpeg", wav: "audio/wav", aiff: "audio/aiff",
aac: "audio/aac", ogg: "audio/ogg", flac: "audio/flac",
m4a: "audio/mp4",
};
const AUDIO_MAX_SIZE = 25 * 1024 * 1024; // 25MB
export async function readFile(input: Record<string, unknown>): Promise<ToolResult> {
const path = resolvePath(input.path as string);
if (!existsSync(path)) return { success: false, output: `File not found: ${path}` };
const ext = path.split(".").pop()?.toLowerCase() || "";
// Image files -> base64 with marker for agent-loop to convert to image content block
if (IMAGE_EXTENSIONS.has(ext)) {
try {
const buffer = readFileSync(path);
const base64 = buffer.toString("base64");
const mediaType = IMAGE_MEDIA_TYPES[ext] || "image/png";
return { success: true, output: `__IMAGE__${mediaType}__${base64}` };
} catch (err) {
return { success: false, output: `Failed to read image: ${err}` };
}
}
// Audio files -> base64 with marker for agent-loop to convert to audio content block
if (AUDIO_EXTENSIONS.has(ext)) {
try {
const stat = statSync(path);
if (stat.size > AUDIO_MAX_SIZE) {
return { success: false, output: `Audio file too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB (max 25MB)` };
}
const buffer = readFileSync(path);
const base64 = buffer.toString("base64");
const mediaType = AUDIO_MEDIA_TYPES[ext] || "audio/mpeg";
return { success: true, output: `__AUDIO__${mediaType}__${base64}` };
} catch (err) {
return { success: false, output: `Failed to read audio file: ${err}` };
}
}
// PDF files -> extract text
if (ext === "pdf") {
try {
const pdfParse = (await import("pdf-parse")).default;
const buffer = readFileSync(path);
const data = await pdfParse(buffer);
let text = data.text || "";
const totalPages = data.numpages || 0;
const pagesParam = input.pages as string | undefined;
// Apply page range filter if specified
if (pagesParam && text) {
const pageTexts = text.split(/\f/); // Form feed splits pages in most PDFs
const { start, end } = parsePageRange(pagesParam, pageTexts.length);
text = pageTexts.slice(start, end).join("\n\n---\n\n");
}
if (text.length > 100_000) {
text = text.slice(0, 100_000) + `\n\n... (truncated)`;
}
return {
success: true,
output: `PDF: ${path} (${totalPages} pages)\n\n${text}`,
};
} catch (err) {
return { success: false, output: `Failed to parse PDF: ${err}` };
}
}
// Text files — existing behavior
const content = readFileSync(path, "utf-8");
const lines = content.split("\n");
const offset = (input.offset as number) || 1; // 1-based
const limit = input.limit as number | undefined;
if (offset > 1 || limit) {
const startIdx = Math.max(0, offset - 1);
const endIdx = limit ? startIdx + limit : lines.length;
const slice = lines.slice(startIdx, endIdx);
const numbered = slice.map((line, i) => {
const lineNum = startIdx + i + 1;
return `${String(lineNum).padStart(6)} ${line}`;
});
let output = numbered.join("\n");
if (endIdx < lines.length) {
output += `\n\n... (showing lines ${startIdx + 1}-${Math.min(endIdx, lines.length)} of ${lines.length})`;
}
return { success: true, output };
}
if (content.length > 100_000) {
return { success: true, output: content.slice(0, 100_000) + `\n\n... (truncated, ${content.length} total chars)` };
}
return { success: true, output: content };
}
function parsePageRange(range: string, totalPages: number): { start: number; end: number } {
const parts = range.split("-");
const start = Math.max(0, parseInt(parts[0], 10) - 1);
const end = parts.length > 1 ? Math.min(totalPages, parseInt(parts[1], 10)) : start + 1;
return { start, end };
}
// ============================================================================
// WRITE FILE
// ============================================================================
/** Compute a unified diff between old and new file lines using prefix/suffix matching */
function computeWriteDiff(oldLines: string[], newLines: string[]): string[] {
const CTX = 3;
const MAX_PER_SIDE = 60;
// Find common prefix
let prefixLen = 0;
while (prefixLen < oldLines.length && prefixLen < newLines.length &&
oldLines[prefixLen] === newLines[prefixLen]) {
prefixLen++;
}
// Find common suffix (not overlapping prefix)
let suffixLen = 0;
while (suffixLen < (oldLines.length - prefixLen) &&
suffixLen < (newLines.length - prefixLen) &&
oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]) {
suffixLen++;
}
// If identical
if (prefixLen + suffixLen >= oldLines.length && prefixLen + suffixLen >= newLines.length) {
return []; // no changes
}
const oldMiddle = oldLines.slice(prefixLen, oldLines.length - suffixLen);
const newMiddle = newLines.slice(prefixLen, newLines.length - suffixLen);
// If most of the file changed, show a compact summary
if (oldMiddle.length > MAX_PER_SIDE * 2 && newMiddle.length > MAX_PER_SIDE * 2) {
const showOld = oldMiddle.slice(0, MAX_PER_SIDE);
const showNew = newMiddle.slice(0, MAX_PER_SIDE);
const ctxStart = Math.max(0, prefixLen - CTX);
const ctxBefore = oldLines.slice(ctxStart, prefixLen);
const parts: string[] = [`@@ -${ctxStart + 1},${ctxBefore.length + showOld.length} +${ctxStart + 1},${ctxBefore.length + showNew.length} @@`];
for (const l of ctxBefore) parts.push(` ${l}`);
for (const l of showOld) parts.push(`-${l}`);
parts.push(`-... (${oldMiddle.length - MAX_PER_SIDE} more lines removed)`);
for (const l of showNew) parts.push(`+${l}`);
parts.push(`+... (${newMiddle.length - MAX_PER_SIDE} more lines added)`);
return parts;
}
// Build single hunk with context
const ctxStart = Math.max(0, prefixLen - CTX);
const ctxBefore = oldLines.slice(ctxStart, prefixLen);
const newSuffixStart = newLines.length - suffixLen;
const ctxAfter = newLines.slice(newSuffixStart, Math.min(newSuffixStart + CTX, newLines.length));
const hunkOldLen = ctxBefore.length + oldMiddle.length + ctxAfter.length;
const hunkNewLen = ctxBefore.length + newMiddle.length + ctxAfter.length;
const parts: string[] = [`@@ -${ctxStart + 1},${hunkOldLen} +${ctxStart + 1},${hunkNewLen} @@`];
for (const l of ctxBefore) parts.push(` ${l}`);
for (const l of oldMiddle.slice(0, MAX_PER_SIDE)) parts.push(`-${l}`);
if (oldMiddle.length > MAX_PER_SIDE) parts.push(`-... (${oldMiddle.length - MAX_PER_SIDE} more lines removed)`);
for (const l of newMiddle.slice(0, MAX_PER_SIDE)) parts.push(`+${l}`);
if (newMiddle.length > MAX_PER_SIDE) parts.push(`+... (${newMiddle.length - MAX_PER_SIDE} more lines added)`);
for (const l of ctxAfter) parts.push(` ${l}`);
return parts;
}
export function writeFile(input: Record<string, unknown>): ToolResult {
const path = resolvePath(input.path as string);
const content = input.content as string;
const existed = existsSync(path);
const oldContent = existed ? readFileSync(path, "utf-8") : null;
backupFile(path); // Save backup before modification
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path, content, "utf-8");
debugLog("tools", `write_file: ${path} (${content.length} chars)`);
notifyFileChanged(path);
const newLines = content.split("\n");
if (!existed || !oldContent) {
// New file — show as all-added unified diff
const previewMax = 30;
const preview = newLines.slice(0, previewMax).map(l => `+${l}`);
if (newLines.length > previewMax) preview.push(`+... (+${newLines.length - previewMax} more lines)`);
return {
success: true,
output: `Created: ${path} (${newLines.length} lines, ${content.length} chars)\n@@ -0,0 +1,${Math.min(newLines.length, previewMax)} @@\n${preview.join("\n")}`,
};
}
// Overwrite — compute diff between old and new content
const oldLines = oldContent.split("\n");
const diff = computeWriteDiff(oldLines, newLines);
// Count changes
let added = 0, removed = 0;
for (const line of diff) {
if (line.startsWith("+")) added++;
else if (line.startsWith("-")) removed++;
}
const summary = `Added ${added} lines, removed ${removed} lines`;
return {
success: true,
output: `Updated: ${path} (${summary})\n${diff.join("\n")}`,
};
}
// ============================================================================
// EDIT FILE
// ============================================================================
export function editFile(input: Record<string, unknown>): ToolResult {
const path = resolvePath(input.path as string);
const oldString = input.old_string as string;
const newString = input.new_string as string;
const replaceAll = (input.replace_all as boolean) ?? false;
if (!existsSync(path)) return { success: false, output: `File not found: ${path}` };
backupFile(path); // Save backup before modification
let content = readFileSync(path, "utf-8");
if (!content.includes(oldString)) return { success: false, output: "old_string not found in file" };
if (replaceAll) {
let count = 0;
if (newString.includes(oldString)) {
// Avoid infinite loop: use split-join for safe replacement
const parts = content.split(oldString);
count = parts.length - 1;
content = parts.join(newString);
} else {
while (content.includes(oldString)) {
content = content.replace(oldString, newString);
count++;
if (count > 10000) break; // safety
}
}
writeFileSync(path, content, "utf-8");
notifyFileChanged(path);
return { success: true, output: `File edited: ${path} (${count} replacements)` };
}
// Single replacement (original behavior)
const idx = content.indexOf(oldString);
const newContent = content.slice(0, idx) + newString + content.slice(idx + oldString.length);
writeFileSync(path, newContent, "utf-8");
notifyFileChanged(path);
// Generate unified diff with context and line numbers
const allOldLines = content.split("\n");
const allNewLines = newContent.split("\n");
const beforeEdit = content.slice(0, idx);
const startLine = beforeEdit.split("\n").length; // 1-based
const oldLines = oldString.split("\n");
const newLines = newString.split("\n");
const CTX = 3;
const MAX_LINES = 20;
const ctxStart = Math.max(1, startLine - CTX);
const ctxBeforeLines = allOldLines.slice(ctxStart - 1, startLine - 1);
const newEndLine = startLine + newLines.length - 1;
const ctxAfterLines = allNewLines.slice(newEndLine, Math.min(newEndLine + CTX, allNewLines.length));
const showOld = oldLines.slice(0, MAX_LINES);
const showNew = newLines.slice(0, MAX_LINES);
const hunkOldLen = ctxBeforeLines.length + showOld.length + ctxAfterLines.length;
const hunkNewLen = ctxBeforeLines.length + showNew.length + ctxAfterLines.length;
const diffParts: string[] = [];
diffParts.push(`@@ -${ctxStart},${hunkOldLen} +${ctxStart},${hunkNewLen} @@`);
for (const l of ctxBeforeLines) diffParts.push(` ${l}`);
for (const l of showOld) diffParts.push(`-${l}`);
if (oldLines.length > MAX_LINES) diffParts.push(`-... (${oldLines.length - MAX_LINES} more lines)`);
for (const l of showNew) diffParts.push(`+${l}`);
if (newLines.length > MAX_LINES) diffParts.push(`+... (${newLines.length - MAX_LINES} more lines)`);
for (const l of ctxAfterLines) diffParts.push(` ${l}`);
return { success: true, output: `File edited: ${path}\n${diffParts.join("\n")}` };
}
// ============================================================================
// MULTI EDIT
// ============================================================================
export function multiEdit(input: Record<string, unknown>): ToolResult {
const path = resolvePath(input.file_path as string);
const edits = input.edits as Array<{ old_string: string; new_string: string }>;
if (!existsSync(path)) return { success: false, output: `File not found: ${path}` };
if (!Array.isArray(edits) || edits.length === 0) return { success: false, output: "edits array is required and must not be empty" };
backupFile(path); // Save backup before modification
let content = readFileSync(path, "utf-8");
const diffParts: string[] = [];
const CTX = 2;
const MAX_LINES = 10;
for (let i = 0; i < edits.length; i++) {
const { old_string, new_string } = edits[i];
const idx = content.indexOf(old_string);
if (idx === -1) {
return {
success: false,
output: `Edit ${i + 1}/${edits.length} failed: old_string not found (${i} edits applied successfully before failure)`,
};
}
// Compute line numbers before applying edit
const allOldLines = content.split("\n");
const beforeEdit = content.slice(0, idx);
const startLine = beforeEdit.split("\n").length;
const oldLines = old_string.split("\n");
const newLines = new_string.split("\n");
const newContent = content.slice(0, idx) + new_string + content.slice(idx + old_string.length);
const allNewLines = newContent.split("\n");
const ctxStart = Math.max(1, startLine - CTX);
const ctxBeforeLines = allOldLines.slice(ctxStart - 1, startLine - 1);
const newEndLine = startLine + newLines.length - 1;
const ctxAfterLines = allNewLines.slice(newEndLine, Math.min(newEndLine + CTX, allNewLines.length));
const showOld = oldLines.slice(0, MAX_LINES);
const showNew = newLines.slice(0, MAX_LINES);
const hunkOldLen = ctxBeforeLines.length + showOld.length + ctxAfterLines.length;
const hunkNewLen = ctxBeforeLines.length + showNew.length + ctxAfterLines.length;
diffParts.push(`@@ -${ctxStart},${hunkOldLen} +${ctxStart},${hunkNewLen} @@`);
for (const l of ctxBeforeLines) diffParts.push(` ${l}`);
for (const l of showOld) diffParts.push(`-${l}`);
if (oldLines.length > MAX_LINES) diffParts.push(`-... (${oldLines.length - MAX_LINES} more)`);
for (const l of showNew) diffParts.push(`+${l}`);
if (newLines.length > MAX_LINES) diffParts.push(`+... (${newLines.length - MAX_LINES} more)`);
for (const l of ctxAfterLines) diffParts.push(` ${l}`);
content = newContent;
}
writeFileSync(path, content, "utf-8");
notifyFileChanged(path);
return {
success: true,
output: `Applied ${edits.length} edits to ${path}\n${diffParts.join("\n")}`,
};
}
// ============================================================================
// NOTEBOOK EDIT
// ============================================================================
export function notebookEdit(input: Record<string, unknown>): ToolResult {
const path = resolvePath(input.notebook_path as string);
const newSource = (input.new_source as string) || "";
const cellType = (input.cell_type as string) || "code";
const editMode = (input.edit_mode as string) || "replace";
const cellId = input.cell_id as string | undefined;
if (!existsSync(path)) return { success: false, output: `Notebook not found: ${path}` };
let notebook: any;
try {
notebook = JSON.parse(readFileSync(path, "utf-8"));
} catch (err) {
return { success: false, output: `Failed to parse notebook: ${err}` };
}
const cells: any[] = notebook.cells || [];
// Find cell by ID or numeric index
let cellIndex = -1;
if (cellId !== undefined) {
cellIndex = cells.findIndex((c) => c.id === cellId);
if (cellIndex === -1) {
const idx = parseInt(cellId, 10);
if (!isNaN(idx) && idx >= 0 && idx < cells.length) cellIndex = idx;
}
}
// Split source into notebook-format lines (each line ends with \n except last)
const sourceLines = newSource.split("\n").map((line, i, arr) =>
i < arr.length - 1 ? line + "\n" : line
);
switch (editMode) {
case "replace": {
if (cellIndex < 0) return { success: false, output: `Cell not found: ${cellId}` };
cells[cellIndex].source = sourceLines;
if (cellType) cells[cellIndex].cell_type = cellType;
break;
}
case "insert": {
const newCell: any = {
cell_type: cellType,
source: sourceLines,
metadata: {},
};
if (cellType === "code") {
newCell.execution_count = null;
newCell.outputs = [];
}
if (cellIndex >= 0) {
cells.splice(cellIndex + 1, 0, newCell);
} else {
cells.push(newCell);
}
break;
}
case "delete": {
if (cellIndex < 0) return { success: false, output: `Cell not found: ${cellId}` };
cells.splice(cellIndex, 1);
break;
}
default:
return { success: false, output: `Unknown edit_mode: ${editMode}` };
}
notebook.cells = cells;
writeFileSync(path, JSON.stringify(notebook, null, 1), "utf-8");
return { success: true, output: `Notebook ${editMode}d cell in ${path} (${cells.length} cells total)` };
}
// ============================================================================
// LIST DIRECTORY
// ============================================================================
export function listDirectory(input: Record<string, unknown>): ToolResult {
const path = resolvePath(input.path as string);
const recursive = input.recursive as boolean ?? false;
if (!existsSync(path)) return { success: false, output: `Directory not found: ${path}` };
if (recursive) {
try {
const escapedPath = path.replace(/'/g, "'\\''");
const output = execSync(`find '${escapedPath}' -maxdepth 4 -not -path '*/.*' 2>/dev/null | head -200`, {
encoding: "utf-8", timeout: 5000,
});
return { success: true, output: output.trim() || "(empty)" };
} catch {
return { success: false, output: "Failed to list directory recursively" };
}
}
const entries = readdirSync(path, { withFileTypes: true });
const lines = entries.map((e) => `${e.isDirectory() ? "[dir] " : " "}${e.name}`);
return { success: true, output: lines.join("\n") || "(empty directory)" };
}
// ============================================================================
// SEARCH FILES / SEARCH CONTENT (legacy)
// ============================================================================
export function searchFiles(input: Record<string, unknown>): ToolResult {
const pattern = (input.pattern as string).replace(/'/g, "'\\''");
const path = resolvePath(input.path as string).replace(/'/g, "'\\''");
try {
const output = execSync(
`find '${path}' -name '${pattern}' -type f -not -path '*/.*' 2>/dev/null | head -100`,
{ encoding: "utf-8", timeout: 10000 }
);
return { success: true, output: output.trim() || "No files found" };
} catch {
return { success: false, output: "Search failed" };
}
}
export function searchContent(input: Record<string, unknown>): ToolResult {
const query = input.query as string;
const path = resolvePath(input.path as string);
const filePattern = input.file_pattern as string | undefined;
let cmd = `grep -rn '${query.replace(/'/g, "'\\''")}' '${path.replace(/'/g, "'\\''")}'`;
if (filePattern) cmd += ` --include='${filePattern.replace(/'/g, "'\\''")}'`;
cmd += " 2>/dev/null | head -50";
try {
const output = execSync(cmd, { encoding: "utf-8", timeout: 10000 });
return { success: true, output: output.trim() || "No matches found" };
} catch {
return { success: true, output: "No matches found" };
}
}