openclacky 0.6.1 → 0.6.3

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.
data/lib/clacky/agent.rb CHANGED
@@ -4,13 +4,13 @@ require "securerandom"
4
4
  require "json"
5
5
  require "tty-prompt"
6
6
  require "set"
7
- require "base64"
8
7
  require_relative "utils/arguments_parser"
8
+ require_relative "utils/file_processor"
9
9
 
10
10
  module Clacky
11
11
  class Agent
12
12
  attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
13
- :cache_stats, :cost_source, :ui
13
+ :cache_stats, :cost_source, :ui, :skill_loader
14
14
 
15
15
  # System prompt for the coding agent
16
16
  SYSTEM_PROMPT = <<~PROMPT.freeze
@@ -49,6 +49,9 @@ module Clacky
49
49
  Adding todos is NOT completion - it's just the planning phase!
50
50
  Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
51
51
  NEVER stop after just adding todos without executing them!
52
+
53
+ NOTE: Available skills are listed below in the AVAILABLE SKILLS section.
54
+ When a user's request matches a skill, you MUST use the skill tool instead of implementing it yourself.
52
55
  PROMPT
53
56
 
54
57
  def initialize(client, config = {}, working_dir: nil, ui: nil)
@@ -79,6 +82,13 @@ module Clacky
79
82
  @ui = ui # UIController for direct UI interaction
80
83
  @debug_logs = [] # Debug logs for troubleshooting
81
84
 
85
+ # Compression tracking
86
+ @compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
87
+ @compressed_summaries = [] # Store summaries from previous compressions for reference
88
+
89
+ # Skill loader for skill management
90
+ @skill_loader = SkillLoader.new(@working_dir)
91
+
82
92
  # Register built-in tools
83
93
  register_builtin_tools
84
94
  end
@@ -108,6 +118,9 @@ module Clacky
108
118
  cache_hit_requests: 0
109
119
  }
110
120
 
121
+ # Restore previous_total_tokens for accurate delta calculation across sessions
122
+ @previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0
123
+
111
124
  # Check if the session ended with an error
112
125
  last_status = session_data.dig(:stats, :last_status)
113
126
  last_error = session_data.dig(:stats, :last_error)
@@ -137,7 +150,7 @@ module Clacky
137
150
  user_messages = @messages.select do |m|
138
151
  m[:role] == "user" && !m[:system_injected]
139
152
  end
140
-
153
+
141
154
  # Extract text content from the last N user messages
142
155
  user_messages.last(limit).map do |msg|
143
156
  extract_text_from_content(msg[:content])
@@ -163,10 +176,11 @@ module Clacky
163
176
  def run(user_input, images: [])
164
177
  @start_time = Time.now
165
178
  @task_cost_source = :estimated # Reset for new task
166
- @previous_total_tokens = 0 # Reset token tracking for new task
179
+ # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
180
+ # across tasks to correctly calculate delta tokens in each iteration
167
181
  @task_start_iterations = @iterations # Track starting iterations for this task
168
182
  @task_start_cost = @total_cost # Track starting cost for this task
169
-
183
+
170
184
  # Track cache stats for current task
