openclacky 0.6.0 → 0.6.2

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -88
  4. data/homebrew/README.md +96 -0
  5. data/homebrew/openclacky.rb +24 -0
  6. data/lib/clacky/agent.rb +139 -67
  7. data/lib/clacky/cli.rb +105 -6
  8. data/lib/clacky/tools/file_reader.rb +135 -2
  9. data/lib/clacky/tools/glob.rb +2 -2
  10. data/lib/clacky/tools/grep.rb +2 -2
  11. data/lib/clacky/tools/run_project.rb +5 -5
  12. data/lib/clacky/tools/safe_shell.rb +140 -17
  13. data/lib/clacky/tools/shell.rb +69 -2
  14. data/lib/clacky/tools/todo_manager.rb +50 -3
  15. data/lib/clacky/tools/trash_manager.rb +1 -1
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +2 -2
  18. data/lib/clacky/ui2/components/common_component.rb +14 -5
  19. data/lib/clacky/ui2/components/input_area.rb +300 -89
  20. data/lib/clacky/ui2/components/message_component.rb +7 -3
  21. data/lib/clacky/ui2/components/todo_area.rb +38 -45
  22. data/lib/clacky/ui2/components/welcome_banner.rb +10 -0
  23. data/lib/clacky/ui2/layout_manager.rb +180 -50
  24. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  25. data/lib/clacky/ui2/screen_buffer.rb +26 -7
  26. data/lib/clacky/ui2/themes/base_theme.rb +32 -46
  27. data/lib/clacky/ui2/themes/hacker_theme.rb +4 -2
  28. data/lib/clacky/ui2/themes/minimal_theme.rb +4 -2
  29. data/lib/clacky/ui2/ui_controller.rb +150 -32
  30. data/lib/clacky/ui2/view_renderer.rb +21 -4
  31. data/lib/clacky/ui2.rb +0 -1
  32. data/lib/clacky/utils/arguments_parser.rb +7 -2
  33. data/lib/clacky/utils/file_processor.rb +201 -0
  34. data/lib/clacky/version.rb +1 -1
  35. data/scripts/install.sh +249 -0
  36. data/scripts/uninstall.sh +146 -0
  37. metadata +21 -2
  38. data/lib/clacky/ui2/components/output_area.rb +0 -112
data/lib/clacky/agent.rb CHANGED
@@ -4,8 +4,8 @@ require "securerandom"
4
4
  require "json"
5
5
  require "tty-prompt"
6
6
  require "set"
7
- require "base64"
8
7
  require_relative "utils/arguments_parser"
8
+ require_relative "utils/file_processor"
9
9
 
10
10
  module Clacky
11
11
  class Agent
@@ -129,6 +129,33 @@ module Clacky
129
129
  end
130
130
  end
131
131
 
132
+ # Get recent user messages from conversation history
133
+ # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
134
+ # @return [Array<String>] Array of recent user message contents
135
+ def get_recent_user_messages(limit: 5)
136
+ # Filter messages to only include real user messages (exclude system-injected ones)
137
+ user_messages = @messages.select do |m|
138
+ m[:role] == "user" && !m[:system_injected]
139
+ end
140
+
141
+ # Extract text content from the last N user messages
142
+ user_messages.last(limit).map do |msg|
143
+ extract_text_from_content(msg[:content])
144
+ end
145
+ end
146
+
147
+ private def extract_text_from_content(content)
148
+ if content.is_a?(String)
149
+ content
150
+ elsif content.is_a?(Array)
151
+ # Extract text from content array (may contain text and images)
152
+ text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
153
+ text_parts.map { |c| c[:text] }.join("\n")
154
+ else
155
+ content.to_s
156
+ end
157
+ end
158
+
132
159
  def add_hook(event, &block)
133
160
  @hooks.add(event, &block)
134
161
  end
@@ -136,7 +163,18 @@ module Clacky
136
163
  def run(user_input, images: [])
137
164
  @start_time = Time.now
138
165
  @task_cost_source = :estimated # Reset for new task
