openclacky 0.6.2 → 0.6.4

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.
@@ -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
@@ -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
@@ -293,69 +294,105 @@ module Clacky
293
294
 
294
295
  # Format result for LLM consumption - limit output size to save tokens
295
296
  # Maximum characters to include in LLM output
296
- MAX_LLM_OUTPUT_CHARS = 2000
297
-
297
+ MAX_LLM_OUTPUT_CHARS = 1000
298
+
298
299
  def format_result_for_llm(result)
299
300
  # Return error info as-is if command failed or timed out
300
301
  return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
301
-
302
+
302
303
  stdout = result[:stdout] || ""
303
304
  stderr = result[:stderr] || ""
304
305
  exit_code = result[:exit_code] || 0
305
-
306
+
306
307
  # Build compact result with truncated output
307
308
  compact = {
308
309
  command: result[:command],
309
310
  exit_code: exit_code,
310
311
  success: result[:success]
311
312
  }
312
-
313
- # Add elapsed time if available
313
+
314
+ # Add elapsed time if available (keep original precision)
314
315
  compact[:elapsed] = result[:elapsed] if result[:elapsed]
315
-
316
- # Truncate stdout to save tokens
317
- if stdout.empty?
318
- compact[:stdout] = ""
319
- else
320
- stdout_lines = stdout.lines
321
- stdout_line_count = stdout_lines.length
322
-
323
- if stdout.length <= MAX_LLM_OUTPUT_CHARS
324
- compact[:stdout] = stdout
325
- else
326
- # Take first N lines that fit within the character limit
327
- accumulated_chars = 0
328
- lines_to_include = []
329
-
330
- stdout_lines.each do |line|
331
- break if accumulated_chars + line.length > MAX_LLM_OUTPUT_CHARS
332
- lines_to_include << line
333
- accumulated_chars += line.length
334
- end
335
-
336
- compact[:stdout] = lines_to_include.join
337
- compact[:stdout] += "\n\n... [Output truncated for LLM: showing #{lines_to_include.length} of #{stdout_line_count} lines, #{accumulated_chars} of #{stdout.length} chars] ...\n"
338
- end
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
339
372
  end
340
-
341
- # Truncate stderr to save tokens (usually more important than stdout, so keep more)
342
- if stderr.empty?
343
- compact[:stderr] = ""
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)] ..."
344
388
  else
345
- stderr_line_count = stderr.lines.length
346
-
347
- if stderr.length <= MAX_LLM_OUTPUT_CHARS
348
- compact[:stderr] = stderr
349
- else
350
- compact[:stderr] = stderr[0...MAX_LLM_OUTPUT_CHARS]
351
- compact[:stderr] += "\n\n... [Error output truncated for LLM: showing #{MAX_LLM_OUTPUT_CHARS} of #{stderr.length} chars, #{stderr_line_count} lines] ...\n"
352
- end
389
+ notice = "... [Output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
353
390
  end
354
-
355
- # Add output_truncated flag
356
- compact[:output_truncated] = result[:output_truncated] if result[:output_truncated]
357
-
358
- compact
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 }
359
396
  end
360
397
  end
361
398
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "net/http"
4
4
  require "uri"
5
+ require "tmpdir"
6
+ require "fileutils"
5
7
 
6
8
  module Clacky
7
9
  module Tools
@@ -18,14 +20,14 @@ module Clacky
18
20
  },
19
21
  max_length: {
20
22
  type: "integer",
21
- description: "Maximum content length to return in characters (default: 50000)",
22
- default: 50000
23
+ description: "Maximum content length to return in characters (default: 3000)",
24
+ default: 3000
23
25
  }
24
26
  },
25
27
  required: %w[url]
26
28
  }
27
29
 
28
- def execute(url:, max_length: 50000)
30
+ def execute(url:, max_length: 3000)
29
31
  # Validate URL
30
32
  begin
31
33
  uri = URI.parse(url)
@@ -46,7 +48,7 @@ module Clacky
46
48
 
47
49
  # Parse HTML if it's an HTML page
48
50
  if content_type.include?("text/html")
49
- result = parse_html(content, max_length)
51
+ result = parse_html(content, max_length, url)
50
52
  result[:url] = url
51
53
  result[:content_type] = content_type
52
54
  result[:status_code] = response.code.to_i
@@ -54,21 +56,45 @@ module Clacky
54
56
  result