171
185
  @task_cache_stats = {
172
186
  cache_creation_input_tokens: 0,
@@ -265,6 +279,100 @@ module Clacky
265
279
  end
266
280
  end
267
281
 
282
+ # ===== Skill-related methods =====
283
+
284
+ # Get the skill loader instance
285
+ # @return [SkillLoader]
286
+ def skill_loader
287
+ @skill_loader
288
+ end
289
+
290
+ # Load all skills from configured locations
291
+ # @return [Array<Skill>]
292
+ def load_skills
293
+ @skill_loader.load_all
294
+ end
295
+
296
+ # Check if input is a skill command and process it
297
+ # @param input [String] User input
298
+ # @return [Hash, nil] Returns { skill: Skill, arguments: String } if skill command, nil otherwise
299
+ def parse_skill_command(input)
300
+ # Check for slash command pattern
301
+ if input.start_with?("/")
302
+ # Extract command and arguments
303
+ match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
304
+ return nil unless match
305
+
306
+ skill_name = match[1]
307
+ arguments = match[2] || ""
308
+
309
+ # Find skill by command
310
+ skill = @skill_loader.find_by_command("/#{skill_name}")
311
+ return nil unless skill
312
+
313
+ # Check if user can invoke this skill
314
+ unless skill.user_invocable?
315
+ return nil
316
+ end
317
+
318
+ { skill: skill, arguments: arguments }
319
+ else
320
+ nil
321
+ end
322
+ end
323
+
324
+ # Execute a skill command
325
+ # @param input [String] User input (should be a skill command)
326
+ # @return [String] The expanded prompt with skill content
327
+ def execute_skill_command(input)
328
+ parsed = parse_skill_command(input)
329
+ return input unless parsed
330
+
331
+ skill = parsed[:skill]
332
+ arguments = parsed[:arguments]
333
+
334
+ # Process skill content with arguments
335
+ expanded_content = skill.process_content(arguments)
336
+
337
+ # Log skill usage
338
+ @ui&.log("Executing skill: #{skill.identifier}", level: :info)
339
+
340
+ expanded_content
341
+ end
342
+
343
+ # Generate skill context - loads all auto-invocable skills
344
+ # @return [String] Skill context to add to system prompt
345
+ def build_skill_context
346
+ # Load all auto-invocable skills
347
+ all_skills = @skill_loader.load_all
348
+ auto_invocable = all_skills.select(&:model_invocation_allowed?)
349
+
350
+ return "" if auto_invocable.empty?
351
+
352
+ context = "\n\n" + "=" * 80 + "\n"
353
+ context += "AVAILABLE SKILLS:\n"
354
+ context += "=" * 80 + "\n\n"
355
+ context += "CRITICAL SKILL USAGE RULES:\n"
356
+ context += "- When a user's request matches any available skill, this is a BLOCKING REQUIREMENT:\n"
357
+ context += " invoke the relevant skill tool BEFORE generating any other response about the task\n"
358
+ context += "- NEVER mention a skill without actually calling the skill tool\n"
359
+ context += "- NEVER implement the skill's functionality yourself - always delegate to the skill\n"
360
+ context += "- Skills provide specialized capabilities - use them instead of manual implementation\n"
361
+ context += "- When users reference '/<skill-name>' (e.g., '/pptx'), they are requesting a skill\n\n"
362
+ context += "Workflow: Use file_reader to read the SKILL.md file, then follow its instructions.\n\n"
363
+ context += "Available skills:\n\n"
364
+
365
+ auto_invocable.each do |skill|
366
+ skill_md_path = skill.directory.join("SKILL.md")
367
+ context += "- name: #{skill.identifier}\n"
368
+ context += " description: #{skill.context_description}\n"
369
+ context += " SKILL.md: #{skill_md_path}\n\n"
370
+ end
371
+
372
+ context += "\n"
373
+ context
374
+ end
375
+
268
376
  # Generate session data for saving
269
377
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
270
378
  # @param error_message [String] Error message if status is :error
@@ -294,7 +402,8 @@ module Clacky
294
402
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
295
403
  last_status: status.to_s,
296
404
  cache_stats: @cache_stats,
297
- debug_logs: @debug_logs
405
+ debug_logs: @debug_logs,
406
+ previous_total_tokens: @previous_total_tokens
298
407
  }
299
408
 
300
409
  # Add error message if status is error
@@ -406,6 +515,10 @@ module Clacky
406
515
  prompt += "=" * 80
407
516
  end
408
517
 
518
+ # Add all loaded skills to system prompt
519
+ skill_context = build_skill_context
520
+ prompt += skill_context if skill_context && !skill_context.empty?
521
+
409
522
  prompt
410
523
  end
411
524
 
@@ -538,7 +651,7 @@ module Clacky
538
651
  denial_message += ": #{confirmation[:feedback]}"
539
652
  end
540
653
  @ui&.show_warning(denial_message)
541
-
654
+
542
655
  denied = true
543
656
  user_feedback = confirmation[:feedback]
544
657
  feedback = user_feedback if user_feedback
@@ -570,6 +683,11 @@ module Clacky
570
683
  args[:todos_storage] = @todos
571
684
  end
572
685
 
686
+ # For safe_shell, skip safety check if user has already confirmed
687
+ if call[:name] == "safe_shell" || call[:name] == "shell"
688
+ args[:skip_safety_check] = true
689
+ end
690
+
573
691
  # Show progress for potentially slow tools (no prefix newline)
