openclacky 0.6.2 → 0.6.4

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)
@@ -213,6 +226,17 @@ module Clacky
213
226
  # Check if done (no more tool calls needed)
214
227
  if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
215
228
  @ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
229
+
230
+ # Debug: log why we're stopping
231
+ if @config.verbose && (response[:tool_calls].nil? || response[:tool_calls].empty?)
232
+ reason = response[:finish_reason] == "stop" ? "API returned finish_reason=stop" : "No tool calls in response"
233
+ @ui&.log("Stopping: #{reason}", level: :debug)
234
+ if response[:content] && response[:content].is_a?(String)
235
+ preview = response[:content].length > 200 ? response[:content][0...200] + "..." : response[:content]
236
+ @ui&.log("Response content: #{preview}", level: :debug)
237
+ end
238
+ end
239
+
216
240
  break
217
241
  end
218
242
 
@@ -266,6 +290,100 @@ module Clacky
266
290
  end
267
291
  end
268
292
 
293
+ # ===== Skill-related methods =====
294
+
295
+ # Get the skill loader instance
296
+ # @return [SkillLoader]
297
+ def skill_loader
298
+ @skill_loader
299
+ end
300
+
301
+ # Load all skills from configured locations
302
+ # @return [Array<Skill>]
303
+ def load_skills
304
+ @skill_loader.load_all
305
+ end
306
+
307
+ # Check if input is a skill command and process it
308
+ # @param input [String] User input
309
+ # @return [Hash, nil] Returns { skill: Skill, arguments: String } if skill command, nil otherwise
310
+ def parse_skill_command(input)
311
+ # Check for slash command pattern
312
+ if input.start_with?("/")
313
+ # Extract command and arguments
314
+ match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
315
+ return nil unless match
316
+
317
+ skill_name = match[1]
318
+ arguments = match[2] || ""
319
+
320
+ # Find skill by command
321
+ skill = @skill_loader.find_by_command("/#{skill_name}")
322
+ return nil unless skill
323
+
324
+ # Check if user can invoke this skill
325
+ unless skill.user_invocable?
326
+ return nil
327
+ end
328
+
329
+ { skill: skill, arguments: arguments }
330
+ else
331
+ nil
332
+ end
333
+ end
334
+
335
+ # Execute a skill command
336
+ # @param input [String] User input (should be a skill command)
337
+ # @return [String] The expanded prompt with skill content
338
+ def execute_skill_command(input)
339
+ parsed = parse_skill_command(input)
340
+ return input unless parsed
341
+
342
+ skill = parsed[:skill]
343
+ arguments = parsed[:arguments]
344
+
345
+ # Process skill content with arguments
346
+ expanded_content = skill.process_content(arguments)
347
+
348
+ # Log skill usage
349
+ @ui&.log("Executing skill: #{skill.identifier}", level: :info)
350
+
351
+ expanded_content
352
+ end
353
+
354
+ # Generate skill context - loads all auto-invocable skills
355
+ # @return [String] Skill context to add to system prompt
356
+ def build_skill_context
357
+ # Load all auto-invocable skills
358
+ all_skills = @skill_loader.load_all
359
+ auto_invocable = all_skills.select(&:model_invocation_allowed?)
360
+
361
+ return "" if auto_invocable.empty?
362
+
363
+ context = "\n\n" + "=" * 80 + "\n"
364
+ context += "AVAILABLE SKILLS:\n"
365
+ context += "=" * 80 + "\n\n"
366
+ context += "CRITICAL SKILL USAGE RULES:\n"
367
+ context += "- When a user's request matches any available skill, this is a BLOCKING REQUIREMENT:\n"
368
+ context += " invoke the relevant skill tool BEFORE generating any other response about the task\n"
369
+ context += "- NEVER mention a skill without actually calling the skill tool\n"
370
+ context += "- NEVER implement the skill's functionality yourself - always delegate to the skill\n"
371
+ context += "- Skills provide specialized capabilities - use them instead of manual implementation\n"
372
+ context += "- When users reference '/<skill-name>' (e.g., '/pptx'), they are requesting a skill\n\n"
373
+ context += "Workflow: Use file_reader to read the SKILL.md file, then follow its instructions.\n\n"
374
+ context += "Available skills:\n\n"
375
+
376
+ auto_invocable.each do |skill|
377
+ skill_md_path = skill.directory.join("SKILL.md")
378
+ context += "- name: #{skill.identifier}\n"
379
+ context += " description: #{skill.context_description}\n"
380
+ context += " SKILL.md: #{skill_md_path}\n\n"
381
+ end
382
+
383
+ context += "\n"
384
+ context
385
+ end
386
+
269
387
  # Generate session data for saving