55
57
  else
56
58
  # For non-HTML content, return raw text
57
- truncated_content = content[0, max_length]
58
- {
59
- url: url,
60
- content_type: content_type,
61
- status_code: response.code.to_i,
62
- content: truncated_content,
63
- truncated: content.length > max_length,
64
- error: nil
65
- }
59
+ result = handle_raw_content(content, max_length, url, content_type, response.code.to_i)
60
+ result
66
61
  end
67
62
  rescue StandardError => e
68
63
  { error: "Failed to fetch URL: #{e.message}" }
69
64
  end
70
65
  end
71
66
 
67
+ def handle_raw_content(content, max_length, url, content_type, status_code)
68
+ truncated = content.length > max_length
69
+ temp_file = nil
70
+
71
+ if truncated
72
+ temp_dir = Dir.mktmpdir
73
+ domain = extract_domain(url)
74
+ safe_name = domain.gsub(/[^\w\-.]/, '_')[0...50]
75
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
76
+ temp_file = File.join(temp_dir, "#{safe_name}_#{timestamp}.txt")
77
+ File.write(temp_file, content)
78
+ end
79
+
80
+ {
81
+ url: url,
82
+ content_type: content_type,
83
+ status_code: status_code,
84
+ content: content[0, max_length],
85
+ truncated: truncated,
86
+ temp_file: temp_file,
87
+ error: nil
88
+ }
89
+ end
90
+
91
+ def extract_domain(url)
92
+ uri = URI.parse(url)
93
+ uri.host || url.gsub(/[^\w\-.]/, '_')
94
+ rescue
95
+ url.gsub(/[^\w\-.]/, '_')
96
+ end
97
+
72
98
  def fetch_url(uri)
73
99
  # Follow redirects (max 5)
74
100
  redirects = 0
@@ -97,7 +123,7 @@ module Clacky
97
123
  end
98
124
  end
99
125
 
100
- def parse_html(html, max_length)
126
+ def parse_html(html, max_length, url = nil)
101
127
  # Extract title
102
128
  title = ""
103
129
  if html =~ %r{<title[^>]*>(.*?)</title>}mi
@@ -122,15 +148,25 @@ module Clacky
122
148
  # Clean up whitespace
123
149
  text = text.gsub(/\s+/, " ").strip
124
150
 
125
- # Truncate if needed
151
+ # Check if we need to save to temp file
126
152
  truncated = text.length > max_length
127
- text = text[0, max_length] if truncated
153
+ temp_file = nil
154
+
155
+ if truncated
156
+ temp_dir = Dir.mktmpdir
157
+ domain = url ? extract_domain(url) : "web_fetch"
158
+ safe_name = domain.gsub(/[^\w\-.]/, '_')[0...50]
159
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
160
+ temp_file = File.join(temp_dir, "#{safe_name}_#{timestamp}.txt")
161
+ File.write(temp_file, text)
162
+ end
128
163
 
129
164
  {
130
165
  title: title,
131
166
  description: description,
132
- content: text,
133
- truncated: truncated
167
+ content: text[0, max_length],
168
+ truncated: truncated,
169
+ temp_file: temp_file
134
170
  }
135
171
  end
136
172
 
@@ -156,6 +192,33 @@ module Clacky
156
192
  "[OK] Fetched: #{display_title}"
157
193
  end
158
194
  end
195
+
196
+ # Format result for LLM consumption - return compact version to save tokens
197
+ def format_result_for_llm(result)
198
+ # Return error as-is
199
+ return result if result[:error]
200
+
201
+ # Build compact result
202
+ compact = {
203
+ url: result[:url],
204
+ title: result[:title],
205
+ description: result[:description],
206
+ status_code: result[:status_code]
207
+ }
208
+
209
+ # Add truncated notice and temp file info if content was truncated
210
+ if result[:truncated] && result[:temp_file]
211
+ compact[:content] = result[:content]
212
+ compact[:truncated] = true
213
+ compact[:temp_file] = result[:temp_file]
214
+ compact[:message] = "[Content truncated - full content saved to temp file. Use file_reader to read it if needed.]"
215
+ else
216
+ compact[:content] = result[:content]
217
+ compact[:truncated] = result[:truncated] || false
218
+ end
219
+
220
+ compact
221
+ end
159
222
  end
160
223
  end
161
224
  end