139
- @previous_total_tokens = 0 # Reset token tracking for new task
166
+ # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
167
+ # across tasks to correctly calculate delta tokens in each iteration
168
+ @task_start_iterations = @iterations # Track starting iterations for this task
169
+ @task_start_cost = @total_cost # Track starting cost for this task
170
+
171
+ # Track cache stats for current task
172
+ @task_cache_stats = {
173
+ cache_creation_input_tokens: 0,
174
+ cache_read_input_tokens: 0,
175
+ total_requests: 0,
176
+ cache_hit_requests: 0
177
+ }
140
178
 
141
179
  # Add system prompt as the first message if this is the first run
142
180
  if @messages.empty?
@@ -193,16 +231,17 @@ module Clacky
193
231
  if action_result[:denied]
194
232
  # If user provided feedback, treat it as a user question/instruction
195
233
  if action_result[:feedback] && !action_result[:feedback].empty?
196
- # Add user feedback as a new user message
234
+ # Add user feedback as a new user message with system_injected marker
197
235
  @messages << {
198
236
  role: "user",
199
- content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions."
237
+ content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions.",
238
+ system_injected: true # Mark as system-injected message for filtering
200
239
  }
201
240
  # Continue loop to let agent respond to feedback
202
241
  next
203
242
  else
204
243
  # User just said "no" without feedback - stop and wait
205
- @ui&.show_assistant_message("Tool execution was denied. Please provide further instructions.")
244
+ @ui&.show_assistant_message("Tool execution was denied. Please give more instructions...")
206
245
  break
207
246
  end
208
247
  end
@@ -221,8 +260,8 @@ module Clacky
221
260
  # Let CLI handle the interrupt message
222
261
  raise
223
262
  rescue StandardError => e
263
+ # Build error result for session data, but let CLI handle error display
224
264
  result = build_result(:error, error: e.message)
225
- @ui&.show_error("Error: #{e.message}")
226
265
  raise
227
266
  end
228
267
  end
@@ -405,8 +444,8 @@ module Clacky
405
444
  end
406
445
  end
407
446
 
408
- # Stop progress thread (but keep progress line visible)
409
- @ui&.stop_progress_thread
447
+ # Clear progress indicator (change to gray and show final time)
448
+ @ui&.clear_progress
410
449
 
411
450
  track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
412
451
 
@@ -494,7 +533,13 @@ module Clacky
494
533
 
495
534
  confirmation = confirm_tool_use?(call)
496
535
  unless confirmation[:approved]
497
- @ui&.show_warning("Tool #{call[:name]} denied")
536
+ # Show denial warning with user feedback if provided
537
+ denial_message = "Tool #{call[:name]} denied"
538
+ if confirmation[:feedback] && !confirmation[:feedback].empty?
539
+ denial_message += ": #{confirmation[:feedback]}"
540
+ end
541
+ @ui&.show_warning(denial_message)
542
+
498
543
  denied = true
499
544
  user_feedback = confirmation[:feedback]
500
545
  feedback = user_feedback if user_feedback
@@ -526,8 +571,17 @@ module Clacky
526
571
  args[:todos_storage] = @todos
527
572
  end
528
573
 
574
+ # Show progress for potentially slow tools (no prefix newline)
575
+ if potentially_slow_tool?(call[:name], args)
576
+ progress_message = build_tool_progress_message(call[:name], args)
577
+ @ui&.show_progress(progress_message, prefix_newline: false)
578
+ end
579
+
529
580
  result = tool.execute(**args)
530
581
 
582
+ # Clear progress if shown
583
+ @ui&.clear_progress if potentially_slow_tool?(call[:name], args)
584
+
531
585
  # Hook: after_tool_use
532
586
  @hooks.trigger(:after_tool_use, call, result)
533
587
 
@@ -606,6 +660,49 @@ module Clacky
606
660
  false
607
661
  end
608
662
 
