openclacky 0.7.0 → 0.7.2

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
data/lib/clacky/agent.rb CHANGED
@@ -6,57 +6,35 @@ require "tty-prompt"
6
6
  require "set"
7
7
  require_relative "utils/arguments_parser"
8
8
  require_relative "utils/file_processor"
9
- require_relative "message_compressor"
9
+
10
+ # Load all agent modules
11
+ require_relative "agent/message_compressor"
12
+ require_relative "agent/message_compressor_helper"
13
+ require_relative "agent/tool_executor"
14
+ require_relative "agent/cost_tracker"
15
+ require_relative "agent/session_serializer"
16
+ require_relative "agent/skill_manager"
17
+ require_relative "agent/system_prompt_builder"
18
+ require_relative "agent/llm_caller"
19
+ require_relative "agent/time_machine"
10
20
 
11
21
  module Clacky
12
22
  class Agent
23
+ # Include all functionality modules
24
+ include MessageCompressorHelper
25
+ include ToolExecutor
26
+ include CostTracker
27
+ include SessionSerializer
28
+ include SkillManager
29
+ include SystemPromptBuilder
30
+ include LlmCaller
31
+ include TimeMachine
32
+
13
33
  attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
14
34
  :cache_stats, :cost_source, :ui, :skill_loader
15
35
 
16
- # System prompt for the coding agent
17
- SYSTEM_PROMPT = <<~PROMPT.freeze
18
- You are OpenClacky, an AI coding assistant and technical co-founder, designed to help non-technical
19
- users complete software development projects. You are responsible for development in the current project.
20
-
21
- Your role is to:
22
- - Understand project requirements and translate them into technical solutions
23
- - Write clean, maintainable, and well-documented code
24
- - Follow best practices and industry standards
25
- - Explain technical concepts in simple terms when needed
26
- - Proactively identify potential issues and suggest improvements
27
- - Help with debugging, testing, and deployment
28
-
29
- Working process:
30
- 1. **For complex tasks with multiple steps**:
31
- - Use todo_manager to create a complete TODO list FIRST
32
- - After creating the TODO list, START EXECUTING each task immediately
33
- - Don't stop after planning - continue to work on the tasks!
34
- 2. Always read existing code before making changes (use file_reader/glob/grep)
35
- 3. Ask clarifying questions if requirements are unclear
36
- 4. Break down complex tasks into manageable steps
37
- 5. **USE TOOLS to create/modify files** - don't just return code
38
- 6. Write code that is secure, efficient, and easy to understand
39
- 7. Test your changes using the shell tool when appropriate
40
- 8. **IMPORTANT**: After completing each step, mark the TODO as completed and continue to the next one
41
- 9. Keep working until ALL TODOs are completed or you need user input
42
- 10. Provide brief explanations after completing actions
43
-
44
- IMPORTANT: You should frequently refer to the existing codebase. For unclear instructions,
45
- prioritize understanding the codebase first before answering or taking action.
46
- Always read relevant code files to understand the project structure, patterns, and conventions.
47
-
48
- CRITICAL RULE FOR TODO MANAGER:
49
- When using todo_manager to add tasks, you MUST continue working immediately after adding ALL todos.
50
- Adding todos is NOT completion - it's just the planning phase!
51
- Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
52
- NEVER stop after just adding todos without executing them!
53
-
54
- NOTE: Available skills are listed below in the AVAILABLE SKILLS section.
55
- When a user's request matches a skill, you MUST use the skill tool instead of implementing it yourself.
56
- PROMPT
57
-
58
36
  def initialize(client, config = {}, working_dir: nil, ui: nil)
59
- @client = client
37
+ @client = client # Client for current model
60
38
  @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
61
39
  @tool_registry = ToolRegistry.new
62
40
  @hooks = HookManager.new
@@ -89,11 +67,14 @@ module Clacky
89
67
 
90
68
  # Message compressor for LLM-based intelligent compression
91
69
  # Uses LLM to preserve key decisions, errors, and context while reducing token count
92
- @message_compressor = MessageCompressor.new(@client, model: @config.model)
70
+ @message_compressor = MessageCompressor.new(@client, model: current_model)
93
71
 
94
72
  # Skill loader for skill management
95
73
  @skill_loader = SkillLoader.new(@working_dir)
96
74
 
75
+ # Initialize Time Machine
76
+ init_time_machine
77
+
97
78
  # Register built-in tools
98
79
  register_builtin_tools
99
80
  end
@@ -105,80 +86,54 @@ module Clacky
105
86
  agent
106
87
  end
107
88
 
108
- def restore_session(session_data)
109
- @session_id = session_data[:session_id]
110
- @messages = session_data[:messages]
111
- @todos = session_data[:todos] || [] # Restore todos from session
112
- @iterations = session_data.dig(:stats, :total_iterations) || 0
113
- @total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
114
- @working_dir = session_data[:working_dir]
115
- @created_at = session_data[:created_at]
116
- @total_tasks = session_data.dig(:stats, :total_tasks) || 0
117
-
118
- # Restore cache statistics if available
119
- @cache_stats = session_data.dig(:stats, :cache_stats) || {
120
- cache_creation_input_tokens: 0,
121
- cache_read_input_tokens: 0,
122
- total_requests: 0,
123
- cache_hit_requests: 0
124
- }
125
-
126
- # Restore previous_total_tokens for accurate delta calculation across sessions
127
- @previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0
128
-
129
- # Check if the session ended with an error
130
- last_status = session_data.dig(:stats, :last_status)
131
- last_error = session_data.dig(:stats, :last_error)
132
-
133
- if last_status == "error" && last_error
134
- # Find and remove the last user message that caused the error
135
- # This allows the user to retry with a different prompt
136
- last_user_index = @messages.rindex { |m| m[:role] == "user" }
137
- if last_user_index
138
- @messages = @messages[0...last_user_index]
139
-
140
- # Trigger a hook to notify about the rollback
141
- @hooks.trigger(:session_rollback, {
142
- reason: "Previous session ended with error",
143
- error_message: last_error,
144
- rolled_back_message_index: last_user_index
145
- })
146
- end
147
- end
89
+ def add_hook(event, &block)
90
+ @hooks.add(event, &block)
148
91
  end
149
92
 
150
- # Get recent user messages from conversation history
151
- # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
152
- # @return [Array<String>] Array of recent user message contents
153
- def get_recent_user_messages(limit: 5)
154
- # Filter messages to only include real user messages (exclude system-injected ones)
155
- user_messages = @messages.select do |m|
156
- m[:role] == "user" && !m[:system_injected]
93
+ # Switch to a different model by name
94
+ # Returns true if switched, false if model not found
95
+ def switch_model(model_name)
96
+ if @config.switch_model(model_name)
97
+ # Re-create client for new model
98
+ @client = Clacky::Client.new(
99
+ @config.api_key,
100
+ base_url: @config.base_url,
101
+ anthropic_format: @config.anthropic_format?
102
+ )
103
+ # Update message compressor with new client and model
104
+ @message_compressor = MessageCompressor.new(@client, model: current_model)
105
+ true
106
+ else
107
+ false
157
108
  end
109
+ end
158
110
 
159
- # Extract text content from the last N user messages
160
- user_messages.last(limit).map do |msg|
161
- extract_text_from_content(msg[:content])
162
- end
111
+ # Get list of available model names
112
+ def available_models
113
+ @config.model_names
163
114
  end
164
115
 
165
- private def extract_text_from_content(content)
166
- if content.is_a?(String)
167
- content
168
- elsif content.is_a?(Array)
169
- # Extract text from content array (may contain text and images)
170
- text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
171
- text_parts.map { |c| c[:text] }.join("\n")
172
- else
173
- content.to_s
174
- end
116
+ # Get current model configuration info
117
+ def current_model_info
118
+ model = @config.current_model
119
+ return nil unless model
120
+
121
+ {
122
+ name: model["name"],
123
+ model: model["model"],
124
+ base_url: model["base_url"]
125
+ }
175
126
  end
176
127
 
177
- def add_hook(event, &block)
178
- @hooks.add(event, &block)
128
+ # Get current model name
129
+ private def current_model
130
+ @config.model_name
179
131
  end
