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.
@@ -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
@@ -3,6 +3,42 @@
3
3
  module Clacky
4
4
  module Tools
5
5
  class Grep < Base
6
+ # Default patterns to ignore when .gitignore is not available
7
+ DEFAULT_IGNORED_PATTERNS = [
8
+ 'node_modules',
9
+ 'vendor/bundle',
10
+ '.git',
11
+ '.svn',
12
+ 'tmp',
13
+ 'log',
14
+ 'coverage',
15
+ 'dist',
16
+ 'build',
17
+ '.bundle',
18
+ '.sass-cache',
19
+ '.DS_Store',
20
+ '*.log'
21
+ ].freeze
22
+
23
+ # Config file patterns that should always be searchable
24
+ CONFIG_FILE_PATTERNS = [
25
+ /\.env/,
26
+ /\.ya?ml$/,
27
+ /\.json$/,
28
+ /\.toml$/,
29
+ /\.ini$/,
30
+ /\.conf$/,
31
+ /\.config$/,
32
+ /config\//,
33
+ /\.config\//
34
+ ].freeze
35
+
36
+ # Maximum file size to search (1MB)
37
+ MAX_FILE_SIZE = 1_048_576
38
+
39
+ # Maximum line length to display (to avoid huge outputs)
40
+ MAX_LINE_LENGTH = 500
41
+
6
42
  self.tool_name = "grep"
7
43
  self.tool_description = "Search file contents using regular expressions. Returns matching lines with context."
8
44
  self.tool_category = "file_system"
@@ -30,53 +66,144 @@ module Clacky
30
66
  },
31
67
  context_lines: {
32
68
  type: "integer",
33
- description: "Number of context lines to show before and after each match",
69
+ description: "Number of context lines to show before and after each match (max: 10)",
34
70
  default: 0
35
71
  },
36
- max_matches: {
72
+ max_files: {
37
73
  type: "integer",
38
74
  description: "Maximum number of matching files to return",
39
75
  default: 50
76
+ },
77
+ max_matches_per_file: {
78
+ type: "integer",
79
+ description: "Maximum number of matches to return per file",
80
+ default: 50
81
+ },
82
+ max_total_matches: {
83
+ type: "integer",
84
+ description: "Maximum total number of matches to return across all files",
85
+ default: 200
86
+ },
87
+ max_file_size: {
88
+ type: "integer",
89
+ description: "Maximum file size in bytes to search (default: 1MB)",
90
+ default: MAX_FILE_SIZE
91
+ },
92
+ max_files_to_search: {
93
+ type: "integer",
94
+ description: "Maximum number of files to search",
95
+ default: 500
40
96
  }
41
97
  },
42
98
  required: %w[pattern]
43
99
  }
44
100
 
45
- def execute(pattern:, path: ".", file_pattern: "**/*", case_insensitive: false, context_lines: 0, max_matches: 50)
101
+ def execute(
102
+ pattern:,
103
+ path: ".",
104
+ file_pattern: "**/*",
105
+ case_insensitive: false,
106
+ context_lines: 0,
107
+ max_files: 50,
108
+ max_matches_per_file: 50,
109
+ max_total_matches: 200,
110
+ max_file_size: MAX_FILE_SIZE,
111
+ max_files_to_search: 500
112
+ )
46
113
  # Validate pattern
47
114
  if pattern.nil? || pattern.strip.empty?
48
115
  return { error: "Pattern cannot be empty" }
49
116
  end
50
117
 
51
- # Validate path
52
- unless File.exist?(path)
118
+ # Validate and expand path
119
+ begin
120
+ expanded_path = File.expand_path(path)
121
+ rescue StandardError => e
122
+ return { error: "Invalid path: #{e.message}" }
123
+ end
124
+
125
+ unless File.exist?(expanded_path)
53
126
  return { error: "Path does not exist: #{path}" }
54
127
  end
55
128
 
129
+ # Limit context_lines
130
+ context_lines = [[context_lines, 0].max, 10].min
131
+
56
132
  begin
57
133
  # Compile regex
58
134
  regex_options = case_insensitive ? Regexp::IGNORECASE : 0
59
135
  regex = Regexp.new(pattern, regex_options)
60
136
 
137
+ # Initialize gitignore parser
138
+ gitignore_path = find_gitignore(expanded_path)
139
+ gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
140
+
61
141
  results = []
62
142
  total_matches = 0
