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.
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
- if enable_caching && supports_prompt_caching?(model)
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 Sonnet and newer Claude models support this
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
- # Claude 3.5 Sonnet (20241022 and newer) supports prompt caching
98
- # Also Claude 3.7 Sonnet and Opus models when they're released
99
- model_str.include?("claude-3.5-sonnet") ||
100
- model_str.include?("claude-3-7") ||
101
- model_str.include?("claude-4")
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
@@ -21,7 +21,7 @@ module Clacky
21
21
  # Start background thread to update elapsed time
22
22
  @update_thread = Thread.new do
23
23
  while @running
24
- sleep 1
24
+ sleep 0.1
25
25
  update if @running
26
26
  end
27
27
  end
@@ -25,29 +25,37 @@ module Clacky
25
25
  }
26
26
 
27
27
  def execute(path:, max_lines: 1000)
28
- unless File.exist?(path)
28
+ # Expand ~ to home directory
29
+ expanded_path = File.expand_path(path)
30
+
31
+ unless File.exist?(expanded_path)
29
32
  return {
30
- path: path,
33
+ path: expanded_path,
31
34
  content: nil,
32
- error: "File not found: #{path}"
35
+ error: "File not found: #{expanded_path}"
33
36
  }
34
37
  end
35
38
 
36
- unless File.file?(path)
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: path,
46
+ path: expanded_path,
39
47
  content: nil,
40
- error: "Path is not a file: #{path}"
48
+ error: "Path is not a file: #{expanded_path}"
41
49
  }
42
50
  end
43
51
 
44
52
  begin
45
- lines = File.readlines(path).first(max_lines)
53
+ lines = File.readlines(expanded_path).first(max_lines)
46
54
  content = lines.join
47
- truncated = File.readlines(path).size > max_lines
55
+ truncated = File.readlines(expanded_path).size > max_lines
48
56
 
49
57
  {
50
- path: 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: 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
@@ -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
- break if current == root
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 file.start_with?(base_path)
284
- relative_path = file[base_path.length + 1..] || file
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
- relative_path = file
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 > 50
116
- "safe_shell(\"#{cmd[0..47]}...\")"
120
+ if cmd.length > 150
121
+ "safe_shell(\"#{cmd[0..147]}...\")"
117
122
  else
118
123
  "safe_shell(\"#{cmd}\")"
119
124
  end