663
+ # Check if a tool is potentially slow and should show progress
664
+ private def potentially_slow_tool?(tool_name, args)
665
+ case tool_name.to_s.downcase
666
+ when 'shell', 'safe_shell'
667
+ # Check if the command is a slow command
668
+ command = args[:command] || args['command']
669
+ return false unless command
670
+
671
+ # List of slow command patterns
672
+ slow_patterns = [
673
+ /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
674
+ /npm\s+(install|run\s+test|run\s+build)/,
675
+ /yarn\s+(install|test|build)/,
676
+ /pnpm\s+install/,
677
+ /cargo\s+(build|test)/,
678
+ /go\s+(build|test)/,
679
+ /make\s+(test|build)/,
680
+ /pytest/,
681
+ /jest/
682
+ ]
683
+
684
+ slow_patterns.any? { |pattern| command.match?(pattern) }
685
+ when 'web_fetch', 'web_search'
686
+ true # Network operations can be slow
687
+ else
688
+ false # Most file operations are fast
689
+ end
690
+ end
691
+
692
+ # Build progress message for tool execution
693
+ private def build_tool_progress_message(tool_name, args)
694
+ case tool_name.to_s.downcase
695
+ when 'shell', 'safe_shell'
696
+ "Running command"
697
+ when 'web_fetch'
698
+ "Fetching web page"
699
+ when 'web_search'
700
+ "Searching web"
701
+ else
702
+ "Executing #{tool_name}"
703
+ end
704
+ end
705
+
609
706
  def track_cost(usage, raw_api_usage: nil)
610
707
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
611
708
  iteration_cost = nil
@@ -637,7 +734,7 @@ module Clacky
637
734
  # Display token usage statistics for this iteration
638
735
  display_iteration_tokens(usage, iteration_cost)
639
736
 
640
- # Track cache usage statistics
737
+ # Track cache usage statistics (global)
641
738
  @cache_stats[:total_requests] += 1
642
739
 
643
740
  if usage[:cache_creation_input_tokens]
@@ -655,6 +752,20 @@ module Clacky
655
752
  @cache_stats[:raw_api_usage_samples] << raw_api_usage
656
753
  @cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
657
754
  end
755
+
756
+ # Track cache usage for current task
757
+ if @task_cache_stats
758
+ @task_cache_stats[:total_requests] += 1
759
+
760
+ if usage[:cache_creation_input_tokens]
761
+ @task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
762
+ end
763
+
764
+ if usage[:cache_read_input_tokens]
765
+ @task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
766
+ @task_cache_stats[:cache_hit_requests] += 1
767
+ end
768
+ end
658
769
  end
659
770
 
660
771
  # Display token usage for current iteration
@@ -843,7 +954,8 @@ module Clacky
843
954
 
844
955
  {
845
956
  role: "user",
846
- content: "[SYSTEM] " + summary_text
957
+ content: "[SYSTEM] " + summary_text,
958
+ system_injected: true
847
959
  }
848
960
  end
849
961
 
@@ -867,9 +979,14 @@ module Clacky
867
979
  when true
868
980
  { approved: true, feedback: nil }
869
981
  when false, nil
982
+ # User denied - add visual marker based on tool type
983
+ tool_name_capitalized = call[:name].capitalize
984
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
870
985
  { approved: false, feedback: nil }
871
986
  else
872
- # String feedback
987
+ # String feedback - also add visual marker
988
+ tool_name_capitalized = call[:name].capitalize
989
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
873
990
  { approved: false, feedback: result.to_s }
874
991
  end
875
992
  else
@@ -1079,14 +1196,20 @@ module Clacky
1079
1196
  end
1080
1197
 
1081
1198
  def build_result(status, error: nil)
1199
+ # Calculate iterations for current task only
1200
+ task_iterations = @iterations - (@task_start_iterations || 0)
1201
+
1202
+ # Calculate cost for current task only
1203
+ task_cost = @total_cost - (@task_start_cost || 0)
1204
+
1082
1205
  {
1083
1206
  status: status,
1084
1207
  session_id: @session_id,
1085
- iterations: @iterations,
1208
+ iterations: task_iterations, # Show only current task iterations
1086
1209
  duration_seconds: Time.now - @start_time,
1087
- total_cost_usd: @total_cost.round(4),
1210
+ total_cost_usd: task_cost.round(4), # Show only current task cost
1088
1211
  cost_source: @task_cost_source, # Add cost source for this task
1089
- cache_stats: @cache_stats,
1212
+ cache_stats: @task_cache_stats || @cache_stats, # Use task cache stats if available
1090
1213
  messages: @messages,
1091
1214
  error: error
1092
1215
  }