574
692
  if potentially_slow_tool?(call[:name], args)
575
693
  progress_message = build_tool_progress_message(call[:name], args)
@@ -592,6 +710,17 @@ module Clacky
592
710
  @ui&.show_tool_result(tool.format_result(result))
593
711
  results << build_success_result(call, result)
594
712
  rescue StandardError => e
713
+ # Log complete error information to debug_logs for troubleshooting
714
+ @debug_logs << {
715
+ timestamp: Time.now.iso8601,
716
+ event: "tool_execution_error",
717
+ tool_name: call[:name],
718
+ tool_args: call[:arguments],
719
+ error_class: e.class.name,
720
+ error_message: e.message,
721
+ backtrace: e.backtrace&.first(20) # Keep first 20 lines of backtrace
722
+ }
723
+
595
724
  @hooks.trigger(:on_tool_error, call, e)
596
725
  @ui&.show_tool_error(e)
597
726
  results << build_error_result(call, e.message)
@@ -666,7 +795,7 @@ module Clacky
666
795
  # Check if the command is a slow command
667
796
  command = args[:command] || args['command']
668
797
  return false unless command
669
-
798
+
670
799
  # List of slow command patterns
671
800
  slow_patterns = [
672
801
  /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
@@ -679,7 +808,7 @@ module Clacky
679
808
  /pytest/,
680
809
  /jest/
681
810
  ]
682
-
811
+
683
812
  slow_patterns.any? { |pattern| command.match?(pattern) }
684
813
  when 'web_fetch', 'web_search'
685
814
  true # Network operations can be slow
@@ -692,11 +821,7 @@ module Clacky
692
821
  private def build_tool_progress_message(tool_name, args)
693
822
  case tool_name.to_s.downcase
694
823
  when 'shell', 'safe_shell'
695
- command = args[:command] || args['command']
696
- # Extract the main command for display
697
- cmd_parts = command.to_s.split
698
- main_cmd = cmd_parts.first(2).join(' ')
699
- "Running #{main_cmd}"
824
+ "Running command"
700
825
  when 'web_fetch'
701
826
  "Fetching web page"
702
827
  when 'web_search'
@@ -755,15 +880,15 @@ module Clacky
755
880
  @cache_stats[:raw_api_usage_samples] << raw_api_usage
756
881
  @cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
757
882
  end
758
-
883
+
759
884
  # Track cache usage for current task
760
885
  if @task_cache_stats
761
886
  @task_cache_stats[:total_requests] += 1
762
-
887
+
763
888
  if usage[:cache_creation_input_tokens]
764
889
  @task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
765
890
  end
766
-
891
+
767
892
  if usage[:cache_read_input_tokens]
768
893
  @task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
769
894
  @task_cache_stats[:cache_hit_requests] += 1
@@ -798,41 +923,430 @@ module Clacky
798
923
  @ui&.show_token_usage(token_data)
799
924
  end
800
925
 
