openclacky 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../utils/file_processor"
4
5
 
5
6
  module Clacky
6
7
  module Tools
@@ -17,14 +18,28 @@ module Clacky
17
18
  },
18
19
  max_lines: {
19
20
  type: "integer",
20
- description: "Maximum number of lines to read (optional)",
21
- default: 1000
21
+ description: "Maximum number of lines to read from start (default: 500)",
22
+ default: 500
23
+ },
24
+ keyword: {
25
+ type: "string",
26
+ description: "Search keyword and return matching lines with context (recommended for large files)"
27
+ },
28
+ start_line: {
29
+ type: "integer",
30
+ description: "Start line number (1-indexed, e.g., 100 reads from line 100)"
31
+ },
32
+ end_line: {
33
+ type: "integer",
34
+ description: "End line number (1-indexed, e.g., 200 reads up to line 200)"
22
35
  }
23
36
  },
24
37
  required: ["path"]
25
38
  }
39
+
26
40
 
27
- def execute(path:, max_lines: 1000)
41
+
42
+ def execute(path:, max_lines: 500, keyword: nil, start_line: nil, end_line: nil)
28
43
  # Expand ~ to home directory
29
44
  expanded_path = File.expand_path(path)
30
45
 
@@ -50,15 +65,67 @@ module Clacky
50
65
  end
51
66
 
52
67
  begin
53
- lines = File.readlines(expanded_path).first(max_lines)
54
- content = lines.join
55
- truncated = File.readlines(expanded_path).size > max_lines
68
+ # Check if file is binary
69
+ if binary_file?(expanded_path)
70
+ return handle_binary_file(expanded_path)
71
+ end
72
+
73
+ # Handle keyword search with context
74
+ if keyword && !keyword.empty?
75
+ return find_with_context(expanded_path, keyword)
76
+ end
77
+
78
+ # Read text file with optional line range
79
+ all_lines = File.readlines(expanded_path)
80
+ total_lines = all_lines.size
81
+
82
+ # Calculate start index (convert 1-indexed to 0-indexed)
83
+ start_idx = start_line ? [start_line - 1, 0].max : 0
84
+
85
+ # Calculate end index based on parameters
86
+ if end_line
87
+ # User specified end_line directly
88
+ end_idx = [end_line - 1, total_lines - 1].min
89
+ elsif start_line
90
+ # start_line + max_lines - 1 (relative to start_line, inclusive)
91
+ calculated_end_line = start_line + max_lines - 1
92
+ end_idx = [calculated_end_line - 1, total_lines - 1].min
93
+ else
94
+ # Read from beginning with max_lines limit
95
+ end_idx = [max_lines - 1, total_lines - 1].min
96
+ end
97
+
98
+ # Check if start_line exceeds file length first
99
+ if start_idx >= total_lines
100
+ return {
101
+ path: expanded_path,
102
+ content: nil,
103
+ lines_read: 0,
104
+ error: "Invalid line range: start_line #{start_line} exceeds total lines (#{total_lines})"
105
+ }
106
+ end
107
+
108
+ # Validate range
109
+ if start_idx > end_idx
110
+ return {
111
+ path: expanded_path,
112
+ content: nil,
113
+ lines_read: 0,
114
+ error: "Invalid line range: start_line #{start_line} > end_line #{end_line || (start_line + max_lines)}"
115
+ }
116
+ end
117
+
118
+ lines = all_lines[start_idx..end_idx] || []
119
+ truncated = total_lines > max_lines && !keyword
56
120
 
57
121
  {
58
122
  path: expanded_path,
59
- content: content,
123
+ content: lines.join,
60
124
  lines_read: lines.size,
125
+ total_lines: total_lines,
61
126
  truncated: truncated,
127
+ start_line: start_line,
128
+ end_line: end_line,
62
129
  error: nil
63
130
  }
64
131
  rescue StandardError => e
@@ -86,16 +153,185 @@ module Clacky
86
153
  return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
87
154
  end
88
155
 