143
+ files_searched = 0
144
+ skipped = {
145
+ binary: 0,
146
+ too_large: 0,
147
+ ignored: 0
148
+ }
149
+ truncation_reason = nil
63
150
 
64
151
  # Get files to search
65
- files = if File.file?(path)
66
- [path]
152
+ files = if File.file?(expanded_path)
153
+ [expanded_path]
67
154
  else
68
- Dir.glob(File.join(path, file_pattern))
155
+ Dir.glob(File.join(expanded_path, file_pattern))
69
156
  .select { |f| File.file?(f) }
70
- .reject { |f| binary_file?(f) }
71
157
  end
72
158
 
73
159
  # Search each file
74
160
  files.each do |file|
75
- break if results.length >= max_matches
161
+ # Check if we've searched enough files
162
+ if files_searched >= max_files_to_search
163
+ truncation_reason ||= "max_files_to_search limit reached"
164
+ break
165
+ end
166
+
167
+ # Skip if file should be ignored (unless it's a config file)
168
+ if should_ignore_file?(file, expanded_path, gitignore) && !is_config_file?(file)
169
+ skipped[:ignored] += 1
170
+ next
171
+ end
172
+
173
+ # Skip binary files
174
+ if binary_file?(file)
175
+ skipped[:binary] += 1
176
+ next
177
+ end
178
+
179
+ # Skip files that are too large
180
+ if File.size(file) > max_file_size
181
+ skipped[:too_large] += 1
182
+ next
183
+ end
184
+
185
+ files_searched += 1
186
+
187
+ # Check if we've found enough matching files
188
+ if results.length >= max_files
189
+ truncation_reason ||= "max_files limit reached"
190
+ break
191
+ end
192
+
193
+ # Check if we've found enough total matches
194
+ if total_matches >= max_total_matches
195
+ truncation_reason ||= "max_total_matches limit reached"
196
+ break
197
+ end
76
198
 
77
- matches = search_file(file, regex, context_lines)
199
+ # Search the file
200
+ matches = search_file(file, regex, context_lines, max_matches_per_file)
78
201
  next if matches.empty?
79
202
 