180
132
 
181
133
  def run(user_input, images: [])
134
+ # Start new task for Time Machine
135
+ task_id = start_new_task
136
+
182
137
  @start_time = Time.now
183
138
  @task_cost_source = :estimated # Reset for new task
184
139
  # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
@@ -208,7 +163,7 @@ module Clacky
208
163
 
209
164
  # Format user message with images if provided
210
165
  user_content = format_user_content(user_input, images)
211
- @messages << { role: "user", content: user_content }
166
+ @messages << { role: "user", content: user_content, task_id: task_id }
212
167
  @total_tasks += 1
213
168
 
214
169
  @hooks.trigger(:on_start, user_input)
@@ -288,6 +243,13 @@ module Clacky
288
243
  end
289
244
 
290
245
  result = build_result(:success)
246
+
247
+ # Save snapshots of modified files for Time Machine
248
+ if @modified_files_in_task && !@modified_files_in_task.empty?
249
+ save_modified_files_snapshot(@modified_files_in_task)
250
+ @modified_files_in_task = [] # Reset for next task
251
+ end
252
+
291
253
  @ui&.show_complete(
292
254
  iterations: result[:iterations],
293
255
  cost: result[:total_cost_usd],
@@ -316,306 +278,30 @@ module Clacky
316
278
  end
317
279
  end
318
280
 
319
- # ===== Skill-related methods =====
320
-
321
- # Get the skill loader instance
322
- # @return [SkillLoader]
323
- def skill_loader
324
- @skill_loader
325
- end
326
-
327
- # Load all skills from configured locations
328
- # @return [Array<Skill>]
329
- def load_skills
330
- @skill_loader.load_all
331
- end
332
-
333
- # Check if input is a skill command and process it
334
- # @param input [String] User input
335
- # @return [Hash, nil] Returns { skill: Skill, arguments: String } if skill command, nil otherwise
336
- def parse_skill_command(input)
337
- # Check for slash command pattern
338
- if input.start_with?("/")
339
- # Extract command and arguments
340
- match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
341
- return nil unless match
342
-
343
- skill_name = match[1]
344
- arguments = match[2] || ""
345
-
346
- # Find skill by command
347
- skill = @skill_loader.find_by_command("/#{skill_name}")
348
- return nil unless skill
349
-
350
- # Check if user can invoke this skill
351
- unless skill.user_invocable?
352
- return nil
353
- end
354
-
355
- { skill: skill, arguments: arguments }
356
- else
357
- nil
358
- end
359
- end
360
-
361
- # Execute a skill command
362
- # @param input [String] User input (should be a skill command)
363
- # @return [String] The expanded prompt with skill content
364
- def execute_skill_command(input)
365
- parsed = parse_skill_command(input)
366
- return input unless parsed
367
-
368
- skill = parsed[:skill]
369
- arguments = parsed[:arguments]
370
-
371
- # Process skill content with arguments
372
- expanded_content = skill.process_content(arguments)
373
-
374
- # Log skill usage
375
- @ui&.log("Executing skill: #{skill.identifier}", level: :info)
376
-
377
- expanded_content
378
- end
379
-
380
- # Generate skill context - loads all auto-invocable skills
381
- # @return [String] Skill context to add to system prompt
382
- def build_skill_context
383
- # Load all auto-invocable skills
384
- all_skills = @skill_loader.load_all
385
- auto_invocable = all_skills.select(&:model_invocation_allowed?)
386
-
387
- return "" if auto_invocable.empty?
388
-
389
- context = "\n\n" + "=" * 80 + "\n"
390
- context += "AVAILABLE SKILLS:\n"
391
- context += "=" * 80 + "\n\n"
392
- context += "CRITICAL SKILL USAGE RULES:\n"
393
- context += "- When a user's request matches any available skill, this is a BLOCKING REQUIREMENT:\n"
394
- context += " invoke the relevant skill tool BEFORE generating any other response about the task\n"
395
- context += "- NEVER mention a skill without actually calling the skill tool\n"
396
- context += "- NEVER implement the skill's functionality yourself - always delegate to the skill\n"
397
- context += "- Skills provide specialized capabilities - use them instead of manual implementation\n"
398
- context += "- When users reference '/<skill-name>' (e.g., '/pptx'), they are requesting a skill\n\n"
399
- context += "Workflow: Use file_reader to read the SKILL.md file, then follow its instructions.\n\n"
400
- context += "Available skills:\n\n"
401
-
402
- auto_invocable.each do |skill|
403
- skill_md_path = skill.directory.join("SKILL.md")
404
- context += "- name: #{skill.identifier}\n"
405
- context += " description: #{skill.context_description}\n"
406
- context += " SKILL.md: #{skill_md_path}\n\n"
407
- end
408
-
409
- context += "\n"
410
- context
411
- end
412
-
413
- # Generate session data for saving
414
- # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
415
- # @param error_message [String] Error message if status is :error
416
- def to_session_data(status: :success, error_message: nil)
417
- # Get last real user message for preview (skip compressed system messages)
418
- last_user_msg = @messages.reverse.find do |m|
419
- m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
420
- end
421
-
422
- # Extract preview text from last user message
423
- last_message_preview = if last_user_msg
424
- content = last_user_msg[:content]
425
- if content.is_a?(String)
426
- # Truncate to 100 characters for preview
427
- content.length > 100 ? "#{content[0..100]}..." : content
428
- else
429
- "User message (non-string content)"
430
- end
431
- else
432
- "No messages"
433
- end
434
-
435
- stats_data = {
436
- total_tasks: @total_tasks,
437
- total_iterations: @iterations,
438
- total_cost_usd: @total_cost.round(4),
439
- duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
440
- last_status: status.to_s,
441
- cache_stats: @cache_stats,
442
- debug_logs: @debug_logs,
443
- previous_total_tokens: @previous_total_tokens
444
- }
445
-
446
- # Add error message if status is error
447
- stats_data[:last_error] = error_message if status == :error && error_message
448
-
449
- {
450
- session_id: @session_id,
451
- created_at: @created_at,
452
- updated_at: Time.now.iso8601,
453
- working_dir: @working_dir,
454
- todos: @todos, # Include todos in session data
455
- config: {
456
- model: @config.model,
457
- permission_mode: @config.permission_mode.to_s,
458
- enable_compression: @config.enable_compression,
459
- enable_prompt_caching: @config.enable_prompt_caching,
460
- keep_recent_messages: @config.keep_recent_messages,
461
- max_tokens: @config.max_tokens,
462
- verbose: @config.verbose
463
- },
464
- stats: stats_data,
465
- messages: @messages,
466
- first_user_message: last_message_preview
467
- }
468
- end
469
-
470
- private
471
-
472
- def should_auto_execute?(tool_name, tool_params = {})
473
- case @config.permission_mode
474
- when :auto_approve
475
- true
476
- when :confirm_safes
477
- # Use SafeShell integration for safety check
478
- is_safe_operation?(tool_name, tool_params)
479
- when :confirm_edits
480
- !editing_tool?(tool_name)
481
- when :plan_only
482
- false
483
- else
484
- false
485
- end
486
- end
487
-
488
- def editing_tool?(tool_name)
489
- AgentConfig::EDITING_TOOLS.include?(tool_name.to_s.downcase)
490
- end
491
-
492
- def is_safe_operation?(tool_name, tool_params = {})
493
- # For shell commands, use SafeShell to check safety
494
- if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
495
- begin
496
- require_relative 'tools/safe_shell'
497
-
498
- # Parse tool_params if it's a JSON string
499
- params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
500
- command = params[:command] || params['command']
501
- return false unless command
502
-
503
- # Use SafeShell to analyze the command
504
- return Tools::SafeShell.command_safe_for_auto_execution?(command)
505
- rescue LoadError
506
- # If SafeShell not available, be conservative
507
- return false
508
- rescue => e
509
- # In case of any error, be conservative
510
- return false
511
- end
512
- end
513
-
514
- # For non-shell tools, consider them safe for now
515
- # You can extend this logic for other tools
516
- !editing_tool?(tool_name)
517
- end
518
-
519
- def build_system_prompt
520
- prompt = SYSTEM_PROMPT.dup
521
-
522
- # Try to load project rules from multiple sources (in order of priority)
523
- rules_files = [
524
- { path: ".clackyrules", name: ".clackyrules" },
525
- { path: ".cursorrules", name: ".cursorrules" },
526
- { path: "CLAUDE.md", name: "CLAUDE.md" }
527
- ]
528
-
529
- rules_content = nil
530
- rules_source = nil
531
-
532
- rules_files.each do |file_info|
533
- full_path = File.join(@working_dir, file_info[:path])
534
- if File.exist?(full_path)
535
- content = File.read(full_path).strip
536
- unless content.empty?
537
- rules_content = content
538
- rules_source = file_info[:name]
539
- break
540
- end
541
- end
542
- end
543
-
544
- # Add rules to prompt if found
545
- if rules_content && rules_source
546
- prompt += "\n\n" + "=" * 80 + "\n"
547
- prompt += "PROJECT-SPECIFIC RULES (from #{rules_source}):\n"
548
- prompt += "=" * 80 + "\n"
549
- prompt += rules_content
550
- prompt += "\n" + "=" * 80 + "\n"
551
- prompt += "⚠️ IMPORTANT: Follow these project-specific rules at all times!\n"
552
- prompt += "=" * 80
553
- end
554
-
555
- # Add all loaded skills to system prompt
556
- skill_context = build_skill_context
557
- prompt += skill_context if skill_context && !skill_context.empty?
558
-
559
- prompt
560
- end
561
-
562
- def think
281
+ private def think
563
282
  # Check API key before starting progress indicator
564
283
  if @client.instance_variable_get(:@api_key).nil? || @client.instance_variable_get(:@api_key).empty?
565
284
  @ui&.show_error("API key is not configured! Please run /config to set up your API key.")
566
285
  raise AgentError, "API key is not configured"
567
286
  end
568
287
 
569
- @ui&.show_progress
570
-
571
288
  # Check if compression is needed
572
- compression_context = compress_messages_if_needed
289
+ compression_context = compress_messages_if_needed(force: false)
573
290
 
574
291
  # If compression is triggered, insert compression message and handle it
575
292
  if compression_context
576
- # Insert compression message into conversation
577
- @messages << compression_context[:compression_message]
578
- end
579
-
580
- # Always send tools definitions to allow multi-step tool calling
581
- tools_to_send = @tool_registry.all_definitions
582
-
583
- # Retry logic for network failures
584
- max_retries = 10
585
- retry_delay = 5
586
- retries = 0
587
-
588
- begin
589
- response = @client.send_messages_with_tools(
590
- @messages,
591
- model: @config.model,
592
- tools: tools_to_send,
593
- max_tokens: @config.max_tokens,
594
- enable_caching: @config.enable_prompt_caching
293
+ # Show compression start notification
294
+ @ui&.show_info(
295
+ "Message history compression starting (~#{compression_context[:original_token_count]} tokens, #{compression_context[:original_message_count]} messages) - Level #{compression_context[:compression_level]}"
595
296
  )
596
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
597
- retries += 1
598
- if retries <= max_retries
599
- @ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
600
- sleep retry_delay
601
- retry
602
- else
603
- @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
604
- raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
605
- end
606
- end
607
-
608
- # Clear progress indicator (change to gray and show final time)
609
- @ui&.clear_progress
610
-
611
- # If this was a compression call, rebuild message list with compressed content
612
- if compression_context
297
+ @messages << compression_context[:compression_message]
298
+ response = call_llm
613
299
  handle_compression_response(response, compression_context)
614
- # Return early - don't process as normal response
615
300
  return nil
616
301
  end
617
302
 
618
- track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
303
+ # Normal LLM call
304
+ response = call_llm
619
305
 
620
306
  # Handle truncated responses (when max_tokens limit is reached)
621
307
  if response[:finish_reason] == "length"
@@ -665,7 +351,7 @@ module Clacky
665
351
  end
666
352
 
667
353
  # Add assistant response to messages
668
- msg = { role: "assistant" }
354
+ msg = { role: "assistant", task_id: @current_task_id }
669
355
  # Always include content field (some APIs require it even with tool_calls)
670
356
  # Use empty string instead of null for better compatibility
671
357
  msg[:content] = response[:content] || ""
@@ -678,7 +364,7 @@ module Clacky
678
364
  response
679
365
  end
680
366
 
681
- def act(tool_calls)
367
+ private def act(tool_calls)
682
368
  return { denied: false, feedback: nil, tool_results: [], awaiting_feedback: false } unless tool_calls
683
369
 
684
370
  denied = false
@@ -695,8 +381,14 @@ module Clacky
695
381
  next
696
382
  end
697
383
 
698
- # Permission check (if not in auto-approve mode)
699
- unless should_auto_execute?(call[:name], call[:arguments])
384
+ # Show preview for edit and write tools even in auto-approve mode
385
+ if should_auto_execute?(call[:name], call[:arguments])
386
+ # In auto-approve mode, show preview for edit and write tools
387
+ if call[:name] == "edit" || call[:name] == "write"
388
+ show_tool_preview(call)
389
+ end
390
+ else
391
+ # Permission check (if not in auto-approve mode)
700
392
  if @config.is_plan_only?
701
393
  @ui&.show_info("Planned: #{call[:name]}")
702
394
  results << build_planned_result(call)
@@ -750,6 +442,17 @@ module Clacky
750
442
  args[:todos_storage] = @todos
751
443
  end
752
444
 
445
+ # Special handling for InvokeSkill: inject agent and skill_loader
446
+ if call[:name] == "invoke_skill"
447
+ args[:agent] = self
448
+ args[:skill_loader] = @skill_loader
449
+ end
450
+
451
+ # Special handling for Time Machine tools: inject agent
452
+ if ["undo_task", "redo_task", "list_tasks"].include?(call[:name])
453
+ args[:agent] = self
454
+ end
455
+
753
456
  # For safe_shell, skip safety check if user has already confirmed
754
457
  if call[:name] == "safe_shell" || call[:name] == "shell"
755
458
  args[:skip_safety_check] = true
@@ -766,6 +469,9 @@ module Clacky
766
469
  # Clear progress if shown
767
470
  @ui&.clear_progress if potentially_slow_tool?(call[:name], args)
768
471
 
472
+ # Track modified files for Time Machine snapshots
473
+ track_modified_files(call[:name], args)
474
+
769
475
  # Hook: after_tool_use
770
476
  @hooks.trigger(:after_tool_use, call, result)
771
477
 
@@ -814,13 +520,13 @@ module Clacky
814
520
  }
815
521
  end
816
522
 
817
- def observe(response, tool_results)
523
+ private def observe(response, tool_results)
818
524
  # Add tool results as messages
819
525
  # Use Client to format results based on API type (Anthropic vs OpenAI)
820
526
  return if tool_results.empty?
821
527
 
822
- formatted_messages = @client.format_tool_results(response, tool_results, model: @config.model)
823
- formatted_messages.each { |msg| @messages << msg }
528
+ formatted_messages = @client.format_tool_results(response, tool_results, model: current_model)
529
+ formatted_messages.each { |msg| @messages << msg.merge(task_id: @current_task_id) }
824
530
  end
825
531
 
826
532
  # Interrupt the agent's current run
@@ -834,978 +540,16 @@ module Clacky
834
540
  @start_time != nil && !should_stop?
835
541
  end
836
542
 
837
- def should_stop?
543
+ private def should_stop?
838
544
  if @interrupted
839
545
  @interrupted = false # Reset for next run
840
546
  return true
841
547
  end
842
548
 
843
-
844
549
  false
845
550
  end
846
551
 
847
- # Check if a tool is potentially slow and should show progress
848
- private def potentially_slow_tool?(tool_name, args)
849
- case tool_name.to_s.downcase
850
- when 'shell', 'safe_shell'
851
- # Check if the command is a slow command
852
- command = args[:command] || args['command']
853
- return false unless command
854
-
855
- # List of slow command patterns
856
- slow_patterns = [
857
- /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
858
- /npm\s+(install|run\s+test|run\s+build)/,
859
- /yarn\s+(install|test|build)/,
860
- /pnpm\s+install/,
861
- /cargo\s+(build|test)/,
862
- /go\s+(build|test)/,
863
- /make\s+(test|build)/,
864
- /pytest/,
865
- /jest/
866
- ]
867
-
868
- slow_patterns.any? { |pattern| command.match?(pattern) }
869
- when 'web_fetch', 'web_search'
870
- true # Network operations can be slow
871
- else
872
- false # Most file operations are fast
873
- end
874
- end
875
-
876
- # Build progress message for tool execution
877
- private def build_tool_progress_message(tool_name, args)
878
- case tool_name.to_s.downcase
879
- when 'shell', 'safe_shell'
880
- "Running command"
881
- when 'web_fetch'
882
- "Fetching web page"
883
- when 'web_search'
884
- "Searching web"
885
- else
886
- "Executing #{tool_name}"
887
- end
888
- end
889
-
890
- def track_cost(usage, raw_api_usage: nil)
891
- # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
892
- iteration_cost = nil
893
- if usage[:api_cost]
894
- @total_cost += usage[:api_cost]
895
- @cost_source = :api
896
- @task_cost_source = :api
897
- iteration_cost = usage[:api_cost]
898
- @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
899
- else
900
- # Priority 2: Calculate from tokens using ModelPricing
901
- result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
902
- cost = result[:cost]
903
- pricing_source = result[:source]
904
-
905
- @total_cost += cost
906
- iteration_cost = cost
907
- # Map pricing source to cost source: :price or :default
908
- @cost_source = pricing_source
909
- @task_cost_source = pricing_source
910
-
911
- if @config.verbose
912
- source_label = pricing_source == :price ? "model pricing" : "default pricing"
913
- @ui&.log("Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}", level: :debug)
914
- @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)
915
- end
916
- end
917
-
918
- # Display token usage statistics for this iteration
919
- display_iteration_tokens(usage, iteration_cost)
920
-
921
- # Track cache usage statistics (global)
922
- @cache_stats[:total_requests] += 1
923
-
924
- if usage[:cache_creation_input_tokens]
925
- @cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
926
- end
927
-
928
- if usage[:cache_read_input_tokens]
929
- @cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
930
- @cache_stats[:cache_hit_requests] += 1
931
- end
932
-
933
- # Store raw API usage samples (keep last 3 for debugging)
934
- if raw_api_usage
935
- @cache_stats[:raw_api_usage_samples] ||= []
936
- @cache_stats[:raw_api_usage_samples] << raw_api_usage
937
- @cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
938
- end
939
-
940
- # Track cache usage for current task
941
- if @task_cache_stats
942
- @task_cache_stats[:total_requests] += 1
943
-
944
- if usage[:cache_creation_input_tokens]
945
- @task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
946
- end
947
-
948
- if usage[:cache_read_input_tokens]
949
- @task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
950
- @task_cache_stats[:cache_hit_requests] += 1
951
- end
952
- end
953
- end
954
-
955
- # Display token usage for current iteration
956
- private def display_iteration_tokens(usage, cost)
957
- prompt_tokens = usage[:prompt_tokens] || 0
958
- completion_tokens = usage[:completion_tokens] || 0
959
- total_tokens = usage[:total_tokens] || (prompt_tokens + completion_tokens)
960
- cache_write = usage[:cache_creation_input_tokens] || 0
961
- cache_read = usage[:cache_read_input_tokens] || 0
962
-
963
- # Calculate token delta from previous iteration
964
- delta_tokens = total_tokens - @previous_total_tokens
965
- @previous_total_tokens = total_tokens # Update for next iteration
966
-
967
- # Prepare data for UI to format and display
968
- token_data = {
969
- delta_tokens: delta_tokens,
970
- prompt_tokens: prompt_tokens,
971
- completion_tokens: completion_tokens,
972
- total_tokens: total_tokens,
973
- cache_write: cache_write,
974
- cache_read: cache_read,
975
- cost: cost
976
- }
977
-
978
- # Let UI handle formatting and display
979
- @ui&.show_token_usage(token_data)
980
- end
981
-
982
- # Estimate token count for a message content
983
- # Simple approximation: characters / 4 (English text)
984
- # For Chinese/other languages, characters / 2 is more accurate
985
- # This is a rough estimate for compression triggering purposes
986
- private def estimate_tokens(content)
987
- return 0 if content.nil?
988
-
989
- text = if content.is_a?(String)
990
- content
991
- elsif content.is_a?(Array)
992
- # Handle content arrays (e.g., with images)
993
- # Add safety check to prevent nil.compact error
994
- mapped = content.map { |c| c[:text] if c.is_a?(Hash) }
995
- (mapped || []).compact.join
996
- else
997
- content.to_s
998
- end
999
-
1000
- return 0 if text.empty?
1001
-
1002
- # Detect language mix - count non-ASCII characters
1003
- ascii_count = text.bytes.count { |b| b < 128 }
1004
- total_bytes = text.bytes.length
1005
-
1006
- # Mix ratio (1.0 = all English, 0.5 = all Chinese)
1007
- mix_ratio = total_bytes > 0 ? ascii_count.to_f / total_bytes : 1.0
1008
-
1009
- # English: ~4 chars/token, Chinese: ~2 chars/token
1010
- base_chars_per_token = mix_ratio * 4 + (1 - mix_ratio) * 2
1011
-
1012
- (text.length / base_chars_per_token).to_i + 50 # Add overhead for message structure
1013
- end
1014
-
1015
- # Calculate total token count for all messages
1016
- # Returns estimated tokens and breakdown by category
1017
- private def total_message_tokens
1018
- system_tokens = 0
1019
- user_tokens = 0
1020
- assistant_tokens = 0
1021
- tool_tokens = 0
1022
- summary_tokens = 0
1023
-
1024
- @messages.each do |msg|
1025
- tokens = estimate_tokens(msg[:content])
1026
- case msg[:role]
1027
- when "system"
1028
- system_tokens += tokens
1029
- when "user"
1030
- user_tokens += tokens
1031
- when "assistant"
1032
- assistant_tokens += tokens
1033
- when "tool"
1034
- tool_tokens += tokens
1035
- end
1036
- end
1037
-
1038
- {
1039
- total: system_tokens + user_tokens + assistant_tokens + tool_tokens,
1040
- system: system_tokens,
1041
- user: user_tokens,
1042
- assistant: assistant_tokens,
1043
- tool: tool_tokens
1044
- }
1045
- end
1046
-
1047
- # Compression thresholds
1048
- COMPRESSION_THRESHOLD = 150_000 # Trigger compression when exceeding this (in tokens)
1049
- MESSAGE_COUNT_THRESHOLD = 150 # Trigger compression when exceeding this (in message count)
1050
- MAX_RECENT_MESSAGES = 20 # Keep this many recent message pairs intact
1051
- TARGET_COMPRESSED_TOKENS = 10_000 # Target size after compression
1052
-
1053
- def compress_messages_if_needed
1054
- # Check if compression is enabled
1055
- return nil unless @config.enable_compression
1056
-
1057
- # Calculate total tokens and message count
1058
- token_counts = total_message_tokens
1059
- total_tokens = token_counts[:total]
1060
- message_count = @messages.length
1061
-
1062
- # Check if we should trigger compression
1063
- # Either: token count exceeds threshold OR message count exceeds threshold
1064
- token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
1065
- message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
1066
-
1067
- # Only compress if we exceed at least one threshold
1068
- return nil unless token_threshold_exceeded || message_count_exceeded
1069
-
1070
- # Calculate how much we need to reduce
1071
- reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
1072
-
1073
- # Don't compress if reduction is minimal (< 10% of current size)
1074
- # Only apply this check when triggered by token threshold
1075
- if token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
1076
- return nil
1077
- end
1078
-
1079
- # If only message count threshold is exceeded, force compression
1080
- # to keep conversation history manageable
1081
-
1082
- # Calculate target size for recent messages based on compression level
1083
- target_recent_count = calculate_target_recent_count(reduction_needed)
1084
-
1085
- # Increment compression level for progressive summarization
1086
- @compression_level += 1
1087
-
1088
- # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
1089
- recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
1090
- recent_messages = [] if recent_messages.nil?
1091
-
1092
- # Build compression instruction message (to be inserted into conversation)
1093
- compression_message = @message_compressor.build_compression_message(@messages, recent_messages: recent_messages)
1094
-
1095
- return nil if compression_message.nil?
1096
-
1097
- # Show compression start notification
1098
- @ui&.show_info(
1099
- "Message history compression starting (~#{total_tokens} tokens, #{@messages.length} messages) - Level #{@compression_level}"
1100
- )
1101
-
1102
- # Return compression context for agent to handle
1103
- {
1104
- compression_message: compression_message,
1105
- recent_messages: recent_messages,
1106
- original_token_count: total_tokens,
1107
- original_message_count: @messages.length,
1108
- compression_level: @compression_level
1109
- }
1110
- end
1111
-
1112
- # Handle compression response and rebuild message list
1113
- def handle_compression_response(response, compression_context)
1114
- # Extract compressed content from response
1115
- compressed_content = response[:content]
1116
-
1117
- # Track cost for compression call
1118
- track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
1119
-
1120
- # Rebuild message list with compression
1121
- # Note: we need to remove the compression instruction message we just added
1122
- original_messages = @messages[0..-2] # All except the last (compression instruction)
1123
-
1124
- @messages = @message_compressor.rebuild_with_compression(
1125
- compressed_content,
1126
- original_messages: original_messages,
1127
- recent_messages: compression_context[:recent_messages]
1128
- )
1129
-
1130
- # Track this compression
1131
- @compressed_summaries << {
1132
- level: compression_context[:compression_level],
1133
- message_count: compression_context[:original_message_count],
1134
- timestamp: Time.now.iso8601,
1135
- strategy: :insert_then_compress
1136
- }
1137
-
1138
- final_tokens = total_message_tokens[:total]
1139
-
1140
- # Show compression info
1141
- @ui&.show_info(
1142
- "History compressed (~#{compression_context[:original_token_count]} -> ~#{final_tokens} tokens, " \
1143
- "level #{compression_context[:compression_level]})"
1144
- )
1145
- end
1146
-
1147
- # Calculate how many recent messages to keep based on how much we need to compress
1148
- private def calculate_target_recent_count(reduction_needed)
1149
- # We want recent messages to be around 20-30% of the total target
1150
- # This keeps the context window useful without being too large
1151
- tokens_per_message = 500 # Average estimate for a message with content
1152
-
1153
- # Target recent messages budget (~20% of target compressed size)
1154
- recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
1155
- target_messages = (recent_budget / tokens_per_message).to_i
1156
-
1157
- # Clamp to reasonable bounds
1158
- [[target_messages, 20].max, MAX_RECENT_MESSAGES].min
1159
- end
1160
-
1161
- # Generate hierarchical summary based on compression level
1162
- # Level 1: Detailed summary with files, decisions, features
1163
- # Level 2: Concise summary with key items
1164
- # Level 3: Minimal summary (just project type)
1165
- # Level 4+: Ultra-minimal (single line)
1166
- private def generate_hierarchical_summary(messages)
1167
- level = @compression_level
1168
-
1169
- # Extract key information from messages
1170
- extracted = extract_key_information(messages)
1171
-
1172
- summary_text = case level
1173
- when 1
1174
- generate_level1_summary(extracted)
1175
- when 2
1176
- generate_level2_summary(extracted)
1177
- when 3
1178
- generate_level3_summary(extracted)
1179
- else
1180
- generate_level4_summary(extracted)
1181
- end
1182
-
1183
- {
1184
- role: "user",
1185
- content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
1186
- system_injected: true,
1187
- compression_level: level
1188
- }
1189
- end
1190
-
1191
- # Extract key information from messages for summarization
1192
- private def extract_key_information(messages)
1193
- return empty_extraction_data if messages.nil?
1194
-
1195
- {
1196
- # Message counts
1197
- user_msgs: messages.count { |m| m[:role] == "user" },
1198
- assistant_msgs: messages.count { |m| m[:role] == "assistant" },
1199
- tool_msgs: messages.count { |m| m[:role] == "tool" },
1200
-
1201
- # Tools used
1202
- tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
1203
-
1204
- # Files created/modified
1205
- files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
1206
- files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
1207
-
1208
- # Key decisions (limit to first 5)
1209
- decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
1210
-
1211
- # Completed tasks (from TODO results)
1212
- completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
1213
-
1214
- # Current in-progress work
1215
- in_progress: find_in_progress(messages),
1216
-
1217
- # Key results from shell commands
1218
- shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
1219
- }
1220
- end
1221
-
1222
- # Helper: safely extract from messages with proper nil handling
1223
- private def extract_from_messages(messages, role_filter = nil, &block)
1224
- return [] if messages.nil?
1225
-
1226
- results = messages
1227
- .select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
1228
- .map(&block)
1229
- .compact
1230
-
1231
- # Flatten if we have nested arrays (from methods returning arrays of items)
1232
- results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
1233
- end
1234
-
1235
- # Helper: extract tool names from tool_calls
1236
- private def extract_tool_names(tool_calls)
1237
- return [] unless tool_calls.is_a?(Array)
1238
- tool_calls.map { |tc| tc.dig(:function, :name) }
1239
- end
1240
-
1241
- # Helper: filter write results by action
1242
- private def filter_write_results(result, action)
1243
- result && result[:action] == action ? result[:file] : nil
1244
- end
1245
-
1246
- # Helper: filter todo results by status
1247
- private def filter_todo_results(result, status)
1248
- result && result[:status] == status ? result[:task] : nil
1249
- end
1250
-
1251
- # Helper: extract decision text from content (returns array of decisions or empty array)
1252
- private def extract_decision_text(content)
1253
- return [] unless content.is_a?(String)
1254
- return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
1255
-
1256
- sentences = content.split(/[.!?]/).select do |s|
1257
- s.include?("decision") || s.include?("chose") || s.include?("using") ||
1258
- s.include?("decided") || s.include?("will use") || s.include?("selected")
1259
- end
1260
- sentences.map(&:strip).map { |s| s[0..100] }
1261
- end
1262
-
1263
- # Helper: find in-progress task
1264
- private def find_in_progress(messages)
1265
- return nil if messages.nil?
1266
-
1267
- messages.reverse_each do |m|
1268
- if m[:role] == "tool"
1269
- content = m[:content].to_s
1270
- if content.include?("in progress") || content.include?("working on")
1271
- return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
1272
- end
1273
- end
1274
- end
1275
- nil
1276
- end
1277
-
1278
- # Helper: empty extraction data
1279
- private def empty_extraction_data
1280
- {
1281
- user_msgs: 0,
1282
- assistant_msgs: 0,
1283
- tool_msgs: 0,
1284
- tools_used: [],
1285
- files_created: [],
1286
- files_modified: [],
1287
- decisions: [],
1288
- completed_tasks: [],
1289
- in_progress: nil,
1290
- shell_results: []
1291
- }
1292
- end
1293
-
1294
- private def parse_write_result(content)
1295
- return nil unless content.is_a?(String)
1296
-
1297
- # Check for "Created: path" or "Updated: path" patterns
1298
- if content.include?("Created:")
1299
- { action: "created", file: content[/Created:\s*(.+)/, 1]&.strip }
1300
- elsif content.include?("Updated:") || content.include?("modified")
1301
- { action: "modified", file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
1302
- else
1303
- nil
1304
- end
1305
- end
1306
-
1307
- private def parse_todo_result(content)
1308
- return nil unless content.is_a?(String)
1309
-
1310
- if content.include?("completed")
1311
- { status: "completed", task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
1312
- elsif content.include?("added")
1313
- { status: "added", task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
1314
- else
1315
- nil
1316
- end
1317
- end
1318
-
1319
- private def parse_shell_result(content)
1320
- return nil unless content.is_a?(String)
1321
-
1322
- if content.include?("passed") || content.include?("success")
1323
- "tests passed"
1324
- elsif content.include?("failed") || content.include?("error")
1325
- "command failed"
1326
- elsif content =~ /bundle install|npm install|go mod download/
1327
- "dependencies installed"
1328
- elsif content.include?("Installed")
1329
- content[/Installed:\s*(.+)/, 1]&.strip
1330
- else
1331
- nil
1332
- end
1333
- end
1334
-
1335
- # Level 1: Detailed summary (for first compression)
1336
- private def generate_level1_summary(data)
1337
- parts = []
1338
-
1339
- parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
1340
-
1341
- # Files created
1342
- if data[:files_created].any?
1343
- files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
1344
- parts << "Created: #{files_list}"
1345
- end
1346
-
1347
- # Files modified
1348
- if data[:files_modified].any?
1349
- files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
1350
- parts << "Modified: #{files_list}"
1351
- end
1352
-
1353
- # Completed tasks
1354
- if data[:completed_tasks].any?
1355
- tasks_list = data[:completed_tasks].first(3).join(", ")
1356
- parts << "Completed: #{tasks_list}"
1357
- end
1358
-
1359
- # In progress
1360
- if data[:in_progress]
1361
- parts << "In Progress: #{data[:in_progress]}"
1362
- end
1363
-
1364
- # Key decisions
1365
- if data[:decisions].any?
1366
- decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
1367
- parts << "Decisions: #{decisions_text}"
1368
- end
1369
-
1370
- # Tools used
1371
- if data[:tools_used].any?
1372
- parts << "Tools: #{data[:tools_used].join(', ')}"
1373
- end
1374
-
1375
- parts << "Continuing with recent conversation..."
1376
- parts.join("\n")
1377
- end
1378
-
1379
- # Level 2: Concise summary (for second compression)
1380
- private def generate_level2_summary(data)
1381
- parts = []
1382
-
1383
- parts << "Conversation summary:"
1384
-
1385
- # Key files (limit to most important)
1386
- all_files = (data[:files_created] + data[:files_modified]).uniq
1387
- if all_files.any?
1388
- key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
1389
- parts << "Files: #{key_files}"
1390
- end
1391
-
1392
- # Key accomplishments
1393
- accomplishments = []
1394
- accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
1395
- accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
1396
- accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
1397
-
1398
- parts << accomplishments.join(", ") if accomplishments.any?
1399
-
1400
- parts << "Recent context follows..."
1401
- parts.join("\n")
1402
- end
1403
-
1404
- # Level 3: Minimal summary (for third compression)
1405
- private def generate_level3_summary(data)
1406
- parts = []
1407
-
1408
- parts << "Project progress:"
1409
-
1410
- # Just counts and key items
1411
- all_files = (data[:files_created] + data[:files_modified]).uniq
1412
- parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
1413
-
1414
- if data[:in_progress]
1415
- parts << "Currently: #{data[:in_progress]}"
1416
- end
1417
-
1418
- parts << "See recent messages for details."
1419
- parts.join("\n")
1420
- end
1421
-
1422
- # Level 4: Ultra-minimal summary (for fourth+ compression)
1423
- private def generate_level4_summary(data)
1424
- all_files = (data[:files_created] + data[:files_modified]).uniq
1425
- "Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
1426
- end
1427
-
1428
- def get_recent_messages_with_tool_pairs(messages, count)
1429
- # This method ensures that assistant messages with tool_calls are always kept together
1430
- # with ALL their corresponding tool_results, maintaining the correct order.
1431
- # This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
1432
-
1433
- return [] if messages.nil? || messages.empty?
1434
-
1435
- # Track which messages to include
1436
- messages_to_include = Set.new
1437
-
1438
- # Start from the end and work backwards
1439
- i = messages.size - 1
1440
- messages_collected = 0
1441
-
1442
- while i >= 0 && messages_collected < count
1443
- msg = messages[i]
1444
-
1445
- # Skip if already marked for inclusion
1446
- if messages_to_include.include?(i)
1447
- i -= 1
1448
- next
1449
- end
1450
-
1451
- # Mark this message for inclusion
1452
- messages_to_include.add(i)
1453
- messages_collected += 1
1454
-
1455
- # If this is an assistant message with tool_calls, we MUST include ALL corresponding tool results
1456
- if msg[:role] == "assistant" && msg[:tool_calls]
1457
- tool_call_ids = msg[:tool_calls].map { |tc| tc[:id] }
1458
-
1459
- # Find all tool results that belong to this assistant message
1460
- # They should be in the messages immediately following this assistant message
1461
- j = i + 1
1462
- while j < messages.size
1463
- next_msg = messages[j]
1464
-
1465
- # If we find a tool result for one of our tool_calls, include it
1466
- if next_msg[:role] == "tool" && tool_call_ids.include?(next_msg[:tool_call_id])
1467
- messages_to_include.add(j)
1468
- elsif next_msg[:role] != "tool"
1469
- # Stop when we hit a non-tool message (start of next turn)
1470
- break
1471
- end
1472
-
1473
- j += 1
1474
- end
1475
- end
1476
-
1477
- # If this is a tool result, make sure its assistant message is also included
1478
- if msg[:role] == "tool"
1479
- # Find the corresponding assistant message
1480
- j = i - 1
1481
- while j >= 0
1482
- prev_msg = messages[j]
1483
- if prev_msg[:role] == "assistant" && prev_msg[:tool_calls]
1484
- # Check if this assistant has the matching tool_call
1485
- has_matching_call = prev_msg[:tool_calls].any? { |tc| tc[:id] == msg[:tool_call_id] }
1486
- if has_matching_call
1487
- unless messages_to_include.include?(j)
1488
- messages_to_include.add(j)
1489
- messages_collected += 1
1490
- end
1491
-
1492
- # Also include all other tool results for this assistant message
1493
- tool_call_ids = prev_msg[:tool_calls].map { |tc| tc[:id] }
1494
- k = j + 1
1495
- while k < messages.size
1496
- result_msg = messages[k]
1497
- if result_msg[:role] == "tool" && tool_call_ids.include?(result_msg[:tool_call_id])
1498
- messages_to_include.add(k)
1499
- elsif result_msg[:role] != "tool"
1500
- break
1501
- end
1502
- k += 1
1503
- end
1504
-
1505
- break
1506
- end
1507
- end
1508
- j -= 1
1509
- end
1510
- end
1511
-
1512
- i -= 1
1513
- end
1514
-
1515
- # Extract the messages in their original order
1516
- messages_to_include.to_a.sort.map { |idx| messages[idx] }
1517
- end
1518
-
1519
- def confirm_tool_use?(call)
1520
- # Show preview first and check for errors
1521
- preview_error = show_tool_preview(call)
1522
-
1523
- # If preview detected an error, auto-deny and provide feedback
1524
- if preview_error && preview_error[:error]
1525
- feedback = build_preview_error_feedback(call[:name], preview_error)
1526
- return { approved: false, feedback: feedback, system_injected: true }
1527
- end
1528
-
1529
- # Request confirmation via UI
1530
- if @ui
1531
- prompt_text = format_tool_prompt(call)
1532
- result = @ui.request_confirmation(prompt_text, default: true)
1533
-
1534
- case result
1535
- when true
1536
- { approved: true, feedback: nil }
1537
- when false, nil
1538
- # User denied - add visual marker based on tool type
1539
- tool_name_capitalized = call[:name].capitalize
1540
- @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
1541
- { approved: false, feedback: nil }
1542
- else
1543
- # String feedback - also add visual marker
1544
- tool_name_capitalized = call[:name].capitalize
1545
- @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
1546
- { approved: false, feedback: result.to_s }
1547
- end
1548
- else
1549
- # Fallback: auto-approve if no UI
1550
- { approved: true, feedback: nil }
1551
- end
1552
- end
1553
-
1554
- private def build_preview_error_feedback(tool_name, error_info)
1555
- case tool_name
1556
- when "edit"
1557
- "Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
1558
- "Please use file_reader to read '#{error_info[:path]}' first, " \
1559
- "find the correct string to replace, and try again with the exact string (including whitespace)."
1560
- else
1561
- "Tool preview error: #{error_info[:error]}"
1562
- end
1563
- end
1564
-
1565
- def format_tool_prompt(call)
1566
- begin
1567
- args = JSON.parse(call[:arguments], symbolize_names: true)
1568
-
1569
- # Try to use tool's format_call method for better formatting
1570
- tool = @tool_registry.get(call[:name]) rescue nil
1571
- if tool
1572
- formatted = tool.format_call(args) rescue nil
1573
- return formatted if formatted
1574
- end
1575
-
1576
- # Fallback to manual formatting for common tools
1577
- case call[:name]
1578
- when "edit"
1579
- path = args[:path] || args[:file_path]
1580
- filename = Utils::PathHelper.safe_basename(path)
1581
- "Edit(#{filename})"
1582
- when "write"
1583
- filename = Utils::PathHelper.safe_basename(args[:path])
1584
- if args[:path] && File.exist?(args[:path])
1585
- "Write(#{filename}) - overwrite existing"
1586
- else
1587
- "Write(#{filename}) - create new"
1588
- end
1589
- when "shell", "safe_shell"
1590
- cmd = args[:command] || ''
1591
- display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
1592
- "#{call[:name]}(\"#{display_cmd}\")"
1593
- else
1594
- "Allow #{call[:name]}"
1595
- end
1596
- rescue JSON::ParserError
1597
- "Allow #{call[:name]}"
1598
- end
1599
- end
1600
-
1601
- def show_tool_preview(call)
1602
- return nil unless @ui
1603
-
1604
- begin
1605
- args = JSON.parse(call[:arguments], symbolize_names: true)
1606
-
1607
- preview_error = nil
1608
- case call[:name]
1609
- when "write"
1610
- preview_error = show_write_preview(args)
1611
- when "edit"
1612
- preview_error = show_edit_preview(args)
1613
- when "shell", "safe_shell"
1614
- show_shell_preview(args)
1615
- else
1616
- # For other tools, show formatted arguments
1617
- tool = @tool_registry.get(call[:name]) rescue nil
1618
- if tool
1619
- formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
1620
- @ui&.show_tool_args(formatted)
1621
- else
1622
- @ui&.show_tool_args(call[:arguments])
1623
- end
1624
- end
1625
-
1626
- preview_error
1627
- rescue JSON::ParserError
1628
- @ui&.show_tool_args(call[:arguments])
1629
- nil
1630
- end
1631
- end
1632
-
1633
- def show_write_preview(args)
1634
- path = args[:path] || args['path']
1635
- new_content = args[:content] || args['content'] || ""
1636
-
1637
- is_new_file = !(path && File.exist?(path))
1638
- @ui&.show_file_write_preview(path, is_new_file: is_new_file)
1639
-
1640
- if is_new_file
1641
- @ui&.show_diff("", new_content, max_lines: 50)
1642
- else
1643
- old_content = File.read(path)
1644
- @ui&.show_diff(old_content, new_content, max_lines: 50)
1645
- end
1646
- nil
1647
- end
1648
-
1649
- def show_edit_preview(args)
1650
- path = args[:path] || args[:file_path] || args['path'] || args['file_path']
1651
- old_string = args[:old_string] || args['old_string'] || ""
1652
- new_string = args[:new_string] || args['new_string'] || ""
1653
-
1654
- @ui&.show_file_edit_preview(path)
1655
-
1656
- if !path || path.empty?
1657
- @ui&.show_file_error("No file path provided")
1658
- return { error: "No file path provided for edit operation" }
1659
- end
1660
-
1661
- unless File.exist?(path)
1662
- @ui&.show_file_error("File not found: #{path}")
1663
- return { error: "File not found: #{path}", path: path }
1664
- end
1665
-
1666
- if old_string.empty?
1667
- @ui&.show_file_error("No old_string provided (nothing to replace)")
1668
- return { error: "No old_string provided (nothing to replace)" }
1669
- end
1670
-
1671
- file_content = File.read(path)
1672
-
1673
- # Check if old_string exists in file
1674
- unless file_content.include?(old_string)
1675
- # Log debug info for troubleshooting
1676
- @debug_logs << {
1677
- timestamp: Time.now.iso8601,
1678
- event: "edit_preview_failed",
1679
- path: path,
1680
- looking_for: old_string[0..500],
1681
- file_content_preview: file_content[0..1000],
1682
- file_size: file_content.length
1683
- }
1684
-
1685
- @ui&.show_file_error("Edit file error")
1686
- return {
1687
- error: "String to replace not found in file",
1688
- path: path,
1689
- looking_for: old_string[0..200]
1690
- }
1691
- end
1692
-
1693
- new_content = file_content.sub(old_string, new_string)
1694
- @ui&.show_diff(file_content, new_content, max_lines: 50)
1695
- nil # No error
1696
- end
1697
-
1698
- def show_shell_preview(args)
1699
- command = args[:command] || ""
1700
- @ui&.show_shell_preview(command)
1701
- nil
1702
- end
1703
-
1704
- def build_success_result(call, result)
1705
- # Try to get tool instance to use its format_result_for_llm method
1706
- tool = @tool_registry.get(call[:name]) rescue nil
1707
-
1708
- formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
1709
- # Tool provides a custom LLM-friendly format
1710
- tool.format_result_for_llm(result)
1711
- else
1712
- # Fallback: use the original result
1713
- result
1714
- end
1715
-
1716
- # Inject TODO reminder for non-todo_manager tools
1717
- formatted_result = inject_todo_reminder(call[:name], formatted_result)
1718
-
1719
- {
1720
- id: call[:id],
1721
- content: JSON.generate(formatted_result)
1722
- }
1723
- end
1724
-
1725
- # Inject TODO reminder into tool results for non-todo_manager tools
1726
- # This helps AI remember to mark TODOs as complete after executing tasks
1727
- private def inject_todo_reminder(tool_name, result)
1728
- # Skip injection for todo_manager tool itself to avoid redundancy
1729
- return result if tool_name == "todo_manager"
1730
-
1731
- # Get pending TODOs
1732
- todo_tool = @tool_registry.get("todo_manager")
1733
- return result unless todo_tool
1734
-
1735
- pending_todos = begin
1736
- todo_result = todo_tool.execute(action: "list", todos_storage: @todos)
1737
- if todo_result.is_a?(Hash) && todo_result[:todos]
1738
- todo_result[:todos].select { |t| t[:status] == "pending" }
1739
- else
1740
- []
1741
- end
1742
- rescue
1743
- []
1744
- end
1745
-
1746
- # Only inject reminder if there are pending TODOs
1747
- return result unless pending_todos && !pending_todos.empty?
1748
-
1749
- # Create a friendly reminder message
1750
- reminder = "\n\n📋 REMINDER: You have #{pending_todos.length} pending TODO(s). " \
1751
- "After completing each task, remember to mark it as complete using " \
1752
- "todo_manager with action 'complete' and the task id."
1753
-
1754
- # Inject reminder based on result type
1755
- case result
1756
- when String
1757
- result + reminder
1758
- when Hash
1759
- result.merge({ _todo_reminder: reminder.strip })
1760
- when Array
1761
- result + [{ _todo_reminder: reminder.strip }]
1762
- else
1763
- result
1764
- end
1765
- end
1766
-
1767
- def build_error_result(call, error_message)
1768
- {
1769
- id: call[:id],
1770
- content: JSON.generate({ error: error_message })
1771
- }
1772
- end
1773
-
1774
- def build_denied_result(call, user_feedback = nil, system_injected = false)
1775
- if system_injected
1776
- # System-generated feedback (e.g., from preview errors)
1777
- tool_content = {
1778
- error: "Tool #{call[:name]} denied: #{user_feedback}",
1779
- system_injected: true
1780
- }
1781
- else
1782
- # User manually denied or provided feedback
1783
- message = if user_feedback && !user_feedback.empty?
1784
- "Tool use denied by user. User feedback: #{user_feedback}"
1785
- else
1786
- "Tool use denied by user"
1787
- end
1788
-
1789
- tool_content = {
1790
- error: message,
1791
- user_feedback: user_feedback
1792
- }
1793
- end
1794
-
1795
- {
1796
- id: call[:id],
1797
- content: JSON.generate(tool_content)
1798
- }
1799
- end
1800
-
1801
- def build_planned_result(call)
1802
- {
1803
- id: call[:id],
1804
- content: JSON.generate({ planned: true, message: "Tool execution skipped (plan mode)" })
1805
- }
1806
- end
1807
-
1808
- def build_result(status, error: nil)
552
+ private def build_result(status, error: nil)
1809
553
  # Calculate iterations for current task only
1810
554
  task_iterations = @iterations - (@task_start_iterations || 0)
1811
555
 
@@ -1825,7 +569,7 @@ module Clacky
1825
569
  }
1826
570
  end
1827
571
 
1828
- def format_tool_calls_for_api(tool_calls)
572
+ private def format_tool_calls_for_api(tool_calls)
1829
573
  return nil unless tool_calls
1830
574
 
1831
575
  tool_calls.map do |call|
@@ -1840,8 +584,7 @@ module Clacky
1840
584
  end
1841
585
  end
1842
586
 
1843
- def register_builtin_tools
1844
-
587
+ private def register_builtin_tools
1845
588
  @tool_registry.register(Tools::SafeShell.new)
1846
589
  @tool_registry.register(Tools::FileReader.new)
1847
590
  @tool_registry.register(Tools::Write.new)
@@ -1853,13 +596,148 @@ module Clacky
1853
596
  @tool_registry.register(Tools::TodoManager.new)
1854
597
  @tool_registry.register(Tools::RunProject.new)
1855
598
  @tool_registry.register(Tools::RequestUserFeedback.new)
599
+ @tool_registry.register(Tools::InvokeSkill.new)
600
+ @tool_registry.register(Tools::UndoTask.new)
601
+ @tool_registry.register(Tools::RedoTask.new)
602
+ @tool_registry.register(Tools::ListTasks.new)
603
+ end
604
+
605
+ # Fork a subagent with specified configuration
606
+ # The subagent inherits all messages and tools from parent agent
607
+ # Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
608
+ # @param model [String, nil] Model name to use (nil = use current model)
609
+ # @param forbidden_tools [Array<String>] List of tool names to forbid
610
+ # @param system_prompt_suffix [String, nil] Additional instructions (inserted as user message for cache reuse)
611
+ # @return [Agent] New subagent instance
612
+ def fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil)
613
+ # Clone config to avoid affecting parent
614
+ subagent_config = @config.dup
615
+
616
+ # Switch to specified model if provided
617
+ if model
618
+ if model == "lite"
619
+ # Special keyword: use lite model if available, otherwise fall back to default
620
+ lite_model = subagent_config.lite_model
621
+ if lite_model
622
+ model_index = subagent_config.models.index(lite_model)
623
+ subagent_config.switch_model(model_index) if model_index
624
+ end
625
+ # If no lite model, just use current (default) model
626
+ else
627
+ # Regular model name lookup
628
+ model_index = subagent_config.model_names.index(model)
629
+ if model_index
630
+ subagent_config.switch_model(model_index)
631
+ else
632
+ raise AgentError, "Model '#{model}' not found in config. Available models: #{subagent_config.model_names.join(', ')}"
633
+ end
634
+ end
635
+ end
636
+
637
+ # Create new client for subagent
638
+ subagent_client = Clacky::Client.new(
639
+ subagent_config.api_key,
640
+ base_url: subagent_config.base_url,
641
+ anthropic_format: subagent_config.anthropic_format?
642
+ )
643
+
644
+ # Create subagent (reuses all tools from parent)
645
+ subagent = self.class.new(
646
+ subagent_client,
647
+ subagent_config,
648
+ working_dir: @working_dir,
649
+ ui: @ui
650
+ )
651
+
652
+ # Deep clone messages to avoid cross-contamination
653
+ subagent.instance_variable_set(:@messages, deep_clone(@messages))
654
+
655
+ # Append system prompt suffix as user message (for cache reuse)
656
+ if system_prompt_suffix
657
+ messages = subagent.instance_variable_get(:@messages)
658
+
659
+ # Build forbidden tools notice if any tools are forbidden
660
+ forbidden_notice = if forbidden_tools.any?
661
+ tool_list = forbidden_tools.map { |t| "`#{t}`" }.join(", ")
662
+ "\n\n[System Notice] The following tools are disabled in this subagent and will be rejected if called: #{tool_list}"
663
+ else
664
+ ""
665
+ end
666
+
667
+ messages << {
668
+ role: "user",
669
+ content: "CRITICAL: TASK CONTEXT SWITCH - FORKED SUBAGENT MODE\n\nYou are now running as a forked subagent — a temporary, isolated agent spawned by the parent agent to handle a specific task. You run independently and cannot communicate back to the parent mid-task. When you finish (i.e., you stop calling tools and return a final response), your output will be automatically summarized and returned to the parent agent as a result so it can continue.\n\n#{system_prompt_suffix}#{forbidden_notice}",
670
+ system_injected: true,
671
+ subagent_instructions: true
672
+ }
673
+ end
674
+
675
+ # Register hook to forbid certain tools at runtime (doesn't affect tool registry for cache)
676
+ if forbidden_tools.any?
677
+ subagent.add_hook(:before_tool_use) do |call|
678
+ if forbidden_tools.include?(call[:name])
679
+ {
680
+ action: :deny,
681
+ reason: "Tool '#{call[:name]}' is forbidden in this subagent context"
682
+ }
683
+ else
684
+ { action: :allow }
685
+ end
686
+ end
687
+ end
688
+
689
+ # Mark subagent metadata for summary generation
690
+ subagent.instance_variable_set(:@is_subagent, true)
691
+ subagent.instance_variable_set(:@parent_message_count, @messages.length)
692
+
693
+ subagent
694
+ end
695
+
696
+ # Generate summary from subagent execution
697
+ # Extracts new messages added by subagent and creates a concise summary
698
+ # This summary will replace the subagent instructions message in parent agent
699
+ # @param subagent [Agent] The subagent that completed execution
700
+ # @return [String] Summary text to insert into parent agent
701
+ def generate_subagent_summary(subagent)
702
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
703
+ new_messages = subagent.messages[parent_count..-1] || []
704
+
705
+ # Extract tool calls
706
+ tool_calls = new_messages
707
+ .select { |m| m[:role] == "assistant" && m[:tool_calls] }
708
+ .flat_map { |m| m[:tool_calls].map { |tc| tc[:name] } }
709
+ .uniq
710
+
711
+ # Extract final assistant response
712
+ last_response = new_messages
713
+ .reverse
714
+ .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].empty? }
715
+ &.dig(:content)
716
+
717
+ # Build summary (this will replace the subagent instructions message)
718
+ parts = []
719
+ parts << "[SUBAGENT SUMMARY]"
720
+ parts << "Completed in #{subagent.iterations} iterations, cost: $#{subagent.total_cost.round(4)}"
721
+ parts << "Tools used: #{tool_calls.join(', ')}" if tool_calls.any?
722
+ parts << ""
723
+ parts << "Results:"
724
+ parts << (last_response || "(No response)")
725
+
726
+ parts.join("\n")
727
+ end
728
+
729
+ # Deep clone helper for messages using Marshal
730
+ # @param obj [Object] Object to clone
731
+ # @return [Object] Deep cloned object
732
+ private def deep_clone(obj)
733
+ Marshal.load(Marshal.dump(obj))
1856
734
  end
1857
735
 
1858
736
  # Format user content with optional images
1859
737
  # @param text [String] User's text input
1860
738
  # @param images [Array<String>] Array of image file paths
1861
739
  # @return [String|Array] String if no images, Array with text and image_url objects if images present
1862
- def format_user_content(text, images)
740
+ private def format_user_content(text, images)
1863
741
  return text if images.nil? || images.empty?
1864
742
 
1865
743
  content = []
@@ -1873,6 +751,18 @@ module Clacky
1873
751
  content
1874
752
  end
1875
753
 
1876
-
754
+ # Track modified files for Time Machine snapshots
755
+ # @param tool_name [String] Name of the tool that was executed
756
+ # @param args [Hash] Arguments passed to the tool
757
+ def track_modified_files(tool_name, args)
758
+ @modified_files_in_task ||= []
759
+
760
+ case tool_name
761
+ when "write", "edit"
762
+ file_path = args[:path]
763
+ full_path = File.expand_path(file_path, @working_dir)
764
+ @modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path)
765
+ end
766
+ end
1877
767
  end
1878
768
  end