270
388
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
271
389
  # @param error_message [String] Error message if status is :error
@@ -295,7 +413,8 @@ module Clacky
295
413
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
296
414
  last_status: status.to_s,
297
415
  cache_stats: @cache_stats,
298
- debug_logs: @debug_logs
416
+ debug_logs: @debug_logs,
417
+ previous_total_tokens: @previous_total_tokens
299
418
  }
300
419
 
301
420
  # Add error message if status is error
@@ -407,6 +526,10 @@ module Clacky
407
526
  prompt += "=" * 80
408
527
  end
409
528
 
529
+ # Add all loaded skills to system prompt
530
+ skill_context = build_skill_context
531
+ prompt += skill_context if skill_context && !skill_context.empty?
532
+
410
533
  prompt
411
534
  end
412
535
 
@@ -414,7 +537,7 @@ module Clacky
414
537
  @ui&.show_progress
415
538
 
416
539
  # Compress messages if needed to reduce cost
417
- compress_messages_if_needed
540
+ compression_message = compress_messages_if_needed
418
541
 
419
542
  # Always send tools definitions to allow multi-step tool calling
420
543
  tools_to_send = @tool_registry.all_definitions
@@ -440,13 +563,16 @@ module Clacky
440
563
  retry
441
564
  else
442
565
  @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
443
- raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
566
+ raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
444
567
  end
445
568
  end
446
569
 
447
570
  # Clear progress indicator (change to gray and show final time)
448
571
  @ui&.clear_progress
449
572
 
573
+ # Show compression message after clearing progress (so it doesn't get deleted)
574
+ @ui&.show_info(compression_message) if compression_message
575
+
450
576
  track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
451
577
 
452
578
  # Handle truncated responses (when max_tokens limit is reached)
@@ -501,7 +627,10 @@ module Clacky
501
627
  # Always include content field (some APIs require it even with tool_calls)
502
628
  # Use empty string instead of null for better compatibility
503
629
  msg[:content] = response[:content] || ""
504
- msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
630
+ # Only add tool_calls if they actually exist (don't add empty arrays)
631
+ if response[:tool_calls]&.any?
632
+ msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls])
633
+ end
505
634
  @messages << msg
506
635
 
507
636
  response
@@ -543,7 +672,8 @@ module Clacky
543
672
  denied = true
544
673
  user_feedback = confirmation[:feedback]
545
674
  feedback = user_feedback if user_feedback
546
- results << build_denied_result(call, user_feedback)
675
+ system_injected = confirmation[:system_injected]
676
+ results << build_denied_result(call, user_feedback, system_injected)
547
677
 
548
678
  # Auto-deny all remaining tools
549
679
  remaining_calls = tool_calls[(index + 1)..-1] || []
@@ -551,7 +681,7 @@ module Clacky
551
681
  reason = user_feedback && !user_feedback.empty? ?
552
682
  user_feedback :
553
683
  "Auto-denied due to user rejection of previous tool"
554
- results << build_denied_result(remaining_call, reason)
684
+ results << build_denied_result(remaining_call, reason, system_injected)
555
685
  end
556
686
  break
557
687
  end
@@ -571,6 +701,11 @@ module Clacky
571
701
  args[:todos_storage] = @todos
572
702
  end
573
703
 
704
+ # For safe_shell, skip safety check if user has already confirmed
705
+ if call[:name] == "safe_shell" || call[:name] == "shell"
706
+ args[:skip_safety_check] = true
707
+ end
708
+
574
709
  # Show progress for potentially slow tools (no prefix newline)
575
710
  if potentially_slow_tool?(call[:name], args)
576
711
  progress_message = build_tool_progress_message(call[:name], args)
@@ -593,9 +728,21 @@ module Clacky
593
728
  @ui&.show_tool_result(tool.format_result(result))
594
729
  results << build_success_result(call, result)
595
730
  rescue StandardError => e