89
- # Handle file reading
156
+ # Handle binary file
157
+ if result[:binary] || result['binary']
158
+ format_type = result[:format] || result['format'] || 'unknown'
159
+ size = result[:size_bytes] || result['size_bytes'] || 0
160
+
161
+ # Check if it has base64 data (LLM-compatible format)
162
+ if result[:base64_data] || result['base64_data']
163
+ size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
164
+ return "Binary file (#{format_type}, #{format_file_size(size)}) - sent to LLM#{size_warning}"
165
+ else
166
+ return "Binary file (#{format_type}, #{format_file_size(size)}) - cannot be read as text"
167
+ end
168
+ end
169
+
170
+ # Handle text file reading
90
171
  lines = result[:lines_read] || result['lines_read'] || 0
91
172
  truncated = result[:truncated] || result['truncated']
92
173
  "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
93
174
  end
175
+
176
+ # Format result for LLM - handles both text and binary (image/PDF) content
177
+ # This method is called by the agent to format tool results before sending to LLM
178
+ def format_result_for_llm(result)
179
+ # For LLM-compatible binary files with base64 data, return as content blocks
180
+ if result[:binary] && result[:base64_data]
181
+ # Create a text description
182
+ description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
183
+
184
+ # Add size warning for large files
185
+ if result[:size_bytes] > 5_000_000
186
+ description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
187
+ end
188
+
189
+ # For images, return both description and image content
190
+ if result[:mime_type]&.start_with?("image/")
191
+ return {
192
+ type: "image",
193
+ path: result[:path],
194
+ format: result[:format],
195
+ size_bytes: result[:size_bytes],
196
+ mime_type: result[:mime_type],
197
+ base64_data: result[:base64_data],
198
+ description: description
199
+ }
200
+ end
201
+
202
+ # For PDFs and other binary formats, just return metadata with base64
203
+ return {
204
+ type: "document",
205
+ path: result[:path],
206
+ format: result[:format],
207
+ size_bytes: result[:size_bytes],
208
+ mime_type: result[:mime_type],
209
+ base64_data: result[:base64_data],
210
+ description: description
211
+ }
212
+ end
213
+
214
+ # For other cases, return the result as-is (agent will JSON.generate it)
215
+ result
216
+ end
217
+
218
+ # Find lines matching keyword with context (5 lines before and after each match)
219
+ private def find_with_context(path, keyword)
220
+ context_lines_count = 5
221
+ all_lines = File.readlines(path)
222
+ total_lines = all_lines.size
223
+ matches = []
224
+
225
+ # Find all matching line indices (case-insensitive)
226
+ all_lines.each_with_index do |line, index|
227
+ if line.include?(keyword)
228
+ start_idx = [index - context_lines_count, 0].max
229
+ end_idx = [index + context_lines_count, total_lines - 1].min
230
+ matches << {
231
+ line_number: index + 1,
232
+ content: all_lines[start_idx..end_idx].join,
233
+ start_line: start_idx + 1,
234
+ end_line: end_idx + 1,
235
+ match_line: index + 1
236
+ }
237
+ end
238
+ end
239
+
240
+ if matches.empty?
241
+ {
242
+ path: path,
243
+ content: nil,
244
+ matches_count: 0,
245
+ error: "Keyword '#{keyword}' not found in file"
246
+ }
247
+ else
248
+ # Combine all matches with separator
249
+ combined_content = matches.map do |m|
250
+ "... Lines #{m[:start_line]}-#{m[:end_line]} (match at line #{m[:line_number]}):\n#{m[:content]}"
251
+ end.join("\n---\n")
252
+
253
+ {
254
+ path: path,
255
+ content: combined_content,
256
+ matches_count: matches.size,
257
+ keyword: keyword,
258
+ error: nil
259
+ }
260
+ end
261
+ end
262
+
263
+ private def binary_file?(path)
264
+ # Use FileProcessor to detect binary files
265
+ File.open(path, 'rb') do |file|
266
+ sample = file.read(8192) || ""
267
+ Utils::FileProcessor.binary_file?(sample)
268
+ end
269
+ rescue StandardError
270
+ # If we can't read the file, assume it's not binary
271
+ false
272
+ end
273
+
274
+ private def handle_binary_file(path)
275
+ # Check if it's a supported format using FileProcessor
276
+ if Utils::FileProcessor.supported_binary_file?(path)
277
+ # Use FileProcessor to convert to base64
278
+ begin
279
+ result = Utils::FileProcessor.file_to_base64(path)
280
+ {
281
+ path: path,
282
+ binary: true,
283
+ format: result[:format],
284
+ mime_type: result[:mime_type],
285
+ size_bytes: result[:size_bytes],
286
+ base64_data: result[:base64_data],
287
+ error: nil
288
+ }
289
+ rescue ArgumentError => e
290
+ # File too large or other error
291
+ file_size = File.size(path)
292
+ ext = File.extname(path).downcase
293
+ {
294
+ path: path,
295
+ binary: true,
296
+ format: ext.empty? ? "unknown" : ext[1..-1],
297
+ size_bytes: file_size,
298
+ content: nil,
299
+ error: e.message
300
+ }
301
+ end
302
+ else
303
+ # Binary file that we can't send to LLM
304
+ file_size = File.size(path)
305
+ ext = File.extname(path).downcase
306
+ {
307
+ path: path,
308
+ binary: true,
309
+ format: ext.empty? ? "unknown" : ext[1..-1],
310
+ size_bytes: file_size,
311
+ content: nil,
312
+ error: "Binary file detected. This format cannot be read as text. File size: #{format_file_size(file_size)}"
313
+ }
314
+ end
315
+ end
316
+
317
+ private def detect_mime_type(path, data)
318
+ Utils::FileProcessor.detect_mime_type(path, data)
319
+ end
320
+
321
+ private def format_file_size(bytes)
322
+ if bytes < 1024
323
+ "#{bytes} bytes"
324
+ elsif bytes < 1024 * 1024
325
+ "#{(bytes / 1024.0).round(2)} KB"
326
+ else
327
+ "#{(bytes / (1024.0 * 1024)).round(2)} MB"
328
+ end
329
+ end
94
330
 