926
+ # Estimate token count for a message content
927
+ # Simple approximation: characters / 4 (English text)
928
+ # For Chinese/other languages, characters / 2 is more accurate
929
+ # This is a rough estimate for compression triggering purposes
930
+ private def estimate_tokens(content)
931
+ return 0 if content.nil?
932
+
933
+ text = if content.is_a?(String)
934
+ content
935
+ elsif content.is_a?(Array)
936
+ # Handle content arrays (e.g., with images)
937
+ # Add safety check to prevent nil.compact error
938
+ mapped = content.map { |c| c[:text] if c.is_a?(Hash) }
939
+ (mapped || []).compact.join
940
+ else
941
+ content.to_s
942
+ end
943
+
944
+ return 0 if text.empty?
945
+
946
+ # Detect language mix - count non-ASCII characters
947
+ ascii_count = text.bytes.count { |b| b < 128 }
948
+ total_bytes = text.bytes.length
949
+
950
+ # Mix ratio (1.0 = all English, 0.5 = all Chinese)
951
+ mix_ratio = total_bytes > 0 ? ascii_count.to_f / total_bytes : 1.0
952
+
953
+ # English: ~4 chars/token, Chinese: ~2 chars/token
954
+ base_chars_per_token = mix_ratio * 4 + (1 - mix_ratio) * 2
955
+
956
+ (text.length / base_chars_per_token).to_i + 50 # Add overhead for message structure
957
+ end
958
+
959
+ # Calculate total token count for all messages
960
+ # Returns estimated tokens and breakdown by category
961
+ private def total_message_tokens
962
+ system_tokens = 0
963
+ user_tokens = 0
964
+ assistant_tokens = 0
965
+ tool_tokens = 0
966
+ summary_tokens = 0
967
+
968
+ @messages.each do |msg|
969
+ tokens = estimate_tokens(msg[:content])
970
+ case msg[:role]
971
+ when "system"
972
+ system_tokens += tokens
973
+ when "user"
974
+ user_tokens += tokens
975
+ when "assistant"
976
+ assistant_tokens += tokens
977
+ when "tool"
978
+ tool_tokens += tokens
979
+ end
980
+ end
981
+
982
+ {
983
+ total: system_tokens + user_tokens + assistant_tokens + tool_tokens,
984
+ system: system_tokens,
985
+ user: user_tokens,
986
+ assistant: assistant_tokens,
987
+ tool: tool_tokens
988
+ }
989
+ end
990
+
991
+ # Compression thresholds
992
+ COMPRESSION_THRESHOLD = 80_000 # Trigger compression when exceeding this (in tokens)
993
+ MESSAGE_COUNT_THRESHOLD = 100 # Trigger compression when exceeding this (in message count)
994
+ TARGET_COMPRESSED_TOKENS = 70_000 # Target size after compression
995
+ MAX_RECENT_MESSAGES = 30 # Keep this many recent message pairs intact
996
+
801
997
  def compress_messages_if_needed
802
998
  # Check if compression is enabled
803
999
  return unless @config.enable_compression
804
1000
 
805
- # Only compress if we have more messages than threshold
806
- threshold = @config.keep_recent_messages + 80 # +80 to trigger at ~100 messages
807
- return if @messages.size <= threshold
1001
+ # Calculate total tokens and message count
1002
+ token_counts = total_message_tokens
1003
+ total_tokens = token_counts[:total]
1004
+ message_count = @messages.length
1005
+
1006
+ # Check if we should trigger compression
1007
+ # Either: token count exceeds threshold OR message count exceeds threshold
1008
+ token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
1009
+ message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
1010
+
1011
+ # Only compress if we exceed at least one threshold
1012
+ return unless token_threshold_exceeded || message_count_exceeded
1013
+
1014
+ # Calculate how much we need to reduce
1015
+ reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
1016
+
1017
+ # Don't compress if reduction is minimal (< 10% of current size)
1018
+ # Only apply this check when triggered by token threshold
1019
+ if token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
1020
+ return
1021
+ end
1022
+
1023
+ # If only message count threshold is exceeded, force compression
1024
+ # to keep conversation history manageable
1025
+
1026
+ # Calculate target size for recent messages based on compression level
1027
+ target_recent_count = calculate_target_recent_count(reduction_needed)
808
1028
 
809
- original_size = @messages.size
810
- target_size = @config.keep_recent_messages + 2
1029
+ # Increment compression level for progressive summarization
1030
+ @compression_level += 1
811
1031
 
812
- @ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
1032
+ original_tokens = total_tokens
1033
+
1034
+ @ui&.show_info("Compressing history (~#{original_tokens} tokens -> ~#{TARGET_COMPRESSED_TOKENS} tokens)...")
1035
+ @ui&.show_info("Compression level: #{@compression_level}")
813
1036
 
814
1037
  # Find the system message (should be first)
815
1038
  system_msg = @messages.find { |m| m[:role] == "system" }
816
1039
 
817
1040
  # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
818
- recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
1041
+ recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
1042
+ recent_messages = [] if recent_messages.nil?
819
1043
 
820
1044
  # Get messages to compress (everything except system and recent)
821
1045
  messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
822
1046
 
1047
+ @ui&.show_info(" debug: total=#{@messages.size}, recent=#{recent_messages.size}, to_compress=#{messages_to_compress.size}")
1048
+
823
1049
  return if messages_to_compress.empty?
824
1050
 