@@ -1132,64 +1255,13 @@ module Clacky
1132
1255
  content << { type: "text", text: text } unless text.nil? || text.empty?
1133
1256
 
1134
1257
  images.each do |image_path|
1135
- image_url = image_path_to_data_url(image_path)
1258
+ image_url = Utils::FileProcessor.image_path_to_data_url(image_path)
1136
1259
  content << { type: "image_url", image_url: { url: image_url } }
1137
1260
  end
1138
1261
 
1139
1262
  content
1140
1263
  end
1141
1264
 
1142
- # Convert image file path to base64 data URL
1143
- # @param path [String] File path to image
1144
- # @return [String] base64 data URL (e.g., "data:image/png;base64,...")
1145
- def image_path_to_data_url(path)
1146
- unless File.exist?(path)
1147
- raise ArgumentError, "Image file not found: #{path}"
1148
- end
1149
-
1150
- # Read file as binary
1151
- image_data = File.binread(path)
1152
-
1153
- # Detect MIME type from file extension or content
1154
- mime_type = detect_image_mime_type(path, image_data)
1155
-
1156
- # Encode to base64
1157
- base64_data = Base64.strict_encode64(image_data)
1158
-
1159
- "data:#{mime_type};base64,#{base64_data}"
1160
- end
1161
1265
 
1162
- # Detect image MIME type
1163
- # @param path [String] File path
1164
- # @param data [String] Binary image data
1165
- # @return [String] MIME type (e.g., "image/png")
1166
- def detect_image_mime_type(path, data)
1167
- # Try to detect from file extension first
1168
- ext = File.extname(path).downcase
1169
- case ext
1170
- when ".png"
1171
- "image/png"
1172
- when ".jpg", ".jpeg"
1173
- "image/jpeg"
1174
- when ".gif"
1175
- "image/gif"
1176
- when ".webp"
1177
- "image/webp"
1178
- else
1179
- # Try to detect from file signature (magic bytes)
1180
- if data.start_with?("\x89PNG".b)
1181
- "image/png"
1182
- elsif data.start_with?("\xFF\xD8\xFF".b)
1183
- "image/jpeg"
1184
- elsif data.start_with?("GIF87a".b) || data.start_with?("GIF89a".b)
1185
- "image/gif"
1186
- elsif data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
1187
- "image/webp"
1188
- else
1189
- # Default to png if unknown
1190
- "image/png"
1191
- end
1192
- end
1193
- end
1194
1266
  end
1195
1267
  end
data/lib/clacky/cli.rb CHANGED
@@ -32,6 +32,10 @@ module Clacky
32
32
  confirm_edits - Auto-approve read-only tools, confirm edits
33
33
  plan_only - Generate plan without executing
34
34
 
35
+ UI themes:
36
+ hacker - Matrix/hacker-style with bracket symbols (default)
37
+ minimal - Clean, simple symbols
38
+
35
39
  Session management:
36
40
  -c, --continue - Continue the most recent session for this directory
37
41
  -l, --list - List recent sessions
@@ -42,6 +46,8 @@ module Clacky
42
46
  LONGDESC
43
47
  option :mode, type: :string, default: "confirm_safes",
44
48
  desc: "Permission mode: auto_approve, confirm_safes, confirm_edits, plan_only"
49
+ option :theme, type: :string, default: "hacker",
50
+ desc: "UI theme: hacker, minimal (default: hacker)"
45
51
  option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Show detailed output"
46
52
  option :path, type: :string, desc: "Project directory path (defaults to current directory)"
47
53
  option :continue, type: :boolean, aliases: "-c", desc: "Continue most recent session"
@@ -82,11 +88,14 @@ module Clacky
82
88
  # Handle session loading/continuation
83
89
  session_manager = Clacky::SessionManager.new
84
90
  agent = nil
91
+ is_session_load = false
85
92
 