95
331
  private
96
332
 
97
333
  # List first-level directory contents (files and directories)
98
- def list_directory_contents(path)
334
+ private def list_directory_contents(path)
99
335
  begin
100
336
  entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
101
337
 
@@ -59,11 +59,6 @@ module Clacky
59
59
  description: "Maximum file size in bytes to search (default: 1MB)",
60
60
  default: MAX_FILE_SIZE
61
61
  },
62
- max_files_to_search: {
63
- type: "integer",
64
- description: "Maximum number of files to search",
65
- default: 500
66
- }
67
62
  },
68
63
  required: %w[pattern]
69
64
  }
@@ -78,7 +73,7 @@ module Clacky
78
73
  max_matches_per_file: 50,
79
74
  max_total_matches: 200,
80
75
  max_file_size: MAX_FILE_SIZE,
81
- max_files_to_search: 500
76
+ max_files_to_search: 10000
82
77
  )
83
78
  # Validate pattern
84
79
  if pattern.nil? || pattern.strip.empty?
@@ -135,7 +130,7 @@ module Clacky
135
130
  end
136
131
 
137
132
  # Skip if file should be ignored (unless it's a config file)
138
- if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
133
+ if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
139
134
  !Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
140
135
  skipped[:ignored] += 1
141
136
  next
@@ -217,12 +212,12 @@ module Clacky
217
212
  matches = result[:total_matches] || 0
218
213
  files = result[:files_with_matches] || 0
219
214
  msg = "[OK] Found #{matches} matches in #{files} files"
220
-
215
+
221
216
  # Add truncation info if present
222
217
  if result[:truncated] && result[:truncation_reason]
223
218
  msg += " (truncated: #{result[:truncation_reason]})"
224
219
  end
225
-
220
+
226
221
  msg
227
222
  end
228
223
  end
@@ -275,12 +270,12 @@ module Clacky
275
270
 
276
271
  def search_file(file, regex, context_lines, max_matches)
