openclacky 0.6.2 → 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
@@ -10,7 +10,7 @@ require_relative "utils/file_processor"
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)
@@ -266,6 +279,100 @@ module Clacky
266
279
  end
267
280
  end
268
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
+
269
376
  # Generate session data for saving
270
377
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
271
378
  # @param error_message [String] Error message if status is :error
@@ -295,7 +402,8 @@ module Clacky
295
402
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
296
403
  last_status: status.to_s,
297
404
  cache_stats: @cache_stats,
298
- debug_logs: @debug_logs
405
+ debug_logs: @debug_logs,
406
+ previous_total_tokens: @previous_total_tokens
299
407
  }
300
408
 
301
409
  # Add error message if status is error
@@ -407,6 +515,10 @@ module Clacky
407
515
  prompt += "=" * 80
408
516
  end
409
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
+
410
522
  prompt
411
523
  end
412
524
 
@@ -571,6 +683,11 @@ module Clacky
571
683
  args[:todos_storage] = @todos
572
684
  end
573
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
+
574
691
  # Show progress for potentially slow tools (no prefix newline)
575
692
  if potentially_slow_tool?(call[:name], args)
576
693
  progress_message = build_tool_progress_message(call[:name], args)
@@ -593,6 +710,17 @@ module Clacky
593
710
  @ui&.show_tool_result(tool.format_result(result))
594
711
  results << build_success_result(call, result)
595
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
+
596
724
  @hooks.trigger(:on_tool_error, call, e)
597
725
  @ui&.show_tool_error(e)
598
726
  results << build_error_result(call, e.message)
@@ -795,41 +923,430 @@ module Clacky
795
923
  @ui&.show_token_usage(token_data)
796
924
  end
797
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
+
798
997
  def compress_messages_if_needed
799
998
  # Check if compression is enabled
800
999
  return unless @config.enable_compression
801
1000
 
802
- # Only compress if we have more messages than threshold
803
- threshold = @config.keep_recent_messages + 80 # +80 to trigger at ~100 messages
804
- 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
805
1005
 
806
- original_size = @messages.size
807
- target_size = @config.keep_recent_messages + 2
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
808
1010
 
809
- @ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
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)
1028
+
1029
+ # Increment compression level for progressive summarization
1030
+ @compression_level += 1
1031
+
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}")
810
1036
 
811
1037
  # Find the system message (should be first)
812
1038
  system_msg = @messages.find { |m| m[:role] == "system" }
813
1039
 
814
1040
  # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
815
- 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?
816
1043
 
817
1044
  # Get messages to compress (everything except system and recent)
818
1045
  messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
819
1046
 
1047
+ @ui&.show_info(" debug: total=#{@messages.size}, recent=#{recent_messages.size}, to_compress=#{messages_to_compress.size}")
1048
+
820
1049
  return if messages_to_compress.empty?
821
1050
 
822
- # Create summary of compressed messages
823
- summary = summarize_messages(messages_to_compress)
1051
+ # Create hierarchical summary based on compression level
1052
+ summary = generate_hierarchical_summary(messages_to_compress)
824
1053
 
825
1054
  # Rebuild messages array: [system, summary, recent_messages]
826
1055
  rebuilt_messages = [system_msg, summary, *recent_messages].compact
827
1056
 
828
1057
  @messages = rebuilt_messages
829
1058
 
830
- 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
831
1164
 
832
- @ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
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 = []
1331
+
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(', ')}"
833
1350
  end
834
1351
 
835
1352
  def get_recent_messages_with_tool_pairs(messages, count)
@@ -837,7 +1354,7 @@ module Clacky
837
1354
  # with ALL their corresponding tool_results, maintaining the correct order.
838
1355
  # This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
839
1356
 
840
- return [] if messages.empty?
1357
+ return [] if messages.nil? || messages.empty?
841
1358
 
842
1359
  # Track which messages to include
843
1360
  messages_to_include = Set.new
@@ -923,42 +1440,6 @@ module Clacky
923
1440
  messages_to_include.to_a.sort.map { |idx| messages[idx] }
924
1441
  end
925
1442
 
926
- def summarize_messages(messages)
927
- # Count different message types
928
- user_msgs = messages.count { |m| m[:role] == "user" }
929
- assistant_msgs = messages.count { |m| m[:role] == "assistant" }
930
- tool_msgs = messages.count { |m| m[:role] == "tool" }
931
-
932
- # Extract key information
933
- tools_used = messages
934
- .select { |m| m[:role] == "assistant" && m[:tool_calls] }
935
- .flat_map { |m| m[:tool_calls].map { |tc| tc.dig(:function, :name) } }
936
- .compact
937
- .uniq
938
-
939
- # Count completed tasks from tool results
940
- completed_todos = messages
941
- .select { |m| m[:role] == "tool" }
942
- .map { |m| JSON.parse(m[:content]) rescue nil }
943
- .compact
944
- .select { |data| data.is_a?(Hash) && data["message"]&.include?("completed") }
945
- .size
946
-
947
- summary_text = "Previous conversation summary (#{messages.size} messages compressed):\n"
948
- summary_text += "- User requests: #{user_msgs}\n"
949
- summary_text += "- Assistant responses: #{assistant_msgs}\n"
950
- summary_text += "- Tool executions: #{tool_msgs}\n"
951
- summary_text += "- Tools used: #{tools_used.join(', ')}\n" if tools_used.any?
952
- summary_text += "- Completed tasks: #{completed_todos}\n" if completed_todos > 0
953
- summary_text += "\nContinuing with recent conversation context..."
954
-
955
- {
956
- role: "user",
957
- content: "[SYSTEM] " + summary_text,
958
- system_injected: true
959
- }
960
- end
961
-
962
1443
  def confirm_tool_use?(call)
963
1444
  # Show preview first and check for errors
964
1445
  preview_error = show_tool_preview(call)
@@ -1179,12 +1660,19 @@ module Clacky
1179
1660
  "Tool use denied by user"
1180
1661
  end
1181
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
+
1182
1673
  {
1183
1674
  id: call[:id],
1184
- content: JSON.generate({
1185
- error: message,
1186
- user_feedback: user_feedback
1187
- })
1675
+ content: JSON.generate(tool_content)
1188
1676
  }
1189
1677
  end
1190
1678