Class: Clacky::Tools::FileReader

Inherits:
Base
  • Object
show all
Defined in:
lib/clacky/tools/file_reader.rb

Constant Summary collapse

MAX_TEXT_FILE_SIZE =

Maximum text file size (1MB)

1 * 1024 * 1024
MAX_CONTENT_CHARS =

Maximum content size to return (~10,000 tokens = ~40,000 characters)

40_000
MAX_LINE_CHARS =

Maximum characters per line (prevent single huge lines from bloating tokens)

1000

Instance Method Summary collapse

Methods inherited from Base

#category, #description, #name, #parameters, #to_function_definition

Instance Method Details

#execute(path:, max_lines: 500, start_line: nil, end_line: nil, working_dir: nil) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/clacky/tools/file_reader.rb', line 47

def execute(path:, max_lines: 500, start_line: nil, end_line: nil, working_dir: nil)
  # Expand path relative to working_dir when provided
  expanded_path = expand_path(path, working_dir: working_dir)

  unless File.exist?(expanded_path)
    return {
      path: expanded_path,
      content: nil,
      error: "File not found: #{expanded_path}"
    }
  end

  # If path is a directory, list its first-level contents (similar to filetree)
  if File.directory?(expanded_path)
    return list_directory_contents(expanded_path)
  end

  unless File.file?(expanded_path)
    return {
      path: expanded_path,
      content: nil,
      error: "Path is not a file: #{expanded_path}"
    }
  end

  begin
    # Check if file is binary
    if Utils::FileProcessor.binary_file_path?(expanded_path)
      return handle_binary_file(expanded_path)
    end

    # Check text file size (only for non-binary files)
    file_size = File.size(expanded_path)
    if file_size > MAX_TEXT_FILE_SIZE
      return {
        path: expanded_path,
        content: nil,
        size_bytes: file_size,
        error: "Text file too large: #{format_file_size(file_size)} (max: #{format_file_size(MAX_TEXT_FILE_SIZE)}). Please use grep tool to search within this file instead."
      }
    end

    # Read text file with optional line range
    all_lines = File.readlines(expanded_path)
    total_lines = all_lines.size

    # Calculate start index (convert 1-indexed to 0-indexed)
    start_idx = start_line ? [start_line - 1, 0].max : 0

    # Calculate end index based on parameters
    if end_line
      # User specified end_line directly
      end_idx = [end_line - 1, total_lines - 1].min
    elsif start_line
      # start_line + max_lines - 1 (relative to start_line, inclusive)
      calculated_end_line = start_line + max_lines - 1
      end_idx = [calculated_end_line - 1, total_lines - 1].min
    else
      # Read from beginning with max_lines limit
      end_idx = [max_lines - 1, total_lines - 1].min
    end

    # Check if start_line exceeds file length first
    if start_idx >= total_lines
      return {
        path: expanded_path,
        content: nil,
        lines_read: 0,
        error: "Invalid line range: start_line #{start_line} exceeds total lines (#{total_lines})"
      }
    end

    # Validate range
    if start_idx > end_idx
      return {
        path: expanded_path,
        content: nil,
        lines_read: 0,
        error: "Invalid line range: start_line #{start_line} > end_line #{end_line || (start_line + max_lines)}"
      }
    end

    lines = all_lines[start_idx..end_idx] || []

    # Truncate individual lines that are too long
    lines = lines.map do |line|
      if line.length > MAX_LINE_CHARS
        line[0...MAX_LINE_CHARS] + "... [Line truncated - #{line.length} chars]\n"
      else
        line
      end
    end

    content = lines.join
    truncated = end_idx < (total_lines - 1)

    # Truncate total content if it exceeds maximum size
    if content.length > MAX_CONTENT_CHARS
      content = content[0...MAX_CONTENT_CHARS] +
               "\n\n[Content truncated - exceeded #{MAX_CONTENT_CHARS} characters (~10,000 tokens)]" +
               "\nUse start_line/end_line parameters to read specific sections, or grep tool to search for keywords."
      truncated = true
    end

    {
      path: expanded_path,
      content: content,
      lines_read: lines.size,
      total_lines: total_lines,
      truncated: truncated,
      start_line: start_line,
      end_line: end_line,
      error: nil
    }
  rescue StandardError => e
    {
      path: expanded_path,
      content: nil,
      error: "Error reading file: #{e.message}"
    }
  end
