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