731
+ # Log complete error information to debug_logs for troubleshooting
732
+ @debug_logs << {
733
+ timestamp: Time.now.iso8601,
734
+ event: "tool_execution_error",
735
+ tool_name: call[:name],
736
+ tool_args: call[:arguments],
737
+ error_class: e.class.name,
738
+ error_message: e.message,
739
+ backtrace: e.backtrace&.first(20) # Keep first 20 lines of backtrace
740
+ }
741
+
596
742
  @hooks.trigger(:on_tool_error, call, e)
597
743
  @ui&.show_tool_error(e)
598
- results << build_error_result(call, e.message)
744
+ # Use build_denied_result with system_injected=true so LLM knows it can retry
745
+ results << build_denied_result(call, e.message, true)
599
746
  end
600
747
  end
601
748
 
@@ -608,35 +755,11 @@ module Clacky
608
755
 
609
756
  def observe(response, tool_results)
610
757
  # Add tool results as messages
611
- # Using OpenAI format which is compatible with most APIs through LiteLLM
612
-
613
- # CRITICAL: Tool results must be in the same order as tool_calls in the response
614
- # Claude/Bedrock API requires this strict ordering
758
+ # Use Client to format results based on API type (Anthropic vs OpenAI)
615
759
  return if tool_results.empty?
616
760
 
617
- # Create a map of tool_call_id -> result for quick lookup
618
- results_map = tool_results.each_with_object({}) do |result, hash|
619
- hash[result[:id]] = result
620
- end
621
-
622
- # Add results in the same order as the original tool_calls
623
- response[:tool_calls].each do |tool_call|
624
- result = results_map[tool_call[:id]]
625
- if result
626
- @messages << {
627
- role: "tool",
628
- tool_call_id: result[:id],
629
- content: result[:content]
630
- }
631
- else
632
- # This shouldn't happen, but add a fallback error result
633
- @messages << {
634
- role: "tool",
635
- tool_call_id: tool_call[:id],
636
- content: JSON.generate({ error: "Tool result missing" })
637
- }
638
- end
639
- end
761
+ formatted_messages = @client.format_tool_results(response, tool_results, model: @config.model)
762
+ formatted_messages.each { |msg| @messages << msg }
640
763
  end
641
764
 
642
765
  # Interrupt the agent's current run
@@ -795,41 +918,426 @@ module Clacky
795
918
  @ui&.show_token_usage(token_data)
796
919
  end
797
920
 
921
+ # Estimate token count for a message content
922
+ # Simple approximation: characters / 4 (English text)
923
+ # For Chinese/other languages, characters / 2 is more accurate
924
+ # This is a rough estimate for compression triggering purposes
925
+ private def estimate_tokens(content)
926
+ return 0 if content.nil?
927
+
928
+ text = if content.is_a?(String)
929
+ content
930
+ elsif content.is_a?(Array)
931
+ # Handle content arrays (e.g., with images)
932
+ # Add safety check to prevent nil.compact error
933
+ mapped = content.map { |c| c[:text] if c.is_a?(Hash) }
934
+ (mapped || []).compact.join
935
+ else
936
+ content.to_s
937
+ end
938
+
939
+ return 0 if text.empty?
940
+
941
+ # Detect language mix - count non-ASCII characters
942
+ ascii_count = text.bytes.count { |b| b < 128 }
943
+ total_bytes = text.bytes.length
944
+
945
+ # Mix ratio (1.0 = all English, 0.5 = all Chinese)
946
+ mix_ratio = total_bytes > 0 ? ascii_count.to_f / total_bytes : 1.0
947
+
948
+ # English: ~4 chars/token, Chinese: ~2 chars/token
949
+ base_chars_per_token = mix_ratio * 4 + (1 - mix_ratio) * 2
950
+
951
+ (text.length / base_chars_per_token).to_i + 50 # Add overhead for message structure
952
+ end
953
+
954
+ # Calculate total token count for all messages
955
+ # Returns estimated tokens and breakdown by category
956
+ private def total_message_tokens
957
+ system_tokens = 0
958
+ user_tokens = 0
959
+ assistant_tokens = 0
960
+ tool_tokens = 0
961
+ summary_tokens = 0
962
+
963
+ @messages.each do |msg|
964
+ tokens = estimate_tokens(msg[:content])
965
+ case msg[:role]
966
+ when "system"
967
+ system_tokens += tokens
968
+ when "user"
969
+ user_tokens += tokens
970
+ when "assistant"
971
+ assistant_tokens += tokens
972
+ when "tool"
973
+ tool_tokens += tokens
974
+ end
975
+ end
976
+
977
+ {
978
+ total: system_tokens + user_tokens + assistant_tokens + tool_tokens,
979
+ system: system_tokens,
980
+ user: user_tokens,
981
+ assistant: assistant_tokens,
982
+ tool: tool_tokens
983
+ }
984
+ end
985
+
986
+ # Compression thresholds
987
+ COMPRESSION_THRESHOLD = 80_000 # Trigger compression when exceeding this (in tokens)
988
+ MESSAGE_COUNT_THRESHOLD = 100 # Trigger compression when exceeding this (in message count)
989
+ TARGET_COMPRESSED_TOKENS = 70_000 # Target size after compression
990
+ MAX_RECENT_MESSAGES = 30 # Keep this many recent message pairs intact
991
+
798
992
  def compress_messages_if_needed