277
272
  matches = []
278
-
273
+
279
274
  # Use File.foreach for memory-efficient line-by-line reading
280
275
  File.foreach(file, chomp: true).with_index do |line, index|
281
276
  # Stop if we have enough matches for this file
282
277
  break if matches.length >= max_matches
283
-
278
+
284
279
  next unless line.match?(regex)
285
280
 
286
281
  # Truncate long lines
@@ -315,10 +310,10 @@ module Clacky
315
310
  (start_line..end_line).each do |i|
316
311
  line_content = lines[i]
317
312
  # Truncate long lines in context too
318
- display_content = line_content.length > MAX_LINE_LENGTH ?
319
- "#{line_content[0...MAX_LINE_LENGTH]}..." :
313
+ display_content = line_content.length > MAX_LINE_LENGTH ?
314
+ "#{line_content[0...MAX_LINE_LENGTH]}..." :
320
315
  line_content
321
-
316
+
322
317
  context << {
323
318
  line_number: i + 1,
324
319
  content: display_content,
@@ -32,20 +32,26 @@ module Clacky
32
32
  required: ["command"]
33
33
  }
34
34
 
35
- def execute(command:, timeout: nil, max_output_lines: 1000)
35
+ def execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false)
36
36
  # Get project root directory
37
37
  project_root = Dir.pwd
38
38
 
39
39
  begin
40
40
  # 1. Extract timeout from command if it starts with "timeout N"
41
41
  command, extracted_timeout = extract_timeout_from_command(command)
42
-
42
+
43
43
  # Use extracted timeout if not explicitly provided
44
44
  timeout ||= extracted_timeout
45
45
 
46
- # 2. Use safety replacer to process command
47
- safety_replacer = CommandSafetyReplacer.new(project_root)
48
- safe_command = safety_replacer.make_command_safe(command)
46
+ # 2. Use safety replacer to process command (skip if user already confirmed)
47
+ if skip_safety_check
48
+ # User has confirmed, execute command as-is (no safety modifications)
49
+ safe_command = command
50
+ safety_replacer = nil
51
+ else
52
+ safety_replacer = CommandSafetyReplacer.new(project_root)
53
+ safe_command = safety_replacer.make_command_safe(command)
54
+ end
49
55
 
50
56
  # 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
51
57
  soft_timeout = 5
@@ -55,7 +61,7 @@ module Clacky
55
61
  result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
56
62
 
57
63
  # 5. Enhance result information
58
- enhance_result(result, command, safe_command)
64
+ enhance_result(result, command, safe_command, safety_replacer)
59
65
 
60
66
  rescue SecurityError => e
61
67
  # Security error, return friendly error message
@@ -145,9 +151,9 @@ module Clacky
145
151
  end
146
152
  end
147
153
 
148
- def enhance_result(result, original_command, safe_command)
154
+ def enhance_result(result, original_command, safe_command, safety_replacer = nil)
149
155
  # If command was replaced, add security information
150
- if original_command != safe_command
156
+ if safety_replacer && original_command != safe_command
151
157
  result[:security_enhanced] = true
152
158
  result[:original_command] = original_command
153
159
  result[:safe_command] = safe_command
@@ -190,6 +196,24 @@ module Clacky
190
196
  end
191
197
  end
192
198
 
199
+ # Override format_result_for_llm to preserve security fields
200
+ def format_result_for_llm(result)
201
+ # If security blocked, return as-is (small and important)
202
+ return result if result[:security_blocked]
203
+
204
+ # Call parent's format_result_for_llm to truncate output
205
+ compact = super(result)
206
+
207
+ # Add security enhancement fields if present (they're small and important for LLM to understand)
208
+ if result[:security_enhanced]
209
+ compact[:security_enhanced] = true
210
+ compact[:original_command] = result[:original_command]
211
+ compact[:safe_command] = result[:safe_command]
212
+ end
213
+
214
+ compact
215
+ end
216
+
193
217
  private def format_non_zero_exit(exit_code, stdout, stderr)
194
218
  stdout_lines = stdout.lines.size
