openclacky 0.5.3 → 0.5.5
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 +13 -0
- data/README.md +2 -0
- data/Rakefile +1 -5
- data/lib/clacky/agent.rb +34 -16
- data/lib/clacky/cli.rb +54 -133
- data/lib/clacky/client.rb +53 -7
- data/lib/clacky/gitignore_parser.rb +114 -0
- data/lib/clacky/model_pricing.rb +280 -0
- data/lib/clacky/tools/grep.rb +268 -30
- data/lib/clacky/tools/safe_shell.rb +9 -4
- data/lib/clacky/tools/shell.rb +60 -22
- data/lib/clacky/ui/banner.rb +22 -11
- data/lib/clacky/ui/enhanced_prompt.rb +540 -0
- data/lib/clacky/ui/statusbar.rb +0 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +3 -2
- metadata +4 -3
- data/lib/clacky/conversation.rb +0 -41
- data/lib/clacky/ui/prompt.rb +0 -72
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc218b590b40d1a2301ef91a73b5a4ded7d6891da5b42eea62f4fa8bce200292
|
|
4
|
+
data.tar.gz: e828ac9fd724d0097f7557401127c513f35138c31d76a06e07c8c70db049cb1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91db158259b243438e5af2fa26d257f30c4c5f755383e4bde6e20b71cf9e5481bc5b2d91cd5c63e5916ed8d6e373050e60dd16fd4b3a0231073702b75ad2702a
|
|
7
|
+
data.tar.gz: '0940f53a61a33f22de700c8609ede85e7d937f096688374eedef072758372f00e87fa6327050d323a7f5f3d869b487ff39bb942fe56534afe4a906b3b6cde494'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.4] - 2026-01-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Automatic Paste Detection**: Rapid input detection automatically identifies paste operations
|
|
14
|
+
- **Word Wrap Display**: Long input lines automatically wrap with scroll indicators (up to 15 visible lines)
|
|
15
|
+
- **Full-width Terminal Display**: Enhanced prompt box uses full terminal width for better visibility
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
- **Smart Ctrl+C Handling**: First press clears content, second press (within 2s) exits
|
|
19
|
+
- **UTF-8 Encoding**: Better handling of multi-byte characters in clipboard operations
|
|
20
|
+
- **Cursor Positioning**: Improved cursor tracking in wrapped lines
|
|
21
|
+
- **Multi-line Paste**: Better display for pasted content with placeholder support
|
|
22
|
+
|
|
10
23
|
## [0.5.0] - 2026-01-11
|
|
11
24
|
|
|
12
25
|
### Added
|
data/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A command-line interface for interacting with AI models. OpenClacky supports Ope
|
|
|
6
6
|
|
|
7
7
|
- 💬 Interactive chat sessions with AI models
|
|
8
8
|
- 🤖 Autonomous AI agent with tool use capabilities
|
|
9
|
+
- 📝 Enhanced input with multi-line support and Unicode (Chinese, etc.)
|
|
10
|
+
- 🖼️ Paste images from clipboard (macOS/Linux)
|
|
9
11
|
- 🚀 Single-message mode for quick queries
|
|
10
12
|
- 🔐 Secure API key management
|
|
11
13
|
- 📝 Multi-turn conversation support
|
data/Rakefile
CHANGED
|
@@ -5,10 +5,6 @@ require "rspec/core/rake_task"
|
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
require "rubocop/rake_task"
|
|
9
|
-
|
|
10
|
-
RuboCop::RakeTask.new
|
|
11
|
-
|
|
12
8
|
namespace :build do
|
|
13
9
|
desc "Build both openclacky and clacky gems"
|
|
14
10
|
task :all do
|
|
@@ -35,4 +31,4 @@ namespace :build do
|
|
|
35
31
|
end
|
|
36
32
|
end
|
|
37
33
|
|
|
38
|
-
task default: %i[spec
|
|
34
|
+
task default: %i[spec]
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -9,13 +9,7 @@ require_relative "utils/arguments_parser"
|
|
|
9
9
|
module Clacky
|
|
10
10
|
class Agent
|
|
11
11
|
attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
12
|
-
:cache_stats
|
|
13
|
-
|
|
14
|
-
# Pricing per 1M tokens (approximate - adjust based on actual model)
|
|
15
|
-
PRICING = {
|
|
16
|
-
input: 0.50, # $0.50 per 1M input tokens
|
|
17
|
-
output: 1.50 # $1.50 per 1M output tokens
|
|
18
|
-
}.freeze
|
|
12
|
+
:cache_stats, :cost_source
|
|
19
13
|
|
|
20
14
|
# System prompt for the coding agent
|
|
21
15
|
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
@@ -76,6 +70,8 @@ module Clacky
|
|
|
76
70
|
@working_dir = working_dir || Dir.pwd
|
|
77
71
|
@created_at = Time.now.iso8601
|
|
78
72
|
@total_tasks = 0
|
|
73
|
+
@cost_source = :estimated # Track whether cost is from API or estimated
|
|
74
|
+
@task_cost_source = :estimated # Track cost source for current task
|
|
79
75
|
|
|
80
76
|
# Register built-in tools
|
|
81
77
|
register_builtin_tools
|
|
@@ -133,6 +129,7 @@ module Clacky
|
|
|
133
129
|
|
|
134
130
|
def run(user_input, &block)
|
|
135
131
|
@start_time = Time.now
|
|
132
|
+
@task_cost_source = :estimated # Reset for new task
|
|
136
133
|
|
|
137
134
|
# Add system prompt as the first message if this is the first run
|
|
138
135
|
if @messages.empty?
|
|
@@ -221,14 +218,14 @@ module Clacky
|
|
|
221
218
|
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
222
219
|
# @param error_message [String] Error message if status is :error
|
|
223
220
|
def to_session_data(status: :success, error_message: nil)
|
|
224
|
-
# Get
|
|
225
|
-
|
|
221
|
+
# Get last real user message for preview (skip compressed system messages)
|
|
222
|
+
last_user_msg = @messages.reverse.find do |m|
|
|
226
223
|
m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
|
|
227
224
|
end
|
|
228
225
|
|
|
229
|
-
# Extract preview text from
|
|
230
|
-
|
|
231
|
-
content =
|
|
226
|
+
# Extract preview text from last user message
|
|
227
|
+
last_message_preview = if last_user_msg
|
|
228
|
+
content = last_user_msg[:content]
|
|
232
229
|
if content.is_a?(String)
|
|
233
230
|
# Truncate to 100 characters for preview
|
|
234
231
|
content.length > 100 ? "#{content[0..100]}..." : content
|
|
@@ -270,7 +267,7 @@ module Clacky
|
|
|
270
267
|
},
|
|
271
268
|
stats: stats_data,
|
|
272
269
|
messages: @messages,
|
|
273
|
-
first_user_message:
|
|
270
|
+
first_user_message: last_message_preview
|
|
274
271
|
}
|
|
275
272
|
end
|
|
276
273
|
|
|
@@ -613,9 +610,29 @@ module Clacky
|
|
|
613
610
|
end
|
|
614
611
|
|
|
615
612
|
def track_cost(usage)
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
613
|
+
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
614
|
+
if usage[:api_cost]
|
|
615
|
+
@total_cost += usage[:api_cost]
|
|
616
|
+
@cost_source = :api
|
|
617
|
+
@task_cost_source = :api
|
|
618
|
+
puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
|
|
619
|
+
else
|
|
620
|
+
# Priority 2: Calculate from tokens using ModelPricing
|
|
621
|
+
result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
|
|
622
|
+
cost = result[:cost]
|
|
623
|
+
pricing_source = result[:source]
|
|
624
|
+
|
|
625
|
+
@total_cost += cost
|
|
626
|
+
# Map pricing source to cost source: :price or :default
|
|
627
|
+
@cost_source = pricing_source
|
|
628
|
+
@task_cost_source = pricing_source
|
|
629
|
+
|
|
630
|
+
if @config.verbose
|
|
631
|
+
source_label = pricing_source == :price ? "model pricing" : "default pricing"
|
|
632
|
+
puts "[DEBUG] Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}"
|
|
633
|
+
puts "[DEBUG] Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}"
|
|
634
|
+
end
|
|
635
|
+
end
|
|
619
636
|
|
|
620
637
|
# Track cache usage statistics
|
|
621
638
|
@cache_stats[:total_requests] += 1
|
|
@@ -1081,6 +1098,7 @@ module Clacky
|
|
|
1081
1098
|
iterations: @iterations,
|
|
1082
1099
|
duration_seconds: Time.now - @start_time,
|
|
1083
1100
|
total_cost_usd: @total_cost.round(4),
|
|
1101
|
+
cost_source: @task_cost_source, # Add cost source for this task
|
|
1084
1102
|
cache_stats: @cache_stats,
|
|
1085
1103
|
messages: @messages,
|
|
1086
1104
|
error: error
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "thor"
|
|
|
4
4
|
require "tty-prompt"
|
|
5
5
|
require "tty-spinner"
|
|
6
6
|
require_relative "ui/banner"
|
|
7
|
-
require_relative "ui/
|
|
7
|
+
require_relative "ui/enhanced_prompt"
|
|
8
8
|
require_relative "ui/statusbar"
|
|
9
9
|
require_relative "ui/formatter"
|
|
10
10
|
|
|
@@ -14,41 +14,15 @@ module Clacky
|
|
|
14
14
|
true
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Start an interactive chat session with Claude AI.
|
|
20
|
-
|
|
21
|
-
If MESSAGE is provided, send it as a single message and exit.
|
|
22
|
-
If no MESSAGE is provided, start an interactive chat session.
|
|
23
|
-
|
|
24
|
-
Examples:
|
|
25
|
-
$ clacky chat "What is Ruby?"
|
|
26
|
-
$ clacky chat
|
|
27
|
-
LONGDESC
|
|
28
|
-
option :model, type: :string, desc: "Model to use (default from config)"
|
|
29
|
-
def chat(message = nil)
|
|
30
|
-
config = Clacky::Config.load
|
|
31
|
-
|
|
32
|
-
unless config.api_key
|
|
33
|
-
say "Error: API key not found. Please run 'clacky config set' first.", :red
|
|
34
|
-
exit 1
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
if message
|
|
38
|
-
# Single message mode
|
|
39
|
-
send_single_message(message, config)
|
|
40
|
-
else
|
|
41
|
-
# Interactive mode
|
|
42
|
-
start_interactive_chat(config)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
17
|
+
# Set agent as the default command
|
|
18
|
+
default_task :agent
|
|
45
19
|
|
|
46
20
|
desc "version", "Show clacky version"
|
|
47
21
|
def version
|
|
48
22
|
say "Clacky version #{Clacky::VERSION}"
|
|
49
23
|
end
|
|
50
24
|
|
|
51
|
-
desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use"
|
|
25
|
+
desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use (default)"
|
|
52
26
|
long_desc <<-LONGDESC
|
|
53
27
|
Run an AI agent in interactive mode that can autonomously use tools to complete tasks.
|
|
54
28
|
|
|
@@ -159,44 +133,48 @@ module Clacky
|
|
|
159
133
|
end
|
|
160
134
|
end
|
|
161
135
|
|
|
162
|
-
desc "
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
say "
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
say "
|
|
136
|
+
desc "price", "Show pricing information for AI models"
|
|
137
|
+
def price
|
|
138
|
+
say "\n💰 Model Pricing Information\n\n", :green
|
|
139
|
+
|
|
140
|
+
say "Clacky supports three pricing modes when calculating API costs:\n\n", :white
|
|
141
|
+
|
|
142
|
+
say " 1. ", :cyan
|
|
143
|
+
say "API-provided cost", :bold
|
|
144
|
+
say " (", :white
|
|
145
|
+
say ":api", :yellow
|
|
146
|
+
say ")", :white
|
|
147
|
+
say "\n The most accurate - uses actual cost data from the API response", :white
|
|
148
|
+
say "\n Supported by: OpenRouter, LiteLLM, and other compatible proxies\n\n"
|
|
149
|
+
|
|
150
|
+
say " 2. ", :cyan
|
|
151
|
+
say "Model-specific pricing", :bold
|
|
152
|
+
say " (", :white
|
|
153
|
+
say ":price", :yellow
|
|
154
|
+
say ")", :white
|
|
155
|
+
say "\n Uses official pricing from model providers (Claude models)", :white
|
|
156
|
+
say "\n Includes tiered pricing and prompt caching discounts\n\n"
|
|
157
|
+
|
|
158
|
+
say " 3. ", :cyan
|
|
159
|
+
say "Default fallback pricing", :bold
|
|
160
|
+
say " (", :white
|
|
161
|
+
say ":default", :yellow
|
|
162
|
+
say ")", :white
|
|
163
|
+
say "\n Conservative estimates for unknown models", :white
|
|
164
|
+
say "\n Input: $0.50/MTok, Output: $1.50/MTok\n\n"
|
|
165
|
+
|
|
166
|
+
say "Priority order: API cost > Model pricing > Default pricing\n\n", :yellow
|
|
167
|
+
|
|
168
|
+
say "Supported models with official pricing:\n", :green
|
|
169
|
+
say " • claude-opus-4.5\n", :cyan
|
|
170
|
+
say " • claude-sonnet-4.5\n", :cyan
|
|
171
|
+
say " • claude-haiku-4.5\n", :cyan
|
|
172
|
+
say " • claude-3-5-sonnet-20241022\n", :cyan
|
|
173
|
+
say " • claude-3-5-sonnet-20240620\n", :cyan
|
|
174
|
+
say " • claude-3-5-haiku-20241022\n\n", :cyan
|
|
175
|
+
|
|
176
|
+
say "For detailed pricing information, visit:\n", :white
|
|
177
|
+
say "https://www.anthropic.com/pricing\n\n", :blue
|
|
200
178
|
end
|
|
201
179
|
|
|
202
180
|
no_commands do
|
|
@@ -408,8 +386,12 @@ module Clacky
|
|
|
408
386
|
cost: total_cost
|
|
409
387
|
)
|
|
410
388
|
|
|
411
|
-
# Use enhanced prompt with "
|
|
412
|
-
|
|
389
|
+
# Use enhanced prompt with "❯" prefix
|
|
390
|
+
result = prompt.read_input(prefix: "❯")
|
|
391
|
+
|
|
392
|
+
# EnhancedPrompt returns { text: String, images: Array } or nil
|
|
393
|
+
# For now, we only use the text part
|
|
394
|
+
current_message = result.nil? ? nil : result[:text]
|
|
413
395
|
|
|
414
396
|
break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
|
|
415
397
|
next if current_message.strip.empty?
|
|
@@ -438,6 +420,7 @@ module Clacky
|
|
|
438
420
|
cost: result[:total_cost_usd].round(4),
|
|
439
421
|
total_tasks: total_tasks,
|
|
440
422
|
total_cost: total_cost.round(4),
|
|
423
|
+
cost_source: result[:cost_source],
|
|
441
424
|
cache_stats: result[:cache_stats]
|
|
442
425
|
)
|
|
443
426
|
rescue Clacky::AgentInterrupted
|
|
@@ -613,7 +596,7 @@ module Clacky
|
|
|
613
596
|
end
|
|
614
597
|
|
|
615
598
|
def ui_prompt
|
|
616
|
-
@ui_prompt ||= UI::
|
|
599
|
+
@ui_prompt ||= UI::EnhancedPrompt.new
|
|
617
600
|
end
|
|
618
601
|
|
|
619
602
|
def ui_statusbar
|
|
@@ -628,68 +611,6 @@ module Clacky
|
|
|
628
611
|
@pastel ||= Pastel.new
|
|
629
612
|
end
|
|
630
613
|
end
|
|
631
|
-
|
|
632
|
-
private
|
|
633
|
-
|
|
634
|
-
def send_single_message(message, config)
|
|
635
|
-
spinner = TTY::Spinner.new("[:spinner] Thinking...", format: :dots)
|
|
636
|
-
spinner.auto_spin
|
|
637
|
-
|
|
638
|
-
client = Clacky::Client.new(config.api_key, base_url: config.base_url)
|
|
639
|
-
response = client.send_message(message, model: options[:model] || config.model)
|
|
640
|
-
|
|
641
|
-
spinner.success("Done!")
|
|
642
|
-
say "\n#{response}", :cyan
|
|
643
|
-
rescue StandardError => e
|
|
644
|
-
spinner.error("Failed!")
|
|
645
|
-
say "Error: #{e.message}", :red
|
|
646
|
-
exit 1
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
def start_interactive_chat(config)
|
|
650
|
-
say "Starting interactive chat with Claude...", :green
|
|
651
|
-
say "Type 'exit' or 'quit' to end the session.\n\n", :yellow
|
|
652
|
-
|
|
653
|
-
conversation = Clacky::Conversation.new(
|
|
654
|
-
config.api_key,
|
|
655
|
-
model: options[:model] || config.model,
|
|
656
|
-
base_url: config.base_url
|
|
657
|
-
)
|
|
658
|
-
|
|
659
|
-
# Use TTY::Prompt for input
|
|
660
|
-
tty_prompt = TTY::Prompt.new(interrupt: :exit)
|
|
661
|
-
|
|
662
|
-
loop do
|
|
663
|
-
# Use TTY::Prompt for better input handling
|
|
664
|
-
begin
|
|
665
|
-
message = tty_prompt.ask("You:", required: false) do |q|
|
|
666
|
-
q.modify :strip
|
|
667
|
-
end
|
|
668
|
-
rescue TTY::Reader::InputInterrupt
|
|
669
|
-
# Handle Ctrl+C
|
|
670
|
-
puts
|
|
671
|
-
break
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
break if message.nil? || %w[exit quit].include?(message&.downcase&.strip)
|
|
675
|
-
next if message.nil? || message.strip.empty?
|
|
676
|
-
|
|
677
|
-
spinner = TTY::Spinner.new("[:spinner] Claude is thinking...", format: :dots)
|
|
678
|
-
spinner.auto_spin
|
|
679
|
-
|
|
680
|
-
begin
|
|
681
|
-
response = conversation.send_message(message)
|
|
682
|
-
spinner.success("Claude:")
|
|
683
|
-
say response, :cyan
|
|
684
|
-
say "\n"
|
|
685
|
-
rescue StandardError => e
|
|
686
|
-
spinner.error("Error!")
|
|
687
|
-
say "Error: #{e.message}", :red
|
|
688
|
-
end
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
say "\nGoodbye!", :green
|
|
692
|
-
end
|
|
693
614
|
end
|
|
694
615
|
|
|
695
616
|
class ConfigCommand < Thor
|
data/lib/clacky/client.rb
CHANGED
|
@@ -55,12 +55,28 @@ module Clacky
|
|
|
55
55
|
# Add tools if provided
|
|
56
56
|
# For Claude API with caching: mark the last tool definition with cache_control
|
|
57
57
|
if tools&.any?
|
|
58
|
-
|
|
58
|
+
caching_supported = supports_prompt_caching?(model)
|
|
59
|
+
caching_enabled = enable_caching && caching_supported
|
|
60
|
+
|
|
61
|
+
# Debug logging for caching decisions
|
|
62
|
+
if verbose || ENV["CLACKY_DEBUG"]
|
|
63
|
+
puts "\n[DEBUG] Prompt Caching Analysis:"
|
|
64
|
+
puts " Model: #{model}"
|
|
65
|
+
puts " Caching Requested: #{enable_caching}"
|
|
66
|
+
puts " Caching Supported: #{caching_supported}"
|
|
67
|
+
puts " Caching Enabled: #{caching_enabled}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if caching_enabled
|
|
59
71
|
# Deep clone tools to avoid modifying original
|
|
60
72
|
cached_tools = tools.map { |tool| deep_clone(tool) }
|
|
61
73
|
# Mark the last tool for caching (Claude caches from cache breakpoint to end)
|
|
62
74
|
cached_tools.last[:cache_control] = { type: "ephemeral" }
|
|
63
75
|
body[:tools] = cached_tools
|
|
76
|
+
|
|
77
|
+
if verbose || ENV["CLACKY_DEBUG"]
|
|
78
|
+
puts " Cache Control Added: Last tool marked for caching"
|
|
79
|
+
end
|
|
64
80
|
else
|
|
65
81
|
body[:tools] = tools
|
|
66
82
|
end
|
|
@@ -91,14 +107,28 @@ module Clacky
|
|
|
91
107
|
private
|
|
92
108
|
|
|
93
109
|
# Check if the model supports prompt caching
|
|
94
|
-
# Currently only Claude 3.5
|
|
110
|
+
# Currently only Claude 3.5+ models support this feature
|
|
95
111
|
def supports_prompt_caching?(model)
|
|
96
112
|
model_str = model.to_s.downcase
|
|
97
|
-
|
|
98
|
-
#
|
|
99
|
-
model_str.include?("claude
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
|
|
114
|
+
# Only Claude models support prompt caching
|
|
115
|
+
return false unless model_str.include?("claude")
|
|
116
|
+
|
|
117
|
+
# Pattern matching for supported Claude versions:
|
|
118
|
+
# - claude-3.5-*, claude-3-5-*, claude-3.5.*
|
|
119
|
+
# - claude-3.7-*, claude-3-7-*, claude-3.7.*
|
|
120
|
+
# - claude-4*, claude-sonnet-4*
|
|
121
|
+
# - anthropic/claude-sonnet-4* (OpenRouter format)
|
|
122
|
+
cache_pattern = /
|
|
123
|
+
claude # Must contain "claude"
|
|
124
|
+
(?: # Non-capturing group for version patterns
|
|
125
|
+
(?:-3[-.]?[5-9])| # 3.5, 3.6, 3.7, 3.8, 3.9 or 3-5, 3-6, etc
|
|
126
|
+
(?:-[4-9])| # 4, 5, 6, 7, 8, 9 (future versions)
|
|
127
|
+
(?:-sonnet-[34]) # OpenRouter: claude-sonnet-3, claude-sonnet-4
|
|
128
|
+
)
|
|
129
|
+
/x
|
|
130
|
+
|
|
131
|
+
model_str.match?(cache_pattern)
|
|
102
132
|
end
|
|
103
133
|
|
|
104
134
|
# Deep clone a hash/array structure (for tool definitions)
|
|
@@ -162,6 +192,11 @@ module Clacky
|
|
|
162
192
|
total_tokens: usage["total_tokens"]
|
|
163
193
|
}
|
|
164
194
|
|
|
195
|
+
# Add OpenRouter cost information if present
|
|
196
|
+
if usage["cost"]
|
|
197
|
+
usage_data[:api_cost] = usage["cost"]
|
|
198
|
+
end
|
|
199
|
+
|
|
165
200
|
# Add cache metrics if present (Claude API with prompt caching)
|
|
166
201
|
if usage["cache_creation_input_tokens"]
|
|
167
202
|
usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"]
|
|
@@ -170,6 +205,17 @@ module Clacky
|
|
|
170
205
|
usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"]
|
|
171
206
|
end
|
|
172
207
|
|
|
208
|
+
# Add OpenRouter cache information from prompt_tokens_details
|
|
209
|
+
if usage["prompt_tokens_details"]
|
|
210
|
+
details = usage["prompt_tokens_details"]
|
|
211
|
+
if details["cached_tokens"] && details["cached_tokens"] > 0
|
|
212
|
+
usage_data[:cache_read_input_tokens] = details["cached_tokens"]
|
|
213
|
+
end
|
|
214
|
+
if details["cache_write_tokens"] && details["cache_write_tokens"] > 0
|
|
215
|
+
usage_data[:cache_creation_input_tokens] = details["cache_write_tokens"]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
173
219
|
{
|
|
174
220
|
content: message["content"],
|
|
175
221
|
tool_calls: parse_tool_calls(message["tool_calls"]),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Parser for .gitignore files to determine which files should be ignored
|
|
5
|
+
class GitignoreParser
|
|
6
|
+
attr_reader :patterns
|
|
7
|
+
|
|
8
|
+
def initialize(gitignore_path = nil)
|
|
9
|
+
@patterns = []
|
|
10
|
+
@negation_patterns = []
|
|
11
|
+
|
|
12
|
+
if gitignore_path && File.exist?(gitignore_path)
|
|
13
|
+
parse_gitignore(gitignore_path)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if a file path should be ignored
|
|
18
|
+
def ignored?(path)
|
|
19
|
+
relative_path = path.start_with?('./') ? path[2..] : path
|
|
20
|
+
|
|
21
|
+
# Check negation patterns first (! prefix in .gitignore)
|
|
22
|
+
@negation_patterns.each do |pattern|
|
|
23
|
+
return false if match_pattern?(relative_path, pattern)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Then check ignore patterns
|
|
27
|
+
@patterns.each do |pattern|
|
|
28
|
+
return true if match_pattern?(relative_path, pattern)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_gitignore(path)
|
|
37
|
+
File.readlines(path, chomp: true).each do |line|
|
|
38
|
+
# Skip comments and empty lines
|
|
39
|
+
next if line.strip.empty? || line.start_with?('#')
|
|
40
|
+
|
|
41
|
+
# Handle negation patterns (lines starting with !)
|
|
42
|
+
if line.start_with?('!')
|
|
43
|
+
@negation_patterns << normalize_pattern(line[1..])
|
|
44
|
+
else
|
|
45
|
+
@patterns << normalize_pattern(line)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
# If we can't parse .gitignore, just continue with empty patterns
|
|
50
|
+
warn "Warning: Failed to parse .gitignore: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_pattern(pattern)
|
|
54
|
+
pattern = pattern.strip
|
|
55
|
+
|
|
56
|
+
# Remove trailing whitespace
|
|
57
|
+
pattern = pattern.rstrip
|
|
58
|
+
|
|
59
|
+
# Store original for directory detection
|
|
60
|
+
is_directory = pattern.end_with?('/')
|
|
61
|
+
pattern = pattern.chomp('/')
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
pattern: pattern,
|
|
65
|
+
is_directory: is_directory,
|
|
66
|
+
is_absolute: pattern.start_with?('/'),
|
|
67
|
+
has_wildcard: pattern.include?('*') || pattern.include?('?'),
|
|
68
|
+
has_double_star: pattern.include?('**')
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def match_pattern?(path, pattern_info)
|
|
73
|
+
pattern = pattern_info[:pattern]
|
|
74
|
+
|
|
75
|
+
# Remove leading slash for absolute patterns
|
|
76
|
+
pattern = pattern[1..] if pattern_info[:is_absolute]
|
|
77
|
+
|
|
78
|
+
# Handle directory patterns
|
|
79
|
+
if pattern_info[:is_directory]
|
|
80
|
+
return false unless File.directory?(path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Handle different wildcard patterns
|
|
84
|
+
if pattern_info[:has_double_star]
|
|
85
|
+
# Convert ** to match any number of directories
|
|
86
|
+
regex_pattern = pattern
|
|
87
|
+
.gsub('**/', '(.*/)?') # **/ matches zero or more directories
|
|
88
|
+
.gsub('**', '.*') # ** at end matches anything
|
|
89
|
+
.gsub('*', '[^/]*') # * matches anything except /
|
|
90
|
+
.gsub('?', '[^/]') # ? matches single character except /
|
|
91
|
+
|
|
92
|
+
regex = Regexp.new("^#{regex_pattern}$")
|
|
93
|
+
return true if path.match?(regex)
|
|
94
|
+
return true if path.split('/').any? { |part| part.match?(regex) }
|
|
95
|
+
elsif pattern_info[:has_wildcard]
|
|
96
|
+
# Convert glob pattern to regex
|
|
97
|
+
regex_pattern = pattern
|
|
98
|
+
.gsub('*', '[^/]*')
|
|
99
|
+
.gsub('?', '[^/]')
|
|
100
|
+
|
|
101
|
+
regex = Regexp.new("^#{regex_pattern}$")
|
|
102
|
+
return true if path.match?(regex)
|
|
103
|
+
return true if File.basename(path).match?(regex)
|
|
104
|
+
else
|
|
105
|
+
# Exact match
|
|
106
|
+
return true if path == pattern
|
|
107
|
+
return true if path.start_with?("#{pattern}/")
|
|
108
|
+
return true if File.basename(path) == pattern
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|