openclacky 0.5.4 → 0.5.6
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/.clackyrules +4 -0
- data/README.md +1 -1
- data/lib/clacky/agent.rb +190 -19
- data/lib/clacky/cli.rb +126 -141
- data/lib/clacky/client.rb +53 -7
- data/lib/clacky/model_pricing.rb +280 -0
- data/lib/clacky/progress_indicator.rb +1 -1
- data/lib/clacky/tools/file_reader.rb +73 -10
- data/lib/clacky/tools/grep.rb +74 -7
- 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 +366 -223
- data/lib/clacky/ui/formatter.rb +1 -1
- data/lib/clacky/ui/statusbar.rb +0 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -1
- metadata +2 -2
- data/lib/clacky/conversation.rb +0 -41
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,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Module for handling AI model pricing
|
|
5
|
+
# Supports different pricing tiers and prompt caching
|
|
6
|
+
module ModelPricing
|
|
7
|
+
# Pricing per 1M tokens (MTok) in USD
|
|
8
|
+
# All pricing is based on official API documentation
|
|
9
|
+
PRICING_TABLE = {
|
|
10
|
+
# Claude 4.5 models - tiered pricing based on prompt length
|
|
11
|
+
"claude-opus-4.5" => {
|
|
12
|
+
input: {
|
|
13
|
+
default: 5.00, # $5/MTok for prompts ≤ 200K tokens
|
|
14
|
+
over_200k: 5.00 # same for all tiers
|
|
15
|
+
},
|
|
16
|
+
output: {
|
|
17
|
+
default: 25.00, # $25/MTok for prompts ≤ 200K tokens
|
|
18
|
+
over_200k: 25.00 # same for all tiers
|
|
19
|
+
},
|
|
20
|
+
cache: {
|
|
21
|
+
write: 6.25, # $6.25/MTok cache write
|
|
22
|
+
read: 0.50 # $0.50/MTok cache read
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"claude-sonnet-4.5" => {
|
|
27
|
+
input: {
|
|
28
|
+
default: 3.00, # $3/MTok for prompts ≤ 200K tokens
|
|
29
|
+
over_200k: 6.00 # $6/MTok for prompts > 200K tokens
|
|
30
|
+
},
|
|
31
|
+
output: {
|
|
32
|
+
default: 15.00, # $15/MTok for prompts ≤ 200K tokens
|
|
33
|
+
over_200k: 22.50 # $22.50/MTok for prompts > 200K tokens
|
|
34
|
+
},
|
|
35
|
+
cache: {
|
|
36
|
+
write_default: 3.75, # $3.75/MTok cache write (≤ 200K)
|
|
37
|
+
write_over_200k: 7.50, # $7.50/MTok cache write (> 200K)
|
|
38
|
+
read_default: 0.30, # $0.30/MTok cache read (≤ 200K)
|
|
39
|
+
read_over_200k: 0.60 # $0.60/MTok cache read (> 200K)
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
"claude-haiku-4.5" => {
|
|
44
|
+
input: {
|
|
45
|
+
default: 1.00, # $1/MTok
|
|
46
|
+
over_200k: 1.00 # same for all tiers
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
default: 5.00, # $5/MTok
|
|
50
|
+
over_200k: 5.00 # same for all tiers
|
|
51
|
+
},
|
|
52
|
+
cache: {
|
|
53
|
+
write: 1.25, # $1.25/MTok cache write
|
|
54
|
+
read: 0.10 # $0.10/MTok cache read
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
# Claude 3.5 models (for backwards compatibility)
|
|
59
|
+
"claude-3-5-sonnet-20241022" => {
|
|
60
|
+
input: {
|
|
61
|
+
default: 3.00,
|
|
62
|
+
over_200k: 6.00
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
default: 15.00,
|
|
66
|
+
over_200k: 22.50
|
|
67
|
+
},
|
|
68
|
+
cache: {
|
|
69
|
+
write_default: 3.75,
|
|
70
|
+
write_over_200k: 7.50,
|
|
71
|
+
read_default: 0.30,
|
|
72
|
+
read_over_200k: 0.60
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
"claude-3-5-sonnet-20240620" => {
|
|
77
|
+
input: {
|
|
78
|
+
default: 3.00,
|
|
79
|
+
over_200k: 6.00
|
|
80
|
+
},
|
|
81
|
+
output: {
|
|
82
|
+
default: 15.00,
|
|
83
|
+
over_200k: 22.50
|
|
84
|
+
},
|
|
85
|
+
cache: {
|
|
86
|
+
write_default: 3.75,
|
|
87
|
+
write_over_200k: 7.50,
|
|
88
|
+
read_default: 0.30,
|
|
89
|
+
read_over_200k: 0.60
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
"claude-3-5-haiku-20241022" => {
|
|
94
|
+
input: {
|
|
95
|
+
default: 1.00,
|
|
96
|
+
over_200k: 1.00
|
|
97
|
+
},
|
|
98
|
+
output: {
|
|
99
|
+
default: 5.00,
|
|
100
|
+
over_200k: 5.00
|
|
101
|
+
},
|
|
102
|
+
cache: {
|
|
103
|
+
write: 1.25,
|
|
104
|
+
read: 0.10
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
# Default fallback pricing (conservative estimates)
|
|
109
|
+
"default" => {
|
|
110
|
+
input: {
|
|
111
|
+
default: 0.50,
|
|
112
|
+
over_200k: 0.50
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
default: 1.50,
|
|
116
|
+
over_200k: 1.50
|
|
117
|
+
},
|
|
118
|
+
cache: {
|
|
119
|
+
write: 0.625,
|
|
120
|
+
read: 0.05
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}.freeze
|
|
124
|
+
|
|
125
|
+
# Threshold for tiered pricing (200K tokens)
|
|
126
|
+
TIERED_PRICING_THRESHOLD = 200_000
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
# Calculate cost for the given model and usage
|
|
130
|
+
#
|
|
131
|
+
# @param model [String] Model identifier
|
|
132
|
+
# @param usage [Hash] Usage statistics containing:
|
|
133
|
+
# - prompt_tokens: number of input tokens
|
|
134
|
+
# - completion_tokens: number of output tokens
|
|
135
|
+
# - cache_creation_input_tokens: tokens written to cache (optional)
|
|
136
|
+
# - cache_read_input_tokens: tokens read from cache (optional)
|
|
137
|
+
# @return [Hash] Hash containing:
|
|
138
|
+
# - cost: Cost in USD (Float)
|
|
139
|
+
# - source: Cost source (:price or :default) (Symbol)
|
|
140
|
+
def calculate_cost(model:, usage:)
|
|
141
|
+
pricing_result = get_pricing_with_source(model)
|
|
142
|
+
pricing = pricing_result[:pricing]
|
|
143
|
+
source = pricing_result[:source]
|
|
144
|
+
|
|
145
|
+
prompt_tokens = usage[:prompt_tokens] || 0
|
|
146
|
+
completion_tokens = usage[:completion_tokens] || 0
|
|
147
|
+
cache_write_tokens = usage[:cache_creation_input_tokens] || 0
|
|
148
|
+
cache_read_tokens = usage[:cache_read_input_tokens] || 0
|
|
149
|
+
|
|
150
|
+
# Determine if we're in the over_200k tier
|
|
151
|
+
total_input_tokens = prompt_tokens + cache_write_tokens + cache_read_tokens
|
|
152
|
+
over_threshold = total_input_tokens > TIERED_PRICING_THRESHOLD
|
|
153
|
+
|
|
154
|
+
# Calculate regular input cost (non-cached tokens)
|
|
155
|
+
regular_input_tokens = prompt_tokens - cache_write_tokens - cache_read_tokens
|
|
156
|
+
input_rate = over_threshold ? pricing[:input][:over_200k] : pricing[:input][:default]
|
|
157
|
+
input_cost = (regular_input_tokens / 1_000_000.0) * input_rate
|
|
158
|
+
|
|
159
|
+
# Calculate output cost
|
|
160
|
+
output_rate = over_threshold ? pricing[:output][:over_200k] : pricing[:output][:default]
|
|
161
|
+
output_cost = (completion_tokens / 1_000_000.0) * output_rate
|
|
162
|
+
|
|
163
|
+
# Calculate cache costs
|
|
164
|
+
cache_cost = calculate_cache_cost(
|
|
165
|
+
pricing: pricing,
|
|
166
|
+
cache_write_tokens: cache_write_tokens,
|
|
167
|
+
cache_read_tokens: cache_read_tokens,
|
|
168
|
+
over_threshold: over_threshold
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
{
|
|
172
|
+
cost: input_cost + output_cost + cache_cost,
|
|
173
|
+
source: source
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get pricing for a specific model
|
|
178
|
+
# Falls back to default pricing if model not found
|
|
179
|
+
#
|
|
180
|
+
# @param model [String] Model identifier
|
|
181
|
+
# @return [Hash] Pricing structure for the model
|
|
182
|
+
def get_pricing(model)
|
|
183
|
+
get_pricing_with_source(model)[:pricing]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get pricing with source information
|
|
187
|
+
#
|
|
188
|
+
# @param model [String] Model identifier
|
|
189
|
+
# @return [Hash] Hash containing:
|
|
190
|
+
# - pricing: Pricing structure for the model
|
|
191
|
+
# - source: :price (matched model) or :default (fallback)
|
|
192
|
+
def get_pricing_with_source(model)
|
|
193
|
+
# Normalize model name (remove version suffixes, handle variations)
|
|
194
|
+
normalized_model = normalize_model_name(model)
|
|
195
|
+
|
|
196
|
+
if normalized_model == "default"
|
|
197
|
+
# Using default fallback pricing
|
|
198
|
+
{
|
|
199
|
+
pricing: PRICING_TABLE["default"],
|
|
200
|
+
source: :default
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
# Found specific pricing for this model
|
|
204
|
+
{
|
|
205
|
+
pricing: PRICING_TABLE[normalized_model],
|
|
206
|
+
source: :price
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# Normalize model name to match pricing table keys
|
|
214
|
+
def normalize_model_name(model)
|
|
215
|
+
return "default" if model.nil? || model.empty?
|
|
216
|
+
|
|
217
|
+
model = model.downcase.strip
|
|
218
|
+
|
|
219
|
+
# Direct match
|
|
220
|
+
return model if PRICING_TABLE.key?(model)
|
|
221
|
+
|
|
222
|
+
# Check for Claude model variations
|
|
223
|
+
# Support both dot and dash separators (e.g., "4.5" or "4-5")
|
|
224
|
+
case model
|
|
225
|
+
when /claude.*opus.*4[.-]?5/i
|
|
226
|
+
"claude-opus-4.5"
|
|
227
|
+
when /claude.*sonnet.*4[.-]?5/i
|
|
228
|
+
"claude-sonnet-4.5"
|
|
229
|
+
when /claude.*haiku.*4[.-]?5/i
|
|
230
|
+
"claude-haiku-4.5"
|
|
231
|
+
when /claude-3-5-sonnet-20241022/i
|
|
232
|
+
"claude-3-5-sonnet-20241022"
|
|
233
|
+
when /claude-3-5-sonnet-20240620/i
|
|
234
|
+
"claude-3-5-sonnet-20240620"
|
|
235
|
+
when /claude-3-5-haiku-20241022/i
|
|
236
|
+
"claude-3-5-haiku-20241022"
|
|
237
|
+
else
|
|
238
|
+
"default"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Calculate cache-related costs
|
|
243
|
+
def calculate_cache_cost(pricing:, cache_write_tokens:, cache_read_tokens:, over_threshold:)
|
|
244
|
+
cache_cost = 0.0
|
|
245
|
+
|
|
246
|
+
# Cache write cost
|
|
247
|
+
if cache_write_tokens > 0
|
|
248
|
+
write_rate = if pricing[:cache].key?(:write)
|
|
249
|
+
# Simple pricing (Opus 4.5, Haiku 4.5)
|
|
250
|
+
pricing[:cache][:write]
|
|
251
|
+
elsif over_threshold
|
|
252
|
+
# Tiered pricing (Sonnet 4.5)
|
|
253
|
+
pricing[:cache][:write_over_200k]
|
|
254
|
+
else
|
|
255
|
+
pricing[:cache][:write_default]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
cache_cost += (cache_write_tokens / 1_000_000.0) * write_rate
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Cache read cost
|
|
262
|
+
if cache_read_tokens > 0
|
|
263
|
+
read_rate = if pricing[:cache].key?(:read)
|
|
264
|
+
# Simple pricing (Opus 4.5, Haiku 4.5)
|
|
265
|
+
pricing[:cache][:read]
|
|
266
|
+
elsif over_threshold
|
|
267
|
+
# Tiered pricing (Sonnet 4.5)
|
|
268
|
+
pricing[:cache][:read_over_200k]
|
|
269
|
+
else
|
|
270
|
+
pricing[:cache][:read_default]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
cache_cost += (cache_read_tokens / 1_000_000.0) * read_rate
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
cache_cost
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -25,29 +25,37 @@ module Clacky
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
def execute(path:, max_lines: 1000)
|
|
28
|
-
|
|
28
|
+
# Expand ~ to home directory
|
|
29
|
+
expanded_path = File.expand_path(path)
|
|
30
|
+
|
|
31
|
+
unless File.exist?(expanded_path)
|
|
29
32
|
return {
|
|
30
|
-
path:
|
|
33
|
+
path: expanded_path,
|
|
31
34
|
content: nil,
|
|
32
|
-
error: "File not found: #{
|
|
35
|
+
error: "File not found: #{expanded_path}"
|
|
33
36
|
}
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
# If path is a directory, list its first-level contents (similar to filetree)
|
|
40
|
+
if File.directory?(expanded_path)
|
|
41
|
+
return list_directory_contents(expanded_path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless File.file?(expanded_path)
|
|
37
45
|
return {
|
|
38
|
-
path:
|
|
46
|
+
path: expanded_path,
|
|
39
47
|
content: nil,
|
|
40
|
-
error: "Path is not a file: #{
|
|
48
|
+
error: "Path is not a file: #{expanded_path}"
|
|
41
49
|
}
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
begin
|
|
45
|
-
lines = File.readlines(
|
|
53
|
+
lines = File.readlines(expanded_path).first(max_lines)
|
|
46
54
|
content = lines.join
|
|
47
|
-
truncated = File.readlines(
|
|
55
|
+
truncated = File.readlines(expanded_path).size > max_lines
|
|
48
56
|
|
|
49
57
|
{
|
|
50
|
-
path:
|
|
58
|
+
path: expanded_path,
|
|
51
59
|
content: content,
|
|
52
60
|
lines_read: lines.size,
|
|
53
61
|
truncated: truncated,
|
|
@@ -55,7 +63,7 @@ module Clacky
|
|
|
55
63
|
}
|
|
56
64
|
rescue StandardError => e
|
|
57
65
|
{
|
|
58
|
-
path:
|
|
66
|
+
path: expanded_path,
|
|
59
67
|
content: nil,
|
|
60
68
|
error: "Error reading file: #{e.message}"
|
|
61
69
|
}
|
|
@@ -70,10 +78,65 @@ module Clacky
|
|
|
70
78
|
def format_result(result)
|
|
71
79
|
return result[:error] if result[:error]
|
|
72
80
|
|
|
81
|
+
# Handle directory listing
|
|
82
|
+
if result[:is_directory] || result['is_directory']
|
|
83
|
+
entries = result[:entries_count] || result['entries_count'] || 0
|
|
84
|
+
dirs = result[:directories_count] || result['directories_count'] || 0
|
|
85
|
+
files = result[:files_count] || result['files_count'] || 0
|
|
86
|
+
return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Handle file reading
|
|
73
90
|
lines = result[:lines_read] || result['lines_read'] || 0
|
|
74
91
|
truncated = result[:truncated] || result['truncated']
|
|
75
92
|
"Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
|
|
76
93
|
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# List first-level directory contents (files and directories)
|
|
98
|
+
def list_directory_contents(path)
|
|
99
|
+
begin
|
|
100
|
+
entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
|
|
101
|
+
|
|
102
|
+
# Separate files and directories
|
|
103
|
+
files = []
|
|
104
|
+
directories = []
|
|
105
|
+
|
|
106
|
+
entries.each do |entry|
|
|
107
|
+
full_path = File.join(path, entry)
|
|
108
|
+
if File.directory?(full_path)
|
|
109
|
+
directories << entry + "/"
|
|
110
|
+
else
|
|
111
|
+
files << entry
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Sort directories and files separately, then combine
|
|
116
|
+
directories.sort!
|
|
117
|
+
files.sort!
|
|
118
|
+
all_entries = directories + files
|
|
119
|
+
|
|
120
|
+
# Format as a tree-like structure
|
|
121
|
+
content = all_entries.map { |entry| " #{entry}" }.join("\n")
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
path: path,
|
|
125
|
+
content: "Directory listing:\n#{content}",
|
|
126
|
+
entries_count: all_entries.size,
|
|
127
|
+
directories_count: directories.size,
|
|
128
|
+
files_count: files.size,
|
|
129
|
+
is_directory: true,
|
|
130
|
+
error: nil
|
|
131
|
+
}
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
{
|
|
134
|
+
path: path,
|
|
135
|
+
content: nil,
|
|
136
|
+
error: "Error reading directory: #{e.message}"
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
77
140
|
end
|
|
78
141
|
end
|
|
79
142
|
end
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -136,7 +136,7 @@ module Clacky
|
|
|
136
136
|
|
|
137
137
|
# Initialize gitignore parser
|
|
138
138
|
gitignore_path = find_gitignore(expanded_path)
|
|
139
|
-
gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil
|
|
139
|
+
gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
|
|
140
140
|
|
|
141
141
|
results = []
|
|
142
142
|
total_matches = 0
|
|
@@ -256,21 +256,77 @@ module Clacky
|
|
|
256
256
|
end
|
|
257
257
|
end
|
|
258
258
|
|
|
259
|
+
# Format result for LLM consumption - return a compact version to save tokens
|
|
260
|
+
def format_result_for_llm(result)
|
|
261
|
+
# If there's an error, return it as-is
|
|
262
|
+
return result if result[:error]
|
|
263
|
+
|
|
264
|
+
# Build a compact summary with file list and sample matches
|
|
265
|
+
compact = {
|
|
266
|
+
summary: {
|
|
267
|
+
total_matches: result[:total_matches],
|
|
268
|
+
files_with_matches: result[:files_with_matches],
|
|
269
|
+
files_searched: result[:files_searched],
|
|
270
|
+
truncated: result[:truncated],
|
|
271
|
+
truncation_reason: result[:truncation_reason]
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Include list of files with match counts
|
|
276
|
+
if result[:results] && !result[:results].empty?
|
|
277
|
+
compact[:files] = result[:results].map do |file_result|
|
|
278
|
+
{
|
|
279
|
+
file: file_result[:file],
|
|
280
|
+
match_count: file_result[:matches].length
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Include sample matches (first 2 matches from first 3 files) for context
|
|
285
|
+
sample_results = result[:results].take(3)
|
|
286
|
+
compact[:sample_matches] = sample_results.map do |file_result|
|
|
287
|
+
{
|
|
288
|
+
file: file_result[:file],
|
|
289
|
+
matches: file_result[:matches].take(2).map do |match|
|
|
290
|
+
{
|
|
291
|
+
line_number: match[:line_number],
|
|
292
|
+
line: match[:line]
|
|
293
|
+
# Omit context to save space - it's rarely needed by LLM
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
compact
|
|
301
|
+
end
|
|
302
|
+
|
|
259
303
|
private
|
|
260
304
|
|
|
261
305
|
# Find .gitignore file in the search path or parent directories
|
|
306
|
+
# Only searches within the search path and up to the current working directory
|
|
262
307
|
def find_gitignore(path)
|
|
263
308
|
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
264
309
|
|
|
265
310
|
# Look for .gitignore in current and parent directories
|
|
266
311
|
current = File.expand_path(search_path)
|
|
312
|
+
cwd = File.expand_path(Dir.pwd)
|
|
267
313
|
root = File.expand_path('/')
|
|
268
314
|
|
|
315
|
+
# Limit search: only go up to current working directory
|
|
316
|
+
# This prevents finding .gitignore files from unrelated parent directories
|
|
317
|
+
# when searching in temporary directories (like /tmp in tests)
|
|
318
|
+
search_limit = if current.start_with?(cwd)
|
|
319
|
+
cwd
|
|
320
|
+
else
|
|
321
|
+
current
|
|
322
|
+
end
|
|
323
|
+
|
|
269
324
|
loop do
|
|
270
325
|
gitignore = File.join(current, '.gitignore')
|
|
271
326
|
return gitignore if File.exist?(gitignore)
|
|
272
327
|
|
|
273
|
-
|
|
328
|
+
# Stop if we've reached the search limit or root
|
|
329
|
+
break if current == search_limit || current == root
|
|
274
330
|
current = File.dirname(current)
|
|
275
331
|
end
|
|
276
332
|
|
|
@@ -279,24 +335,35 @@ module Clacky
|
|
|
279
335
|
|
|
280
336
|
# Check if file should be ignored based on .gitignore or default patterns
|
|
281
337
|
def should_ignore_file?(file, base_path, gitignore)
|
|
338
|
+
# Always calculate path relative to base_path for consistency
|
|
339
|
+
# Expand both paths to handle symlinks and relative paths correctly
|
|
340
|
+
expanded_file = File.expand_path(file)
|
|
341
|
+
expanded_base = File.expand_path(base_path)
|
|
342
|
+
|
|
343
|
+
# For files, use the directory as base
|
|
344
|
+
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
345
|
+
|
|
282
346
|
# Calculate relative path
|
|
283
|
-
if
|
|
284
|
-
relative_path =
|
|
347
|
+
if expanded_file.start_with?(expanded_base)
|
|
348
|
+
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
285
349
|
else
|
|
286
|
-
|
|
350
|
+
# File is outside base path - use just the filename
|
|
351
|
+
relative_path = File.basename(expanded_file)
|
|
287
352
|
end
|
|
353
|
+
|
|
354
|
+
# Clean up relative path
|
|
288
355
|
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
289
|
-
relative_path ||= file
|
|
290
356
|
|
|
291
357
|
if gitignore
|
|
292
358
|
# Use .gitignore rules
|
|
293
359
|
gitignore.ignored?(relative_path)
|
|
294
360
|
else
|
|
295
|
-
# Use default ignore patterns
|
|
361
|
+
# Use default ignore patterns - only match against relative path components
|
|
296
362
|
DEFAULT_IGNORED_PATTERNS.any? do |pattern|
|
|
297
363
|
if pattern.include?('*')
|
|
298
364
|
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
299
365
|
else
|
|
366
|
+
# Match pattern as a path component (not substring of absolute path)
|
|
300
367
|
relative_path.start_with?("#{pattern}/") ||
|
|
301
368
|
relative_path.include?("/#{pattern}/") ||
|
|
302
369
|
relative_path == pattern ||
|
|
@@ -26,12 +26,17 @@ module Clacky
|
|
|
26
26
|
hard_timeout: {
|
|
27
27
|
type: "integer",
|
|
28
28
|
description: "Hard timeout in seconds (force kill)"
|
|
29
|
+
},
|
|
30
|
+
max_output_lines: {
|
|
31
|
+
type: "integer",
|
|
32
|
+
description: "Maximum number of output lines to return (default: 1000)",
|
|
33
|
+
default: 1000
|
|
29
34
|
}
|
|
30
35
|
},
|
|
31
36
|
required: ["command"]
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
def execute(command:, soft_timeout: nil, hard_timeout: nil)
|
|
39
|
+
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
|
|
35
40
|
# Get project root directory
|
|
36
41
|
project_root = Dir.pwd
|
|
37
42
|
|
|
@@ -41,7 +46,7 @@ module Clacky
|
|
|
41
46
|
safe_command = safety_replacer.make_command_safe(command)
|
|
42
47
|
|
|
43
48
|
# 2. Call parent class execution method
|
|
44
|
-
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout)
|
|
49
|
+
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
45
50
|
|
|
46
51
|
# 3. Enhance result information
|
|
47
52
|
enhance_result(result, command, safe_command)
|
|
@@ -112,8 +117,8 @@ module Clacky
|
|
|
112
117
|
return "safe_shell(<no command>)" if cmd.empty?
|
|
113
118
|
|
|
114
119
|
# Truncate long commands intelligently
|
|
115
|
-
if cmd.length >
|
|
116
|
-
"safe_shell(\"#{cmd[0..
|
|
120
|
+
if cmd.length > 150
|
|
121
|
+
"safe_shell(\"#{cmd[0..147]}...\")"
|
|
117
122
|
else
|
|
118
123
|
"safe_shell(\"#{cmd}\")"
|
|
119
124
|
end
|