86
93
  if options[:continue]
87
94
  agent = load_latest_session(client, agent_config, session_manager, working_dir)
95
+ is_session_load = !agent.nil?
88
96
  elsif options[:attach]
89
97
  agent = load_session_by_number(client, agent_config, session_manager, working_dir, options[:attach])
98
+ is_session_load = !agent.nil?
90
99
  end
91
100
 
92
101
  # Create new agent if no session loaded
@@ -98,7 +107,7 @@ module Clacky
98
107
  Dir.chdir(working_dir) if should_chdir
99
108
 
100
109
  begin
101
- run_agent_with_ui2(agent, working_dir, agent_config, message, session_manager, client)
110
+ run_agent_with_ui2(agent, working_dir, agent_config, message, session_manager, client, is_session_load: is_session_load)
102
111
  rescue StandardError => e
103
112
  # Save session on error
104
113
  if session_manager
@@ -124,6 +133,80 @@ module Clacky
124
133
  end
125
134
  end
126
135
 
136
+ desc "new PROJECT_NAME", "Create a new Rails project from the official template"
137
+ long_desc <<-LONGDESC
138
+ Create a new Rails project from the official template.
139
+
140
+ This command will:
141
+ 1. Clone the template from git@github.com:clacky-ai/rails-template-7x-starter.git
142
+ 2. Change into the project directory
143
+ 3. Run bin/setup to install dependencies and configure the project
144
+
145
+ Example:
146
+ $ clacky new my_rails_app
147
+ LONGDESC
148
+ def new(project_name = nil)
149
+ unless project_name
150
+ say "Error: Project name is required.", :red
151
+ say "Usage: clacky new <project_name>", :yellow
152
+ exit 1
153
+ end
154
+
155
+ # Validate project name
156
+ unless project_name.match?(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
157
+ say "Error: Invalid project name. Use only letters, numbers, underscores, and hyphens.", :red
158
+ exit 1
159
+ end
160
+
161
+ template_repo = "git@github.com:clacky-ai/rails-template-7x-starter.git"
162
+ current_dir = Dir.pwd
163
+ target_dir = File.join(current_dir, project_name)
164
+
165
+ # Check if target directory already exists
166
+ if Dir.exist?(target_dir)
167
+ say "Error: Directory '#{project_name}' already exists.", :red
168
+ exit 1
169
+ end
170
+
171
+ say "Creating new Rails project: #{project_name}", :green
172
+
173
+ # Clone the template repository
174
+ say "\n📦 Cloning template repository...", :cyan
175
+ clone_command = "git clone #{template_repo} #{project_name}"
176
+
177
+ clone_result = system(clone_command)
178
+
179
+ unless clone_result
180
+ say "\n❌ Failed to clone repository. Please check your git configuration and network connection.", :red
181
+ exit 1
182
+ end
183
+
184
+ say "✓ Repository cloned successfully", :green
185
+
186
+ # Run bin/setup
187
+ say "\n⚙️ Running bin/setup...", :cyan
188
+
189
+ Dir.chdir(target_dir)
190
+
191
+ setup_command = "./bin/setup"
192
+
193
+ setup_result = system(setup_command)
194
+
195
+ Dir.chdir(current_dir)
196
+
197
+ unless setup_result
198
+ say "\n❌ Failed to run bin/setup. Please check the setup script for errors.", :red
199
+ say "You can try running it manually:", :yellow
200
+ say " cd #{project_name} && ./bin/setup", :cyan
201
+ exit 1
202
+ end
203
+
204
+ say "\n✅ Project '#{project_name}' created successfully!", :green
205
+ say "\nNext steps:", :green
206
+ say " cd #{project_name}", :cyan
207
+ say " clacky agent", :cyan
208
+ end
209
+
127
210
  desc "price", "Show pricing information for AI models"
128
211
  def price
129
212
  say "\n💰 Model Pricing Information\n\n", :green
@@ -231,7 +314,7 @@ module Clacky
231
314
  return nil
232
315
  end
233
316
 
234
- say "Loading latest session: #{session_data[:session_id][0..7]}", :green
317
+ # Don't print message here - will be shown by UI after banner
235
318
  Clacky::Agent.from_session(client, agent_config, session_data)
236
319
  end
237
320
 
@@ -276,7 +359,7 @@ module Clacky
276
359
  end
277
360
  end
278
361
 
279
- say "Loading session: #{session_data[:session_id][0..7]}", :green
362
+ # Don't print message here - will be shown by UI after banner
280
363
  Clacky::Agent.from_session(client, agent_config, session_data)
281
364
  end
282
365
 
@@ -296,12 +379,21 @@ module Clacky
296
379
  end
297
380
 
298
381
  # Run agent with UI2 split-screen interface
299
- def run_agent_with_ui2(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil)
382
+ def run_agent_with_ui2(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil, is_session_load: false)
383
+ # Validate theme
384
+ theme_name = options[:theme] || "hacker"
385
+ available_themes = UI2::ThemeManager.available_themes.map(&:to_s)
386
+ unless available_themes.include?(theme_name)
387
+ say "Error: Unknown theme '#{theme_name}'. Available themes: #{available_themes.join(', ')}", :red
388
+ exit 1
389
+ end
390
+
300
391
  # Create UI2 controller with configuration
301
392
  ui_controller = UI2::UIController.new(
302
393
  working_dir: working_dir,
303
394
  mode: agent_config.permission_mode.to_s,
304
- model: agent_config.model
395
+ model: agent_config.model,
396
+ theme: theme_name
305
397
  )
306
398
 
307
399
  # Inject UI into agent
@@ -359,6 +451,8 @@ module Clacky
359
451
  ui_controller.show_info("Session cleared. Starting fresh.")
360
452
  # Update session bar with reset values
361
453
  ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
454
+ # Clear todo area display
455
+ ui_controller.update_todos([])
362
456
  next
363
457
  when "/exit", "/quit"
364
458
  ui_controller.stop
@@ -400,7 +494,12 @@ module Clacky
400
494
  end
401
495
 
402
496
  # Initialize UI screen first
403
- ui_controller.initialize_and_show_banner
497
+ if is_session_load
498
+ recent_user_messages = agent.get_recent_user_messages(limit: 5)
499
+ ui_controller.initialize_and_show_banner(recent_user_messages: recent_user_messages)
500
+ else
501
+ ui_controller.initialize_and_show_banner
502
+ end
404
503
 
405
504
  # If there's an initial message, process it
406
505
  if initial_message && !initial_message.strip.empty?
@@ -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
@@ -23,6 +24,8 @@ module Clacky
23
24
  },