end

#format_call(args) ⇒ Object



170
171
172
173
# File 'lib/clacky/tools/file_reader.rb', line 170

def format_call(args)
  path = args[:path] || args['path']
  "Read(#{Utils::PathHelper.safe_basename(path)})"
end

#format_result(result) ⇒ Object



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/clacky/tools/file_reader.rb', line 175

def format_result(result)
  return result[:error] if result[:error]

  # Handle directory listing
  if result[:is_directory] || result['is_directory']
    entries = result[:entries_count] || result['entries_count'] || 0
    dirs = result[:directories_count] || result['directories_count'] || 0
    files = result[:files_count] || result['files_count'] || 0
    return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
  end

  # Handle binary file
  if result[:binary] || result['binary']
    format_type = result[:format] || result['format'] || 'unknown'
    size = result[:size_bytes] || result['size_bytes'] || 0

    # Check if it has base64 data (LLM-compatible format)
    if result[:base64_data] || result['base64_data']
      size_warning = size > Utils::FileProcessor::MAX_FILE_SIZE ? " (WARNING: large file)" : ""
      return "Binary file (#{format_type}, #{format_file_size(size)}) - sent to LLM#{size_warning}"
    else
      return "Binary file (#{format_type}, #{format_file_size(size)}) - cannot be read as text"
    end
  end

  # Handle text file reading
  lines = result[:lines_read] || result['lines_read'] || 0
  truncated = result[:truncated] || result['truncated']
  "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
end

#format_result_for_llm(result) ⇒ Object

Format result for LLM - handles both text and binary (image/PDF) content This method is called by the agent to format tool results before sending to LLM



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/clacky/tools/file_reader.rb', line 208

def format_result_for_llm(result)
  # For LLM-compatible binary files with base64 data, return as content blocks
  if result[:binary] && result[:base64_data]
    # Create a text description
    description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"

    # Add size warning for large files
    if result[:size_bytes] > Utils::FileProcessor::MAX_FILE_SIZE
      description += "\nWARNING: Large file (>#{Utils::FileProcessor::MAX_FILE_SIZE / 1024}KB) - may consume significant tokens"
    end

    # For images, return content blocks Array (same pattern as browser tool)
    # so build_success_result passes it through as-is instead of JSON.generate-ing it.
    if result[:mime_type]&.start_with?("image/")
      return [
        { type: "text", text: description },
        { type: "image_url", image_url: { url: "data:#{result[:mime_type]};base64,#{result[:base64_data]}" } }
      ]
    end

    # For PDFs and other binary formats, just return metadata with base64
    return {
      type: "document",
      path: result[:path],
      format: result[:format],
      size_bytes: result[:size_bytes],
      mime_type: result[:mime_type],
      base64_data: result[:base64_data],
      description: description
    }
  end

  # For error cases, return hash as-is
  return result if result[:error] || result[:content].nil?

  # For directory listings, return as-is (no raw file content to preserve)
  return result if result[:is_directory]

  # For plain text files: return a plain string so the agent sends it
  # directly to the LLM without JSON-encoding (avoids \" / \n escaping).
  header = "File: #{result[:path]}"
  header += " (lines #{result[:start_line]}-#{result[:end_line]})" if result[:start_line]
  header += " [#{result[:lines_read]}/#{result[:total_lines]} lines]"
  header += " [TRUNCATED]" if result[:truncated]
  "#{header}\n\n#{result[:content]}"
end