203
+ # Add remaining matches respecting max_total_matches
204
+ remaining_matches = max_total_matches - total_matches
205
+ matches = matches.take(remaining_matches) if remaining_matches < matches.length
206
+
80
207
  results << {
81
208
  file: File.expand_path(file),
82
209
  matches: matches
@@ -87,9 +214,11 @@ module Clacky
87
214
  {
88
215
  results: results,
89
216
  total_matches: total_matches,
90
- files_searched: files.length,
217
+ files_searched: files_searched,
91
218
  files_with_matches: results.length,
92
- truncated: results.length >= max_matches,
219
+ skipped_files: skipped,
220
+ truncated: !truncation_reason.nil?,
221
+ truncation_reason: truncation_reason,
93
222
  error: nil
94
223
  }
95
224
  rescue RegexpError => e
@@ -116,36 +245,119 @@ module Clacky
116
245
  else
117
246
  matches = result[:total_matches] || 0
118
247
  files = result[:files_with_matches] || 0
119
- "✓ Found #{matches} matches in #{files} files"
248
+ msg = "✓ Found #{matches} matches in #{files} files"
249
+
250
+ # Add truncation info if present
251
+ if result[:truncated] && result[:truncation_reason]
252
+ msg += " (truncated: #{result[:truncation_reason]})"
253
+ end
254
+
255
+ msg
120
256
  end
121
257
  end
122
258
 
123
259
  private
124
260
 
125
- def search_file(file, regex, context_lines)
126
- matches = []
127
- lines = File.readlines(file, chomp: true)
261
+ # Find .gitignore file in the search path or parent directories
262
+ # Only searches within the search path and up to the current working directory
263
+ def find_gitignore(path)
264
+ search_path = File.directory?(path) ? path : File.dirname(path)
265
+
266
+ # Look for .gitignore in current and parent directories
267
+ current = File.expand_path(search_path)
268
+ cwd = File.expand_path(Dir.pwd)
269
+ root = File.expand_path('/')
270
+
271
+ # Limit search: only go up to current working directory
272
+ # This prevents finding .gitignore files from unrelated parent directories
273
+ # when searching in temporary directories (like /tmp in tests)
274
+ search_limit = if current.start_with?(cwd)
275
+ cwd
276
+ else
277
+ current
278
+ end
279
+
280
+ loop do
281
+ gitignore = File.join(current, '.gitignore')
282
+ return gitignore if File.exist?(gitignore)
283
+
284
+ # Stop if we've reached the search limit or root
285
+ break if current == search_limit || current == root
286
+ current = File.dirname(current)
287
+ end
288
+
289
+ nil
290
+ end
291
+
292
+ # Check if file should be ignored based on .gitignore or default patterns
293
+ def should_ignore_file?(file, base_path, gitignore)
294
+ # Always calculate path relative to base_path for consistency
295
+ # Expand both paths to handle symlinks and relative paths correctly
296
+ expanded_file = File.expand_path(file)
297
+ expanded_base = File.expand_path(base_path)
298
+
299
+ # For files, use the directory as base
300
+ expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
301
+
302
+ # Calculate relative path
303
+ if expanded_file.start_with?(expanded_base)
304
+ relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
305
+ else
306
+ # File is outside base path - use just the filename
307
+ relative_path = File.basename(expanded_file)
308
+ end
309
+
310
+ # Clean up relative path
311
+ relative_path = relative_path.sub(/^\.\//, '') if relative_path
312
+
313
+ if gitignore
314
+ # Use .gitignore rules
315
+ gitignore.ignored?(relative_path)
316
+ else
317
+ # Use default ignore patterns - only match against relative path components
318
+ DEFAULT_IGNORED_PATTERNS.any? do |pattern|
319
+ if pattern.include?('*')
320
+ File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
321
+ else
322
+ # Match pattern as a path component (not substring of absolute path)
323
+ relative_path.start_with?("#{pattern}/") ||
324
+ relative_path.include?("/#{pattern}/") ||
325
+ relative_path == pattern ||
326
+ File.basename(relative_path) == pattern
327
+ end
328
+ end
329
+ end
330
+ end
128
331
 
129
- lines.each_with_index do |line, index|
332
+ # Check if file is a config file (should not be ignored even if in .gitignore)
333
+ def is_config_file?(file)
334
+ CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
335
+ end
336
+
337
+ def search_file(file, regex, context_lines, max_matches)
338
+ matches = []
339
+
340
+ # Use File.foreach for memory-efficient line-by-line reading
341
+ File.foreach(file, chomp: true).with_index do |line, index|
342
+ # Stop if we have enough matches for this file
343
+ break if matches.length >= max_matches
344
+
130
345
  next unless line.match?(regex)
131
346
 
132
- # Get context
133
- start_line = [0, index - context_lines].max
134
- end_line = [lines.length - 1, index + context_lines].min
347
+ # Truncate long lines
348
+ display_line = line.length > MAX_LINE_LENGTH ? "#{line[0...MAX_LINE_LENGTH]}..." : line
135
349
 
136
- context = []
137
- (start_line..end_line).each do |i|
138
- context << {
139
- line_number: i + 1,
140
- content: lines[i],
141
- is_match: i == index
142
- }
350
+ # Get context if requested
351
+ if context_lines > 0
352
+ context = get_line_context(file, index, context_lines)
353
+ else
354
+ context = nil
143
355
  end
144
356
 
145
357
  matches << {
146
358
  line_number: index + 1,
147
- line: line,
148
- context: context_lines > 0 ? context : nil
359
+ line: display_line,
360
+ context: context
149
361
  }
150
362
  end
151
363
 
@@ -154,6 +366,32 @@ module Clacky
154
366
  []
155
367
  end
156
368
 
369
+ # Get context lines around a match
370
+ def get_line_context(file, match_index, context_lines)
371
+ lines = File.readlines(file, chomp: true)
372
+ start_line = [0, match_index - context_lines].max
373
+ end_line = [lines.length - 1, match_index + context_lines].min
374
+
375
+ context = []
376
+ (start_line..end_line).each do |i|
377
+ line_content = lines[i]
378
+ # Truncate long lines in context too
379
+ display_content = line_content.length > MAX_LINE_LENGTH ?
380
+ "#{line_content[0...MAX_LINE_LENGTH]}..." :
381
+ line_content
382
+
383
+ context << {
384
+ line_number: i + 1,
385
+ content: display_content,
386
+ is_match: i == match_index
387
+ }
388
+ end
389
+
390
+ context
391
+ rescue StandardError
392
+ nil
393
+ end
394
+
157
395
  def binary_file?(file)
158
396
  # Simple heuristic: check if file contains null bytes in first 8KB
159
397
  return false unless File.exist?(file)
@@ -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