openclacky 0.5.5 → 0.6.0
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.
- checksums.yaml +4 -4
- data/.clackyrules +4 -0
- data/CHANGELOG.md +43 -0
- data/README.md +1 -1
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +354 -296
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +157 -330
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/progress_indicator.rb +1 -1
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/file_reader.rb +73 -10
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +44 -116
- data/lib/clacky/tools/run_project.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +49 -13
- data/lib/clacky/tools/shell.rb +1 -49
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +38 -26
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +89 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1029 -0
- data/lib/clacky/ui2/components/message_component.rb +76 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +137 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
- data/lib/clacky/ui2/layout_manager.rb +331 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/screen_buffer.rb +238 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +99 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
- data/lib/clacky/ui2/ui_controller.rb +720 -0
- data/lib/clacky/ui2/view_renderer.rb +160 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +38 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -540
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
data/lib/clacky/agent.rb
CHANGED
|
@@ -4,12 +4,13 @@ require "securerandom"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "tty-prompt"
|
|
6
6
|
require "set"
|
|
7
|
+
require "base64"
|
|
7
8
|
require_relative "utils/arguments_parser"
|
|
8
9
|
|
|
9
10
|
module Clacky
|
|
10
11
|
class Agent
|
|
11
12
|
attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
12
|
-
:cache_stats, :cost_source
|
|
13
|
+
:cache_stats, :cost_source, :ui
|
|
13
14
|
|
|
14
15
|
# System prompt for the coding agent
|
|
15
16
|
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
@@ -50,7 +51,7 @@ module Clacky
|
|
|
50
51
|
NEVER stop after just adding todos without executing them!
|
|
51
52
|
PROMPT
|
|
52
53
|
|
|
53
|
-
def initialize(client, config = {}, working_dir: nil)
|
|
54
|
+
def initialize(client, config = {}, working_dir: nil, ui: nil)
|
|
54
55
|
@client = client
|
|
55
56
|
@config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
|
|
56
57
|
@tool_registry = ToolRegistry.new
|
|
@@ -64,7 +65,8 @@ module Clacky
|
|
|
64
65
|
cache_creation_input_tokens: 0,
|
|
65
66
|
cache_read_input_tokens: 0,
|
|
66
67
|
total_requests: 0,
|
|
67
|
-
cache_hit_requests: 0
|
|
68
|
+
cache_hit_requests: 0,
|
|
69
|
+
raw_api_usage_samples: [] # Store raw API usage for debugging
|
|
68
70
|
}
|
|
69
71
|
@start_time = nil
|
|
70
72
|
@working_dir = working_dir || Dir.pwd
|
|
@@ -72,14 +74,18 @@ module Clacky
|
|
|
72
74
|
@total_tasks = 0
|
|
73
75
|
@cost_source = :estimated # Track whether cost is from API or estimated
|
|
74
76
|
@task_cost_source = :estimated # Track cost source for current task
|
|
77
|
+
@previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
|
|
78
|
+
@interrupted = false # Flag for user interrupt
|
|
79
|
+
@ui = ui # UIController for direct UI interaction
|
|
80
|
+
@debug_logs = [] # Debug logs for troubleshooting
|
|
75
81
|
|
|
76
82
|
# Register built-in tools
|
|
77
83
|
register_builtin_tools
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
# Restore from a saved session
|
|
81
|
-
def self.from_session(client, config, session_data)
|
|
82
|
-
agent = new(client, config)
|
|
87
|
+
def self.from_session(client, config, session_data, ui: nil)
|
|
88
|
+
agent = new(client, config, ui: ui)
|
|
83
89
|
agent.restore_session(session_data)
|
|
84
90
|
agent
|
|
85
91
|
end
|
|
@@ -127,27 +133,28 @@ module Clacky
|
|
|
127
133
|
@hooks.add(event, &block)
|
|
128
134
|
end
|
|
129
135
|
|
|
130
|
-
def run(user_input,
|
|
136
|
+
def run(user_input, images: [])
|
|
131
137
|
@start_time = Time.now
|
|
132
138
|
@task_cost_source = :estimated # Reset for new task
|
|
139
|
+
@previous_total_tokens = 0 # Reset token tracking for new task
|
|
133
140
|
|
|
134
141
|
# Add system prompt as the first message if this is the first run
|
|
135
142
|
if @messages.empty?
|
|
136
143
|
system_prompt = build_system_prompt
|
|
137
144
|
system_message = { role: "system", content: system_prompt }
|
|
138
145
|
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
end
|
|
146
|
+
# Note: Don't set cache_control on system prompt
|
|
147
|
+
# System prompt is usually < 1024 tokens (minimum for caching)
|
|
148
|
+
# Cache control will be set on tools and conversation history instead
|
|
143
149
|
|
|
144
150
|
@messages << system_message
|
|
145
151
|
end
|
|
146
152
|
|
|
147
|
-
|
|
153
|
+
# Format user message with images if provided
|
|
154
|
+
user_content = format_user_content(user_input, images)
|
|
155
|
+
@messages << { role: "user", content: user_content }
|
|
148
156
|
@total_tasks += 1
|
|
149
157
|
|
|
150
|
-
emit_event(:on_start, { input: user_input }, &block)
|
|
151
158
|
@hooks.trigger(:on_start, user_input)
|
|
152
159
|
|
|
153
160
|
begin
|
|
@@ -155,30 +162,29 @@ module Clacky
|
|
|
155
162
|
break if should_stop?
|
|
156
163
|
|
|
157
164
|
@iterations += 1
|
|
158
|
-
emit_event(:on_iteration, { iteration: @iterations }, &block)
|
|
159
165
|
@hooks.trigger(:on_iteration, @iterations)
|
|
160
166
|
|
|
161
167
|
# Think: LLM reasoning with tool support
|
|
162
|
-
response = think
|
|
168
|
+
response = think
|
|
163
169
|
|
|
164
170
|
# Debug: check for potential infinite loops
|
|
165
171
|
if @config.verbose
|
|
166
|
-
|
|
172
|
+
@ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
|
|
167
173
|
end
|
|
168
174
|
|
|
169
175
|
# Check if done (no more tool calls needed)
|
|
170
176
|
if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
171
|
-
|
|
177
|
+
@ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
|
|
172
178
|
break
|
|
173
179
|
end
|
|
174
180
|
|
|
175
|
-
#
|
|
181
|
+
# Show assistant message if there's content before tool calls
|
|
176
182
|
if response[:content] && !response[:content].empty?
|
|
177
|
-
|
|
183
|
+
@ui&.show_assistant_message(response[:content])
|
|
178
184
|
end
|
|
179
185
|
|
|
180
186
|
# Act: Execute tool calls
|
|
181
|
-
action_result = act(response[:tool_calls]
|
|
187
|
+
action_result = act(response[:tool_calls])
|
|
182
188
|
|
|
183
189
|
# Observe: Add tool results to conversation context
|
|
184
190
|
observe(response, action_result[:tool_results])
|
|
@@ -188,7 +194,6 @@ module Clacky
|
|
|
188
194
|
# If user provided feedback, treat it as a user question/instruction
|
|
189
195
|
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
190
196
|
# Add user feedback as a new user message
|
|
191
|
-
# Use a clear format that signals this is important user input
|
|
192
197
|
@messages << {
|
|
193
198
|
role: "user",
|
|
194
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."
|
|
@@ -197,19 +202,27 @@ module Clacky
|
|
|
197
202
|
next
|
|
198
203
|
else
|
|
199
204
|
# User just said "no" without feedback - stop and wait
|
|
200
|
-
|
|
205
|
+
@ui&.show_assistant_message("Tool execution was denied. Please provide further instructions.")
|
|
201
206
|
break
|
|
202
207
|
end
|
|
203
208
|
end
|
|
204
209
|
end
|
|
205
210
|
|
|
206
211
|
result = build_result(:success)
|
|
207
|
-
|
|
212
|
+
@ui&.show_complete(
|
|
213
|
+
iterations: result[:iterations],
|
|
214
|
+
cost: result[:total_cost_usd],
|
|
215
|
+
duration: result[:duration_seconds],
|
|
216
|
+
cache_stats: result[:cache_stats]
|
|
217
|
+
)
|
|
208
218
|
@hooks.trigger(:on_complete, result)
|
|
209
219
|
result
|
|
220
|
+
rescue Clacky::AgentInterrupted
|
|
221
|
+
# Let CLI handle the interrupt message
|
|
222
|
+
raise
|
|
210
223
|
rescue StandardError => e
|
|
211
224
|
result = build_result(:error, error: e.message)
|
|
212
|
-
|
|
225
|
+
@ui&.show_error("Error: #{e.message}")
|
|
213
226
|
raise
|
|
214
227
|
end
|
|
215
228
|
end
|
|
@@ -242,7 +255,8 @@ module Clacky
|
|
|
242
255
|
total_cost_usd: @total_cost.round(4),
|
|
243
256
|
duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
|
|
244
257
|
last_status: status.to_s,
|
|
245
|
-
cache_stats: @cache_stats
|
|
258
|
+
cache_stats: @cache_stats,
|
|
259
|
+
debug_logs: @debug_logs
|
|
246
260
|
}
|
|
247
261
|
|
|
248
262
|
# Add error message if status is error
|
|
@@ -257,8 +271,6 @@ module Clacky
|
|
|
257
271
|
config: {
|
|
258
272
|
model: @config.model,
|
|
259
273
|
permission_mode: @config.permission_mode.to_s,
|
|
260
|
-
max_iterations: @config.max_iterations,
|
|
261
|
-
max_cost_usd: @config.max_cost_usd,
|
|
262
274
|
enable_compression: @config.enable_compression,
|
|
263
275
|
enable_prompt_caching: @config.enable_prompt_caching,
|
|
264
276
|
keep_recent_messages: @config.keep_recent_messages,
|
|
@@ -274,9 +286,6 @@ module Clacky
|
|
|
274
286
|
private
|
|
275
287
|
|
|
276
288
|
def should_auto_execute?(tool_name, tool_params = {})
|
|
277
|
-
# Check if tool is disallowed
|
|
278
|
-
return false if @config.disallowed_tools.include?(tool_name)
|
|
279
|
-
|
|
280
289
|
case @config.permission_mode
|
|
281
290
|
when :auto_approve
|
|
282
291
|
true
|
|
@@ -362,120 +371,104 @@ module Clacky
|
|
|
362
371
|
prompt
|
|
363
372
|
end
|
|
364
373
|
|
|
365
|
-
def think
|
|
366
|
-
|
|
374
|
+
def think
|
|
375
|
+
@ui&.show_progress
|
|
367
376
|
|
|
368
377
|
# Compress messages if needed to reduce cost
|
|
369
|
-
compress_messages_if_needed
|
|
378
|
+
compress_messages_if_needed
|
|
370
379
|
|
|
371
380
|
# Always send tools definitions to allow multi-step tool calling
|
|
372
|
-
tools_to_send = @tool_registry.
|
|
381
|
+
tools_to_send = @tool_registry.all_definitions
|
|
373
382
|
|
|
374
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
383
|
+
# Retry logic for network failures
|
|
384
|
+
max_retries = 10
|
|
385
|
+
retry_delay = 5
|
|
386
|
+
retries = 0
|
|
377
387
|
|
|
378
388
|
begin
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
retries
|
|
395
|
-
if retries <= max_retries
|
|
396
|
-
progress.finish
|
|
397
|
-
puts "\n⚠️ Network request failed: #{e.class.name} - #{e.message}"
|
|
398
|
-
puts "🔄 Retry #{retries}/#{max_retries}, waiting #{retry_delay} seconds..."
|
|
399
|
-
sleep retry_delay
|
|
400
|
-
progress.start
|
|
401
|
-
retry
|
|
402
|
-
else
|
|
403
|
-
progress.finish
|
|
404
|
-
puts "\n❌ Network request failed after #{max_retries} retries, giving up"
|
|
405
|
-
raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
406
|
-
end
|
|
389
|
+
response = @client.send_messages_with_tools(
|
|
390
|
+
@messages,
|
|
391
|
+
model: @config.model,
|
|
392
|
+
tools: tools_to_send,
|
|
393
|
+
max_tokens: @config.max_tokens,
|
|
394
|
+
enable_caching: @config.enable_prompt_caching
|
|
395
|
+
)
|
|
396
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
397
|
+
retries += 1
|
|
398
|
+
if retries <= max_retries
|
|
399
|
+
@ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
|
|
400
|
+
sleep retry_delay
|
|
401
|
+
retry
|
|
402
|
+
else
|
|
403
|
+
@ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
|
|
404
|
+
raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
407
405
|
end
|
|
406
|
+
end
|
|
408
407
|
|
|
409
|
-
|
|
408
|
+
# Stop progress thread (but keep progress line visible)
|
|
409
|
+
@ui&.stop_progress_thread
|
|
410
410
|
|
|
411
|
-
|
|
412
|
-
if response[:finish_reason] == "length"
|
|
413
|
-
# Count recent truncations to prevent infinite loops
|
|
414
|
-
recent_truncations = @messages.last(5).count { |m|
|
|
415
|
-
m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
|
|
416
|
-
}
|
|
411
|
+
track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
417
412
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
error_response = {
|
|
425
|
-
content: "I apologize, but this task is too complex to complete in a single response. " \
|
|
426
|
-
"Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
|
|
427
|
-
"For example, when creating a long document:\n" \
|
|
428
|
-
"1. First create the file with a basic structure\n" \
|
|
429
|
-
"2. Then use edit() to add content section by section",
|
|
430
|
-
finish_reason: "stop",
|
|
431
|
-
tool_calls: nil
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
# Add this as an assistant message so it appears in conversation
|
|
435
|
-
@messages << {
|
|
436
|
-
role: "assistant",
|
|
437
|
-
content: error_response[:content]
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
return error_response
|
|
441
|
-
end
|
|
413
|
+
# Handle truncated responses (when max_tokens limit is reached)
|
|
414
|
+
if response[:finish_reason] == "length"
|
|
415
|
+
# Count recent truncations to prevent infinite loops
|
|
416
|
+
recent_truncations = @messages.last(5).count { |m|
|
|
417
|
+
m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
|
|
418
|
+
}
|
|
442
419
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"
|
|
420
|
+
if recent_truncations >= 2
|
|
421
|
+
# Too many truncations - task is too complex
|
|
422
|
+
@ui&.show_error("Response truncated multiple times. Task is too complex.")
|
|
423
|
+
|
|
424
|
+
# Create a response that tells the user to break down the task
|
|
425
|
+
error_response = {
|
|
426
|
+
content: "I apologize, but this task is too complex to complete in a single response. " \
|
|
427
|
+
"Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
|
|
428
|
+
"For example, when creating a long document:\n" \
|
|
429
|
+
"1. First create the file with a basic structure\n" \
|
|
430
|
+
"2. Then use edit() to add content section by section",
|
|
431
|
+
finish_reason: "stop",
|
|
432
|
+
tool_calls: nil
|
|
451
433
|
}
|
|
452
434
|
|
|
453
|
-
|
|
435
|
+
# Add this as an assistant message so it appears in conversation
|
|
436
|
+
@messages << {
|
|
437
|
+
role: "assistant",
|
|
438
|
+
content: error_response[:content]
|
|
439
|
+
}
|
|
454
440
|
|
|
455
|
-
|
|
456
|
-
return think(&block)
|
|
441
|
+
return error_response
|
|
457
442
|
end
|
|
458
443
|
|
|
459
|
-
#
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
444
|
+
# Insert system message to guide LLM to retry with smaller steps
|
|
445
|
+
@messages << {
|
|
446
|
+
role: "user",
|
|
447
|
+
content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
|
|
448
|
+
"- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
|
|
449
|
+
"- Break down large tasks into multiple smaller steps\n" \
|
|
450
|
+
"- Avoid putting more than 2000 characters in a single tool call argument\n" \
|
|
451
|
+
"- Use multiple tool calls instead of one large call"
|
|
452
|
+
}
|
|
466
453
|
|
|
467
|
-
|
|
468
|
-
puts "\n[DEBUG] Assistant response added to messages:"
|
|
469
|
-
puts JSON.pretty_generate(msg)
|
|
470
|
-
end
|
|
454
|
+
@ui&.show_warning("Response truncated. Retrying with smaller steps...")
|
|
471
455
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
progress.finish
|
|
456
|
+
# Recursively retry
|
|
457
|
+
return think
|
|
475
458
|
end
|
|
459
|
+
|
|
460
|
+
# Add assistant response to messages
|
|
461
|
+
msg = { role: "assistant" }
|
|
462
|
+
# Always include content field (some APIs require it even with tool_calls)
|
|
463
|
+
# Use empty string instead of null for better compatibility
|
|
464
|
+
msg[:content] = response[:content] || ""
|
|
465
|
+
msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
|
|
466
|
+
@messages << msg
|
|
467
|
+
|
|
468
|
+
response
|
|
476
469
|
end
|
|
477
470
|
|
|
478
|
-
def act(tool_calls
|
|
471
|
+
def act(tool_calls)
|
|
479
472
|
return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
|
|
480
473
|
|
|
481
474
|
denied = false
|
|
@@ -486,7 +479,7 @@ module Clacky
|
|
|
486
479
|
# Hook: before_tool_use
|
|
487
480
|
hook_result = @hooks.trigger(:before_tool_use, call)
|
|
488
481
|
if hook_result[:action] == :deny
|
|
489
|
-
|
|
482
|
+
@ui&.show_warning("Tool #{call[:name]} denied by hook")
|
|
490
483
|
results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
|
|
491
484
|
next
|
|
492
485
|
end
|
|
@@ -494,34 +487,32 @@ module Clacky
|
|
|
494
487
|
# Permission check (if not in auto-approve mode)
|
|
495
488
|
unless should_auto_execute?(call[:name], call[:arguments])
|
|
496
489
|
if @config.is_plan_only?
|
|
497
|
-
|
|
490
|
+
@ui&.show_info("Planned: #{call[:name]}")
|
|
498
491
|
results << build_planned_result(call)
|
|
499
492
|
next
|
|
500
493
|
end
|
|
501
494
|
|
|
502
|
-
confirmation = confirm_tool_use?(call
|
|
495
|
+
confirmation = confirm_tool_use?(call)
|
|
503
496
|
unless confirmation[:approved]
|
|
504
|
-
|
|
497
|
+
@ui&.show_warning("Tool #{call[:name]} denied")
|
|
505
498
|
denied = true
|
|
506
499
|
user_feedback = confirmation[:feedback]
|
|
507
500
|
feedback = user_feedback if user_feedback
|
|
508
501
|
results << build_denied_result(call, user_feedback)
|
|
509
502
|
|
|
510
|
-
#
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
end
|
|
518
|
-
break
|
|
503
|
+
# Auto-deny all remaining tools
|
|
504
|
+
remaining_calls = tool_calls[(index + 1)..-1] || []
|
|
505
|
+
remaining_calls.each do |remaining_call|
|
|
506
|
+
reason = user_feedback && !user_feedback.empty? ?
|
|
507
|
+
user_feedback :
|
|
508
|
+
"Auto-denied due to user rejection of previous tool"
|
|
509
|
+
results << build_denied_result(remaining_call, reason)
|
|
519
510
|
end
|
|
520
|
-
|
|
511
|
+
break
|
|
521
512
|
end
|
|
522
513
|
end
|
|
523
514
|
|
|
524
|
-
|
|
515
|
+
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
525
516
|
|
|
526
517
|
# Execute tool
|
|
527
518
|
begin
|
|
@@ -540,11 +531,16 @@ module Clacky
|
|
|
540
531
|
# Hook: after_tool_use
|
|
541
532
|
@hooks.trigger(:after_tool_use, call, result)
|
|
542
533
|
|
|
543
|
-
|
|
534
|
+
# Update todos display after todo_manager execution
|
|
535
|
+
if call[:name] == "todo_manager"
|
|
536
|
+
@ui&.update_todos(@todos.dup)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
@ui&.show_tool_result(tool.format_result(result))
|
|
544
540
|
results << build_success_result(call, result)
|
|
545
541
|
rescue StandardError => e
|
|
546
542
|
@hooks.trigger(:on_tool_error, call, e)
|
|
547
|
-
|
|
543
|
+
@ui&.show_tool_error(e)
|
|
548
544
|
results << build_error_result(call, e.message)
|
|
549
545
|
end
|
|
550
546
|
end
|
|
@@ -589,51 +585,58 @@ module Clacky
|
|
|
589
585
|
end
|
|
590
586
|
end
|
|
591
587
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
588
|
+
# Interrupt the agent's current run
|
|
589
|
+
# Called when user presses Ctrl+C during agent execution
|
|
590
|
+
def interrupt!
|
|
591
|
+
@interrupted = true
|
|
592
|
+
end
|
|
597
593
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
594
|
+
# Check if agent is currently running
|
|
595
|
+
def running?
|
|
596
|
+
@start_time != nil && !should_stop?
|
|
597
|
+
end
|
|
602
598
|
|
|
603
|
-
|
|
604
|
-
if @
|
|
605
|
-
|
|
599
|
+
def should_stop?
|
|
600
|
+
if @interrupted
|
|
601
|
+
@interrupted = false # Reset for next run
|
|
606
602
|
return true
|
|
607
603
|
end
|
|
608
604
|
|
|
605
|
+
|
|
609
606
|
false
|
|
610
607
|
end
|
|
611
608
|
|
|
612
|
-
def track_cost(usage)
|
|
609
|
+
def track_cost(usage, raw_api_usage: nil)
|
|
613
610
|
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
611
|
+
iteration_cost = nil
|
|
614
612
|
if usage[:api_cost]
|
|
615
613
|
@total_cost += usage[:api_cost]
|
|
616
614
|
@cost_source = :api
|
|
617
615
|
@task_cost_source = :api
|
|
618
|
-
|
|
616
|
+
iteration_cost = usage[:api_cost]
|
|
617
|
+
@ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
|
|
619
618
|
else
|
|
620
619
|
# Priority 2: Calculate from tokens using ModelPricing
|
|
621
620
|
result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
|
|
622
621
|
cost = result[:cost]
|
|
623
622
|
pricing_source = result[:source]
|
|
624
|
-
|
|
623
|
+
|
|
625
624
|
@total_cost += cost
|
|
625
|
+
iteration_cost = cost
|
|
626
626
|
# Map pricing source to cost source: :price or :default
|
|
627
627
|
@cost_source = pricing_source
|
|
628
628
|
@task_cost_source = pricing_source
|
|
629
|
-
|
|
629
|
+
|
|
630
630
|
if @config.verbose
|
|
631
631
|
source_label = pricing_source == :price ? "model pricing" : "default pricing"
|
|
632
|
-
|
|
633
|
-
|
|
632
|
+
@ui&.log("Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}", level: :debug)
|
|
633
|
+
@ui&.log("Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}", level: :debug)
|
|
634
634
|
end
|
|
635
635
|
end
|
|
636
636
|
|
|
637
|
+
# Display token usage statistics for this iteration
|
|
638
|
+
display_iteration_tokens(usage, iteration_cost)
|
|
639
|
+
|
|
637
640
|
# Track cache usage statistics
|
|
638
641
|
@cache_stats[:total_requests] += 1
|
|
639
642
|
|
|
@@ -645,6 +648,40 @@ module Clacky
|
|
|
645
648
|
@cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
646
649
|
@cache_stats[:cache_hit_requests] += 1
|
|
647
650
|
end
|
|
651
|
+
|
|
652
|
+
# Store raw API usage samples (keep last 3 for debugging)
|
|
653
|
+
if raw_api_usage
|
|
654
|
+
@cache_stats[:raw_api_usage_samples] ||= []
|
|
655
|
+
@cache_stats[:raw_api_usage_samples] << raw_api_usage
|
|
656
|
+
@cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# Display token usage for current iteration
|
|
661
|
+
private def display_iteration_tokens(usage, cost)
|
|
662
|
+
prompt_tokens = usage[:prompt_tokens] || 0
|
|
663
|
+
completion_tokens = usage[:completion_tokens] || 0
|
|
664
|
+
total_tokens = usage[:total_tokens] || (prompt_tokens + completion_tokens)
|
|
665
|
+
cache_write = usage[:cache_creation_input_tokens] || 0
|
|
666
|
+
cache_read = usage[:cache_read_input_tokens] || 0
|
|
667
|
+
|
|
668
|
+
# Calculate token delta from previous iteration
|
|
669
|
+
delta_tokens = total_tokens - @previous_total_tokens
|
|
670
|
+
@previous_total_tokens = total_tokens # Update for next iteration
|
|
671
|
+
|
|
672
|
+
# Prepare data for UI to format and display
|
|
673
|
+
token_data = {
|
|
674
|
+
delta_tokens: delta_tokens,
|
|
675
|
+
prompt_tokens: prompt_tokens,
|
|
676
|
+
completion_tokens: completion_tokens,
|
|
677
|
+
total_tokens: total_tokens,
|
|
678
|
+
cache_write: cache_write,
|
|
679
|
+
cache_read: cache_read,
|
|
680
|
+
cost: cost
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
# Let UI handle formatting and display
|
|
684
|
+
@ui&.show_token_usage(token_data)
|
|
648
685
|
end
|
|
649
686
|
|
|
650
687
|
def compress_messages_if_needed
|
|
@@ -658,57 +695,30 @@ module Clacky
|
|
|
658
695
|
original_size = @messages.size
|
|
659
696
|
target_size = @config.keep_recent_messages + 2
|
|
660
697
|
|
|
661
|
-
|
|
662
|
-
progress = ProgressIndicator.new(
|
|
663
|
-
verbose: @config.verbose,
|
|
664
|
-
message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
|
|
665
|
-
)
|
|
666
|
-
progress.start
|
|
698
|
+
@ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
|
|
667
699
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
671
|
-
|
|
672
|
-
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
673
|
-
recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
|
|
700
|
+
# Find the system message (should be first)
|
|
701
|
+
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
674
702
|
|
|
675
|
-
|
|
676
|
-
|
|
703
|
+
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
704
|
+
recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
|
|
677
705
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return
|
|
681
|
-
end
|
|
706
|
+
# Get messages to compress (everything except system and recent)
|
|
707
|
+
messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
|
|
682
708
|
|
|
683
|
-
|
|
684
|
-
summary = summarize_messages(messages_to_compress)
|
|
709
|
+
return if messages_to_compress.empty?
|
|
685
710
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
711
|
+
# Create summary of compressed messages
|
|
712
|
+
summary = summarize_messages(messages_to_compress)
|
|
689
713
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
|
|
693
|
-
end
|
|
714
|
+
# Rebuild messages array: [system, summary, recent_messages]
|
|
715
|
+
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
694
716
|
|
|
695
|
-
|
|
717
|
+
@messages = rebuilt_messages
|
|
696
718
|
|
|
697
|
-
|
|
719
|
+
final_size = @messages.size
|
|
698
720
|
|
|
699
|
-
|
|
700
|
-
progress.finish
|
|
701
|
-
puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
|
|
702
|
-
|
|
703
|
-
# Show detailed summary in verbose mode
|
|
704
|
-
if @config.verbose
|
|
705
|
-
puts "\n[COMPRESSION SUMMARY]"
|
|
706
|
-
puts summary[:content]
|
|
707
|
-
puts ""
|
|
708
|
-
end
|
|
709
|
-
ensure
|
|
710
|
-
progress.finish
|
|
711
|
-
end
|
|
721
|
+
@ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
|
|
712
722
|
end
|
|
713
723
|
|
|
714
724
|
def get_recent_messages_with_tool_pairs(messages, count)
|
|
@@ -837,77 +847,46 @@ module Clacky
|
|
|
837
847
|
}
|
|
838
848
|
end
|
|
839
849
|
|
|
840
|
-
def
|
|
841
|
-
return unless block
|
|
842
|
-
|
|
843
|
-
block.call({
|
|
844
|
-
type: type,
|
|
845
|
-
data: data,
|
|
846
|
-
iteration: @iterations,
|
|
847
|
-
cost: @total_cost
|
|
848
|
-
})
|
|
849
|
-
end
|
|
850
|
-
|
|
851
|
-
def confirm_tool_use?(call, &block)
|
|
852
|
-
emit_event(:tool_confirmation_required, call, &block)
|
|
853
|
-
|
|
850
|
+
def confirm_tool_use?(call)
|
|
854
851
|
# Show preview first and check for errors
|
|
855
852
|
preview_error = show_tool_preview(call)
|
|
856
853
|
|
|
857
|
-
# If preview detected an error
|
|
858
|
-
# auto-deny and provide detailed feedback
|
|
854
|
+
# If preview detected an error, auto-deny and provide feedback
|
|
859
855
|
if preview_error && preview_error[:error]
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
# Build helpful feedback message
|
|
863
|
-
feedback = case call[:name]
|
|
864
|
-
when "edit"
|
|
865
|
-
"The edit operation will fail because the old_string was not found in the file. " \
|
|
866
|
-
"Please use file_reader to read '#{preview_error[:path]}' first, " \
|
|
867
|
-
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
868
|
-
else
|
|
869
|
-
"Tool preview error: #{preview_error[:error]}"
|
|
870
|
-
end
|
|
871
|
-
|
|
856
|
+
@ui&.show_warning("Tool call auto-denied due to preview error")
|
|
857
|
+
feedback = build_preview_error_feedback(call[:name], preview_error)
|
|
872
858
|
return { approved: false, feedback: feedback }
|
|
873
859
|
end
|
|
874
860
|
|
|
875
|
-
#
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
# Use TTY::Prompt for better input handling
|
|
880
|
-
tty_prompt = TTY::Prompt.new(interrupt: :exit)
|
|
861
|
+
# Request confirmation via UI
|
|
862
|
+
if @ui
|
|
863
|
+
prompt_text = format_tool_prompt(call)
|
|
864
|
+
result = @ui.request_confirmation(prompt_text, default: true)
|
|
881
865
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
866
|
+
case result
|
|
867
|
+
when true
|
|
868
|
+
{ approved: true, feedback: nil }
|
|
869
|
+
when false, nil
|
|
870
|
+
{ approved: false, feedback: nil }
|
|
871
|
+
else
|
|
872
|
+
# String feedback
|
|
873
|
+
{ approved: false, feedback: result.to_s }
|
|
885
874
|
end
|
|
886
|
-
|
|
887
|
-
#
|
|
888
|
-
|
|
889
|
-
return { approved: false, feedback: nil }
|
|
890
|
-
end
|
|
891
|
-
|
|
892
|
-
# Handle nil response (EOF/pipe input)
|
|
893
|
-
if response.nil? || response.empty?
|
|
894
|
-
return { approved: true, feedback: nil } # Empty means approved
|
|
895
|
-
end
|
|
896
|
-
|
|
897
|
-
response_lower = response.downcase
|
|
898
|
-
|
|
899
|
-
# "y"/"yes" = approved
|
|
900
|
-
if response_lower == "y" || response_lower == "yes"
|
|
901
|
-
return { approved: true, feedback: nil }
|
|
875
|
+
else
|
|
876
|
+
# Fallback: auto-approve if no UI
|
|
877
|
+
{ approved: true, feedback: nil }
|
|
902
878
|
end
|
|
879
|
+
end
|
|
903
880
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
881
|
+
private def build_preview_error_feedback(tool_name, error_info)
|
|
882
|
+
case tool_name
|
|
883
|
+
when "edit"
|
|
884
|
+
"The edit operation will fail because the old_string was not found in the file. " \
|
|
885
|
+
"Please use file_reader to read '#{error_info[:path]}' first, " \
|
|
886
|
+
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
887
|
+
else
|
|
888
|
+
"Tool preview error: #{error_info[:error]}"
|
|
907
889
|
end
|
|
908
|
-
|
|
909
|
-
# Any other input = denied with feedback
|
|
910
|
-
{ approved: false, feedback: response }
|
|
911
890
|
end
|
|
912
891
|
|
|
913
892
|
def format_tool_prompt(call)
|
|
@@ -947,6 +926,8 @@ module Clacky
|
|
|
947
926
|
end
|
|
948
927
|
|
|
949
928
|
def show_tool_preview(call)
|
|
929
|
+
return nil unless @ui
|
|
930
|
+
|
|
950
931
|
begin
|
|
951
932
|
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
952
933
|
|
|
@@ -957,22 +938,22 @@ module Clacky
|
|
|
957
938
|
when "edit"
|
|
958
939
|
preview_error = show_edit_preview(args)
|
|
959
940
|
when "shell", "safe_shell"
|
|
960
|
-
|
|
941
|
+
show_shell_preview(args)
|
|
961
942
|
else
|
|
962
943
|
# For other tools, show formatted arguments
|
|
963
944
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
964
945
|
if tool
|
|
965
946
|
formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
|
|
966
|
-
|
|
947
|
+
@ui&.show_tool_args(formatted)
|
|
967
948
|
else
|
|
968
|
-
|
|
949
|
+
@ui&.show_tool_args(call[:arguments])
|
|
969
950
|
end
|
|
970
951
|
end
|
|
971
952
|
|
|
972
|
-
|
|
953
|
+
preview_error
|
|
973
954
|
rescue JSON::ParserError
|
|
974
|
-
|
|
975
|
-
|
|
955
|
+
@ui&.show_tool_args(call[:arguments])
|
|
956
|
+
nil
|
|
976
957
|
end
|
|
977
958
|
end
|
|
978
959
|
|
|
@@ -980,17 +961,16 @@ module Clacky
|
|
|
980
961
|
path = args[:path] || args['path']
|
|
981
962
|
new_content = args[:content] || args['content'] || ""
|
|
982
963
|
|
|
983
|
-
|
|
964
|
+
is_new_file = !(path && File.exist?(path))
|
|
965
|
+
@ui&.show_file_write_preview(path, is_new_file: is_new_file)
|
|
984
966
|
|
|
985
|
-
if
|
|
986
|
-
|
|
987
|
-
puts "Modifying existing file\n"
|
|
988
|
-
show_diff(old_content, new_content, max_lines: 50)
|
|
967
|
+
if is_new_file
|
|
968
|
+
@ui&.show_diff("", new_content, max_lines: 50)
|
|
989
969
|
else
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
show_diff("", new_content, max_lines: 50)
|
|
970
|
+
old_content = File.read(path)
|
|
971
|
+
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
993
972
|
end
|
|
973
|
+
nil
|
|
994
974
|
end
|
|
995
975
|
|
|
996
976
|
def show_edit_preview(args)
|
|
@@ -998,20 +978,20 @@ module Clacky
|
|
|
998
978
|
old_string = args[:old_string] || args['old_string'] || ""
|
|
999
979
|
new_string = args[:new_string] || args['new_string'] || ""
|
|
1000
980
|
|
|
1001
|
-
|
|
981
|
+
@ui&.show_file_edit_preview(path)
|
|
1002
982
|
|
|
1003
983
|
if !path || path.empty?
|
|
1004
|
-
|
|
984
|
+
@ui&.show_file_error("No file path provided")
|
|
1005
985
|
return { error: "No file path provided for edit operation" }
|
|
1006
986
|
end
|
|
1007
987
|
|
|
1008
988
|
unless File.exist?(path)
|
|
1009
|
-
|
|
1010
|
-
return { error: "File not found: #{path}" }
|
|
989
|
+
@ui&.show_file_error("File not found: #{path}")
|
|
990
|
+
return { error: "File not found: #{path}", path: path }
|
|
1011
991
|
end
|
|
1012
992
|
|
|
1013
993
|
if old_string.empty?
|
|
1014
|
-
|
|
994
|
+
@ui&.show_file_error("No old_string provided (nothing to replace)")
|
|
1015
995
|
return { error: "No old_string provided (nothing to replace)" }
|
|
1016
996
|
end
|
|
1017
997
|
|
|
@@ -1019,9 +999,19 @@ module Clacky
|
|
|
1019
999
|
|
|
1020
1000
|
# Check if old_string exists in file
|
|
1021
1001
|
unless file_content.include?(old_string)
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1002
|
+
# Log debug info for troubleshooting
|
|
1003
|
+
@debug_logs << {
|
|
1004
|
+
timestamp: Time.now.iso8601,
|
|
1005
|
+
event: "edit_preview_failed",
|
|
1006
|
+
path: path,
|
|
1007
|
+
looking_for: old_string[0..500],
|
|
1008
|
+
file_content_preview: file_content[0..1000],
|
|
1009
|
+
file_size: file_content.length
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
@ui&.show_file_error("String to replace not found in file")
|
|
1013
|
+
@ui&.show_file_error("Looking for (first 100 chars):")
|
|
1014
|
+
@ui&.show_file_error(old_string[0..100].inspect)
|
|
1025
1015
|
return {
|
|
1026
1016
|
error: "String to replace not found in file",
|
|
1027
1017
|
path: path,
|
|
@@ -1030,34 +1020,31 @@ module Clacky
|
|
|
1030
1020
|
end
|
|
1031
1021
|
|
|
1032
1022
|
new_content = file_content.sub(old_string, new_string)
|
|
1033
|
-
show_diff(file_content, new_content, max_lines: 50)
|
|
1023
|
+
@ui&.show_diff(file_content, new_content, max_lines: 50)
|
|
1034
1024
|
nil # No error
|
|
1035
1025
|
end
|
|
1036
1026
|
|
|
1037
1027
|
def show_shell_preview(args)
|
|
1038
1028
|
command = args[:command] || ""
|
|
1039
|
-
|
|
1029
|
+
@ui&.show_shell_preview(command)
|
|
1030
|
+
nil
|
|
1040
1031
|
end
|
|
1041
1032
|
|
|
1042
|
-
def
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
diff = Diffy::Diff.new(old_content, new_content, context: 3)
|
|
1046
|
-
all_lines = diff.to_s(:color).lines
|
|
1047
|
-
display_lines = all_lines.first(max_lines)
|
|
1033
|
+
def build_success_result(call, result)
|
|
1034
|
+
# Try to get tool instance to use its format_result_for_llm method
|
|
1035
|
+
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1048
1036
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1037
|
+
formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
|
|
1038
|
+
# Tool provides a custom LLM-friendly format
|
|
1039
|
+
tool.format_result_for_llm(result)
|
|
1040
|
+
else
|
|
1041
|
+
# Fallback: use the original result
|
|
1042
|
+
result
|
|
1043
|
+
end
|
|
1056
1044
|
|
|
1057
|
-
def build_success_result(call, result)
|
|
1058
1045
|
{
|
|
1059
1046
|
id: call[:id],
|
|
1060
|
-
content: JSON.generate(
|
|
1047
|
+
content: JSON.generate(formatted_result)
|
|
1061
1048
|
}
|
|
1062
1049
|
end
|
|
1063
1050
|
|
|
@@ -1133,5 +1120,76 @@ module Clacky
|
|
|
1133
1120
|
@tool_registry.register(Tools::TodoManager.new)
|
|
1134
1121
|
@tool_registry.register(Tools::RunProject.new)
|
|
1135
1122
|
end
|
|
1123
|
+
|
|
1124
|
+
# Format user content with optional images
|
|
1125
|
+
# @param text [String] User's text input
|
|
1126
|
+
# @param images [Array<String>] Array of image file paths
|
|
1127
|
+
# @return [String|Array] String if no images, Array with text and image_url objects if images present
|
|
1128
|
+
def format_user_content(text, images)
|
|
1129
|
+
return text if images.nil? || images.empty?
|
|
1130
|
+
|
|
1131
|
+
content = []
|
|
1132
|
+
content << { type: "text", text: text } unless text.nil? || text.empty?
|
|
1133
|
+
|
|
1134
|
+
images.each do |image_path|
|
|
1135
|
+
image_url = image_path_to_data_url(image_path)
|
|
1136
|
+
content << { type: "image_url", image_url: { url: image_url } }
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
content
|
|
1140
|
+
end
|
|
1141
|
+
|
|
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
|
+
|
|
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
|
|
1136
1194
|
end
|
|
1137
1195
|
end
|