24
25
  required: ["path"]
25
26
  }
27
+
28
+
26
29
 
27
30
  def execute(path:, max_lines: 1000)
28
31
  # Expand ~ to home directory
@@ -50,6 +53,12 @@ module Clacky
50
53
  end
51
54
 
52
55
  begin
56
+ # Check if file is binary
57
+ if binary_file?(expanded_path)
58
+ return handle_binary_file(expanded_path)
59
+ end
60
+
61
+ # Read text file
53
62
  lines = File.readlines(expanded_path).first(max_lines)
54
63
  content = lines.join
55
64
  truncated = File.readlines(expanded_path).size > max_lines
@@ -86,16 +95,140 @@ module Clacky
86
95
  return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
87
96
  end
88
97
 
89
- # Handle file reading
98
+ # Handle binary file
99
+ if result[:binary] || result['binary']
100
+ format_type = result[:format] || result['format'] || 'unknown'
101
+ size = result[:size_bytes] || result['size_bytes'] || 0
102
+
103
+ # Check if it has base64 data (LLM-compatible format)
104
+ if result[:base64_data] || result['base64_data']
105
+ size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
106
+ return "Binary file (#{format_type}, #{format_file_size(size)}) - sent to LLM#{size_warning}"
107
+ else
108
+ return "Binary file (#{format_type}, #{format_file_size(size)}) - cannot be read as text"
109
+ end
110
+ end
111
+
112
+ # Handle text file reading
90
113
  lines = result[:lines_read] || result['lines_read'] || 0
