openclacky 0.5.6 → 0.6.1
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 +71 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +376 -346
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +167 -398
- 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 +66 -10
- data/lib/clacky/tools/grep.rb +6 -122
- data/lib/clacky/tools/run_project.rb +10 -5
- data/lib/clacky/tools/safe_shell.rb +149 -20
- data/lib/clacky/tools/shell.rb +3 -51
- data/lib/clacky/tools/todo_manager.rb +50 -3
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_fetch.rb +4 -4
- data/lib/clacky/tools/web_search.rb +40 -28
- 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 +98 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1124 -0
- data/lib/clacky/ui2/components/message_component.rb +80 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +130 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +103 -0
- data/lib/clacky/ui2/layout_manager.rb +437 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/markdown_renderer.rb +80 -0
- data/lib/clacky/ui2/screen_buffer.rb +257 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +85 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +58 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +52 -0
- data/lib/clacky/ui2/ui_controller.rb +778 -0
- data/lib/clacky/ui2/view_renderer.rb +177 -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 +53 -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
|
|
@@ -125,24 +129,60 @@ module Clacky
|
|
|
125
129
|
end
|
|
126
130
|
end
|
|
127
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
|
+
|
|
128
159
|
def add_hook(event, &block)
|
|
129
160
|
@hooks.add(event, &block)
|
|
130
161
|
end
|
|
131
162
|
|
|
132
|
-
def run(user_input, images: []
|
|
163
|
+
def run(user_input, images: [])
|
|
133
164
|
@start_time = Time.now
|
|
134
165
|
@task_cost_source = :estimated # Reset for new task
|
|
135
166
|
@previous_total_tokens = 0 # Reset token tracking for new task
|
|
167
|
+
@task_start_iterations = @iterations # Track starting iterations for this task
|
|
168
|
+
@task_start_cost = @total_cost # Track starting cost for this task
|
|
169
|
+
|
|
170
|
+
# Track cache stats for current task
|
|
171
|
+
@task_cache_stats = {
|
|
172
|
+
cache_creation_input_tokens: 0,
|
|
173
|
+
cache_read_input_tokens: 0,
|
|
174
|
+
total_requests: 0,
|
|
175
|
+
cache_hit_requests: 0
|
|
176
|
+
}
|
|
136
177
|
|
|
137
178
|
# Add system prompt as the first message if this is the first run
|
|
138
179
|
if @messages.empty?
|
|
139
180
|
system_prompt = build_system_prompt
|
|
140
181
|
system_message = { role: "system", content: system_prompt }
|
|
141
182
|
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
end
|
|
183
|
+
# Note: Don't set cache_control on system prompt
|
|
184
|
+
# System prompt is usually < 1024 tokens (minimum for caching)
|
|
185
|
+
# Cache control will be set on tools and conversation history instead
|
|
146
186
|
|
|
147
187
|
@messages << system_message
|
|
148
188
|
end
|
|
@@ -152,7 +192,6 @@ module Clacky
|
|
|
152
192
|
@messages << { role: "user", content: user_content }
|
|
153
193
|
@total_tasks += 1
|
|
154
194
|
|
|
155
|
-
emit_event(:on_start, { input: user_input }, &block)
|
|
156
195
|
@hooks.trigger(:on_start, user_input)
|
|
157
196
|
|
|
158
197
|
begin
|
|
@@ -160,30 +199,29 @@ module Clacky
|
|
|
160
199
|
break if should_stop?
|
|
161
200
|
|
|
162
201
|
@iterations += 1
|
|
163
|
-
emit_event(:on_iteration, { iteration: @iterations }, &block)
|
|
164
202
|
@hooks.trigger(:on_iteration, @iterations)
|
|
165
203
|
|
|
166
204
|
# Think: LLM reasoning with tool support
|
|
167
|
-
response = think
|
|
205
|
+
response = think
|
|
168
206
|
|
|
169
207
|
# Debug: check for potential infinite loops
|
|
170
208
|
if @config.verbose
|
|
171
|
-
|
|
209
|
+
@ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
|
|
172
210
|
end
|
|
173
211
|
|
|
174
212
|
# Check if done (no more tool calls needed)
|
|
175
213
|
if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
176
|
-
|
|
214
|
+
@ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
|
|
177
215
|
break
|
|
178
216
|
end
|
|
179
217
|
|
|
180
|
-
#
|
|
218
|
+
# Show assistant message if there's content before tool calls
|
|
181
219
|
if response[:content] && !response[:content].empty?
|
|
182
|
-
|
|
220
|
+
@ui&.show_assistant_message(response[:content])
|
|
183
221
|
end
|
|
184
222
|
|
|
185
223
|
# Act: Execute tool calls
|
|
186
|
-
action_result = act(response[:tool_calls]
|
|
224
|
+
action_result = act(response[:tool_calls])
|
|
187
225
|
|
|
188
226
|
# Observe: Add tool results to conversation context
|
|
189
227
|
observe(response, action_result[:tool_results])
|
|
@@ -192,29 +230,37 @@ module Clacky
|
|
|
192
230
|
if action_result[:denied]
|
|
193
231
|
# If user provided feedback, treat it as a user question/instruction
|
|
194
232
|
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
195
|
-
# Add user feedback as a new user message
|
|
196
|
-
# Use a clear format that signals this is important user input
|
|
233
|
+
# Add user feedback as a new user message with system_injected marker
|
|
197
234
|
@messages << {
|
|
198
235
|
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."
|
|
236
|
+
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
|
+
system_injected: true # Mark as system-injected message for filtering
|
|
200
238
|
}
|
|
201
239
|
# Continue loop to let agent respond to feedback
|
|
202
240
|
next
|
|
203
241
|
else
|
|
204
242
|
# User just said "no" without feedback - stop and wait
|
|
205
|
-
|
|
243
|
+
@ui&.show_assistant_message("Tool execution was denied. Please give more instructions...")
|
|
206
244
|
break
|
|
207
245
|
end
|
|
208
246
|
end
|
|
209
247
|
end
|
|
210
248
|
|
|
211
249
|
result = build_result(:success)
|
|
212
|
-
|
|
250
|
+
@ui&.show_complete(
|
|
251
|
+
iterations: result[:iterations],
|
|
252
|
+
cost: result[:total_cost_usd],
|
|
253
|
+
duration: result[:duration_seconds],
|
|
254
|
+
cache_stats: result[:cache_stats]
|
|
255
|
+
)
|
|
213
256
|
@hooks.trigger(:on_complete, result)
|
|
214
257
|
result
|
|
258
|
+
rescue Clacky::AgentInterrupted
|
|
259
|
+
# Let CLI handle the interrupt message
|
|
260
|
+
raise
|
|
215
261
|
rescue StandardError => e
|
|
262
|
+
# Build error result for session data, but let CLI handle error display
|
|
216
263
|
result = build_result(:error, error: e.message)
|
|
217
|
-
emit_event(:on_complete, result, &block)
|
|
218
264
|
raise
|
|
219
265
|
end
|
|
220
266
|
end
|
|
@@ -247,7 +293,8 @@ module Clacky
|
|
|
247
293
|
total_cost_usd: @total_cost.round(4),
|
|
248
294
|
duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
|
|
249
295
|
last_status: status.to_s,
|
|
250
|
-
cache_stats: @cache_stats
|
|
296
|
+
cache_stats: @cache_stats,
|
|
297
|
+
debug_logs: @debug_logs
|
|
251
298
|
}
|
|
252
299
|
|
|
253
300
|
# Add error message if status is error
|
|
@@ -262,8 +309,6 @@ module Clacky
|
|
|
262
309
|
config: {
|
|
263
310
|
model: @config.model,
|
|
264
311
|
permission_mode: @config.permission_mode.to_s,
|
|
265
|
-
max_iterations: @config.max_iterations,
|
|
266
|
-
max_cost_usd: @config.max_cost_usd,
|
|
267
312
|
enable_compression: @config.enable_compression,
|
|
268
313
|
enable_prompt_caching: @config.enable_prompt_caching,
|
|
269
314
|
keep_recent_messages: @config.keep_recent_messages,
|
|
@@ -279,9 +324,6 @@ module Clacky
|
|
|
279
324
|
private
|
|
280
325
|
|
|
281
326
|
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
327
|
case @config.permission_mode
|
|
286
328
|
when :auto_approve
|
|
287
329
|
true
|
|
@@ -367,120 +409,104 @@ module Clacky
|
|
|
367
409
|
prompt
|
|
368
410
|
end
|
|
369
411
|
|
|
370
|
-
def think
|
|
371
|
-
|
|
412
|
+
def think
|
|
413
|
+
@ui&.show_progress
|
|
372
414
|
|
|
373
415
|
# Compress messages if needed to reduce cost
|
|
374
|
-
compress_messages_if_needed
|
|
416
|
+
compress_messages_if_needed
|
|
375
417
|
|
|
376
418
|
# Always send tools definitions to allow multi-step tool calling
|
|
377
|
-
tools_to_send = @tool_registry.
|
|
419
|
+
tools_to_send = @tool_registry.all_definitions
|
|
378
420
|
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
421
|
+
# Retry logic for network failures
|
|
422
|
+
max_retries = 10
|
|
423
|
+
retry_delay = 5
|
|
424
|
+
retries = 0
|
|
382
425
|
|
|
383
426
|
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
|
|
427
|
+
response = @client.send_messages_with_tools(
|
|
428
|
+
@messages,
|
|
429
|
+
model: @config.model,
|
|
430
|
+
tools: tools_to_send,
|
|
431
|
+
max_tokens: @config.max_tokens,
|
|
432
|
+
enable_caching: @config.enable_prompt_caching
|
|
433
|
+
)
|
|
434
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
435
|
+
retries += 1
|
|
436
|
+
if retries <= max_retries
|
|
437
|
+
@ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
|
|
438
|
+
sleep retry_delay
|
|
439
|
+
retry
|
|
440
|
+
else
|
|
441
|
+
@ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
|
|
442
|
+
raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
412
443
|
end
|
|
444
|
+
end
|
|
413
445
|
|
|
414
|
-
|
|
446
|
+
# Clear progress indicator (change to gray and show final time)
|
|
447
|
+
@ui&.clear_progress
|
|
415
448
|
|
|
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
|
-
}
|
|
449
|
+
track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
422
450
|
|
|
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
|
|
451
|
+
# Handle truncated responses (when max_tokens limit is reached)
|
|
452
|
+
if response[:finish_reason] == "length"
|
|
453
|
+
# Count recent truncations to prevent infinite loops
|
|
454
|
+
recent_truncations = @messages.last(5).count { |m|
|
|
455
|
+
m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
|
|
456
|
+
}
|
|
447
457
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
"
|
|
458
|
+
if recent_truncations >= 2
|
|
459
|
+
# Too many truncations - task is too complex
|
|
460
|
+
@ui&.show_error("Response truncated multiple times. Task is too complex.")
|
|
461
|
+
|
|
462
|
+
# Create a response that tells the user to break down the task
|
|
463
|
+
error_response = {
|
|
464
|
+
content: "I apologize, but this task is too complex to complete in a single response. " \
|
|
465
|
+
"Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
|
|
466
|
+
"For example, when creating a long document:\n" \
|
|
467
|
+
"1. First create the file with a basic structure\n" \
|
|
468
|
+
"2. Then use edit() to add content section by section",
|
|
469
|
+
finish_reason: "stop",
|
|
470
|
+
tool_calls: nil
|
|
456
471
|
}
|
|
457
472
|
|
|
458
|
-
|
|
473
|
+
# Add this as an assistant message so it appears in conversation
|
|
474
|
+
@messages << {
|
|
475
|
+
role: "assistant",
|
|
476
|
+
content: error_response[:content]
|
|
477
|
+
}
|
|
459
478
|
|
|
460
|
-
|
|
461
|
-
return think(&block)
|
|
479
|
+
return error_response
|
|
462
480
|
end
|
|
463
481
|
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
482
|
+
# Insert system message to guide LLM to retry with smaller steps
|
|
483
|
+
@messages << {
|
|
484
|
+
role: "user",
|
|
485
|
+
content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
|
|
486
|
+
"- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
|
|
487
|
+
"- Break down large tasks into multiple smaller steps\n" \
|
|
488
|
+
"- Avoid putting more than 2000 characters in a single tool call argument\n" \
|
|
489
|
+
"- Use multiple tool calls instead of one large call"
|
|
490
|
+
}
|
|
471
491
|
|
|
472
|
-
|
|
473
|
-
puts "\n[DEBUG] Assistant response added to messages:"
|
|
474
|
-
puts JSON.pretty_generate(msg)
|
|
475
|
-
end
|
|
492
|
+
@ui&.show_warning("Response truncated. Retrying with smaller steps...")
|
|
476
493
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
progress.finish
|
|
494
|
+
# Recursively retry
|
|
495
|
+
return think
|
|
480
496
|
end
|
|
497
|
+
|
|
498
|
+
# Add assistant response to messages
|
|
499
|
+
msg = { role: "assistant" }
|
|
500
|
+
# Always include content field (some APIs require it even with tool_calls)
|
|
501
|
+
# Use empty string instead of null for better compatibility
|
|
502
|
+
msg[:content] = response[:content] || ""
|
|
503
|
+
msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
|
|
504
|
+
@messages << msg
|
|
505
|
+
|
|
506
|
+
response
|
|
481
507
|
end
|
|
482
508
|
|
|
483
|
-
def act(tool_calls
|
|
509
|
+
def act(tool_calls)
|
|
484
510
|
return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
|
|
485
511
|
|
|
486
512
|
denied = false
|
|
@@ -491,7 +517,7 @@ module Clacky
|
|
|
491
517
|
# Hook: before_tool_use
|
|
492
518
|
hook_result = @hooks.trigger(:before_tool_use, call)
|
|
493
519
|
if hook_result[:action] == :deny
|
|
494
|
-
|
|
520
|
+
@ui&.show_warning("Tool #{call[:name]} denied by hook")
|
|
495
521
|
results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
|
|
496
522
|
next
|
|
497
523
|
end
|
|
@@ -499,34 +525,38 @@ module Clacky
|
|
|
499
525
|
# Permission check (if not in auto-approve mode)
|
|
500
526
|
unless should_auto_execute?(call[:name], call[:arguments])
|
|
501
527
|
if @config.is_plan_only?
|
|
502
|
-
|
|
528
|
+
@ui&.show_info("Planned: #{call[:name]}")
|
|
503
529
|
results << build_planned_result(call)
|
|
504
530
|
next
|
|
505
531
|
end
|
|
506
532
|
|
|
507
|
-
confirmation = confirm_tool_use?(call
|
|
533
|
+
confirmation = confirm_tool_use?(call)
|
|
508
534
|
unless confirmation[:approved]
|
|
509
|
-
|
|
535
|
+
# Show denial warning with user feedback if provided
|
|
536
|
+
denial_message = "Tool #{call[:name]} denied"
|
|
537
|
+
if confirmation[:feedback] && !confirmation[:feedback].empty?
|
|
538
|
+
denial_message += ": #{confirmation[:feedback]}"
|
|
539
|
+
end
|
|
540
|
+
@ui&.show_warning(denial_message)
|
|
541
|
+
|
|
510
542
|
denied = true
|
|
511
543
|
user_feedback = confirmation[:feedback]
|
|
512
544
|
feedback = user_feedback if user_feedback
|
|
513
545
|
results << build_denied_result(call, user_feedback)
|
|
514
546
|
|
|
515
|
-
#
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
end
|
|
523
|
-
break
|
|
547
|
+
# Auto-deny all remaining tools
|
|
548
|
+
remaining_calls = tool_calls[(index + 1)..-1] || []
|
|
549
|
+
remaining_calls.each do |remaining_call|
|
|
550
|
+
reason = user_feedback && !user_feedback.empty? ?
|
|
551
|
+
user_feedback :
|
|
552
|
+
"Auto-denied due to user rejection of previous tool"
|
|
553
|
+
results << build_denied_result(remaining_call, reason)
|
|
524
554
|
end
|
|
525
|
-
|
|
555
|
+
break
|
|
526
556
|
end
|
|
527
557
|
end
|
|
528
558
|
|
|
529
|
-
|
|
559
|
+
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
530
560
|
|
|
531
561
|
# Execute tool
|
|
532
562
|
begin
|
|
@@ -540,16 +570,30 @@ module Clacky
|
|
|
540
570
|
args[:todos_storage] = @todos
|
|
541
571
|
end
|
|
542
572
|
|
|
573
|
+
# Show progress for potentially slow tools (no prefix newline)
|
|
574
|
+
if potentially_slow_tool?(call[:name], args)
|
|
575
|
+
progress_message = build_tool_progress_message(call[:name], args)
|
|
576
|
+
@ui&.show_progress(progress_message, prefix_newline: false)
|
|
577
|
+
end
|
|
578
|
+
|
|
543
579
|
result = tool.execute(**args)
|
|
544
580
|
|
|
581
|
+
# Clear progress if shown
|
|
582
|
+
@ui&.clear_progress if potentially_slow_tool?(call[:name], args)
|
|
583
|
+
|
|
545
584
|
# Hook: after_tool_use
|
|
546
585
|
@hooks.trigger(:after_tool_use, call, result)
|
|
547
586
|
|
|
548
|
-
|
|
587
|
+
# Update todos display after todo_manager execution
|
|
588
|
+
if call[:name] == "todo_manager"
|
|
589
|
+
@ui&.update_todos(@todos.dup)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
@ui&.show_tool_result(tool.format_result(result))
|
|
549
593
|
results << build_success_result(call, result)
|
|
550
594
|
rescue StandardError => e
|
|
551
595
|
@hooks.trigger(:on_tool_error, call, e)
|
|
552
|
-
|
|
596
|
+
@ui&.show_tool_error(e)
|
|
553
597
|
results << build_error_result(call, e.message)
|
|
554
598
|
end
|
|
555
599
|
end
|
|
@@ -594,27 +638,75 @@ module Clacky
|
|
|
594
638
|
end
|
|
595
639
|
end
|
|
596
640
|
|
|
641
|
+
# Interrupt the agent's current run
|
|
642
|
+
# Called when user presses Ctrl+C during agent execution
|
|
643
|
+
def interrupt!
|
|
644
|
+
@interrupted = true
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Check if agent is currently running
|
|
648
|
+
def running?
|
|
649
|
+
@start_time != nil && !should_stop?
|
|
650
|
+
end
|
|
651
|
+
|
|
597
652
|
def should_stop?
|
|
598
|
-
if @
|
|
599
|
-
|
|
653
|
+
if @interrupted
|
|
654
|
+
@interrupted = false # Reset for next run
|
|
600
655
|
return true
|
|
601
656
|
end
|
|
602
657
|
|
|
603
|
-
if @total_cost >= @config.max_cost_usd
|
|
604
|
-
puts "\n⚠️ Reached maximum cost ($#{@config.max_cost_usd})" if @config.verbose
|
|
605
|
-
return true
|
|
606
|
-
end
|
|
607
658
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
659
|
+
false
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Check if a tool is potentially slow and should show progress
|
|
663
|
+
private def potentially_slow_tool?(tool_name, args)
|
|
664
|
+
case tool_name.to_s.downcase
|
|
665
|
+
when 'shell', 'safe_shell'
|
|
666
|
+
# Check if the command is a slow command
|
|
667
|
+
command = args[:command] || args['command']
|
|
668
|
+
return false unless command
|
|
669
|
+
|
|
670
|
+
# List of slow command patterns
|
|
671
|
+
slow_patterns = [
|
|
672
|
+
/bundle\s+(install|exec\s+rspec|exec\s+rake)/,
|
|
673
|
+
/npm\s+(install|run\s+test|run\s+build)/,
|
|
674
|
+
/yarn\s+(install|test|build)/,
|
|
675
|
+
/pnpm\s+install/,
|
|
676
|
+
/cargo\s+(build|test)/,
|
|
677
|
+
/go\s+(build|test)/,
|
|
678
|
+
/make\s+(test|build)/,
|
|
679
|
+
/pytest/,
|
|
680
|
+
/jest/
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
slow_patterns.any? { |pattern| command.match?(pattern) }
|
|
684
|
+
when 'web_fetch', 'web_search'
|
|
685
|
+
true # Network operations can be slow
|
|
686
|
+
else
|
|
687
|
+
false # Most file operations are fast
|
|
612
688
|
end
|
|
689
|
+
end
|
|
613
690
|
|
|
614
|
-
|
|
691
|
+
# Build progress message for tool execution
|
|
692
|
+
private def build_tool_progress_message(tool_name, args)
|
|
693
|
+
case tool_name.to_s.downcase
|
|
694
|
+
when 'shell', 'safe_shell'
|
|
695
|
+
command = args[:command] || args['command']
|
|
696
|
+
# Extract the main command for display
|
|
697
|
+
cmd_parts = command.to_s.split
|
|
698
|
+
main_cmd = cmd_parts.first(2).join(' ')
|
|
699
|
+
"Running #{main_cmd}"
|
|
700
|
+
when 'web_fetch'
|
|
701
|
+
"Fetching web page"
|
|
702
|
+
when 'web_search'
|
|
703
|
+
"Searching web"
|
|
704
|
+
else
|
|
705
|
+
"Executing #{tool_name}"
|
|
706
|
+
end
|
|
615
707
|
end
|
|
616
708
|
|
|
617
|
-
def track_cost(usage)
|
|
709
|
+
def track_cost(usage, raw_api_usage: nil)
|
|
618
710
|
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
619
711
|
iteration_cost = nil
|
|
620
712
|
if usage[:api_cost]
|
|
@@ -622,7 +714,7 @@ module Clacky
|
|
|
622
714
|
@cost_source = :api
|
|
623
715
|
@task_cost_source = :api
|
|
624
716
|
iteration_cost = usage[:api_cost]
|
|
625
|
-
|
|
717
|
+
@ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
|
|
626
718
|
else
|
|
627
719
|
# Priority 2: Calculate from tokens using ModelPricing
|
|
628
720
|
result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
|
|
@@ -637,15 +729,15 @@ module Clacky
|
|
|
637
729
|
|
|
638
730
|
if @config.verbose
|
|
639
731
|
source_label = pricing_source == :price ? "model pricing" : "default pricing"
|
|
640
|
-
|
|
641
|
-
|
|
732
|
+
@ui&.log("Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}", level: :debug)
|
|
733
|
+
@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
734
|
end
|
|
643
735
|
end
|
|
644
736
|
|
|
645
737
|
# Display token usage statistics for this iteration
|
|
646
738
|
display_iteration_tokens(usage, iteration_cost)
|
|
647
739
|
|
|
648
|
-
# Track cache usage statistics
|
|
740
|
+
# Track cache usage statistics (global)
|
|
649
741
|
@cache_stats[:total_requests] += 1
|
|
650
742
|
|
|
651
743
|
if usage[:cache_creation_input_tokens]
|
|
@@ -656,6 +748,27 @@ module Clacky
|
|
|
656
748
|
@cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
657
749
|
@cache_stats[:cache_hit_requests] += 1
|
|
658
750
|
end
|
|
751
|
+
|
|
752
|
+
# Store raw API usage samples (keep last 3 for debugging)
|
|
753
|
+
if raw_api_usage
|
|
754
|
+
@cache_stats[:raw_api_usage_samples] ||= []
|
|
755
|
+
@cache_stats[:raw_api_usage_samples] << raw_api_usage
|
|
756
|
+
@cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Track cache usage for current task
|
|
760
|
+
if @task_cache_stats
|
|
761
|
+
@task_cache_stats[:total_requests] += 1
|
|
762
|
+
|
|
763
|
+
if usage[:cache_creation_input_tokens]
|
|
764
|
+
@task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
if usage[:cache_read_input_tokens]
|
|
768
|
+
@task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
769
|
+
@task_cache_stats[:cache_hit_requests] += 1
|
|
770
|
+
end
|
|
771
|
+
end
|
|
659
772
|
end
|
|
660
773
|
|
|
661
774
|
# Display token usage for current iteration
|
|
@@ -669,53 +782,20 @@ module Clacky
|
|
|
669
782
|
# Calculate token delta from previous iteration
|
|
670
783
|
delta_tokens = total_tokens - @previous_total_tokens
|
|
671
784
|
@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
|
-
|
|
698
|
-
# Input tokens (with cache breakdown if available)
|
|
699
|
-
if cache_write > 0 || cache_read > 0
|
|
700
|
-
input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
|
|
701
|
-
token_info << "Input: #{input_detail}"
|
|
702
|
-
else
|
|
703
|
-
token_info << "Input: #{prompt_tokens}"
|
|
704
|
-
end
|
|
705
|
-
|
|
706
|
-
# Output tokens
|
|
707
|
-
token_info << "Output: #{completion_tokens}"
|
|
708
|
-
|
|
709
|
-
# Total
|
|
710
|
-
token_info << "Total: #{total_tokens}"
|
|
711
785
|
|
|
712
|
-
#
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
786
|
+
# Prepare data for UI to format and display
|
|
787
|
+
token_data = {
|
|
788
|
+
delta_tokens: delta_tokens,
|
|
789
|
+
prompt_tokens: prompt_tokens,
|
|
790
|
+
completion_tokens: completion_tokens,
|
|
791
|
+
total_tokens: total_tokens,
|
|
792
|
+
cache_write: cache_write,
|
|
793
|
+
cache_read: cache_read,
|
|
794
|
+
cost: cost
|
|
795
|
+
}
|
|
716
796
|
|
|
717
|
-
#
|
|
718
|
-
|
|
797
|
+
# Let UI handle formatting and display
|
|
798
|
+
@ui&.show_token_usage(token_data)
|
|
719
799
|
end
|
|
720
800
|
|
|
721
801
|
def compress_messages_if_needed
|
|
@@ -729,57 +809,30 @@ module Clacky
|
|
|
729
809
|
original_size = @messages.size
|
|
730
810
|
target_size = @config.keep_recent_messages + 2
|
|
731
811
|
|
|
732
|
-
|
|
733
|
-
progress = ProgressIndicator.new(
|
|
734
|
-
verbose: @config.verbose,
|
|
735
|
-
message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
|
|
736
|
-
)
|
|
737
|
-
progress.start
|
|
812
|
+
@ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
|
|
738
813
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
814
|
+
# Find the system message (should be first)
|
|
815
|
+
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
742
816
|
|
|
743
|
-
|
|
744
|
-
|
|
817
|
+
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
818
|
+
recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
|
|
745
819
|
|
|
746
|
-
|
|
747
|
-
|
|
820
|
+
# Get messages to compress (everything except system and recent)
|
|
821
|
+
messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
|
|
748
822
|
|
|
749
|
-
|
|
750
|
-
progress.finish
|
|
751
|
-
return
|
|
752
|
-
end
|
|
823
|
+
return if messages_to_compress.empty?
|
|
753
824
|
|
|
754
|
-
|
|
755
|
-
|
|
825
|
+
# Create summary of compressed messages
|
|
826
|
+
summary = summarize_messages(messages_to_compress)
|
|
756
827
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
828
|
+
# Rebuild messages array: [system, summary, recent_messages]
|
|
829
|
+
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
760
830
|
|
|
761
|
-
|
|
762
|
-
if @config.enable_prompt_caching && rebuilt_messages.first&.dig(:role) == "system"
|
|
763
|
-
rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
|
|
764
|
-
end
|
|
831
|
+
@messages = rebuilt_messages
|
|
765
832
|
|
|
766
|
-
|
|
833
|
+
final_size = @messages.size
|
|
767
834
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
# Finish progress and show completion message
|
|
771
|
-
progress.finish
|
|
772
|
-
puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
|
|
773
|
-
|
|
774
|
-
# Show detailed summary in verbose mode
|
|
775
|
-
if @config.verbose
|
|
776
|
-
puts "\n[COMPRESSION SUMMARY]"
|
|
777
|
-
puts summary[:content]
|
|
778
|
-
puts ""
|
|
779
|
-
end
|
|
780
|
-
ensure
|
|
781
|
-
progress.finish
|
|
782
|
-
end
|
|
835
|
+
@ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
|
|
783
836
|
end
|
|
784
837
|
|
|
785
838
|
def get_recent_messages_with_tool_pairs(messages, count)
|
|
@@ -908,77 +961,51 @@ module Clacky
|
|
|
908
961
|
}
|
|
909
962
|
end
|
|
910
963
|
|
|
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
|
-
|
|
964
|
+
def confirm_tool_use?(call)
|
|
925
965
|
# Show preview first and check for errors
|
|
926
966
|
preview_error = show_tool_preview(call)
|
|
927
967
|
|
|
928
|
-
# If preview detected an error
|
|
929
|
-
# auto-deny and provide detailed feedback
|
|
968
|
+
# If preview detected an error, auto-deny and provide feedback
|
|
930
969
|
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
|
-
|
|
970
|
+
@ui&.show_warning("Tool call auto-denied due to preview error")
|
|
971
|
+
feedback = build_preview_error_feedback(call[:name], preview_error)
|
|
943
972
|
return { approved: false, feedback: feedback }
|
|
944
973
|
end
|
|
945
974
|
|
|
946
|
-
#
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
975
|
+
# Request confirmation via UI
|
|
976
|
+
if @ui
|
|
977
|
+
prompt_text = format_tool_prompt(call)
|
|
978
|
+
result = @ui.request_confirmation(prompt_text, default: true)
|
|
979
|
+
|
|
980
|
+
case result
|
|
981
|
+
when true
|
|
982
|
+
{ approved: true, feedback: nil }
|
|
983
|
+
when false, nil
|
|
984
|
+
# User denied - add visual marker based on tool type
|
|
985
|
+
tool_name_capitalized = call[:name].capitalize
|
|
986
|
+
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
987
|
+
{ approved: false, feedback: nil }
|
|
988
|
+
else
|
|
989
|
+
# String feedback - also add visual marker
|
|
990
|
+
tool_name_capitalized = call[:name].capitalize
|
|
991
|
+
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
992
|
+
{ approved: false, feedback: result.to_s }
|
|
956
993
|
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 }
|
|
994
|
+
else
|
|
995
|
+
# Fallback: auto-approve if no UI
|
|
996
|
+
{ approved: true, feedback: nil }
|
|
973
997
|
end
|
|
998
|
+
end
|
|
974
999
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1000
|
+
private def build_preview_error_feedback(tool_name, error_info)
|
|
1001
|
+
case tool_name
|
|
1002
|
+
when "edit"
|
|
1003
|
+
"The edit operation will fail because the old_string was not found in the file. " \
|
|
1004
|
+
"Please use file_reader to read '#{error_info[:path]}' first, " \
|
|
1005
|
+
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
1006
|
+
else
|
|
1007
|
+
"Tool preview error: #{error_info[:error]}"
|
|
978
1008
|
end
|
|
979
|
-
|
|
980
|
-
# Any other input = denied with feedback
|
|
981
|
-
{ approved: false, feedback: response }
|
|
982
1009
|
end
|
|
983
1010
|
|
|
984
1011
|
def format_tool_prompt(call)
|
|
@@ -1018,6 +1045,8 @@ module Clacky
|
|
|
1018
1045
|
end
|
|
1019
1046
|
|
|
1020
1047
|
def show_tool_preview(call)
|
|
1048
|
+
return nil unless @ui
|
|
1049
|
+
|
|
1021
1050
|
begin
|
|
1022
1051
|
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
1023
1052
|
|
|
@@ -1028,22 +1057,22 @@ module Clacky
|
|
|
1028
1057
|
when "edit"
|
|
1029
1058
|
preview_error = show_edit_preview(args)
|
|
1030
1059
|
when "shell", "safe_shell"
|
|
1031
|
-
|
|
1060
|
+
show_shell_preview(args)
|
|
1032
1061
|
else
|
|
1033
1062
|
# For other tools, show formatted arguments
|
|
1034
1063
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1035
1064
|
if tool
|
|
1036
1065
|
formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
|
|
1037
|
-
|
|
1066
|
+
@ui&.show_tool_args(formatted)
|
|
1038
1067
|
else
|
|
1039
|
-
|
|
1068
|
+
@ui&.show_tool_args(call[:arguments])
|
|
1040
1069
|
end
|
|
1041
1070
|
end
|
|
1042
1071
|
|
|
1043
|
-
|
|
1072
|
+
preview_error
|
|
1044
1073
|
rescue JSON::ParserError
|
|
1045
|
-
|
|
1046
|
-
|
|
1074
|
+
@ui&.show_tool_args(call[:arguments])
|
|
1075
|
+
nil
|
|
1047
1076
|
end
|
|
1048
1077
|
end
|
|
1049
1078
|
|
|
@@ -1051,17 +1080,16 @@ module Clacky
|
|
|
1051
1080
|
path = args[:path] || args['path']
|
|
1052
1081
|
new_content = args[:content] || args['content'] || ""
|
|
1053
1082
|
|
|
1054
|
-
|
|
1083
|
+
is_new_file = !(path && File.exist?(path))
|
|
1084
|
+
@ui&.show_file_write_preview(path, is_new_file: is_new_file)
|
|
1055
1085
|
|
|
1056
|
-
if
|
|
1057
|
-
|
|
1058
|
-
puts "Modifying existing file\n"
|
|
1059
|
-
show_diff(old_content, new_content, max_lines: 50)
|
|
1086
|
+
if is_new_file
|
|
1087
|
+
@ui&.show_diff("", new_content, max_lines: 50)
|
|
1060
1088
|
else
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
show_diff("", new_content, max_lines: 50)
|
|
1089
|
+
old_content = File.read(path)
|
|
1090
|
+
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
1064
1091
|
end
|
|
1092
|
+
nil
|
|
1065
1093
|
end
|
|
1066
1094
|
|
|
1067
1095
|
def show_edit_preview(args)
|
|
@@ -1069,20 +1097,20 @@ module Clacky
|
|
|
1069
1097
|
old_string = args[:old_string] || args['old_string'] || ""
|
|
1070
1098
|
new_string = args[:new_string] || args['new_string'] || ""
|
|
1071
1099
|
|
|
1072
|
-
|
|
1100
|
+
@ui&.show_file_edit_preview(path)
|
|
1073
1101
|
|
|
1074
1102
|
if !path || path.empty?
|
|
1075
|
-
|
|
1103
|
+
@ui&.show_file_error("No file path provided")
|
|
1076
1104
|
return { error: "No file path provided for edit operation" }
|
|
1077
1105
|
end
|
|
1078
1106
|
|
|
1079
1107
|
unless File.exist?(path)
|
|
1080
|
-
|
|
1081
|
-
return { error: "File not found: #{path}" }
|
|
1108
|
+
@ui&.show_file_error("File not found: #{path}")
|
|
1109
|
+
return { error: "File not found: #{path}", path: path }
|
|
1082
1110
|
end
|
|
1083
1111
|
|
|
1084
1112
|
if old_string.empty?
|
|
1085
|
-
|
|
1113
|
+
@ui&.show_file_error("No old_string provided (nothing to replace)")
|
|
1086
1114
|
return { error: "No old_string provided (nothing to replace)" }
|
|
1087
1115
|
end
|
|
1088
1116
|
|
|
@@ -1090,9 +1118,19 @@ module Clacky
|
|
|
1090
1118
|
|
|
1091
1119
|
# Check if old_string exists in file
|
|
1092
1120
|
unless file_content.include?(old_string)
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1121
|
+
# Log debug info for troubleshooting
|
|
1122
|
+
@debug_logs << {
|
|
1123
|
+
timestamp: Time.now.iso8601,
|
|
1124
|
+
event: "edit_preview_failed",
|
|
1125
|
+
path: path,
|
|
1126
|
+
looking_for: old_string[0..500],
|
|
1127
|
+
file_content_preview: file_content[0..1000],
|
|
1128
|
+
file_size: file_content.length
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
@ui&.show_file_error("String to replace not found in file")
|
|
1132
|
+
@ui&.show_file_error("Looking for (first 100 chars):")
|
|
1133
|
+
@ui&.show_file_error(old_string[0..100].inspect)
|
|
1096
1134
|
return {
|
|
1097
1135
|
error: "String to replace not found in file",
|
|
1098
1136
|
path: path,
|
|
@@ -1101,34 +1139,20 @@ module Clacky
|
|
|
1101
1139
|
end
|
|
1102
1140
|
|
|
1103
1141
|
new_content = file_content.sub(old_string, new_string)
|
|
1104
|
-
show_diff(file_content, new_content, max_lines: 50)
|
|
1142
|
+
@ui&.show_diff(file_content, new_content, max_lines: 50)
|
|
1105
1143
|
nil # No error
|
|
1106
1144
|
end
|
|
1107
1145
|
|
|
1108
1146
|
def show_shell_preview(args)
|
|
1109
1147
|
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"
|
|
1148
|
+
@ui&.show_shell_preview(command)
|
|
1149
|
+
nil
|
|
1126
1150
|
end
|
|
1127
1151
|
|
|
1128
1152
|
def build_success_result(call, result)
|
|
1129
1153
|
# Try to get tool instance to use its format_result_for_llm method
|
|
1130
1154
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1131
|
-
|
|
1155
|
+
|
|
1132
1156
|
formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
|
|
1133
1157
|
# Tool provides a custom LLM-friendly format
|
|
1134
1158
|
tool.format_result_for_llm(result)
|
|
@@ -1136,7 +1160,7 @@ module Clacky
|
|
|
1136
1160
|
# Fallback: use the original result
|
|
1137
1161
|
result
|
|
1138
1162
|
end
|
|
1139
|
-
|
|
1163
|
+
|
|
1140
1164
|
{
|
|
1141
1165
|
id: call[:id],
|
|
1142
1166
|
content: JSON.generate(formatted_result)
|
|
@@ -1174,14 +1198,20 @@ module Clacky
|
|
|
1174
1198
|
end
|
|
1175
1199
|
|
|
1176
1200
|
def build_result(status, error: nil)
|
|
1201
|
+
# Calculate iterations for current task only
|
|
1202
|
+
task_iterations = @iterations - (@task_start_iterations || 0)
|
|
1203
|
+
|
|
1204
|
+
# Calculate cost for current task only
|
|
1205
|
+
task_cost = @total_cost - (@task_start_cost || 0)
|
|
1206
|
+
|
|
1177
1207
|
{
|
|
1178
1208
|
status: status,
|
|
1179
1209
|
session_id: @session_id,
|
|
1180
|
-
iterations:
|
|
1210
|
+
iterations: task_iterations, # Show only current task iterations
|
|
1181
1211
|
duration_seconds: Time.now - @start_time,
|
|
1182
|
-
total_cost_usd:
|
|
1212
|
+
total_cost_usd: task_cost.round(4), # Show only current task cost
|
|
1183
1213
|
cost_source: @task_cost_source, # Add cost source for this task
|
|
1184
|
-
cache_stats: @cache_stats,
|
|
1214
|
+
cache_stats: @task_cache_stats || @cache_stats, # Use task cache stats if available
|
|
1185
1215
|
messages: @messages,
|
|
1186
1216
|
error: error
|
|
1187
1217
|
}
|