openclacky 0.5.6 → 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/CHANGELOG.md +43 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +245 -340
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +156 -397
- 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/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +4 -120
- 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 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
data/lib/clacky/agent.rb
CHANGED
|
@@ -10,7 +10,7 @@ require_relative "utils/arguments_parser"
|
|
|
10
10
|
module Clacky
|
|
11
11
|
class Agent
|
|
12
12
|
attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
13
|
-
:cache_stats, :cost_source
|
|
13
|
+
:cache_stats, :cost_source, :ui
|
|
14
14
|
|
|
15
15
|
# System prompt for the coding agent
|
|
16
16
|
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
@@ -51,7 +51,7 @@ module Clacky
|
|
|
51
51
|
NEVER stop after just adding todos without executing them!
|
|
52
52
|
PROMPT
|
|
53
53
|
|
|
54
|
-
def initialize(client, config = {}, working_dir: nil)
|
|
54
|
+
def initialize(client, config = {}, working_dir: nil, ui: nil)
|
|
55
55
|
@client = client
|
|
56
56
|
@config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
|
|
57
57
|
@tool_registry = ToolRegistry.new
|
|
@@ -65,7 +65,8 @@ module Clacky
|
|
|
65
65
|
cache_creation_input_tokens: 0,
|
|
66
66
|
cache_read_input_tokens: 0,
|
|
67
67
|
total_requests: 0,
|
|
68
|
-
cache_hit_requests: 0
|
|
68
|
+
cache_hit_requests: 0,
|
|
69
|
+
raw_api_usage_samples: [] # Store raw API usage for debugging
|
|
69
70
|
}
|
|
70
71
|
@start_time = nil
|
|
71
72
|
@working_dir = working_dir || Dir.pwd
|
|
@@ -74,14 +75,17 @@ module Clacky
|
|
|
74
75
|
@cost_source = :estimated # Track whether cost is from API or estimated
|
|
75
76
|
@task_cost_source = :estimated # Track cost source for current task
|
|
76
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
|
|
77
81
|
|
|
78
82
|
# Register built-in tools
|
|
79
83
|
register_builtin_tools
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# Restore from a saved session
|
|
83
|
-
def self.from_session(client, config, session_data)
|
|
84
|
-
agent = new(client, config)
|
|
87
|
+
def self.from_session(client, config, session_data, ui: nil)
|
|
88
|
+
agent = new(client, config, ui: ui)
|
|
85
89
|
agent.restore_session(session_data)
|
|
86
90
|
agent
|
|
87
91
|
end
|
|
@@ -129,7 +133,7 @@ module Clacky
|
|
|
129
133
|
@hooks.add(event, &block)
|
|
130
134
|
end
|
|
131
135
|
|
|
132
|
-
def run(user_input, images: []
|
|
136
|
+
def run(user_input, images: [])
|
|
133
137
|
@start_time = Time.now
|
|
134
138
|
@task_cost_source = :estimated # Reset for new task
|
|
135
139
|
@previous_total_tokens = 0 # Reset token tracking for new task
|
|
@@ -139,10 +143,9 @@ module Clacky
|
|
|
139
143
|
system_prompt = build_system_prompt
|
|
140
144
|
system_message = { role: "system", content: system_prompt }
|
|
141
145
|
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
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
|
|
146
149
|
|
|
147
150
|
@messages << system_message
|
|
148
151
|
end
|
|
@@ -152,7 +155,6 @@ module Clacky
|
|
|
152
155
|
@messages << { role: "user", content: user_content }
|
|
153
156
|
@total_tasks += 1
|
|
154
157
|
|
|
155
|
-
emit_event(:on_start, { input: user_input }, &block)
|
|
156
158
|
@hooks.trigger(:on_start, user_input)
|
|
157
159
|
|
|
158
160
|
begin
|
|
@@ -160,30 +162,29 @@ module Clacky
|
|
|
160
162
|
break if should_stop?
|
|
161
163
|
|
|
162
164
|
@iterations += 1
|
|
163
|
-
emit_event(:on_iteration, { iteration: @iterations }, &block)
|
|
164
165
|
@hooks.trigger(:on_iteration, @iterations)
|
|
165
166
|
|
|
166
167
|
# Think: LLM reasoning with tool support
|
|
167
|
-
response = think
|
|
168
|
+
response = think
|
|
168
169
|
|
|
169
170
|
# Debug: check for potential infinite loops
|
|
170
171
|
if @config.verbose
|
|
171
|
-
|
|
172
|
+
@ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
|
|
172
173
|
end
|
|
173
174
|
|
|
174
175
|
# Check if done (no more tool calls needed)
|
|
175
176
|
if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
176
|
-
|
|
177
|
+
@ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
|
|
177
178
|
break
|
|
178
179
|
end
|
|
179
180
|
|
|
180
|
-
#
|
|
181
|
+
# Show assistant message if there's content before tool calls
|
|
181
182
|
if response[:content] && !response[:content].empty?
|
|
182
|
-
|
|
183
|
+
@ui&.show_assistant_message(response[:content])
|
|
183
184
|
end
|
|
184
185
|
|
|
185
186
|
# Act: Execute tool calls
|
|
186
|
-
action_result = act(response[:tool_calls]
|
|
187
|
+
action_result = act(response[:tool_calls])
|
|
187
188
|
|
|
188
189
|
# Observe: Add tool results to conversation context
|
|
189
190
|
observe(response, action_result[:tool_results])
|
|
@@ -193,7 +194,6 @@ module Clacky
|
|
|
193
194
|
# If user provided feedback, treat it as a user question/instruction
|
|
194
195
|
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
195
196
|
# Add user feedback as a new user message
|
|
196
|
-
# Use a clear format that signals this is important user input
|
|
197
197
|
@messages << {
|
|
198
198
|
role: "user",
|
|
199
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."
|
|
@@ -202,19 +202,27 @@ module Clacky
|
|
|
202
202
|
next
|
|
203
203
|
else
|
|
204
204
|
# User just said "no" without feedback - stop and wait
|
|
205
|
-
|
|
205
|
+
@ui&.show_assistant_message("Tool execution was denied. Please provide further instructions.")
|
|
206
206
|
break
|
|
207
207
|
end
|
|
208
208
|
end
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
result = build_result(:success)
|
|
212
|
-
|
|
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
|
+
)
|
|
213
218
|
@hooks.trigger(:on_complete, result)
|
|
214
219
|
result
|
|
220
|
+
rescue Clacky::AgentInterrupted
|
|
221
|
+
# Let CLI handle the interrupt message
|
|
222
|
+
raise
|
|
215
223
|
rescue StandardError => e
|
|
216
224
|
result = build_result(:error, error: e.message)
|
|
217
|
-
|
|
225
|
+
@ui&.show_error("Error: #{e.message}")
|
|
218
226
|
raise
|
|
219
227
|
end
|
|
220
228
|
end
|
|
@@ -247,7 +255,8 @@ module Clacky
|
|
|
247
255
|
total_cost_usd: @total_cost.round(4),
|
|
248
256
|
duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
|
|
249
257
|
last_status: status.to_s,
|
|
250
|
-
cache_stats: @cache_stats
|
|
258
|
+
cache_stats: @cache_stats,
|
|
259
|
+
debug_logs: @debug_logs
|
|
251
260
|
}
|
|
252
261
|
|
|
253
262
|
# Add error message if status is error
|
|
@@ -262,8 +271,6 @@ module Clacky
|
|
|
262
271
|
config: {
|
|
263
272
|
model: @config.model,
|
|
264
273
|
permission_mode: @config.permission_mode.to_s,
|
|
265
|
-
max_iterations: @config.max_iterations,
|
|
266
|
-
max_cost_usd: @config.max_cost_usd,
|
|
267
274
|
enable_compression: @config.enable_compression,
|
|
268
275
|
enable_prompt_caching: @config.enable_prompt_caching,
|
|
269
276
|
keep_recent_messages: @config.keep_recent_messages,
|
|
@@ -279,9 +286,6 @@ module Clacky
|
|
|
279
286
|
private
|
|
280
287
|
|
|
281
288
|
def should_auto_execute?(tool_name, tool_params = {})
|
|
282
|
-
# Check if tool is disallowed
|
|
283
|
-
return false if @config.disallowed_tools.include?(tool_name)
|
|
284
|
-
|
|
285
289
|
case @config.permission_mode
|
|
286
290
|
when :auto_approve
|
|
287
291
|
true
|
|
@@ -367,120 +371,104 @@ module Clacky
|
|
|
367
371
|
prompt
|
|
368
372
|
end
|
|
369
373
|
|
|
370
|
-
def think
|
|
371
|
-
|
|
374
|
+
def think
|
|
375
|
+
@ui&.show_progress
|
|
372
376
|
|
|
373
377
|
# Compress messages if needed to reduce cost
|
|
374
|
-
compress_messages_if_needed
|
|
378
|
+
compress_messages_if_needed
|
|
375
379
|
|
|
376
380
|
# Always send tools definitions to allow multi-step tool calling
|
|
377
|
-
tools_to_send = @tool_registry.
|
|
381
|
+
tools_to_send = @tool_registry.all_definitions
|
|
378
382
|
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
383
|
+
# Retry logic for network failures
|
|
384
|
+
max_retries = 10
|
|
385
|
+
retry_delay = 5
|
|
386
|
+
retries = 0
|
|
382
387
|
|
|
383
388
|
begin
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
retries
|
|
400
|
-
if retries <= max_retries
|
|
401
|
-
progress.finish
|
|
402
|
-
puts "\n⚠️ Network request failed: #{e.class.name} - #{e.message}"
|
|
403
|
-
puts "🔄 Retry #{retries}/#{max_retries}, waiting #{retry_delay} seconds..."
|
|
404
|
-
sleep retry_delay
|
|
405
|
-
progress.start
|
|
406
|
-
retry
|
|
407
|
-
else
|
|
408
|
-
progress.finish
|
|
409
|
-
puts "\n❌ Network request failed after #{max_retries} retries, giving up"
|
|
410
|
-
raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
411
|
-
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}"
|
|
412
405
|
end
|
|
406
|
+
end
|
|
413
407
|
|
|
414
|
-
|
|
408
|
+
# Stop progress thread (but keep progress line visible)
|
|
409
|
+
@ui&.stop_progress_thread
|
|
415
410
|
|
|
416
|
-
|
|
417
|
-
if response[:finish_reason] == "length"
|
|
418
|
-
# Count recent truncations to prevent infinite loops
|
|
419
|
-
recent_truncations = @messages.last(5).count { |m|
|
|
420
|
-
m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
|
|
421
|
-
}
|
|
411
|
+
track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
422
412
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
error_response = {
|
|
430
|
-
content: "I apologize, but this task is too complex to complete in a single response. " \
|
|
431
|
-
"Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
|
|
432
|
-
"For example, when creating a long document:\n" \
|
|
433
|
-
"1. First create the file with a basic structure\n" \
|
|
434
|
-
"2. Then use edit() to add content section by section",
|
|
435
|
-
finish_reason: "stop",
|
|
436
|
-
tool_calls: nil
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
# Add this as an assistant message so it appears in conversation
|
|
440
|
-
@messages << {
|
|
441
|
-
role: "assistant",
|
|
442
|
-
content: error_response[:content]
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
return error_response
|
|
446
|
-
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
|
+
}
|
|
447
419
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
"
|
|
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
|
|
456
433
|
}
|
|
457
434
|
|
|
458
|
-
|
|
435
|
+
# Add this as an assistant message so it appears in conversation
|
|
436
|
+
@messages << {
|
|
437
|
+
role: "assistant",
|
|
438
|
+
content: error_response[:content]
|
|
439
|
+
}
|
|
459
440
|
|
|
460
|
-
|
|
461
|
-
return think(&block)
|
|
441
|
+
return error_response
|
|
462
442
|
end
|
|
463
443
|
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
+
}
|
|
471
453
|
|
|
472
|
-
|
|
473
|
-
puts "\n[DEBUG] Assistant response added to messages:"
|
|
474
|
-
puts JSON.pretty_generate(msg)
|
|
475
|
-
end
|
|
454
|
+
@ui&.show_warning("Response truncated. Retrying with smaller steps...")
|
|
476
455
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
progress.finish
|
|
456
|
+
# Recursively retry
|
|
457
|
+
return think
|
|
480
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
|
|
481
469
|
end
|
|
482
470
|
|
|
483
|
-
def act(tool_calls
|
|
471
|
+
def act(tool_calls)
|
|
484
472
|
return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
|
|
485
473
|
|
|
486
474
|
denied = false
|
|
@@ -491,7 +479,7 @@ module Clacky
|
|
|
491
479
|
# Hook: before_tool_use
|
|
492
480
|
hook_result = @hooks.trigger(:before_tool_use, call)
|
|
493
481
|
if hook_result[:action] == :deny
|
|
494
|
-
|
|
482
|
+
@ui&.show_warning("Tool #{call[:name]} denied by hook")
|
|
495
483
|
results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
|
|
496
484
|
next
|
|
497
485
|
end
|
|
@@ -499,34 +487,32 @@ module Clacky
|
|
|
499
487
|
# Permission check (if not in auto-approve mode)
|
|
500
488
|
unless should_auto_execute?(call[:name], call[:arguments])
|
|
501
489
|
if @config.is_plan_only?
|
|
502
|
-
|
|
490
|
+
@ui&.show_info("Planned: #{call[:name]}")
|
|
503
491
|
results << build_planned_result(call)
|
|
504
492
|
next
|
|
505
493
|
end
|
|
506
494
|
|
|
507
|
-
confirmation = confirm_tool_use?(call
|
|
495
|
+
confirmation = confirm_tool_use?(call)
|
|
508
496
|
unless confirmation[:approved]
|
|
509
|
-
|
|
497
|
+
@ui&.show_warning("Tool #{call[:name]} denied")
|
|
510
498
|
denied = true
|
|
511
499
|
user_feedback = confirmation[:feedback]
|
|
512
500
|
feedback = user_feedback if user_feedback
|
|
513
501
|
results << build_denied_result(call, user_feedback)
|
|
514
502
|
|
|
515
|
-
#
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
end
|
|
523
|
-
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)
|
|
524
510
|
end
|
|
525
|
-
|
|
511
|
+
break
|
|
526
512
|
end
|
|
527
513
|
end
|
|
528
514
|
|
|
529
|
-
|
|
515
|
+
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
530
516
|
|
|
531
517
|
# Execute tool
|
|
532
518
|
begin
|
|
@@ -545,11 +531,16 @@ module Clacky
|
|
|
545
531
|
# Hook: after_tool_use
|
|
546
532
|
@hooks.trigger(:after_tool_use, call, result)
|
|
547
533
|
|
|
548
|
-
|
|
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))
|
|
549
540
|
results << build_success_result(call, result)
|
|
550
541
|
rescue StandardError => e
|
|
551
542
|
@hooks.trigger(:on_tool_error, call, e)
|
|
552
|
-
|
|
543
|
+
@ui&.show_tool_error(e)
|
|
553
544
|
results << build_error_result(call, e.message)
|
|
554
545
|
end
|
|
555
546
|
end
|
|
@@ -594,27 +585,28 @@ module Clacky
|
|
|
594
585
|
end
|
|
595
586
|
end
|
|
596
587
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
602
593
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
594
|
+
# Check if agent is currently running
|
|
595
|
+
def running?
|
|
596
|
+
@start_time != nil && !should_stop?
|
|
597
|
+
end
|
|
607
598
|
|
|
608
|
-
|
|
609
|
-
if @
|
|
610
|
-
|
|
599
|
+
def should_stop?
|
|
600
|
+
if @interrupted
|
|
601
|
+
@interrupted = false # Reset for next run
|
|
611
602
|
return true
|
|
612
603
|
end
|
|
613
604
|
|
|
605
|
+
|
|
614
606
|
false
|
|
615
607
|
end
|
|
616
608
|
|
|
617
|
-
def track_cost(usage)
|
|
609
|
+
def track_cost(usage, raw_api_usage: nil)
|
|
618
610
|
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
619
611
|
iteration_cost = nil
|
|
620
612
|
if usage[:api_cost]
|
|
@@ -622,7 +614,7 @@ module Clacky
|
|
|
622
614
|
@cost_source = :api
|
|
623
615
|
@task_cost_source = :api
|
|
624
616
|
iteration_cost = usage[:api_cost]
|
|
625
|
-
|
|
617
|
+
@ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
|
|
626
618
|
else
|
|
627
619
|
# Priority 2: Calculate from tokens using ModelPricing
|
|
628
620
|
result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
|
|
@@ -637,8 +629,8 @@ module Clacky
|
|
|
637
629
|
|
|
638
630
|
if @config.verbose
|
|
639
631
|
source_label = pricing_source == :price ? "model pricing" : "default pricing"
|
|
640
|
-
|
|
641
|
-
|
|
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)
|
|
642
634
|
end
|
|
643
635
|
end
|
|
644
636
|
|
|
@@ -656,6 +648,13 @@ module Clacky
|
|
|
656
648
|
@cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
657
649
|
@cache_stats[:cache_hit_requests] += 1
|
|
658
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
|
|
659
658
|
end
|
|
660
659
|
|
|
661
660
|
# Display token usage for current iteration
|
|
@@ -669,53 +668,20 @@ module Clacky
|
|
|
669
668
|
# Calculate token delta from previous iteration
|
|
670
669
|
delta_tokens = total_tokens - @previous_total_tokens
|
|
671
670
|
@previous_total_tokens = total_tokens # Update for next iteration
|
|
672
|
-
|
|
673
|
-
# Build token summary string
|
|
674
|
-
token_info = []
|
|
675
|
-
|
|
676
|
-
# Delta tokens with color coding at the beginning
|
|
677
|
-
require 'pastel'
|
|
678
|
-
pastel = Pastel.new
|
|
679
|
-
|
|
680
|
-
delta_str = "+#{delta_tokens}"
|
|
681
|
-
colored_delta = if delta_tokens > 10000
|
|
682
|
-
pastel.red.bold(delta_str) # Error level: red for > 10k
|
|
683
|
-
elsif delta_tokens > 5000
|
|
684
|
-
pastel.yellow.bold(delta_str) # Warn level: yellow for > 5k
|
|
685
|
-
else
|
|
686
|
-
pastel.green(delta_str) # Normal: green for <= 5k
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
token_info << colored_delta
|
|
690
|
-
|
|
691
|
-
# Cache status indicator
|
|
692
|
-
cache_used = cache_read > 0 || cache_write > 0
|
|
693
|
-
if cache_used
|
|
694
|
-
cache_indicator = "✓ Cached"
|
|
695
|
-
token_info << pastel.cyan(cache_indicator)
|
|
696
|
-
end
|
|
697
671
|
|
|
698
|
-
#
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
# Total
|
|
710
|
-
token_info << "Total: #{total_tokens}"
|
|
711
|
-
|
|
712
|
-
# Cost for this iteration
|
|
713
|
-
if cost
|
|
714
|
-
token_info << "Cost: $#{cost.round(6)}"
|
|
715
|
-
end
|
|
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
|
+
}
|
|
716
682
|
|
|
717
|
-
#
|
|
718
|
-
|
|
683
|
+
# Let UI handle formatting and display
|
|
684
|
+
@ui&.show_token_usage(token_data)
|
|
719
685
|
end
|
|
720
686
|
|
|
721
687
|
def compress_messages_if_needed
|
|
@@ -729,57 +695,30 @@ module Clacky
|
|
|
729
695
|
original_size = @messages.size
|
|
730
696
|
target_size = @config.keep_recent_messages + 2
|
|
731
697
|
|
|
732
|
-
|
|
733
|
-
progress = ProgressIndicator.new(
|
|
734
|
-
verbose: @config.verbose,
|
|
735
|
-
message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
|
|
736
|
-
)
|
|
737
|
-
progress.start
|
|
738
|
-
|
|
739
|
-
begin
|
|
740
|
-
# Find the system message (should be first)
|
|
741
|
-
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
742
|
-
|
|
743
|
-
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
744
|
-
recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
|
|
698
|
+
@ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
|
|
745
699
|
|
|
746
|
-
|
|
747
|
-
|
|
700
|
+
# Find the system message (should be first)
|
|
701
|
+
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
748
702
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
return
|
|
752
|
-
end
|
|
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)
|
|
753
705
|
|
|
754
|
-
|
|
755
|
-
|
|
706
|
+
# Get messages to compress (everything except system and recent)
|
|
707
|
+
messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
|
|
756
708
|
|
|
757
|
-
|
|
758
|
-
# Preserve cache_control on system message if it exists
|
|
759
|
-
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
709
|
+
return if messages_to_compress.empty?
|
|
760
710
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
|
|
764
|
-
end
|
|
711
|
+
# Create summary of compressed messages
|
|
712
|
+
summary = summarize_messages(messages_to_compress)
|
|
765
713
|
|
|
766
|
-
|
|
714
|
+
# Rebuild messages array: [system, summary, recent_messages]
|
|
715
|
+
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
767
716
|
|
|
768
|
-
|
|
717
|
+
@messages = rebuilt_messages
|
|
769
718
|
|
|
770
|
-
|
|
771
|
-
progress.finish
|
|
772
|
-
puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
|
|
719
|
+
final_size = @messages.size
|
|
773
720
|
|
|
774
|
-
|
|
775
|
-
if @config.verbose
|
|
776
|
-
puts "\n[COMPRESSION SUMMARY]"
|
|
777
|
-
puts summary[:content]
|
|
778
|
-
puts ""
|
|
779
|
-
end
|
|
780
|
-
ensure
|
|
781
|
-
progress.finish
|
|
782
|
-
end
|
|
721
|
+
@ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
|
|
783
722
|
end
|
|
784
723
|
|
|
785
724
|
def get_recent_messages_with_tool_pairs(messages, count)
|
|
@@ -908,77 +847,46 @@ module Clacky
|
|
|
908
847
|
}
|
|
909
848
|
end
|
|
910
849
|
|
|
911
|
-
def
|
|
912
|
-
return unless block
|
|
913
|
-
|
|
914
|
-
block.call({
|
|
915
|
-
type: type,
|
|
916
|
-
data: data,
|
|
917
|
-
iteration: @iterations,
|
|
918
|
-
cost: @total_cost
|
|
919
|
-
})
|
|
920
|
-
end
|
|
921
|
-
|
|
922
|
-
def confirm_tool_use?(call, &block)
|
|
923
|
-
emit_event(:tool_confirmation_required, call, &block)
|
|
924
|
-
|
|
850
|
+
def confirm_tool_use?(call)
|
|
925
851
|
# Show preview first and check for errors
|
|
926
852
|
preview_error = show_tool_preview(call)
|
|
927
853
|
|
|
928
|
-
# If preview detected an error
|
|
929
|
-
# auto-deny and provide detailed feedback
|
|
854
|
+
# If preview detected an error, auto-deny and provide feedback
|
|
930
855
|
if preview_error && preview_error[:error]
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
# Build helpful feedback message
|
|
934
|
-
feedback = case call[:name]
|
|
935
|
-
when "edit"
|
|
936
|
-
"The edit operation will fail because the old_string was not found in the file. " \
|
|
937
|
-
"Please use file_reader to read '#{preview_error[:path]}' first, " \
|
|
938
|
-
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
939
|
-
else
|
|
940
|
-
"Tool preview error: #{preview_error[:error]}"
|
|
941
|
-
end
|
|
942
|
-
|
|
856
|
+
@ui&.show_warning("Tool call auto-denied due to preview error")
|
|
857
|
+
feedback = build_preview_error_feedback(call[:name], preview_error)
|
|
943
858
|
return { approved: false, feedback: feedback }
|
|
944
859
|
end
|
|
945
860
|
|
|
946
|
-
#
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
# Use TTY::Prompt for better input handling
|
|
951
|
-
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)
|
|
952
865
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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 }
|
|
956
874
|
end
|
|
957
|
-
|
|
958
|
-
#
|
|
959
|
-
|
|
960
|
-
return { approved: false, feedback: nil }
|
|
961
|
-
end
|
|
962
|
-
|
|
963
|
-
# Handle nil response (EOF/pipe input)
|
|
964
|
-
if response.nil? || response.empty?
|
|
965
|
-
return { approved: true, feedback: nil } # Empty means approved
|
|
966
|
-
end
|
|
967
|
-
|
|
968
|
-
response_lower = response.downcase
|
|
969
|
-
|
|
970
|
-
# "y"/"yes" = approved
|
|
971
|
-
if response_lower == "y" || response_lower == "yes"
|
|
972
|
-
return { approved: true, feedback: nil }
|
|
875
|
+
else
|
|
876
|
+
# Fallback: auto-approve if no UI
|
|
877
|
+
{ approved: true, feedback: nil }
|
|
973
878
|
end
|
|
879
|
+
end
|
|
974
880
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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]}"
|
|
978
889
|
end
|
|
979
|
-
|
|
980
|
-
# Any other input = denied with feedback
|
|
981
|
-
{ approved: false, feedback: response }
|
|
982
890
|
end
|
|
983
891
|
|
|
984
892
|
def format_tool_prompt(call)
|
|
@@ -1018,6 +926,8 @@ module Clacky
|
|
|
1018
926
|
end
|
|
1019
927
|
|
|
1020
928
|
def show_tool_preview(call)
|
|
929
|
+
return nil unless @ui
|
|
930
|
+
|
|
1021
931
|
begin
|
|
1022
932
|
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
1023
933
|
|
|
@@ -1028,22 +938,22 @@ module Clacky
|
|
|
1028
938
|
when "edit"
|
|
1029
939
|
preview_error = show_edit_preview(args)
|
|
1030
940
|
when "shell", "safe_shell"
|
|
1031
|
-
|
|
941
|
+
show_shell_preview(args)
|
|
1032
942
|
else
|
|
1033
943
|
# For other tools, show formatted arguments
|
|
1034
944
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1035
945
|
if tool
|
|
1036
946
|
formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
|
|
1037
|
-
|
|
947
|
+
@ui&.show_tool_args(formatted)
|
|
1038
948
|
else
|
|
1039
|
-
|
|
949
|
+
@ui&.show_tool_args(call[:arguments])
|
|
1040
950
|
end
|
|
1041
951
|
end
|
|
1042
952
|
|
|
1043
|
-
|
|
953
|
+
preview_error
|
|
1044
954
|
rescue JSON::ParserError
|
|
1045
|
-
|
|
1046
|
-
|
|
955
|
+
@ui&.show_tool_args(call[:arguments])
|
|
956
|
+
nil
|
|
1047
957
|
end
|
|
1048
958
|
end
|
|
1049
959
|
|
|
@@ -1051,17 +961,16 @@ module Clacky
|
|
|
1051
961
|
path = args[:path] || args['path']
|
|
1052
962
|
new_content = args[:content] || args['content'] || ""
|
|
1053
963
|
|
|
1054
|
-
|
|
964
|
+
is_new_file = !(path && File.exist?(path))
|
|
965
|
+
@ui&.show_file_write_preview(path, is_new_file: is_new_file)
|
|
1055
966
|
|
|
1056
|
-
if
|
|
1057
|
-
|
|
1058
|
-
puts "Modifying existing file\n"
|
|
1059
|
-
show_diff(old_content, new_content, max_lines: 50)
|
|
967
|
+
if is_new_file
|
|
968
|
+
@ui&.show_diff("", new_content, max_lines: 50)
|
|
1060
969
|
else
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
show_diff("", new_content, max_lines: 50)
|
|
970
|
+
old_content = File.read(path)
|
|
971
|
+
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
1064
972
|
end
|
|
973
|
+
nil
|
|
1065
974
|
end
|
|
1066
975
|
|
|
1067
976
|
def show_edit_preview(args)
|
|
@@ -1069,20 +978,20 @@ module Clacky
|
|
|
1069
978
|
old_string = args[:old_string] || args['old_string'] || ""
|
|
1070
979
|
new_string = args[:new_string] || args['new_string'] || ""
|
|
1071
980
|
|
|
1072
|
-
|
|
981
|
+
@ui&.show_file_edit_preview(path)
|
|
1073
982
|
|
|
1074
983
|
if !path || path.empty?
|
|
1075
|
-
|
|
984
|
+
@ui&.show_file_error("No file path provided")
|
|
1076
985
|
return { error: "No file path provided for edit operation" }
|
|
1077
986
|
end
|
|
1078
987
|
|
|
1079
988
|
unless File.exist?(path)
|
|
1080
|
-
|
|
1081
|
-
return { error: "File not found: #{path}" }
|
|
989
|
+
@ui&.show_file_error("File not found: #{path}")
|
|
990
|
+
return { error: "File not found: #{path}", path: path }
|
|
1082
991
|
end
|
|
1083
992
|
|
|
1084
993
|
if old_string.empty?
|
|
1085
|
-
|
|
994
|
+
@ui&.show_file_error("No old_string provided (nothing to replace)")
|
|
1086
995
|
return { error: "No old_string provided (nothing to replace)" }
|
|
1087
996
|
end
|
|
1088
997
|
|
|
@@ -1090,9 +999,19 @@ module Clacky
|
|
|
1090
999
|
|
|
1091
1000
|
# Check if old_string exists in file
|
|
1092
1001
|
unless file_content.include?(old_string)
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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)
|
|
1096
1015
|
return {
|
|
1097
1016
|
error: "String to replace not found in file",
|
|
1098
1017
|
path: path,
|
|
@@ -1101,34 +1020,20 @@ module Clacky
|
|
|
1101
1020
|
end
|
|
1102
1021
|
|
|
1103
1022
|
new_content = file_content.sub(old_string, new_string)
|
|
1104
|
-
show_diff(file_content, new_content, max_lines: 50)
|
|
1023
|
+
@ui&.show_diff(file_content, new_content, max_lines: 50)
|
|
1105
1024
|
nil # No error
|
|
1106
1025
|
end
|
|
1107
1026
|
|
|
1108
1027
|
def show_shell_preview(args)
|
|
1109
1028
|
command = args[:command] || ""
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
def show_diff(old_content, new_content, max_lines: 50)
|
|
1114
|
-
require 'diffy'
|
|
1115
|
-
|
|
1116
|
-
diff = Diffy::Diff.new(old_content, new_content, context: 3)
|
|
1117
|
-
all_lines = diff.to_s(:color).lines
|
|
1118
|
-
display_lines = all_lines.first(max_lines)
|
|
1119
|
-
|
|
1120
|
-
display_lines.each { |line| puts line.chomp }
|
|
1121
|
-
puts "\n... (#{all_lines.size - max_lines} more lines, diff truncated)" if all_lines.size > max_lines
|
|
1122
|
-
rescue LoadError
|
|
1123
|
-
# Fallback if diffy is not available
|
|
1124
|
-
puts " Old size: #{old_content.bytesize} bytes"
|
|
1125
|
-
puts " New size: #{new_content.bytesize} bytes"
|
|
1029
|
+
@ui&.show_shell_preview(command)
|
|
1030
|
+
nil
|
|
1126
1031
|
end
|
|
1127
1032
|
|
|
1128
1033
|
def build_success_result(call, result)
|
|
1129
1034
|
# Try to get tool instance to use its format_result_for_llm method
|
|
1130
1035
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1131
|
-
|
|
1036
|
+
|
|
1132
1037
|
formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
|
|
1133
1038
|
# Tool provides a custom LLM-friendly format
|
|
1134
1039
|
tool.format_result_for_llm(result)
|
|
@@ -1136,7 +1041,7 @@ module Clacky
|
|
|
1136
1041
|
# Fallback: use the original result
|
|
1137
1042
|
result
|
|
1138
1043
|
end
|
|
1139
|
-
|
|
1044
|
+
|
|
1140
1045
|
{
|
|
1141
1046
|
id: call[:id],
|
|
1142
1047
|
content: JSON.generate(formatted_result)
|