import { promises as fs } from "node:fs"; import * as path from "node:path"; import type { ImageContent, TextContent, } from "@letta-ai/letta-client/resources/agents/messages"; import { LETTA_CLOUD_API_URL } from "../../auth/oauth.js"; import { resizeImageIfNeeded } from "../../cli/helpers/imageResize.js"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; import { settingsManager } from "../../settings-manager.js"; import { debugLog } from "../../utils/debug.js"; import { OVERFLOW_CONFIG, writeOverflowFile } from "./overflow.js"; import { LIMITS } from "./truncation.js"; import { validateRequiredParams } from "./validation.js"; /** * Check if the server supports images in tool responses. * Currently only api.letta.com supports this feature. */ function serverSupportsImageToolReturns(): boolean { const settings = settingsManager.getSettings(); const baseURL = process.env.LETTA_BASE_URL || settings.env?.LETTA_BASE_URL || LETTA_CLOUD_API_URL; return baseURL === LETTA_CLOUD_API_URL; } interface ReadArgs { file_path: string; offset?: number; limit?: number; } // Tool return content types - either a string or array of content parts export type ToolReturnContent = string | Array; interface ReadResult { content: ToolReturnContent; } // Supported image extensions const IMAGE_EXTENSIONS = new Set([ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ]); function isImageFile(filePath: string): boolean { const ext = path.extname(filePath).toLowerCase(); return IMAGE_EXTENSIONS.has(ext); } function getMediaType(ext: string): string { const types: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/png", // Convert BMP to PNG }; return types[ext] || "image/png"; } async function readImageFile( filePath: string, ): Promise> { const buffer = await fs.readFile(filePath); const ext = path.extname(filePath).toLowerCase(); const mediaType = getMediaType(ext); // Use shared image resize utility const result = await resizeImageIfNeeded(buffer, mediaType); return [ { type: "text", text: `[Image: ${path.basename(filePath)}${result.resized ? " (resized to fit API limits)" : ""}]`, }, { type: "image", source: { type: "base64", media_type: result.mediaType, data: result.data, }, }, ]; } async function isBinaryFile(filePath: string): Promise { try { const fd = await fs.open(filePath, "r"); try { const stats = await fd.stat(); const bufferSize = Math.min(8192, stats.size); if (bufferSize === 0) return false; const buffer = Buffer.alloc(bufferSize); const { bytesRead } = await fd.read(buffer, 0, bufferSize, 0); if (bytesRead === 0) return false; // Check for null bytes (definite binary indicator) for (let i = 0; i < bytesRead; i++) { if (buffer[i] === 0) return true; } // Count control characters (excluding whitespace) // This catches files that are mostly control characters but lack null bytes const text = buffer.slice(0, bytesRead).toString("utf-8"); let controlCharCount = 0; for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); // Allow tab(9), newline(10), carriage return(13) if (code < 9 || (code > 13 && code < 32)) { controlCharCount++; } } return controlCharCount / text.length > 0.3; } finally { await fd.close(); } } catch { return false; } } function formatWithLineNumbers( content: string, offset?: number, limit?: number, workingDirectory?: string, ): string { const lines = content.split("\n"); const originalLineCount = lines.length; const startLine = offset || 0; // Apply default limit if not specified (Claude Code: 2000 lines) const effectiveLimit = limit ?? LIMITS.READ_MAX_LINES; const endLine = Math.min(startLine + effectiveLimit, lines.length); const actualStartLine = Math.min(startLine, lines.length); const actualEndLine = Math.min(endLine, lines.length); const selectedLines = lines.slice(actualStartLine, actualEndLine); // Apply per-line character limit (Claude Code: 2000 chars/line) let linesWereTruncatedInLength = false; const formattedLines = selectedLines.map((line, index) => { const lineNumber = actualStartLine + index + 1; const maxLineNumber = actualStartLine + selectedLines.length; const padding = Math.max(1, maxLineNumber.toString().length); const paddedNumber = lineNumber.toString().padStart(padding); // Truncate long lines if (line.length > LIMITS.READ_MAX_CHARS_PER_LINE) { linesWereTruncatedInLength = true; const truncated = line.slice(0, LIMITS.READ_MAX_CHARS_PER_LINE); return `${paddedNumber}→${truncated}... [line truncated]`; } return `${paddedNumber}→${line}`; }); let result = formattedLines.join("\n"); // Add truncation notices if applicable const notices: string[] = []; const wasTruncatedByLineCount = actualEndLine < originalLineCount; // Write to overflow file if content was truncated and overflow is enabled let overflowPath: string | undefined; if ( (wasTruncatedByLineCount || linesWereTruncatedInLength) && OVERFLOW_CONFIG.ENABLED && workingDirectory ) { try { overflowPath = writeOverflowFile(content, workingDirectory, "Read"); } catch (error) { // Silently fail if overflow file creation fails debugLog("read", "Failed to write overflow file: %O", error); } } if (wasTruncatedByLineCount && !limit) { // Only show this notice if user didn't explicitly set a limit notices.push( `\n\n[File truncated: showing lines ${actualStartLine + 1}-${actualEndLine} of ${originalLineCount} total lines. Use offset and limit parameters to read other sections.]`, ); } if (linesWereTruncatedInLength) { notices.push( `\n\n[Some lines exceeded ${LIMITS.READ_MAX_CHARS_PER_LINE.toLocaleString()} characters and were truncated.]`, ); } if (overflowPath) { notices.push(`\n\n[Full file content written to: ${overflowPath}]`); } if (notices.length > 0) { result += notices.join(""); } return result; } export async function read(args: ReadArgs): Promise { validateRequiredParams(args, ["file_path"], "Read"); const { file_path, offset, limit } = args; const userCwd = process.env.USER_CWD || process.cwd(); const resolvedPath = path.isAbsolute(file_path) ? file_path : path.resolve(userCwd, file_path); try { const stats = await fs.stat(resolvedPath); if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${resolvedPath}`); // Check if this is an image file if (isImageFile(resolvedPath)) { // Check if server supports images in tool responses if (!serverSupportsImageToolReturns()) { throw new Error( `This server does not support images in tool responses.`, ); } // Images have a higher size limit (20MB raw, will be resized if needed) const maxImageSize = 20 * 1024 * 1024; if (stats.size > maxImageSize) { throw new Error( `Image file too large: ${stats.size} bytes (max ${maxImageSize} bytes)`, ); } const imageContent = await readImageFile(resolvedPath); return { content: imageContent }; } // Regular text file handling const maxSize = 10 * 1024 * 1024; // 10MB if (stats.size > maxSize) throw new Error( `File too large: ${stats.size} bytes (max ${maxSize} bytes)`, ); if (await isBinaryFile(resolvedPath)) throw new Error(`Cannot read binary file: ${resolvedPath}`); const content = await fs.readFile(resolvedPath, "utf-8"); if (content.trim() === "") { return { content: `${SYSTEM_REMINDER_OPEN}\nThe file ${resolvedPath} exists but has empty contents.\n${SYSTEM_REMINDER_CLOSE}`, }; } const formattedContent = formatWithLineNumbers( content, offset, limit, userCwd, ); return { content: formattedContent }; } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === "ENOENT") { throw new Error( `File does not exist. Attempted path: ${resolvedPath}. Current working directory: ${userCwd}`, ); } else if (err.code === "EACCES") throw new Error(`Permission denied: ${resolvedPath}`); else if (err.code === "EISDIR") throw new Error(`Path is a directory: ${resolvedPath}`); else if (err.message) throw err; else throw new Error(`Failed to read file: ${String(err)}`); } }