825
- # Create summary of compressed messages
826
- summary = summarize_messages(messages_to_compress)
1051
+ # Create hierarchical summary based on compression level
1052
+ summary = generate_hierarchical_summary(messages_to_compress)
827
1053
 
828
1054
  # Rebuild messages array: [system, summary, recent_messages]
829
1055
  rebuilt_messages = [system_msg, summary, *recent_messages].compact
830
1056
 
831
1057
  @messages = rebuilt_messages
832
1058
 
833
- final_size = @messages.size
1059
+ # Track this compression for progressive summarization
1060
+ @compressed_summaries << {
1061
+ level: @compression_level,
1062
+ message_count: messages_to_compress.size,
1063
+ timestamp: Time.now.iso8601
1064
+ }
1065
+
1066
+ final_tokens = total_message_tokens[:total]
1067
+
1068
+ @ui&.show_info("Compressed (~#{original_tokens} -> ~#{final_tokens} tokens, level #{@compression_level})")
1069
+ end
1070
+
1071
+ # Calculate how many recent messages to keep based on how much we need to compress
1072
+ private def calculate_target_recent_count(reduction_needed)
1073
+ # We want recent messages to be around 20-30% of the total target
1074
+ # This keeps the context window useful without being too large
1075
+ tokens_per_message = 500 # Average estimate for a message with content
1076
+
1077
+ # Target recent messages budget (~20% of target compressed size)
1078
+ recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
1079
+ target_messages = (recent_budget / tokens_per_message).to_i
1080
+
1081
+ # Clamp to reasonable bounds
1082
+ [[target_messages, 20].max, MAX_RECENT_MESSAGES].min
1083
+ end
1084
+
1085
+ # Generate hierarchical summary based on compression level
1086
+ # Level 1: Detailed summary with files, decisions, features
1087
+ # Level 2: Concise summary with key items
1088
+ # Level 3: Minimal summary (just project type)
1089
+ # Level 4+: Ultra-minimal (single line)
1090
+ private def generate_hierarchical_summary(messages)
1091
+ level = @compression_level
1092
+
1093
+ # Extract key information from messages
1094
+ extracted = extract_key_information(messages)
1095
+
1096
+ summary_text = case level
1097
+ when 1
1098
+ generate_level1_summary(extracted)
1099
+ when 2
1100
+ generate_level2_summary(extracted)
1101
+ when 3
1102
+ generate_level3_summary(extracted)
1103
+ else
1104
+ generate_level4_summary(extracted)
1105
+ end
1106
+
1107
+ {
1108
+ role: "user",
1109
+ content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
1110
+ system_injected: true,
1111
+ compression_level: level
1112
+ }
1113
+ end
1114
+
1115
+ # Extract key information from messages for summarization
1116
+ private def extract_key_information(messages)
1117
+ return empty_extraction_data if messages.nil?
1118
+
1119
+ {
1120
+ # Message counts
1121
+ user_msgs: messages.count { |m| m[:role] == "user" },
1122
+ assistant_msgs: messages.count { |m| m[:role] == "assistant" },
1123
+ tool_msgs: messages.count { |m| m[:role] == "tool" },
1124
+
1125
+ # Tools used
1126
+ tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
1127
+
1128
+ # Files created/modified
1129
+ files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
1130
+ files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
1131
+
1132
+ # Key decisions (limit to first 5)
1133
+ decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
1134
+
1135
+ # Completed tasks (from TODO results)
1136
+ completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
1137
+
1138
+ # Current in-progress work
1139
+ in_progress: find_in_progress(messages),
1140
+
1141
+ # Key results from shell commands
1142
+ shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
1143
+ }
1144
+ end
1145
+
1146
+ # Helper: safely extract from messages with proper nil handling
1147
+ private def extract_from_messages(messages, role_filter = nil, &block)
1148
+ return [] if messages.nil?
1149
+
1150
+ results = messages
1151
+ .select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
1152
+ .map(&block)
1153
+ .compact
1154
+
1155
+ # Flatten if we have nested arrays (from methods returning arrays of items)
1156
+ results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
1157
+ end
1158
+
1159
+ # Helper: extract tool names from tool_calls
1160
+ private def extract_tool_names(tool_calls)
1161
+ return [] unless tool_calls.is_a?(Array)
1162
+ tool_calls.map { |tc| tc.dig(:function, :name) }
1163
+ end
1164
+
1165
+ # Helper: filter write results by action
1166
+ private def filter_write_results(result, action)
1167
+ result && result[:action] == action ? result[:file] : nil
1168
+ end
1169
+
1170
+ # Helper: filter todo results by status
1171
+ private def filter_todo_results(result, status)
1172
+ result && result[:status] == status ? result[:task] : nil
1173
+ end
1174
+
1175
+ # Helper: extract decision text from content (returns array of decisions or empty array)
1176
+ private def extract_decision_text(content)
1177
+ return [] unless content.is_a?(String)
1178
+ return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
1179
+
1180
+ sentences = content.split(/[.!?]/).select do |s|
1181
+ s.include?("decision") || s.include?("chose") || s.include?("using") ||
1182
+ s.include?("decided") || s.include?("will use") || s.include?("selected")
1183
+ end
1184
+ sentences.map(&:strip).map { |s| s[0..100] }
1185
+ end
1186
+
1187
+ # Helper: find in-progress task
1188
+ private def find_in_progress(messages)
1189
+ return nil if messages.nil?
1190
+
1191
+ messages.reverse_each do |m|
1192
+ if m[:role] == "tool"
1193
+ content = m[:content].to_s
1194
+ if content.include?("in progress") || content.include?("working on")
1195
+ return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
1196
+ end
1197
+ end
1198
+ end
1199
+ nil
1200
+ end
1201
+
1202
+ # Helper: empty extraction data
1203
+ private def empty_extraction_data
1204
+ {
1205
+ user_msgs: 0,
1206
+ assistant_msgs: 0,
1207
+ tool_msgs: 0,
1208
+ tools_used: [],
1209
+ files_created: [],
1210
+ files_modified: [],
1211
+ decisions: [],
1212
+ completed_tasks: [],
1213
+ in_progress: nil,
1214
+ shell_results: []
1215
+ }
1216
+ end
1217
+
1218
+ private def parse_write_result(content)
1219
+ return nil unless content.is_a?(String)
1220
+
1221
+ # Check for "Created: path" or "Updated: path" patterns
1222
+ if content.include?("Created:")
1223
+ { action: "created", file: content[/Created:\s*(.+)/, 1]&.strip }
1224
+ elsif content.include?("Updated:") || content.include?("modified")
1225
+ { action: "modified", file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
1226
+ else
1227
+ nil
1228
+ end
1229
+ end
1230
+
1231
+ private def parse_todo_result(content)
1232
+ return nil unless content.is_a?(String)
1233
+
1234
+ if content.include?("completed")
1235
+ { status: "completed", task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
1236
+ elsif content.include?("added")
1237
+ { status: "added", task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
1238
+ else
1239
+ nil
1240
+ end
1241
+ end
1242
+
1243
+ private def parse_shell_result(content)
1244
+ return nil unless content.is_a?(String)
1245
+
1246
+ if content.include?("passed") || content.include?("success")
1247
+ "tests passed"
1248
+ elsif content.include?("failed") || content.include?("error")
1249
+ "command failed"
1250
+ elsif content =~ /bundle install|npm install|go mod download/
1251
+ "dependencies installed"
1252
+ elsif content.include?("Installed")
1253
+ content[/Installed:\s*(.+)/, 1]&.strip
1254
+ else
1255
+ nil
1256
+ end
1257
+ end
1258
+
1259
+ # Level 1: Detailed summary (for first compression)
1260
+ private def generate_level1_summary(data)
1261
+ parts = []
1262
+
1263
+ parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
1264
+
1265
+ # Files created
1266
+ if data[:files_created].any?
1267
+ files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
1268
+ parts << "Created: #{files_list}"
1269
+ end
1270
+
1271
+ # Files modified
1272
+ if data[:files_modified].any?
1273
+ files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
1274
+ parts << "Modified: #{files_list}"
1275
+ end
1276
+
1277
+ # Completed tasks
1278
+ if data[:completed_tasks].any?
1279
+ tasks_list = data[:completed_tasks].first(3).join(", ")
1280
+ parts << "Completed: #{tasks_list}"
1281
+ end
1282
+
1283
+ # In progress
1284
+ if data[:in_progress]
1285
+ parts << "In Progress: #{data[:in_progress]}"
1286
+ end
1287
+
1288
+ # Key decisions
1289
+ if data[:decisions].any?
1290
+ decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
1291
+ parts << "Decisions: #{decisions_text}"
1292
+ end
1293
+
1294
+ # Tools used
1295
+ if data[:tools_used].any?
1296
+ parts << "Tools: #{data[:tools_used].join(', ')}"
1297
+ end
1298
+
1299
+ parts << "Continuing with recent conversation..."
1300
+ parts.join("\n")
1301
+ end
1302
+
1303
+ # Level 2: Concise summary (for second compression)
1304
+ private def generate_level2_summary(data)
1305
+ parts = []
1306
+
1307
+ parts << "Conversation summary:"
1308
+
1309
+ # Key files (limit to most important)
1310
+ all_files = (data[:files_created] + data[:files_modified]).uniq
1311
+ if all_files.any?
1312
+ key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
1313
+ parts << "Files: #{key_files}"
1314
+ end
1315
+
1316
+ # Key accomplishments
1317
+ accomplishments = []
1318
+ accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
1319
+ accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
1320
+ accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
1321
+
1322
+ parts << accomplishments.join(", ") if accomplishments.any?
1323
+
1324
+ parts << "Recent context follows..."
1325
+ parts.join("\n")
1326
+ end
1327
+
1328
+ # Level 3: Minimal summary (for third compression)
1329
+ private def generate_level3_summary(data)
1330
+ parts = []
834
1331
 
835
- @ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
1332
+ parts << "Project progress:"
1333
+
1334
+ # Just counts and key items
1335
+ all_files = (data[:files_created] + data[:files_modified]).uniq
1336
+ parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
1337
+
1338
+ if data[:in_progress]
1339
+ parts << "Currently: #{data[:in_progress]}"
1340
+ end
1341
+
1342
+ parts << "See recent messages for details."
1343
+ parts.join("\n")
1344
+ end
1345
+
1346
+ # Level 4: Ultra-minimal summary (for fourth+ compression)
1347
+ private def generate_level4_summary(data)
1348
+ all_files = (data[:files_created] + data[:files_modified]).uniq
1349
+ "Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
836
1350
  end
837
1351
 
838
1352
  def get_recent_messages_with_tool_pairs(messages, count)
@@ -840,7 +1354,7 @@ module Clacky
840
1354
  # with ALL their corresponding tool_results, maintaining the correct order.
841
1355
  # This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
842
1356
 
843
- return [] if messages.empty?
1357
+ return [] if messages.nil? || messages.empty?
844
1358
 
845
1359
  # Track which messages to include
846
1360
  messages_to_include = Set.new
@@ -926,41 +1440,6 @@ module Clacky
926
1440
  messages_to_include.to_a.sort.map { |idx| messages[idx] }
927
1441
  end
928
1442
 
929
- def summarize_messages(messages)
930
- # Count different message types
931
- user_msgs = messages.count { |m| m[:role] == "user" }
932
- assistant_msgs = messages.count { |m| m[:role] == "assistant" }
933
- tool_msgs = messages.count { |m| m[:role] == "tool" }
934
-
935
- # Extract key information
936
- tools_used = messages
937
- .select { |m| m[:role] == "assistant" && m[:tool_calls] }
938
- .flat_map { |m| m[:tool_calls].map { |tc| tc.dig(:function, :name) } }
939
- .compact
940
- .uniq
941
-
942
- # Count completed tasks from tool results
943
- completed_todos = messages
944
- .select { |m| m[:role] == "tool" }
945
- .map { |m| JSON.parse(m[:content]) rescue nil }
946
- .compact
947
- .select { |data| data.is_a?(Hash) && data["message"]&.include?("completed") }
948
- .size
949
-
950
- summary_text = "Previous conversation summary (#{messages.size} messages compressed):\n"
951
- summary_text += "- User requests: #{user_msgs}\n"
952
- summary_text += "- Assistant responses: #{assistant_msgs}\n"
953
- summary_text += "- Tool executions: #{tool_msgs}\n"
954
- summary_text += "- Tools used: #{tools_used.join(', ')}\n" if tools_used.any?
955
- summary_text += "- Completed tasks: #{completed_todos}\n" if completed_todos > 0
956
- summary_text += "\nContinuing with recent conversation context..."
957
-
958
- {
959
- role: "user",
960
- content: "[SYSTEM] " + summary_text
961
- }
962
- end
963
-
964
1443
  def confirm_tool_use?(call)
965
1444
  # Show preview first and check for errors
966
1445
  preview_error = show_tool_preview(call)
@@ -1181,12 +1660,19 @@ module Clacky
1181
1660
  "Tool use denied by user"
1182
1661
  end
1183
1662
 
1663
+ # For edit tool, remind AI to use the exact same old_string from the previous tool call
1664
+ tool_content = {
1665
+ error: message,
1666
+ user_feedback: user_feedback
1667
+ }
1668
+
1669
+ if call[:name] == "edit"
1670
+ tool_content[:hint] = "Keep old_string unchanged. Simply re-read the file if needed and retry with the exact same old_string."
1671
+ end
1672
+
1184
1673
  {
1185
1674
  id: call[:id],
1186
- content: JSON.generate({
1187
- error: message,
1188
- user_feedback: user_feedback
1189
- })
1675
+ content: JSON.generate(tool_content)
1190
1676
  }
1191
1677
  end
1192
1678
 
@@ -1200,10 +1686,10 @@ module Clacky
1200
1686
  def build_result(status, error: nil)
1201
1687
  # Calculate iterations for current task only
1202
1688
  task_iterations = @iterations - (@task_start_iterations || 0)
1203
-
1689
+
1204
1690
  # Calculate cost for current task only
1205
1691
  task_cost = @total_cost - (@task_start_cost || 0)
1206
-
1692
+
1207
1693
  {
1208
1694
  status: status,
1209
1695
  session_id: @session_id,
@@ -1257,64 +1743,13 @@ module Clacky
1257
1743
  content << { type: "text", text: text } unless text.nil? || text.empty?
1258
1744
 
1259
1745
  images.each do |image_path|
1260
- image_url = image_path_to_data_url(image_path)
1746
+ image_url = Utils::FileProcessor.image_path_to_data_url(image_path)
1261
1747
  content << { type: "image_url", image_url: { url: image_url } }
1262
1748
  end
1263
1749
 
1264
1750
  content
1265
1751
  end
1266
1752
 
1267
- # Convert image file path to base64 data URL
1268
- # @param path [String] File path to image
1269
- # @return [String] base64 data URL (e.g., "data:image/png;base64,...")
1270
- def image_path_to_data_url(path)
1271
- unless File.exist?(path)
1272
- raise ArgumentError, "Image file not found: #{path}"
1273
- end
1274
-
1275
- # Read file as binary
1276
- image_data = File.binread(path)
1277
-
1278
- # Detect MIME type from file extension or content
1279
- mime_type = detect_image_mime_type(path, image_data)
1280
-
1281
- # Encode to base64
1282
- base64_data = Base64.strict_encode64(image_data)
1283
-
1284
- "data:#{mime_type};base64,#{base64_data}"
1285
- end
1286
1753
 
1287
- # Detect image MIME type
1288
- # @param path [String] File path
1289
- # @param data [String] Binary image data
1290
- # @return [String] MIME type (e.g., "image/png")
1291
- def detect_image_mime_type(path, data)
1292
- # Try to detect from file extension first
1293
- ext = File.extname(path).downcase
1294
- case ext
1295
- when ".png"
1296
- "image/png"
1297
- when ".jpg", ".jpeg"
1298
- "image/jpeg"
1299
- when ".gif"
1300
- "image/gif"
1301
- when ".webp"
1302
- "image/webp"
1303
- else
1304
- # Try to detect from file signature (magic bytes)
1305
- if data.start_with?("\x89PNG".b)
1306
- "image/png"
1307
- elsif data.start_with?("\xFF\xD8\xFF".b)
1308
- "image/jpeg"
1309
- elsif data.start_with?("GIF87a".b) || data.start_with?("GIF89a".b)
1310
- "image/gif"
1311
- elsif data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
1312
- "image/webp"
1313
- else
1314
- # Default to png if unknown
1315
- "image/png"
1316
- end
1317
- end
1318
- end
1319
1754
  end
1320
1755
  end