openclacky 0.5.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8253fadf5425c27f9b8ebfecfe195c4a21c0f0d944e0c22030dc77411dda41e
4
- data.tar.gz: 345301c2cc266afab03564ea08c3801bc1adae239e129d7fc9ac99280e47ec07
3
+ metadata.gz: 52ab39c40c69d821b45d01ed1d14086a50446309421891e9cd6724a9b32864cf
4
+ data.tar.gz: 0d61ef02a23f8a04157f8b7b2308a4f0c68fd37bba1407894b9841cd707c6873
5
5
  SHA512:
6
- metadata.gz: 2fe927a2d4c86be0ecd45e4defe9ae8646ded14153ac05178bf751a29f108b4ed9744f2e7fd64714437e70e4be1dcc092640154400e9c639deb29d558c42fa0e
7
- data.tar.gz: 25f7e33a14d03a1a03e5a0947dab559a5e27244cd7333342bd1014033cce6fac141ae3794eb0fc6dd435260045a695c161bbe07f39f0a253974939cda048edb9
6
+ metadata.gz: d654bd29a093bf23f1e0c162867251e08a28ec722deaae7a9d234f2aed913329d991df29391778612c458c51b6775881ee0867cb2d959c0f7072b14e662d0c38
7
+ data.tar.gz: 8056dff60d1c845751021e2c852e58071360c8d6a6b81cec72be238e647521ae36a211d72af88602239f8abcab5a15b7a345b8efd98e68b62a0a6dfde8a63b23
data/.clackyrules CHANGED
@@ -22,7 +22,9 @@ It provides chat functionality and autonomous AI agent capabilities with tool us
22
22
  - Use frozen string literals: `# frozen_string_literal: true`
23
23
  - Keep classes focused and single-responsibility
24
24
  - Use meaningful variable and method names
25
+ - **IMPORTANT**: All code comments must be written in English
25
26
  - Add descriptive comments for complex logic
27
+ - Use clear, self-documenting code with English naming
26
28
 
27
29
  ### Architecture Patterns
28
30
  - Tools inherit from `Clacky::Tools::Base`
data/README.md CHANGED
@@ -12,6 +12,7 @@ A command-line interface for interacting with AI models. OpenClacky supports Ope
12
12
  - šŸŽØ Colorful terminal output
13
13
  - 🌐 OpenAI-compatible API support (OpenAI, Gitee AI, DeepSeek, etc.)
14
14
  - šŸ› ļø Rich built-in tools: file operations, web search, code execution, and more
15
+ - ⚔ Prompt caching support for Claude models (reduces costs up to 90%)
15
16
 
16
17
  ## Installation
17
18
 
data/lib/clacky/agent.rb CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "json"
5
- require "readline"
5
+ require "tty-prompt"
6
6
  require "set"
7
7
  require_relative "utils/arguments_parser"
8
8
 
9
9
  module Clacky
10
10
  class Agent
11
- attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos
11
+ attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
12
+ :cache_stats
12
13
 
13
14
  # Pricing per 1M tokens (approximate - adjust based on actual model)