799
993
  # Check if compression is enabled
800
994
  return unless @config.enable_compression
801
995
 
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
996
+ # Calculate total tokens and message count
997
+ token_counts = total_message_tokens
998
+ total_tokens = token_counts[:total]
999
+ message_count = @messages.length
1000
+
1001
+ # Check if we should trigger compression
1002
+ # Either: token count exceeds threshold OR message count exceeds threshold
1003
+ token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
1004
+ message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
1005
+
1006
+ # Only compress if we exceed at least one threshold
1007
+ return unless token_threshold_exceeded || message_count_exceeded
805
1008
 
806
- original_size = @messages.size
807
- target_size = @config.keep_recent_messages + 2
1009
+ # Calculate how much we need to reduce
1010
+ reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
808
1011
 
809
- @ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
1012
+ # Don't compress if reduction is minimal (< 10% of current size)
1013
+ # Only apply this check when triggered by token threshold
1014
+ if token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
1015
+ return
1016
+ end
1017
+
1018
+ # If only message count threshold is exceeded, force compression
1019
+ # to keep conversation history manageable
1020
+
1021
+ # Calculate target size for recent messages based on compression level
1022
+ target_recent_count = calculate_target_recent_count(reduction_needed)
1023
+
1024
+ # Increment compression level for progressive summarization
1025
+ @compression_level += 1
1026
+
1027
+ original_tokens = total_tokens
810
1028
 
811
1029
  # Find the system message (should be first)
812
1030
  system_msg = @messages.find { |m| m[:role] == "system" }
813
1031
 
814
1032
  # 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)
1033
+ recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
1034
+ recent_messages = [] if recent_messages.nil?
816
1035
 
817
1036
  # Get messages to compress (everything except system and recent)
818
1037
  messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
819
1038
 
820
1039
  return if messages_to_compress.empty?
821
1040
 
822
- # Create summary of compressed messages
823
- summary = summarize_messages(messages_to_compress)
1041
+ # Create hierarchical summary based on compression level
1042
+ summary = generate_hierarchical_summary(messages_to_compress)
824
1043
 
825
1044
  # Rebuild messages array: [system, summary, recent_messages]
826
1045
  rebuilt_messages = [system_msg, summary, *recent_messages].compact
827
1046
 
828
1047
  @messages = rebuilt_messages
829
1048
 