195
219
  has_output = stdout_lines > 0
@@ -331,7 +355,12 @@ module Clacky
331
355
 
332
356
  def replace_chmod_command(command)
333
357
  # Parse chmod command to ensure it's safe
334
- parts = Shellwords.split(command)
358
+ begin
359
+ parts = Shellwords.split(command)
360
+ rescue Shellwords::BadQuotedString => e
361
+ # If Shellwords.split fails, use simple split as fallback
362
+ parts = command.split(/\s+/)
363
+ end
335
364
 
336
365
  # Only allow chmod +x on files in project directory
337
366
  files = parts[2..-1] || []
@@ -340,8 +369,6 @@ module Clacky
340
369
  # Allow chmod +x as it's generally safe
341
370
  log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
342
371
  command
343
- rescue Shellwords::BadQuotedString
344
- raise SecurityError, "Invalid chmod command syntax: #{command}"
345
372
  end
346
373
 
347
374
  def replace_curl_pipe_command(command)
@@ -370,7 +397,14 @@ module Clacky
370
397
 
371
398
  def validate_and_allow(command)
372
399
  # Check basic file operation commands
373
- parts = Shellwords.split(command)
400
+ begin
401
+ parts = Shellwords.split(command)
402
+ rescue Shellwords::BadQuotedString => e
403
+ # If Shellwords.split fails due to quote issues, try simple split as fallback
404
+ # This handles cases where paths don't actually need shell escaping
405
+ parts = command.split(/\s+/)
406
+ end
407
+
374
408
  cmd = parts.first
375
409
  args = parts[1..-1]
376
410
 
@@ -384,8 +418,6 @@ module Clacky
384
418
  end
385
419
 
386
420
  command
387
- rescue Shellwords::BadQuotedString
388
- raise SecurityError, "Invalid command syntax: #{command}"
389
421
  end
390
422
 
391
423
  def validate_general_command(command)
@@ -419,11 +451,15 @@ module Clacky
419
451
  end
420
452
 
421
453
  def parse_rm_files(command)
422
- parts = Shellwords.split(command)
454
+ begin
455
+ parts = Shellwords.split(command)
456
+ rescue Shellwords::BadQuotedString => e
457
+ # If Shellwords.split fails, use simple split as fallback
458
+ parts = command.split(/\s+/)
459
+ end
460
+
423
461
  # Skip rm command itself and option parameters
424
462
  parts.drop(1).reject { |part| part.start_with?('-') }
425
- rescue Shellwords::BadQuotedString
426
- raise SecurityError, "Invalid command syntax: #{command}"
427
463
  end
428
464
 
429
465
  def validate_file_path(path)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
3
4
  require_relative "base"
4
5
 
5
6
  module Clacky