14
15
  PRICING = {
@@ -65,6 +66,12 @@ module Clacky
65
66
  @todos = [] # Store todos in memory
66
67
  @iterations = 0
67
68
  @total_cost = 0.0
69
+ @cache_stats = {
70
+ cache_creation_input_tokens: 0,
71
+ cache_read_input_tokens: 0,
72
+ total_requests: 0,
73
+ cache_hit_requests: 0
74
+ }
68
75
  @start_time = nil
69
76
  @working_dir = working_dir || Dir.pwd
70
77
  @created_at = Time.now.iso8601
@@ -91,6 +98,14 @@ module Clacky
91
98
  @created_at = session_data[:created_at]
92
99
  @total_tasks = session_data.dig(:stats, :total_tasks) || 0
93
100
 
101
+ # Restore cache statistics if available
102
+ @cache_stats = session_data.dig(:stats, :cache_stats) || {
103
+ cache_creation_input_tokens: 0,
104
+ cache_read_input_tokens: 0,
105
+ total_requests: 0,
106
+ cache_hit_requests: 0
107
+ }
108
+
94
109
  # Check if the session ended with an error
95
110
  last_status = session_data.dig(:stats, :last_status)
96
111
  last_error = session_data.dig(:stats, :last_error)
@@ -103,7 +118,7 @@ module Clacky
103
118
  @messages = @messages[0...last_user_index]
104
119
 
105
120
  # Trigger a hook to notify about the rollback
106
- trigger_hook(:session_rollback, {
121
+ @hooks.trigger(:session_rollback, {
107
122
  reason: "Previous session ended with error",
108
123
  error_message: last_error,
109
124
  rolled_back_message_index: last_user_index
@@ -122,7 +137,14 @@ module Clacky
122
137
  # Add system prompt as the first message if this is the first run
123
138
  if @messages.empty?
124
139
  system_prompt = build_system_prompt
125
- @messages << { role: "system", content: system_prompt }
140
+ system_message = { role: "system", content: system_prompt }
141
+
142
+ # Enable caching for system prompt if configured and model supports it
143
+ if @config.enable_prompt_caching
144
+ system_message[:cache_control] = { type: "ephemeral" }
145
+ end
146
+
147
+ @messages << system_message
126
148
  end
127
149
 
128
150
  @messages << { role: "user", content: user_input }
@@ -222,7 +244,8 @@ module Clacky
222
244
  total_iterations: @iterations,
223
245
  total_cost_usd: @total_cost.round(4),
224
246
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
225
- last_status: status.to_s
247
+ last_status: status.to_s,
248
+ cache_stats: @cache_stats
226
249
  }
227
250
 
228
251
  # Add error message if status is error
@@ -240,6 +263,7 @@ module Clacky
240
263
  max_iterations: @config.max_iterations,
241
264
  max_cost_usd: @config.max_cost_usd,
242
265
  enable_compression: @config.enable_compression,
266
+ enable_prompt_caching: @config.enable_prompt_caching,
243
267
  keep_recent_messages: @config.keep_recent_messages,
244
268
  max_tokens: @config.max_tokens,
245
269
  verbose: @config.verbose
@@ -366,7 +390,8 @@ module Clacky
366
390
  model: @config.model,
367
391
  tools: tools_to_send,
368
392
  max_tokens: @config.max_tokens,
369
- verbose: @config.verbose
393
+ verbose: @config.verbose,
394
+ enable_caching: @config.enable_prompt_caching
370
395
  )
371
396
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
372
397
  retries += 1
@@ -386,6 +411,54 @@ module Clacky
386
411
 
387
412
  track_cost(response[:usage])
388
413
 
414
+ # Handle truncated responses (when max_tokens limit is reached)
415
+ if response[:finish_reason] == "length"
416
+ # Count recent truncations to prevent infinite loops
417
+ recent_truncations = @messages.last(5).count { |m|
418
+ m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
419
+ }
420
+
421
+ if recent_truncations >= 2
422
+ # Too many truncations - task is too complex
423
+ progress.finish
424
+ puts "\nāš ļø Response truncated multiple times. Task is too complex for a single response." if @config.verbose
425
+
426
+ # Create a response that tells the user to break down the task
427
+ error_response = {
428
+ content: "I apologize, but this task is too complex to complete in a single response. " \
429
+ "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
430
+ "For example, when creating a long document:\n" \
431
+ "1. First create the file with a basic structure\n" \
432
+ "2. Then use edit() to add content section by section",
433
+ finish_reason: "stop",
434
+ tool_calls: nil
435
+ }
436
+
437
+ # Add this as an assistant message so it appears in conversation
438
+ @messages << {
439
+ role: "assistant",
440
+ content: error_response[:content]
441
+ }
442
+
443
+ return error_response
444
+ end
445
+
446
+ # Insert system message to guide LLM to retry with smaller steps
447
+ @messages << {
448
+ role: "user",
449
+ content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
450
+ "- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
451
+ "- Break down large tasks into multiple smaller steps\n" \
452
+ "- Avoid putting more than 2000 characters in a single tool call argument\n" \
453
+ "- Use multiple tool calls instead of one large call"
454
+ }
455
+
456
+ puts "āš ļø Response truncated due to length limit. Retrying with smaller steps..." if @config.verbose
457
+
458
+ # Recursively retry
459
+ return think(&block)
460
+ end
461
+
389
462
  # Add assistant response to messages
390
463
  msg = { role: "assistant" }
391
464
  # Always include content field (some APIs require it even with tool_calls)
@@ -543,6 +616,18 @@ module Clacky
543
616
  input_cost = (usage[:prompt_tokens] / 1_000_000.0) * PRICING[:input]
544
617
  output_cost = (usage[:completion_tokens] / 1_000_000.0) * PRICING[:output]
545
618
  @total_cost += input_cost + output_cost
619
+
620
+ # Track cache usage statistics
621
+ @cache_stats[:total_requests] += 1
622
+
623
+ if usage[:cache_creation_input_tokens]
624
+ @cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
625
+ end
626
+
627
+ if usage[:cache_read_input_tokens]
628
+ @cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
629
+ @cache_stats[:cache_hit_requests] += 1
630
+ end
546
631
  end
547
632
 
548
633
  def compress_messages_if_needed
@@ -582,7 +667,15 @@ module Clacky
582
667
  summary = summarize_messages(messages_to_compress)
583
668
 
584
669
  # Rebuild messages array: [system, summary, recent_messages]
585
- @messages = [system_msg, summary, *recent_messages].compact
670
+ # Preserve cache_control on system message if it exists
671
+ rebuilt_messages = [system_msg, summary, *recent_messages].compact
672
+
673
+ # Re-apply cache control to system message if caching is enabled
674
+ if @config.enable_prompt_caching && rebuilt_messages.first&.dig(:role) == "system"
675
+ rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
676
+ end
677
+
678
+ @messages = rebuilt_messages
586
679
 
587
680
  final_size = @messages.size
588
681
 
@@ -610,7 +703,7 @@ module Clacky
610
703
 
611
704
  # Track which messages to include
612
705
  messages_to_include = Set.new
613
-
706
+
614
707
  # Start from the end and work backwards
615
708
  i = messages.size - 1
616
709
  messages_collected = 0
@@ -631,13 +724,13 @@ module Clacky
631
724
  # If this is an assistant message with tool_calls, we MUST include ALL corresponding tool results
632
725
  if msg[:role] == "assistant" && msg[:tool_calls]
633
726
  tool_call_ids = msg[:tool_calls].map { |tc| tc[:id] }
634
-
727
+
635
728
  # Find all tool results that belong to this assistant message
636
729
  # They should be in the messages immediately following this assistant message
637
730
  j = i + 1
638
731
  while j < messages.size
639
732
  next_msg = messages[j]
640
-
733
+
641
734
  # If we find a tool result for one of our tool_calls, include it
642
735
  if next_msg[:role] == "tool" && tool_call_ids.include?(next_msg[:tool_call_id])
643
736
  messages_to_include.add(j)
@@ -645,7 +738,7 @@ module Clacky
645
738
  # Stop when we hit a non-tool message (start of next turn)
646
739
  break
647
740
  end
648
-
741
+
649
742
  j += 1
650
743
  end
651
744
  end
@@ -766,18 +859,28 @@ module Clacky
766
859
  prompt_text = format_tool_prompt(call)
767
860
  puts "\nā“ #{prompt_text}"
768
861
 
769
- # Use Readline for better input handling (backspace, arrow keys, etc.)
770
- response = Readline.readline(" (Enter/y to approve, n to deny, or provide feedback): ", true)
862
+ # Use TTY::Prompt for better input handling
863
+ tty_prompt = TTY::Prompt.new(interrupt: :exit)
771
864
 
772
- if response.nil? # Handle EOF/pipe input
865
+ begin
866
+ response = tty_prompt.ask(" (Enter/y to approve, n to deny, or provide feedback):", required: false) do |q|
867
+ q.modify :strip
868
+ end
869
+ rescue TTY::Reader::InputInterrupt
870
+ # Handle Ctrl+C
871
+ puts
773
872
  return { approved: false, feedback: nil }
774
873
  end
775
874
 
776
- response = response.chomp
875
+ # Handle nil response (EOF/pipe input)
876
+ if response.nil? || response.empty?
877
+ return { approved: true, feedback: nil } # Empty means approved
878
+ end
879
+
777
880
  response_lower = response.downcase
778
881
 
779
- # Empty response (just Enter) or "y"/"yes" = approved
780
- if response.empty? || response_lower == "y" || response_lower == "yes"
882
+ # "y"/"yes" = approved
883
+ if response_lower == "y" || response_lower == "yes"
781
884
  return { approved: true, feedback: nil }
782
885
  end
783
886
 
@@ -978,6 +1081,7 @@ module Clacky
978
1081
  iterations: @iterations,
979
1082
  duration_seconds: Time.now - @start_time,
980
1083
  total_cost_usd: @total_cost.round(4),
1084
+ cache_stats: @cache_stats,
981
1085
  messages: @messages,
982
1086
  error: error
983
1087
  }
@@ -7,7 +7,8 @@ module Clacky
7
7
 
8
8
  attr_accessor :model, :max_iterations, :max_cost_usd, :timeout_seconds,
9
9
  :permission_mode, :allowed_tools, :disallowed_tools,
10
- :max_tokens, :verbose, :enable_compression, :keep_recent_messages
10
+ :max_tokens, :verbose, :enable_compression, :keep_recent_messages,
11
+ :enable_prompt_caching
11
12
 
12
13
  def initialize(options = {})
13
14
  @model = options[:model] || "gpt-3.5-turbo"
@@ -21,6 +22,8 @@ module Clacky
21
22
  @verbose = options[:verbose] || false
22
23
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
23
24
  @keep_recent_messages = options[:keep_recent_messages] || 20
25
+ # Enable prompt caching by default for cost savings
26
+ @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
24
27
  end
25
28
 
26
29