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