import * as fs from 'fs';
import * as path from 'path';
import * as mime from 'mime-types';
import { tool } from 'ai';
import { z } from 'zod';
import { ExecutionContext } from '../types/agent';
import {
handleToolError,
validateWorkspacePath,
resolveWorkspacePath,
createSuccessResponse,
validateFileExists,
ToolResponse
} from './tool-utils';
* File read result with metadata
*/
export interface FileReadResult {
content: string;
filePath: string;
fileType: 'text' | 'image' | 'pdf' | 'binary';
mimeType?: string;
lineCount?: number;
isTruncated?: boolean;
linesShown?: [number, number];
size: number;
}
const DEFAULT_MAX_LINES = 1000;
const MAX_LINE_LENGTH = 2000;
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
* Check if a file is likely binary by sampling content
*/
function isBinaryFile(filePath: string): boolean {
try {
const fd = fs.openSync(filePath, 'r');
const fileSize = fs.fstatSync(fd).size;
if (fileSize === 0) {
fs.closeSync(fd);
return false;
}
const bufferSize = Math.min(4096, fileSize);
const buffer = Buffer.alloc(bufferSize);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
fs.closeSync(fd);
if (bytesRead === 0) {return false;}
for (let i = 0; i < bytesRead; i++) {
if (buffer[i] === 0) {
return true;
}
}
let nonPrintableCount = 0;
for (let i = 0; i < bytesRead; i++) {
if (buffer[i] < 9 || (buffer[i] > 13 && buffer[i] < 32)) {
nonPrintableCount++;
}
}
return nonPrintableCount / bytesRead > 0.3;
} catch {
return false;
}
}
* Detect file type based on extension and content
*/
function detectFileType(filePath: string): 'text' | 'image' | 'pdf' | 'binary' {
const ext = path.extname(filePath).toLowerCase();
const mimeType = mime.lookup(filePath);
if (mimeType && mimeType.startsWith('image/')) {
return 'image';
}
if (mimeType === 'application/pdf') {
return 'pdf';
}
const binaryExtensions = [
'.exe', '.dll', '.so', '.dylib', '.zip', '.tar', '.gz', '.7z',
'.bin', '.dat', '.class', '.jar', '.war', '.pyc', '.pyo',
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.wasm', '.obj', '.o', '.a', '.lib'
];
if (binaryExtensions.includes(ext)) {
return 'binary';
}
if (isBinaryFile(filePath)) {
return 'binary';
}
return 'text';
}
* Process text file content with line range support
*/
async function processTextFile(
filePath: string,
startLine?: number,
lineCount?: number,
encoding: string = 'utf-8'
): Promise<{ content: string; metadata: Partial<FileReadResult> }> {
const content = await fs.promises.readFile(filePath, encoding as BufferEncoding);
const lines = content.split('\n');
const originalLineCount = lines.length;
const actualStartLine = Math.max((startLine || 1) - 1, 0);
const actualLineCount = lineCount || Math.min(DEFAULT_MAX_LINES, originalLineCount);
const endLine = Math.min(actualStartLine + actualLineCount, originalLineCount);
const selectedLines = lines.slice(actualStartLine, endLine);
let linesWereTruncated = false;
const processedLines = selectedLines.map(line => {
if (line.length > MAX_LINE_LENGTH) {
linesWereTruncated = true;
return line.substring(0, MAX_LINE_LENGTH) + '... [line truncated]';
}
return line;
});
const contentWasTruncated = endLine < originalLineCount;
const isTruncated = contentWasTruncated || linesWereTruncated;
let processedContent = processedLines.join('\n');
if (contentWasTruncated) {
processedContent = `[Content truncated: showing lines ${actualStartLine + 1}-${endLine} of ${originalLineCount} total lines]\n\n` + processedContent;
} else if (linesWereTruncated) {
processedContent = `[Some lines truncated due to length (max ${MAX_LINE_LENGTH} chars)]\n\n` + processedContent;
}
return {
content: processedContent,
metadata: {
lineCount: originalLineCount,
isTruncated,
linesShown: [actualStartLine + 1, endLine]
}
};
}
* Process image or PDF file
*/
async function processMediaFile(
filePath: string,
fileType: 'image' | 'pdf'
): Promise<{ content: string; metadata: Partial<FileReadResult> }> {
const buffer = await fs.promises.readFile(filePath);
const base64Data = buffer.toString('base64');
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
const fileName = path.basename(filePath);
const fileSize = (buffer.length / 1024).toFixed(1);
return {
content: `[${fileType.toUpperCase()} FILE: ${fileName}]\nFile size: ${fileSize} KB\nMIME type: ${mimeType}\nBase64 data available for webview display.`,
metadata: {
mimeType
}
};
}
* Create SuperDesign read tool with execution context
*/
export function createReadTool(context: ExecutionContext) {
return tool({
description: 'Read the contents of a file within the SuperDesign workspace. Supports text files, images (PNG, JPG, SVG, etc.), and handles large files with line-range reading.',
parameters: z.object({
filePath: z.string().describe('Path to the file to read, relative to the workspace root or absolute path within workspace'),
startLine: z.number().optional().describe('Optional: Starting line number to read from (1-based). Use with lineCount for large files.'),
lineCount: z.number().optional().describe('Optional: Number of lines to read. Use with startLine to read specific sections.'),
encoding: z.string().optional().describe('Optional: File encoding (utf-8, ascii, etc.). Defaults to utf-8.')
}),
execute: async ({ filePath, startLine, lineCount, encoding }): Promise<ToolResponse> => {
const startTime = Date.now();
try {
const pathError = validateWorkspacePath(filePath, context);
if (pathError) {
return pathError;
}
const absolutePath = resolveWorkspacePath(filePath, context);
const fileError = validateFileExists(absolutePath, filePath);
if (fileError) {
return fileError;
}
const stats = fs.statSync(absolutePath);
if (stats.isDirectory()) {
return handleToolError(`Path is a directory, not a file: ${filePath}`, 'Path validation', 'validation');
}
if (stats.size > MAX_FILE_SIZE_BYTES) {
const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
return handleToolError(
`File too large (${sizeMB}MB). Maximum size: ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB`,
'File size check',
'validation'
);
}
const fileType = detectFileType(absolutePath);
context.outputChannel.appendLine(`[read] Reading ${fileType} file: ${filePath} (${(stats.size / 1024).toFixed(1)} KB)`);
let content: string;
let metadata: Partial<FileReadResult> = {};
switch (fileType) {
case 'text': {
const result = await processTextFile(
absolutePath,
startLine,
lineCount,
encoding
);
content = result.content;
metadata = result.metadata;
break;
}
case 'image':
case 'pdf': {
const result = await processMediaFile(absolutePath, fileType);
content = result.content;
metadata = result.metadata;
break;
}
case 'binary': {
const fileName = path.basename(absolutePath);
const fileSize = (stats.size / 1024).toFixed(1);
content = `[BINARY FILE: ${fileName}]\nFile size: ${fileSize} KB\nCannot display binary content as text.`;
break;
}
default:
return handleToolError(`Unsupported file type: ${fileType}`, 'File type detection', 'validation');
}
const fileReadResult: FileReadResult = {
content,
filePath,
fileType,
mimeType: mime.lookup(absolutePath) || undefined,
size: stats.size,
...metadata
};
const duration = Date.now() - startTime;
context.outputChannel.appendLine(`[read] File read completed in ${duration}ms`);
return createSuccessResponse(fileReadResult);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
context.outputChannel.appendLine(`[read] Read failed: ${errorMessage}`);
return handleToolError(error, 'Read tool execution', 'execution');
}
}
});
}