830
- final_size = @messages.size
1049
+ # Track this compression for progressive summarization
1050
+ @compressed_summaries << {
1051
+ level: @compression_level,
1052
+ message_count: messages_to_compress.size,
1053
+ timestamp: Time.now.iso8601
1054
+ }
1055
+
1056
+ final_tokens = total_message_tokens[:total]
1057
+
1058
+ # Return compression message (to be shown after clearing progress)
1059
+ "History compressed (~#{original_tokens} -> ~#{final_tokens} tokens, level #{@compression_level})"
1060
+ end
1061
+
1062
+ # Calculate how many recent messages to keep based on how much we need to compress
1063
+ private def calculate_target_recent_count(reduction_needed)
1064
+ # We want recent messages to be around 20-30% of the total target
1065
+ # This keeps the context window useful without being too large
1066
+ tokens_per_message = 500 # Average estimate for a message with content
1067
+
1068
+ # Target recent messages budget (~20% of target compressed size)
1069
+ recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
1070
+ target_messages = (recent_budget / tokens_per_message).to_i
1071
+
1072
+ # Clamp to reasonable bounds
1073
+ [[target_messages, 20].max, MAX_RECENT_MESSAGES].min
1074
+ end
1075
+
1076
+ # Generate hierarchical summary based on compression level
1077
+ # Level 1: Detailed summary with files, decisions, features
1078
+ # Level 2: Concise summary with key items
1079
+ # Level 3: Minimal summary (just project type)
1080
+ # Level 4+: Ultra-minimal (single line)
1081
+ private def generate_hierarchical_summary(messages)
1082
+ level = @compression_level
1083
+
1084
+ # Extract key information from messages
1085
+ extracted = extract_key_information(messages)
1086
+
1087
+ summary_text = case level
1088
+ when 1
1089
+ generate_level1_summary(extracted)
1090
+ when 2
1091
+ generate_level2_summary(extracted)
1092
+ when 3
1093
+ generate_level3_summary(extracted)
1094
+ else
1095
+ generate_level4_summary(extracted)
1096
+ end
1097
+
1098
+ {
1099
+ role: "user",
1100
+ content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
1101
+ system_injected: true,
1102
+ compression_level: level
1103
+ }
1104
+ end
1105
+
1106
+ # Extract key information from messages for summarization
1107
+ private def extract_key_information(messages)
1108
+ return empty_extraction_data if messages.nil?
1109
+
1110
+ {
1111
+ # Message counts
1112
+ user_msgs: messages.count { |m| m[:role] == "user" },
1113
+ assistant_msgs: messages.count { |m| m[:role] == "assistant" },
1114
+ tool_msgs: messages.count { |m| m[:role] == "tool" },
1115
+
1116
+ # Tools used
1117
+ tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
1118
+
1119
+ # Files created/modified
1120
+ files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
1121
+ files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
1122
+
1123
+ # Key decisions (limit to first 5)
1124
+ decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
1125
+
1126
+ # Completed tasks (from TODO results)
1127
+ completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
1128
+
1129
+ # Current in-progress work
1130
+ in_progress: find_in_progress(messages),
1131
+
1132
+ # Key results from shell commands
1133
+ shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
1134
+ }
1135
+ end
1136
+
1137
+ # Helper: safely extract from messages with proper nil handling
1138
+ private def extract_from_messages(messages, role_filter = nil, &block)
1139
+ return [] if messages.nil?
1140
+
1141
+ results = messages
1142
+ .select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
1143
+ .map(&block)
1144
+ .compact
831
1145
 
