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.
- checksums.yaml +4 -4
- data/lib/clacky/agent.rb +542 -54
- data/lib/clacky/cli.rb +341 -2
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +34 -43
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- metadata +5 -1
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
|
-
#
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
823
|
-
summary =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|