91
114
  truncated = result[:truncated] || result['truncated']
92
115
  "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
93
116
  end
117
+
118
+ # Format result for LLM - handles both text and binary (image/PDF) content
119
+ # This method is called by the agent to format tool results before sending to LLM
120
+ def format_result_for_llm(result)
121
+ # For LLM-compatible binary files with base64 data, return as content blocks
122
+ if result[:binary] && result[:base64_data]
123
+ # Create a text description
124
+ description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
125
+
126
+ # Add size warning for large files
127
+ if result[:size_bytes] > 5_000_000
128
+ description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
129
+ end
130
+
131
+ # For images, return both description and image content
132
+ if result[:mime_type]&.start_with?("image/")
133
+ return {
134
+ type: "image",
135
+ path: result[:path],
136
+ format: result[:format],
137
+ size_bytes: result[:size_bytes],
138
+ mime_type: result[:mime_type],
139
+ base64_data: result[:base64_data],
140
+ description: description
141
+ }
142
+ end
143
+
144
+ # For PDFs and other binary formats, just return metadata with base64
145
+ return {
146
+ type: "document",
147
+ path: result[:path],
148
+ format: result[:format],
149
+ size_bytes: result[:size_bytes],
150
+ mime_type: result[:mime_type],
151
+ base64_data: result[:base64_data],
152
+ description: description
153
+ }
154
+ end
155
+
156
+ # For other cases, return the result as-is (agent will JSON.generate it)
157
+ result
158
+ end
159
+
160
+ private def binary_file?(path)
161
+ # Use FileProcessor to detect binary files
162
+ File.open(path, 'rb') do |file|
163
+ sample = file.read(8192) || ""
164
+ Utils::FileProcessor.binary_file?(sample)
165
+ end
166
+ rescue StandardError
167
+ # If we can't read the file, assume it's not binary
168
+ false
169
+ end
170
+
171
+ private def handle_binary_file(path)
172
+ # Check if it's a supported format using FileProcessor
173
+ if Utils::FileProcessor.supported_binary_file?(path)
174
+ # Use FileProcessor to convert to base64
175
+ begin
176
+ result = Utils::FileProcessor.file_to_base64(path)
177
+ {
178
+ path: path,
179
+ binary: true,
180
+ format: result[:format],
181
+ mime_type: result[:mime_type],
182
+ size_bytes: result[:size_bytes],
183
+ base64_data: result[:base64_data],
184
+ error: nil
185
+ }
186
+ rescue ArgumentError => e
187
+ # File too large or other error
188
+ file_size = File.size(path)
189
+ ext = File.extname(path).downcase
190
+ {
191
+ path: path,
192
+ binary: true,
193
+ format: ext.empty? ? "unknown" : ext[1..-1],
194
+ size_bytes: file_size,
195
+ content: nil,
196
+ error: e.message
197
+ }
198
+ end
199
+ else
200
+ # Binary file that we can't send to LLM
201
+ file_size = File.size(path)
202
+ ext = File.extname(path).downcase
203
+ {
204
+ path: path,
205
+ binary: true,
206
+ format: ext.empty? ? "unknown" : ext[1..-1],
207
+ size_bytes: file_size,
208
+ content: nil,
209
+ error: "Binary file detected. This format cannot be read as text. File size: #{format_file_size(file_size)}"
210
+ }
211
+ end
212
+ end
213
+
214
+ private def detect_mime_type(path, data)
215
+ Utils::FileProcessor.detect_mime_type(path, data)
216
+ end
217
+
218
+ private def format_file_size(bytes)
219
+ if bytes < 1024
220
+ "#{bytes} bytes"
221
+ elsif bytes < 1024 * 1024
222
+ "#{(bytes / 1024.0).round(2)} KB"
223
+ else
224
+ "#{(bytes / (1024.0 * 1024)).round(2)} MB"
225
+ end
226
+ end
94
227
 
95
228
  private
96
229
 
97
230
  # List first-level directory contents (files and directories)
98
- def list_directory_contents(path)
231
+ private def list_directory_contents(path)
99
232
  begin
100
233
  entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
101
234