832
- @ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
1146
+ # Flatten if we have nested arrays (from methods returning arrays of items)
1147
+ results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
1148
+ end
1149
+
1150
+ # Helper: extract tool names from tool_calls
1151
+ private def extract_tool_names(tool_calls)
1152
+ return [] unless tool_calls.is_a?(Array)
1153
+ tool_calls.map { |tc| tc.dig(:function, :name) }
1154
+ end
1155
+
1156
+ # Helper: filter write results by action
1157
+ private def filter_write_results(result, action)
1158
+ result && result[:action] == action ? result[:file] : nil
1159
+ end
1160
+
1161
+ # Helper: filter todo results by status
1162
+ private def filter_todo_results(result, status)
1163
+ result && result[:status] == status ? result[:task] : nil
1164
+ end
1165
+
1166
+ # Helper: extract decision text from content (returns array of decisions or empty array)
1167
+ private def extract_decision_text(content)
1168
+ return [] unless content.is_a?(String)
1169
+ return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
1170
+
1171
+ sentences = content.split(/[.!?]/).select do |s|
1172
+ s.include?("decision") || s.include?("chose") || s.include?("using") ||
1173
+ s.include?("decided") || s.include?("will use") || s.include?("selected")
1174
+ end
1175
+ sentences.map(&:strip).map { |s| s[0..100] }
1176
+ end
1177
+
1178
+ # Helper: find in-progress task
1179
+ private def find_in_progress(messages)
1180
+ return nil if messages.nil?
1181
+
1182
+ messages.reverse_each do |m|
1183
+ if m[:role] == "tool"
1184
+ content = m[:content].to_s
1185
+ if content.include?("in progress") || content.include?("working on")
1186
+ return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
1187
+ end
1188
+ end
1189
+ end
1190
+ nil
1191
+ end
1192
+
1193
+ # Helper: empty extraction data
1194
+ private def empty_extraction_data
1195
+ {
1196
+ user_msgs: 0,
1197
+ assistant_msgs: 0,
1198
+ tool_msgs: 0,
1199
+ tools_used: [],
1200
+ files_created: [],
1201
+ files_modified: [],
1202
+ decisions: [],
1203
+ completed_tasks: [],
1204
+ in_progress: nil,
1205
+ shell_results: []
1206
+ }
1207
+ end
1208
+
1209
+ private def parse_write_result(content)
1210
+ return nil unless content.is_a?(String)
1211
+
1212
+ # Check for "Created: path" or "Updated: path" patterns
1213
+ if content.include?("Created:")
1214
+ { action: "created", file: content[/Created:\s*(.+)/, 1]&.strip }
1215
+ elsif content.include?("Updated:") || content.include?("modified")
1216
+ { action: "modified", file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
1217
+ else
1218
+ nil
1219
+ end
1220
+ end
1221
+
1222
+ private def parse_todo_result(content)
1223
+ return nil unless content.is_a?(String)
1224
+
1225
+ if content.include?("completed")
1226
+ { status: "completed", task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
1227
+ elsif content.include?("added")
1228
+ { status: "added", task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
1229
+ else
1230
+ nil
1231
+ end
1232
+ end
1233
+
1234
+ private def parse_shell_result(content)
1235
+ return nil unless content.is_a?(String)
1236
+
1237
+ if content.include?("passed") || content.include?("success")
1238
+ "tests passed"
1239
+ elsif content.include?("failed") || content.include?("error")
1240
+ "command failed"
1241
+ elsif content =~ /bundle install|npm install|go mod download/
1242
+ "dependencies installed"
1243
+ elsif content.include?("Installed")
1244
+ content[/Installed:\s*(.+)/, 1]&.strip
1245
+ else
1246
+ nil
1247
+ end
1248
+ end
1249
+
1250
+ # Level 1: Detailed summary (for first compression)
1251
+ private def generate_level1_summary(data)
1252
+ parts = []
1253
+
1254
+ parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
1255
+
1256
+ # Files created
1257
+ if data[:files_created].any?
1258
+ files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
1259
+ parts << "Created: #{files_list}"
1260
+ end
1261
+
1262
+ # Files modified
1263
+ if data[:files_modified].any?
1264
+ files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
1265
+ parts << "Modified: #{files_list}"
1266
+ end
1267
+
1268
+ # Completed tasks
1269
+ if data[:completed_tasks].any?
1270
+ tasks_list = data[:completed_tasks].first(3).join(", ")
1271
+ parts << "Completed: #{tasks_list}"
1272
+ end
1273
+
1274
+ # In progress
1275
+ if data[:in_progress]
1276
+ parts << "In Progress: #{data[:in_progress]}"
1277
+ end
1278
+
1279
+ # Key decisions
1280
+ if data[:decisions].any?
1281
+ decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
1282
+ parts << "Decisions: #{decisions_text}"
1283
+ end
1284
+
1285
+ # Tools used
1286
+ if data[:tools_used].any?
1287
+ parts << "Tools: #{data[:tools_used].join(', ')}"
1288
+ end
1289
+
1290
+ parts << "Continuing with recent conversation..."
1291
+ parts.join("\n")
1292
+ end
1293
+
1294
+ # Level 2: Concise summary (for second compression)
1295
+ private def generate_level2_summary(data)
1296
+ parts = []
1297
+
1298
+ parts << "Conversation summary:"
1299
+
1300
+ # Key files (limit to most important)
1301
+ all_files = (data[:files_created] + data[:files_modified]).uniq
1302
+ if all_files.any?
1303
+ key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
1304
+ parts << "Files: #{key_files}"
1305
+ end
1306
+
1307
+ # Key accomplishments
1308
+ accomplishments = []
1309
+ accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
1310
+ accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
1311
+ accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
1312
+
1313
+ parts << accomplishments.join(", ") if accomplishments.any?
1314
+
1315
+ parts << "Recent context follows..."
1316
+ parts.join("\n")
1317
+ end
1318
+
1319
+ # Level 3: Minimal summary (for third compression)
1320
+ private def generate_level3_summary(data)
1321
+ parts = []
1322
+
1323
+ parts << "Project progress:"
1324
+
1325
+ # Just counts and key items
1326
+ all_files = (data[:files_created] + data[:files_modified]).uniq
1327
+ parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
1328
+
1329
+ if data[:in_progress]
1330
+ parts << "Currently: #{data[:in_progress]}"
1331
+ end
1332
+
1333
+ parts << "See recent messages for details."
1334
+ parts.join("\n")
1335
+ end
1336
+
1337
+ # Level 4: Ultra-minimal summary (for fourth+ compression)
1338
+ private def generate_level4_summary(data)
1339
+ all_files = (data[:files_created] + data[:files_modified]).uniq
1340
+ "Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
833
1341
  end
834
1342
 
835
1343
  def get_recent_messages_with_tool_pairs(messages, count)
@@ -837,7 +1345,7 @@ module Clacky
837
1345
  # with ALL their corresponding tool_results, maintaining the correct order.
838
1346
  # This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
839
1347
 
840
- return [] if messages.empty?
1348
+ return [] if messages.nil? || messages.empty?
841
1349
 
842
1350
  # Track which messages to include
843
1351
  messages_to_include = Set.new
@@ -923,51 +1431,14 @@ module Clacky
923
1431
  messages_to_include.to_a.sort.map { |idx| messages[idx] }
924
1432
  end
925
1433
 
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
1434
  def confirm_tool_use?(call)
963
1435
  # Show preview first and check for errors
964
1436
  preview_error = show_tool_preview(call)
965
1437
 
966
1438
  # If preview detected an error, auto-deny and provide feedback
967
1439
  if preview_error && preview_error[:error]
968
- @ui&.show_warning("Tool call auto-denied due to preview error")
969
1440
  feedback = build_preview_error_feedback(call[:name], preview_error)
970
- return { approved: false, feedback: feedback }
1441
+ return { approved: false, feedback: feedback, system_injected: true }
971
1442
  end
972
1443
 
973
1444
  # Request confirmation via UI
@@ -998,7 +1469,7 @@ module Clacky
998
1469
  private def build_preview_error_feedback(tool_name, error_info)
999
1470
  case tool_name
1000
1471
  when "edit"
1001
- "The edit operation will fail because the old_string was not found in the file. " \
1472
+ "Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
1002
1473
  "Please use file_reader to read '#{error_info[:path]}' first, " \
1003
1474
  "find the correct string to replace, and try again with the exact string (including whitespace)."
1004
1475
  else
@@ -1126,9 +1597,7 @@ module Clacky
1126
1597
  file_size: file_content.length
1127
1598
  }
1128
1599
 
1129
- @ui&.show_file_error("String to replace not found in file")
1130
- @ui&.show_file_error("Looking for (first 100 chars):")
1131
- @ui&.show_file_error(old_string[0..100].inspect)
1600
+ @ui&.show_file_error("Edit file error")
1132
1601
  return {
1133
1602
  error: "String to replace not found in file",
1134
1603
  path: path,
@@ -1172,19 +1641,30 @@ module Clacky
1172
1641
  }
1173
1642
  end
1174
1643
 
1175
- def build_denied_result(call, user_feedback = nil)
1176
- message = if user_feedback && !user_feedback.empty?
1177
- "Tool use denied by user. User feedback: #{user_feedback}"
1178
- else
1179
- "Tool use denied by user"
1180
- end
1644
+ def build_denied_result(call, user_feedback = nil, system_injected = false)
1645
+ if system_injected
1646
+ # System-generated feedback (e.g., from preview errors)
1647
+ tool_content = {
1648
+ error: "Tool #{call[:name]} denied: #{user_feedback}",
1649
+ system_injected: true
1650
+ }
1651
+ else
1652
+ # User manually denied or provided feedback
1653
+ message = if user_feedback && !user_feedback.empty?
1654
+ "Tool use denied by user. User feedback: #{user_feedback}"
1655
+ else
1656
+ "Tool use denied by user"
1657
+ end
1181
1658
 
1182
- {
1183
- id: call[:id],
1184
- content: JSON.generate({
1659
+ tool_content = {
1185
1660
  error: message,
1186
1661
  user_feedback: user_feedback
1187
- })
1662
+ }
1663
+ end
1664
+
1665
+ {
1666
+ id: call[:id],
1667
+ content: JSON.generate(tool_content)
1188
1668
  }
1189
1669
  end
1190
1670