clacky 0.5.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.
@@ -0,0 +1,964 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+ require "readline"
6
+ require_relative "utils/arguments_parser"
7
+
8
+ module Clacky
9
+ class Agent
10
+ attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos
11
+
12
+ # Pricing per 1M tokens (approximate - adjust based on actual model)
13
+ PRICING = {
14
+ input: 0.50, # $0.50 per 1M input tokens
15
+ output: 1.50 # $1.50 per 1M output tokens
16
+ }.freeze
17
+
18
+ # System prompt for the coding agent
19
+ SYSTEM_PROMPT = <<~PROMPT.freeze
20
+ You are OpenClacky, an AI coding assistant and technical co-founder, designed to help non-technical
21
+ users complete software development projects. You are responsible for development in the current project.
22
+
23
+ Your role is to:
24
+ - Understand project requirements and translate them into technical solutions
25
+ - Write clean, maintainable, and well-documented code
26
+ - Follow best practices and industry standards
27
+ - Explain technical concepts in simple terms when needed
28
+ - Proactively identify potential issues and suggest improvements
29
+ - Help with debugging, testing, and deployment
30
+
31
+ Working process:
32
+ 1. **For complex tasks with multiple steps**:
33
+ - Use todo_manager to create a complete TODO list FIRST
34
+ - After creating the TODO list, START EXECUTING each task immediately
35
+ - Don't stop after planning - continue to work on the tasks!
36
+ 2. Always read existing code before making changes (use file_reader/glob/grep)
37
+ 3. Ask clarifying questions if requirements are unclear
38
+ 4. Break down complex tasks into manageable steps
39
+ 5. **USE TOOLS to create/modify files** - don't just return code
40
+ 6. Write code that is secure, efficient, and easy to understand
41
+ 7. Test your changes using the shell tool when appropriate
42
+ 8. **IMPORTANT**: After completing each step, mark the TODO as completed and continue to the next one
43
+ 9. Keep working until ALL TODOs are completed or you need user input
44
+ 10. Provide brief explanations after completing actions
45
+
46
+ IMPORTANT: You should frequently refer to the existing codebase. For unclear instructions,
47
+ prioritize understanding the codebase first before answering or taking action.
48
+ Always read relevant code files to understand the project structure, patterns, and conventions.
49
+
50
+ CRITICAL RULE FOR TODO MANAGER:
51
+ When using todo_manager to add tasks, you MUST continue working immediately after adding ALL todos.
52
+ Adding todos is NOT completion - it's just the planning phase!
53
+ Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
54
+ NEVER stop after just adding todos without executing them!
55
+ PROMPT
56
+
57
+ def initialize(client, config = {}, working_dir: nil)
58
+ @client = client
59
+ @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
60
+ @tool_registry = ToolRegistry.new
61
+ @hooks = HookManager.new
62
+ @session_id = SecureRandom.uuid
63
+ @messages = []
64
+ @todos = [] # Store todos in memory
65
+ @iterations = 0
66
+ @total_cost = 0.0
67
+ @start_time = nil
68
+ @working_dir = working_dir || Dir.pwd
69
+ @created_at = Time.now.iso8601
70
+ @total_tasks = 0
71
+
72
+ # Register built-in tools
73
+ register_builtin_tools
74
+ end
75
+
76
+ # Restore from a saved session
77
+ def self.from_session(client, config, session_data)
78
+ agent = new(client, config)
79
+ agent.restore_session(session_data)
80
+ agent
81
+ end
82
+
83
+ def restore_session(session_data)
84
+ @session_id = session_data[:session_id]
85
+ @messages = session_data[:messages]
86
+ @todos = session_data[:todos] || [] # Restore todos from session
87
+ @iterations = session_data.dig(:stats, :total_iterations) || 0
88
+ @total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
89
+ @working_dir = session_data[:working_dir]
90
+ @created_at = session_data[:created_at]
91
+ @total_tasks = session_data.dig(:stats, :total_tasks) || 0
92
+
93
+ # Check if the session ended with an error
94
+ last_status = session_data.dig(:stats, :last_status)
95
+ last_error = session_data.dig(:stats, :last_error)
96
+
97
+ if last_status == "error" && last_error
98
+ # Find and remove the last user message that caused the error
99
+ # This allows the user to retry with a different prompt
100
+ last_user_index = @messages.rindex { |m| m[:role] == "user" }
101
+ if last_user_index
102
+ @messages = @messages[0...last_user_index]
103
+
104
+ # Trigger a hook to notify about the rollback
105
+ trigger_hook(:session_rollback, {
106
+ reason: "Previous session ended with error",
107
+ error_message: last_error,
108
+ rolled_back_message_index: last_user_index
109
+ })
110
+ end
111
+ end
112
+ end
113
+
114
+ def add_hook(event, &block)
115
+ @hooks.add(event, &block)
116
+ end
117
+
118
+ def run(user_input, &block)
119
+ @start_time = Time.now
120
+
121
+ # Add system prompt as the first message if this is the first run
122
+ if @messages.empty?
123
+ system_prompt = build_system_prompt
124
+ @messages << { role: "system", content: system_prompt }
125
+ end
126
+
127
+ @messages << { role: "user", content: user_input }
128
+ @total_tasks += 1
129
+
130
+ emit_event(:on_start, { input: user_input }, &block)
131
+ @hooks.trigger(:on_start, user_input)
132
+
133
+ begin
134
+ loop do
135
+ break if should_stop?
136
+
137
+ @iterations += 1
138
+ emit_event(:on_iteration, { iteration: @iterations }, &block)
139
+ @hooks.trigger(:on_iteration, @iterations)
140
+
141
+ # Think: LLM reasoning with tool support
142
+ response = think(&block)
143
+
144
+ # Debug: check for potential infinite loops
145
+ if @config.verbose
146
+ puts "[DEBUG] Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}"
147
+ end
148
+
149
+ # Check if done (no more tool calls needed)
150
+ if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
151
+ emit_event(:answer, { content: response[:content] }, &block)
152
+ break
153
+ end
154
+
155
+ # Emit assistant_message event if there's content before tool calls
156
+ if response[:content] && !response[:content].empty?
157
+ emit_event(:assistant_message, { content: response[:content] }, &block)
158
+ end
159
+
160
+ # Act: Execute tool calls
161
+ action_result = act(response[:tool_calls], &block)
162
+
163
+ # Observe: Add tool results to conversation context
164
+ observe(response, action_result[:tool_results])
165
+
166
+ # Check if user denied any tool
167
+ if action_result[:denied]
168
+ # If user provided feedback, treat it as a user question/instruction
169
+ if action_result[:feedback] && !action_result[:feedback].empty?
170
+ # Add user feedback as a new user message
171
+ # Use a clear format that signals this is important user input
172
+ @messages << {
173
+ role: "user",
174
+ 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."
175
+ }
176
+ # Continue loop to let agent respond to feedback
177
+ next
178
+ else
179
+ # User just said "no" without feedback - stop and wait
180
+ emit_event(:answer, { content: "Tool execution was denied. Please provide further instructions." }, &block)
181
+ break
182
+ end
183
+ end
184
+ end
185
+
186
+ result = build_result(:success)
187
+ emit_event(:on_complete, result, &block)
188
+ @hooks.trigger(:on_complete, result)
189
+ result
190
+ rescue StandardError => e
191
+ result = build_result(:error, error: e.message)
192
+ emit_event(:on_complete, result, &block)
193
+ raise
194
+ end
195
+ end
196
+
197
+ # Generate session data for saving
198
+ # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
199
+ # @param error_message [String] Error message if status is :error
200
+ def to_session_data(status: :success, error_message: nil)
201
+ # Get first real user message for preview (skip compressed system messages)
202
+ first_user_msg = @messages.find do |m|
203
+ m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
204
+ end
205
+
206
+ # Extract preview text from first user message
207
+ first_message_preview = if first_user_msg
208
+ content = first_user_msg[:content]
209
+ if content.is_a?(String)
210
+ # Truncate to 100 characters for preview
211
+ content.length > 100 ? "#{content[0..100]}..." : content
212
+ else
213
+ "User message (non-string content)"
214
+ end
215
+ else
216
+ "No messages"
217
+ end
218
+
219
+ stats_data = {
220
+ total_tasks: @total_tasks,
221
+ total_iterations: @iterations,
222
+ total_cost_usd: @total_cost.round(4),
223
+ duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
224
+ last_status: status.to_s
225
+ }
226
+
227
+ # Add error message if status is error
228
+ stats_data[:last_error] = error_message if status == :error && error_message
229
+
230
+ {
231
+ session_id: @session_id,
232
+ created_at: @created_at,
233
+ updated_at: Time.now.iso8601,
234
+ working_dir: @working_dir,
235
+ todos: @todos, # Include todos in session data
236
+ config: {
237
+ model: @config.model,
238
+ permission_mode: @config.permission_mode.to_s,
239
+ max_iterations: @config.max_iterations,
240
+ max_cost_usd: @config.max_cost_usd,
241
+ enable_compression: @config.enable_compression,
242
+ keep_recent_messages: @config.keep_recent_messages,
243
+ max_tokens: @config.max_tokens,
244
+ verbose: @config.verbose
245
+ },
246
+ stats: stats_data,
247
+ messages: @messages,
248
+ first_user_message: first_message_preview
249
+ }
250
+ end
251
+
252
+ private
253
+
254
+ def should_auto_execute?(tool_name, tool_params = {})
255
+ # Check if tool is disallowed
256
+ return false if @config.disallowed_tools.include?(tool_name)
257
+
258
+ case @config.permission_mode
259
+ when :auto_approve
260
+ true
261
+ when :confirm_safes
262
+ # Use SafeShell integration for safety check
263
+ is_safe_operation?(tool_name, tool_params)
264
+ when :confirm_edits
265
+ !editing_tool?(tool_name)
266
+ when :plan_only
267
+ false
268
+ else
269
+ false
270
+ end
271
+ end
272
+
273
+ def editing_tool?(tool_name)
274
+ AgentConfig::EDITING_TOOLS.include?(tool_name.to_s.downcase)
275
+ end
276
+
277
+ def is_safe_operation?(tool_name, tool_params = {})
278
+ # For shell commands, use SafeShell to check safety
279
+ if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
280
+ begin
281
+ require_relative 'tools/safe_shell'
282
+
283
+ # Parse tool_params if it's a JSON string
284
+ params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
285
+ command = params[:command] || params['command']
286
+ return false unless command
287
+
288
+ # Use SafeShell to analyze the command
289
+ return Tools::SafeShell.command_safe_for_auto_execution?(command)
290
+ rescue LoadError
291
+ # If SafeShell not available, be conservative
292
+ return false
293
+ rescue => e
294
+ # In case of any error, be conservative
295
+ return false
296
+ end
297
+ end
298
+
299
+ # For non-shell tools, consider them safe for now
300
+ # You can extend this logic for other tools
301
+ !editing_tool?(tool_name)
302
+ end
303
+
304
+ def build_system_prompt
305
+ prompt = SYSTEM_PROMPT.dup
306
+
307
+ # Try to load project rules from multiple sources (in order of priority)
308
+ rules_files = [
309
+ { path: ".clackyrules", name: ".clackyrules" },
310
+ { path: ".cursorrules", name: ".cursorrules" },
311
+ { path: "CLAUDE.md", name: "CLAUDE.md" }
312
+ ]
313
+
314
+ rules_content = nil
315
+ rules_source = nil
316
+
317
+ rules_files.each do |file_info|
318
+ full_path = File.join(@working_dir, file_info[:path])
319
+ if File.exist?(full_path)
320
+ content = File.read(full_path).strip
321
+ unless content.empty?
322
+ rules_content = content
323
+ rules_source = file_info[:name]
324
+ break
325
+ end
326
+ end
327
+ end
328
+
329
+ # Add rules to prompt if found
330
+ if rules_content && rules_source
331
+ prompt += "\n\n" + "=" * 80 + "\n"
332
+ prompt += "PROJECT-SPECIFIC RULES (from #{rules_source}):\n"
333
+ prompt += "=" * 80 + "\n"
334
+ prompt += rules_content
335
+ prompt += "\n" + "=" * 80 + "\n"
336
+ prompt += "⚠️ IMPORTANT: Follow these project-specific rules at all times!\n"
337
+ prompt += "=" * 80
338
+ end
339
+
340
+ prompt
341
+ end
342
+
343
+ def think(&block)
344
+ emit_event(:thinking, { iteration: @iterations }, &block)
345
+
346
+ # Compress messages if needed to reduce cost
347
+ compress_messages_if_needed if @config.enable_compression
348
+
349
+ # Always send tools definitions to allow multi-step tool calling
350
+ tools_to_send = @tool_registry.allowed_definitions(@config.allowed_tools)
351
+
352
+ # Show progress indicator while waiting for LLM response
353
+ progress = ProgressIndicator.new(verbose: @config.verbose)
354
+ progress.start
355
+
356
+ begin
357
+ # Retry logic for network failures
358
+ max_retries = 10
359
+ retry_delay = 5
360
+ retries = 0
361
+
362
+ begin
363
+ response = @client.send_messages_with_tools(
364
+ @messages,
365
+ model: @config.model,
366
+ tools: tools_to_send,
367
+ max_tokens: @config.max_tokens,
368
+ verbose: @config.verbose
369
+ )
370
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
371
+ retries += 1
372
+ if retries <= max_retries
373
+ progress.finish
374
+ puts "\n⚠️ Network request failed: #{e.class.name} - #{e.message}"
375
+ puts "🔄 Retry #{retries}/#{max_retries}, waiting #{retry_delay} seconds..."
376
+ sleep retry_delay
377
+ progress.start
378
+ retry
379
+ else
380
+ progress.finish
381
+ puts "\n❌ Network request failed after #{max_retries} retries, giving up"
382
+ raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
383
+ end
384
+ end
385
+
386
+ track_cost(response[:usage])
387
+
388
+ # Add assistant response to messages
389
+ msg = { role: "assistant" }
390
+ # Always include content field (some APIs require it even with tool_calls)
391
+ # Use empty string instead of null for better compatibility
392
+ msg[:content] = response[:content] || ""
393
+ msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
394
+ @messages << msg
395
+
396
+ if @config.verbose
397
+ puts "\n[DEBUG] Assistant response added to messages:"
398
+ puts JSON.pretty_generate(msg)
399
+ end
400
+
401
+ response
402
+ ensure
403
+ progress.finish
404
+ end
405
+ end
406
+
407
+ def act(tool_calls, &block)
408
+ return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
409
+
410
+ denied = false
411
+ feedback = nil
412
+ results = []
413
+
414
+ tool_calls.each_with_index do |call, index|
415
+ # Hook: before_tool_use
416
+ hook_result = @hooks.trigger(:before_tool_use, call)
417
+ if hook_result[:action] == :deny
418
+ emit_event(:tool_denied, call, &block)
419
+ results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
420
+ next
421
+ end
422
+
423
+ # Permission check (if not in auto-approve mode)
424
+ unless should_auto_execute?(call[:name], call[:arguments])
425
+ if @config.is_plan_only?
426
+ emit_event(:tool_planned, call, &block)
427
+ results << build_planned_result(call)
428
+ next
429
+ end
430
+
431
+ confirmation = confirm_tool_use?(call, &block)
432
+ unless confirmation[:approved]
433
+ emit_event(:tool_denied, call, &block)
434
+ denied = true
435
+ user_feedback = confirmation[:feedback]
436
+ feedback = user_feedback if user_feedback
437
+ results << build_denied_result(call, user_feedback)
438
+
439
+ # If user provided feedback, stop processing remaining tools immediately
440
+ # Let the agent respond to the feedback in the next iteration
441
+ if user_feedback && !user_feedback.empty?
442
+ # Fill in denied results for all remaining tool calls to avoid mismatch
443
+ remaining_calls = tool_calls[(index + 1)..-1] || []
444
+ remaining_calls.each do |remaining_call|
445
+ results << build_denied_result(remaining_call, "Auto-denied due to user feedback on previous tool")
446
+ end
447
+ break
448
+ end
449
+ next
450
+ end
451
+ end
452
+
453
+ emit_event(:tool_call, call, &block)
454
+
455
+ # Execute tool
456
+ begin
457
+ tool = @tool_registry.get(call[:name])
458
+
459
+ # Parse and validate arguments with JSON repair capability
460
+ args = Utils::ArgumentsParser.parse_and_validate(call, @tool_registry)
461
+
462
+ # Special handling for TodoManager: inject todos array
463
+ if call[:name] == "todo_manager"
464
+ args[:todos_storage] = @todos
465
+ end
466
+
467
+ result = tool.execute(**args)
468
+
469
+ # Hook: after_tool_use
470
+ @hooks.trigger(:after_tool_use, call, result)
471
+
472
+ emit_event(:observation, { tool: call[:name], result: result }, &block)
473
+ results << build_success_result(call, result)
474
+ rescue StandardError => e
475
+ @hooks.trigger(:on_tool_error, call, e)
476
+ emit_event(:tool_error, { call: call, error: e }, &block)
477
+ results << build_error_result(call, e.message)
478
+ end
479
+ end
480
+
481
+ {
482
+ denied: denied,
483
+ feedback: feedback,
484
+ tool_results: results
485
+ }
486
+ end
487
+
488
+ def observe(response, tool_results)
489
+ # Add tool results as messages
490
+ # Using OpenAI format which is compatible with most APIs through LiteLLM
491
+ tool_results.each do |result|
492
+ @messages << {
493
+ role: "tool",
494
+ tool_call_id: result[:id],
495
+ content: result[:content]
496
+ }
497
+ end
498
+ end
499
+
500
+ def should_stop?
501
+ if @iterations >= @config.max_iterations
502
+ puts "\n⚠️ Reached maximum iterations (#{@config.max_iterations})" if @config.verbose
503
+ return true
504
+ end
505
+
506
+ if @total_cost >= @config.max_cost_usd
507
+ puts "\n⚠️ Reached maximum cost ($#{@config.max_cost_usd})" if @config.verbose
508
+ return true
509
+ end
510
+
511
+ # Check timeout only if configured (nil means no timeout)
512
+ if @config.timeout_seconds && Time.now - @start_time > @config.timeout_seconds
513
+ puts "\n⚠️ Reached timeout (#{@config.timeout_seconds}s)" if @config.verbose
514
+ return true
515
+ end
516
+
517
+ false
518
+ end
519
+
520
+ def track_cost(usage)
521
+ input_cost = (usage[:prompt_tokens] / 1_000_000.0) * PRICING[:input]
522
+ output_cost = (usage[:completion_tokens] / 1_000_000.0) * PRICING[:output]
523
+ @total_cost += input_cost + output_cost
524
+ end
525
+
526
+ def compress_messages_if_needed
527
+ # Check if compression is enabled
528
+ return unless @config.enable_compression
529
+
530
+ # Only compress if we have more messages than threshold
531
+ threshold = @config.keep_recent_messages + 20 # +20 to avoid compressing too frequently
532
+ return if @messages.size <= threshold
533
+
534
+ original_size = @messages.size
535
+ target_size = @config.keep_recent_messages + 2
536
+
537
+ # Show compression progress using ProgressIndicator
538
+ progress = ProgressIndicator.new(
539
+ verbose: @config.verbose,
540
+ message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
541
+ )
542
+ progress.start
543
+
544
+ begin
545
+ # Find the system message (should be first)
546
+ system_msg = @messages.find { |m| m[:role] == "system" }
547
+
548
+ # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
549
+ recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
550
+
551
+ # Get messages to compress (everything except system and recent)
552
+ messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
553
+
554
+ if messages_to_compress.empty?
555
+ progress.finish
556
+ return
557
+ end
558
+
559
+ # Create summary of compressed messages
560
+ summary = summarize_messages(messages_to_compress)
561
+
562
+ # Rebuild messages array: [system, summary, recent_messages]
563
+ @messages = [system_msg, summary, *recent_messages].compact
564
+
565
+ final_size = @messages.size
566
+
567
+ # Finish progress and show completion message
568
+ progress.finish
569
+ puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
570
+
571
+ # Show detailed summary in verbose mode
572
+ if @config.verbose
573
+ puts "\n[COMPRESSION SUMMARY]"
574
+ puts summary[:content]
575
+ puts ""
576
+ end
577
+ ensure
578
+ progress.finish
579
+ end
580
+ end
581
+
582
+ def get_recent_messages_with_tool_pairs(messages, count)
583
+ # Start from the end and work backwards
584
+ recent = []
585
+ i = messages.size - 1
586
+
587
+ while i >= 0 && recent.size < count
588
+ msg = messages[i]
589
+
590
+ # Skip if already added
591
+ if recent.include?(msg)
592
+ i -= 1
593
+ next
594
+ end
595
+
596
+ recent.unshift(msg)
597
+
598
+ # If this is a tool result message, make sure we include the corresponding assistant message with tool_calls
599
+ if msg[:role] == "tool"
600
+ # Find the previous assistant message with tool_calls
601
+ j = i - 1
602
+ while j >= 0
603
+ prev_msg = messages[j]
604
+ if prev_msg[:role] == "assistant" && prev_msg[:tool_calls]
605
+ # Check if this assistant message has the tool_call that matches our tool result message
606
+ has_matching_call = prev_msg[:tool_calls].any? { |tc| tc[:id] == msg[:tool_call_id] }
607
+ if has_matching_call && !recent.include?(prev_msg)
608
+ # Insert at the beginning to maintain order
609
+ recent.unshift(prev_msg)
610
+
611
+ # CRITICAL: If this assistant message has multiple tool_calls,
612
+ # we MUST include ALL corresponding tool results.
613
+ # Otherwise Bedrock Claude will throw a validation error.
614
+ if prev_msg[:tool_calls].size > 1
615
+ tool_call_ids = prev_msg[:tool_calls].map { |tc| tc[:id] }
616
+
617
+ # Find all tool result messages that correspond to this assistant message's tool calls
618
+ k = j + 1
619
+ while k < messages.size
620
+ result_msg = messages[k]
621
+ if result_msg[:role] == "tool" &&
622
+ tool_call_ids.include?(result_msg[:tool_call_id]) &&
623
+ !recent.include?(result_msg)
624
+ # Add this tool result to maintain the complete tool_use/tool_result pairing
625
+ recent << result_msg
626
+ end
627
+ k += 1
628
+ end
629
+ end
630
+
631
+ break
632
+ end
633
+ end
634
+ j -= 1
635
+ end
636
+ end
637
+
638
+ i -= 1
639
+ end
640
+
641
+ recent
642
+ end
643
+
644
+ def summarize_messages(messages)
645
+ # Count different message types
646
+ user_msgs = messages.count { |m| m[:role] == "user" }
647
+ assistant_msgs = messages.count { |m| m[:role] == "assistant" }
648
+ tool_msgs = messages.count { |m| m[:role] == "tool" }
649
+
650
+ # Extract key information
651
+ tools_used = messages
652
+ .select { |m| m[:role] == "assistant" && m[:tool_calls] }
653
+ .flat_map { |m| m[:tool_calls].map { |tc| tc.dig(:function, :name) } }
654
+ .compact
655
+ .uniq
656
+
657
+ # Count completed tasks from tool results
658
+ completed_todos = messages
659
+ .select { |m| m[:role] == "tool" }
660
+ .map { |m| JSON.parse(m[:content]) rescue nil }
661
+ .compact
662
+ .select { |data| data.is_a?(Hash) && data["message"]&.include?("completed") }
663
+ .size
664
+
665
+ summary_text = "Previous conversation summary (#{messages.size} messages compressed):\n"
666
+ summary_text += "- User requests: #{user_msgs}\n"
667
+ summary_text += "- Assistant responses: #{assistant_msgs}\n"
668
+ summary_text += "- Tool executions: #{tool_msgs}\n"
669
+ summary_text += "- Tools used: #{tools_used.join(', ')}\n" if tools_used.any?
670
+ summary_text += "- Completed tasks: #{completed_todos}\n" if completed_todos > 0
671
+ summary_text += "\nContinuing with recent conversation context..."
672
+
673
+ {
674
+ role: "user",
675
+ content: "[SYSTEM] " + summary_text
676
+ }
677
+ end
678
+
679
+ def emit_event(type, data, &block)
680
+ return unless block
681
+
682
+ block.call({
683
+ type: type,
684
+ data: data,
685
+ iteration: @iterations,
686
+ cost: @total_cost
687
+ })
688
+ end
689
+
690
+ def confirm_tool_use?(call, &block)
691
+ emit_event(:tool_confirmation_required, call, &block)
692
+
693
+ # Show preview first and check for errors
694
+ preview_error = show_tool_preview(call)
695
+
696
+ # If preview detected an error (e.g., edit with non-existent string),
697
+ # auto-deny and provide detailed feedback
698
+ if preview_error && preview_error[:error]
699
+ puts "\n❌ Tool call auto-denied due to preview error"
700
+
701
+ # Build helpful feedback message
702
+ feedback = case call[:name]
703
+ when "edit"
704
+ "The edit operation will fail because the old_string was not found in the file. " \
705
+ "Please use file_reader to read '#{preview_error[:path]}' first, " \
706
+ "find the correct string to replace, and try again with the exact string (including whitespace)."
707
+ else
708
+ "Tool preview error: #{preview_error[:error]}"
709
+ end
710
+
711
+ return { approved: false, feedback: feedback }
712
+ end
713
+
714
+ # Then show the confirmation prompt with better formatting
715
+ prompt_text = format_tool_prompt(call)
716
+ puts "\n❓ #{prompt_text}"
717
+
718
+ # Use Readline for better input handling (backspace, arrow keys, etc.)
719
+ response = Readline.readline(" (Enter/y to approve, n to deny, or provide feedback): ", true)
720
+
721
+ if response.nil? # Handle EOF/pipe input
722
+ return { approved: false, feedback: nil }
723
+ end
724
+
725
+ response = response.chomp
726
+ response_lower = response.downcase
727
+
728
+ # Empty response (just Enter) or "y"/"yes" = approved
729
+ if response.empty? || response_lower == "y" || response_lower == "yes"
730
+ return { approved: true, feedback: nil }
731
+ end
732
+
733
+ # "n"/"no" = denied without feedback
734
+ if response_lower == "n" || response_lower == "no"
735
+ return { approved: false, feedback: nil }
736
+ end
737
+
738
+ # Any other input = denied with feedback
739
+ { approved: false, feedback: response }
740
+ end
741
+
742
+ def format_tool_prompt(call)
743
+ begin
744
+ args = JSON.parse(call[:arguments], symbolize_names: true)
745
+
746
+ # Try to use tool's format_call method for better formatting
747
+ tool = @tool_registry.get(call[:name]) rescue nil
748
+ if tool
749
+ formatted = tool.format_call(args) rescue nil
750
+ return formatted if formatted
751
+ end
752
+
753
+ # Fallback to manual formatting for common tools
754
+ case call[:name]
755
+ when "edit"
756
+ path = args[:path] || args[:file_path]
757
+ filename = Utils::PathHelper.safe_basename(path)
758
+ "Edit(#{filename})"
759
+ when "write"
760
+ filename = Utils::PathHelper.safe_basename(args[:path])
761
+ if args[:path] && File.exist?(args[:path])
762
+ "Write(#{filename}) - overwrite existing"
763
+ else
764
+ "Write(#{filename}) - create new"
765
+ end
766
+ when "shell", "safe_shell"
767
+ cmd = args[:command] || ''
768
+ display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
769
+ "#{call[:name]}(\"#{display_cmd}\")"
770
+ else
771
+ "Allow #{call[:name]}"
772
+ end
773
+ rescue JSON::ParserError
774
+ "Allow #{call[:name]}"
775
+ end
776
+ end
777
+
778
+ def show_tool_preview(call)
779
+ begin
780
+ args = JSON.parse(call[:arguments], symbolize_names: true)
781
+
782
+ preview_error = nil
783
+ case call[:name]
784
+ when "write"
785
+ preview_error = show_write_preview(args)
786
+ when "edit"
787
+ preview_error = show_edit_preview(args)
788
+ when "shell", "safe_shell"
789
+ preview_error = show_shell_preview(args)
790
+ else
791
+ # For other tools, show formatted arguments
792
+ tool = @tool_registry.get(call[:name]) rescue nil
793
+ if tool
794
+ formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
795
+ puts "\nArgs: #{formatted}"
796
+ else
797
+ puts "\nArgs: #{call[:arguments]}"
798
+ end
799
+ end
800
+
801
+ return preview_error
802
+ rescue JSON::ParserError
803
+ puts " Args: #{call[:arguments]}"
804
+ return nil
805
+ end
806
+ end
807
+
808
+ def show_write_preview(args)
809
+ path = args[:path] || args['path']
810
+ new_content = args[:content] || args['content'] || ""
811
+
812
+ puts "\n📝 File: #{path || '(unknown)'}"
813
+
814
+ if path && File.exist?(path)
815
+ old_content = File.read(path)
816
+ puts "Modifying existing file\n"
817
+ show_diff(old_content, new_content, max_lines: 50)
818
+ else
819
+ puts "Creating new file\n"
820
+ # Show diff from empty content to new content (all additions)
821
+ show_diff("", new_content, max_lines: 50)
822
+ end
823
+ end
824
+
825
+ def show_edit_preview(args)
826
+ path = args[:path] || args[:file_path] || args['path'] || args['file_path']
827
+ old_string = args[:old_string] || args['old_string'] || ""
828
+ new_string = args[:new_string] || args['new_string'] || ""
829
+
830
+ puts "\n📝 File: #{path || '(unknown)'}"
831
+
832
+ if !path || path.empty?
833
+ puts " ⚠️ No file path provided"
834
+ return { error: "No file path provided for edit operation" }
835
+ end
836
+
837
+ unless File.exist?(path)
838
+ puts " ⚠️ File not found: #{path}"
839
+ return { error: "File not found: #{path}" }
840
+ end
841
+
842
+ if old_string.empty?
843
+ puts " ⚠️ No old_string provided (nothing to replace)"
844
+ return { error: "No old_string provided (nothing to replace)" }
845
+ end
846
+
847
+ file_content = File.read(path)
848
+
849
+ # Check if old_string exists in file
850
+ unless file_content.include?(old_string)
851
+ puts " ⚠️ String to replace not found in file"
852
+ puts " Looking for (first 100 chars):"
853
+ puts " #{old_string[0..100].inspect}"
854
+ return {
855
+ error: "String to replace not found in file",
856
+ path: path,
857
+ looking_for: old_string[0..200]
858
+ }
859
+ end
860
+
861
+ new_content = file_content.sub(old_string, new_string)
862
+ show_diff(file_content, new_content, max_lines: 50)
863
+ nil # No error
864
+ end
865
+
866
+ def show_shell_preview(args)
867
+ command = args[:command] || ""
868
+ puts "\n💻 Command: #{command}"
869
+ end
870
+
871
+ def show_diff(old_content, new_content, max_lines: 50)
872
+ require 'diffy'
873
+
874
+ diff = Diffy::Diff.new(old_content, new_content, context: 3)
875
+ all_lines = diff.to_s(:color).lines
876
+ display_lines = all_lines.first(max_lines)
877
+
878
+ display_lines.each { |line| puts line.chomp }
879
+ puts "\n... (#{all_lines.size - max_lines} more lines, diff truncated)" if all_lines.size > max_lines
880
+ rescue LoadError
881
+ # Fallback if diffy is not available
882
+ puts " Old size: #{old_content.bytesize} bytes"
883
+ puts " New size: #{new_content.bytesize} bytes"
884
+ end
885
+
886
+ def build_success_result(call, result)
887
+ {
888
+ id: call[:id],
889
+ content: JSON.generate(result)
890
+ }
891
+ end
892
+
893
+ def build_error_result(call, error_message)
894
+ {
895
+ id: call[:id],
896
+ content: JSON.generate({ error: error_message })
897
+ }
898
+ end
899
+
900
+ def build_denied_result(call, user_feedback = nil)
901
+ message = if user_feedback && !user_feedback.empty?
902
+ "Tool use denied by user. User feedback: #{user_feedback}"
903
+ else
904
+ "Tool use denied by user"
905
+ end
906
+
907
+ {
908
+ id: call[:id],
909
+ content: JSON.generate({
910
+ error: message,
911
+ user_feedback: user_feedback
912
+ })
913
+ }
914
+ end
915
+
916
+ def build_planned_result(call)
917
+ {
918
+ id: call[:id],
919
+ content: JSON.generate({ planned: true, message: "Tool execution skipped (plan mode)" })
920
+ }
921
+ end
922
+
923
+ def build_result(status, error: nil)
924
+ {
925
+ status: status,
926
+ session_id: @session_id,
927
+ iterations: @iterations,
928
+ duration_seconds: Time.now - @start_time,
929
+ total_cost_usd: @total_cost.round(4),
930
+ messages: @messages,
931
+ error: error
932
+ }
933
+ end
934
+
935
+ def format_tool_calls_for_api(tool_calls)
936
+ return nil unless tool_calls
937
+
938
+ tool_calls.map do |call|
939
+ {
940
+ id: call[:id],
941
+ type: call[:type] || "function",
942
+ function: {
943
+ name: call[:name],
944
+ arguments: call[:arguments]
945
+ }
946
+ }
947
+ end
948
+ end
949
+
950
+ def register_builtin_tools
951
+
952
+ @tool_registry.register(Tools::SafeShell.new)
953
+ @tool_registry.register(Tools::FileReader.new)
954
+ @tool_registry.register(Tools::Write.new)
955
+ @tool_registry.register(Tools::Edit.new)
956
+ @tool_registry.register(Tools::Glob.new)
957
+ @tool_registry.register(Tools::Grep.new)
958
+ @tool_registry.register(Tools::WebSearch.new)
959
+ @tool_registry.register(Tools::WebFetch.new)
960
+ @tool_registry.register(Tools::TodoManager.new)
961
+ @tool_registry.register(Tools::RunProject.new)
962
+ end
963
+ end
964
+ end