@@ -35,7 +36,7 @@ module Clacky
35
36
  INTERACTION_PATTERNS = [
36
37
  [/\[Y\/n\]|\[y\/N\]|\(yes\/no\)|\(Y\/n\)|\(y\/N\)/i, 'confirmation'],
37
38
  [/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
38
- [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^>\s*$/, 'repl'],
39
+ [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^\>\s*$/, 'repl'],
39
40
  [/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
40
41
  [/Are you sure|Continue\?|Proceed\?|Confirm|Overwrite/i, 'question'],
41
42
  [/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
@@ -143,7 +144,7 @@ module Clacky
143
144
 
144
145
  stdout_output = stdout_buffer.string
145
146
  stderr_output = stderr_buffer.string
146
-
147
+
147
148
  {
148
149
  command: command,
149
150
  stdout: truncate_output(stdout_output, max_output_lines),
@@ -157,7 +158,7 @@ module Clacky
157
158
  rescue StandardError => e
158
159
  stdout_output = stdout_buffer.string
159
160
  stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
160
-
161
+
161
162
  {
162
163
  command: command,
163
164
  stdout: truncate_output(stdout_output, max_output_lines),
@@ -253,10 +254,10 @@ module Clacky
253
254
  # Truncate output to max_lines, adding a truncation notice if needed
254
255
  def truncate_output(output, max_lines)
255
256
  return output if output.nil? || output.empty?
256
-
257
+
257
258
  lines = output.lines
258
259
  return output if lines.length <= max_lines
259
-
260
+
260
261
  truncated_lines = lines.first(max_lines)
261
262
  truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
262
263
  truncated_lines.join + truncation_notice
@@ -290,6 +291,109 @@ module Clacky
290
291
  "[Exit #{exit_code}] #{error_msg[0..50]}"
291
292
  end
292
293
  end
294
+
295
+ # Format result for LLM consumption - limit output size to save tokens
296
+ # Maximum characters to include in LLM output
297
+ MAX_LLM_OUTPUT_CHARS = 1000
298
+
299
+ def format_result_for_llm(result)
300
+ # Return error info as-is if command failed or timed out
301
+ return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
302
+
303
+ stdout = result[:stdout] || ""
304
+ stderr = result[:stderr] || ""
305
+ exit_code = result[:exit_code] || 0
306
+
307
+ # Build compact result with truncated output
308
+ compact = {
309
+ command: result[:command],
310
+ exit_code: exit_code,
311
+ success: result[:success]
312
+ }
313
+
314
+ # Add elapsed time if available (keep original precision)
315
+ compact[:elapsed] = result[:elapsed] if result[:elapsed]
316
+
317
+ # Extract command name for temp file naming
318
+ command_name = extract_command_name(result[:command])
319
+
320
+ # Process stdout: truncate and optionally save to temp file
321
+ stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
322
+ compact[:stdout] = stdout_info[:content]
323
+ compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
324
+
325
+ # Process stderr: truncate and optionally save to temp file
326
+ stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
327
+ compact[:stderr] = stderr_info[:content]
328
+ compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
329
+
330
+ # Add output_truncated flag if present
331
+ compact[:output_truncated] = true if result[:output_truncated]
332
+
333
+ compact
334
+ end
335
+
336
+ # Extract command name from full command for temp file naming
337
+ def extract_command_name(command)
338
+ first_word = command.strip.split(/\s+/).first
339
+ File.basename(first_word, ".*")
340
+ end
341
+
342
+ # Truncate output for LLM and optionally save full content to temp file
343
+ def truncate_and_save(output, max_chars, label, command_name)
344
+ return { content: "", temp_file: nil } if output.empty?
345
+
346
+ return { content: output, temp_file: nil } if output.length <= max_chars
347
+
348
+ # Sanitize command name for file name
349
+ safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
350
+
351
+ # Use Ruby tmpdir for safe temp file creation
352
+ temp_dir = Dir.mktmpdir
353
+ temp_file = File.join(temp_dir, "#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
354
+
355
+ # Write full output to temp file
356
+ File.write(temp_file, output)
357
+
358
+ # For LLM display: show first and last lines to preserve structure
359
+ lines = output.lines
360
+ return { content: output, temp_file: nil } if lines.length <= 2
361
+
362
+ # Reserve 60 chars for truncation notice
363
+ available_chars = max_chars - 60
364
+
365
+ # Take first few lines
366
+ first_part = []
367
+ accumulated = 0
368
+ lines.each do |line|
369
+ break if accumulated + line.length > available_chars / 2
370
+ first_part << line
371
+ accumulated += line.length
372
+ end
373
+
374
+ # Take last few lines
375
+ last_part = []
376
+ accumulated = 0
377
+ lines.reverse_each do |line|
378
+ break if accumulated + line.length > available_chars / 2
379
+ last_part.unshift(line)
380
+ accumulated += line.length
381
+ end
382
+
383
+ total_lines = lines.length
384
+
385
+ # Create notice message based on label
386
+ if label == "stderr"
387
+ notice = "... [Error output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
388
+ else
389
+ notice = "... [Output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
390
+ end
391
+
392
+ # Combine with compact notice
393
+ content = first_part.join + "\n#{notice}\n" + last_part.join
394
+
395
+ { content: content, temp_file: temp_file }
396
+ end
293
397
  end
294
398
  end
295
399
  end