openclacky 0.5.4 → 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
@@ -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
@@ -259,18 +259,30 @@ module Clacky
259
259
  private
260
260
 
261
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
262
263
  def find_gitignore(path)
263
264
  search_path = File.directory?(path) ? path : File.dirname(path)
264
265
 
265
266
  # Look for .gitignore in current and parent directories
266
267
  current = File.expand_path(search_path)
268
+ cwd = File.expand_path(Dir.pwd)
267
269
  root = File.expand_path('/')
268
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
+
269
280
  loop do
270
281
  gitignore = File.join(current, '.gitignore')
271
282
  return gitignore if File.exist?(gitignore)
272
283
 
273
- break if current == root
284
+ # Stop if we've reached the search limit or root
285
+ break if current == search_limit || current == root
274
286
  current = File.dirname(current)
275
287
  end
276
288
 
@@ -279,24 +291,35 @@ module Clacky
279
291
 
280
292
  # Check if file should be ignored based on .gitignore or default patterns
281
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
+
282
302
  # Calculate relative path
283
- if file.start_with?(base_path)
284
- relative_path = file[base_path.length + 1..] || file
303
+ if expanded_file.start_with?(expanded_base)
304
+ relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
285
305
  else
286
- relative_path = file
306
+ # File is outside base path - use just the filename
307
+ relative_path = File.basename(expanded_file)
287
308
  end
309
+
310
+ # Clean up relative path
288
311
  relative_path = relative_path.sub(/^\.\//, '') if relative_path
289
- relative_path ||= file
290
312
 
291
313
  if gitignore
292
314
  # Use .gitignore rules
293
315
  gitignore.ignored?(relative_path)
294
316
  else
295
- # Use default ignore patterns
317
+ # Use default ignore patterns - only match against relative path components
296
318
  DEFAULT_IGNORED_PATTERNS.any? do |pattern|
297
319
  if pattern.include?('*')
298
320
  File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
299
321
  else
322
+ # Match pattern as a path component (not substring of absolute path)
300
323
  relative_path.start_with?("#{pattern}/") ||
301
324
  relative_path.include?("/#{pattern}/") ||
302
325
  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
@@ -22,6 +22,11 @@ module Clacky
22
22
  hard_timeout: {
23
23
  type: "integer",
24
24
  description: "Hard timeout in seconds (force kill)"
25
+ },
26
+ max_output_lines: {
27
+ type: "integer",
28
+ description: "Maximum number of output lines to return (default: 1000)",
29
+ default: 1000
25
30
  }
26
31
  },
27
32
  required: ["command"]
@@ -51,7 +56,7 @@ module Clacky
51
56
  'go build'
52
57
  ].freeze
53
58
 
54
- def execute(command:, soft_timeout: nil, hard_timeout: nil)
59
+ def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
55
60
  require "open3"
56
61
  require "stringio"
57
62
 
@@ -82,7 +87,8 @@ module Clacky
82
87
  stderr_buffer.string,
83
88
  elapsed,
84
89
  :hard_timeout,
85
- hard_timeout
90
+ hard_timeout,
91
+ max_output_lines
86
92
  )
87
93
  end
88
94
 
@@ -97,7 +103,8 @@ module Clacky
97
103
  command,
98
104
  stdout_buffer.string,
99
105
  stderr_buffer.string,
100
- interaction
106
+ interaction,
107
+ max_output_lines
101
108
  )
102
109
  end
103
110
 
@@ -114,7 +121,8 @@ module Clacky
114
121
  command,
115
122
  stdout_buffer.string,
116
123
  stderr_buffer.string,
117
- elapsed
124
+ elapsed,
125
+ max_output_lines
118
126
  )
119
127
  end
120
128
  end
@@ -151,22 +159,30 @@ module Clacky
151
159
  rescue StandardError
152
160
  end
153
161
 
162
+ stdout_output = stdout_buffer.string
163
+ stderr_output = stderr_buffer.string
164
+
154
165
  {
155
166
  command: command,
156
- stdout: stdout_buffer.string,
157
- stderr: stderr_buffer.string,
167
+ stdout: truncate_output(stdout_output, max_output_lines),
168
+ stderr: truncate_output(stderr_output, max_output_lines),
158
169
  exit_code: wait_thr.value.exitstatus,
159
170
  success: wait_thr.value.success?,
160
- elapsed: Time.now - start_time
171
+ elapsed: Time.now - start_time,
172
+ output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
161
173
  }
162
174
  end
163
175
  rescue StandardError => e
176
+ stdout_output = stdout_buffer.string
177
+ stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
178
+
164
179
  {
165
180
  command: command,
166
- stdout: stdout_buffer.string,
167
- stderr: "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}",
181
+ stdout: truncate_output(stdout_output, max_output_lines),
182
+ stderr: truncate_output(stderr_output, max_output_lines),
168
183
  exit_code: -1,
169
- success: false
184
+ success: false,
185
+ output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
170
186
  }
171
187
  end
172
188
  end
@@ -206,16 +222,17 @@ module Clacky
206
222
  nil
207
223
  end
208
224
 
209
- def format_waiting_input_result(command, stdout, stderr, interaction)
225
+ def format_waiting_input_result(command, stdout, stderr, interaction, max_output_lines)
210
226
  {
211
227
  command: command,
212
- stdout: stdout,
213
- stderr: stderr,
228
+ stdout: truncate_output(stdout, max_output_lines),
229
+ stderr: truncate_output(stderr, max_output_lines),
214
230
  exit_code: -2,
215
231
  success: false,
216
232
  state: 'WAITING_INPUT',
217
233
  interaction_type: interaction[:type],
218
- message: format_waiting_message(stdout, interaction)
234
+ message: format_waiting_message(truncate_output(stdout, max_output_lines), interaction),
235
+ output_truncated: output_truncated?(stdout, stderr, max_output_lines)
219
236
  }
220
237
  end
221
238
 
@@ -238,16 +255,17 @@ module Clacky
238
255
  MSG
239
256
  end
240
257
 
241
- def format_stuck_result(command, stdout, stderr, elapsed)
258
+ def format_stuck_result(command, stdout, stderr, elapsed, max_output_lines)
242
259
  {
243
260
  command: command,
244
- stdout: stdout,
245
- stderr: stderr,
261
+ stdout: truncate_output(stdout, max_output_lines),
262
+ stderr: truncate_output(stderr, max_output_lines),
246
263
  exit_code: -3,
247
264
  success: false,
248
265
  state: 'STUCK',
249
266
  elapsed: elapsed,
250
- message: format_stuck_message(stdout, elapsed)
267
+ message: format_stuck_message(truncate_output(stdout, max_output_lines), elapsed),
268
+ output_truncated: output_truncated?(stdout, stderr, max_output_lines)
251
269
  }
252
270
  end
253
271
 
@@ -267,18 +285,38 @@ module Clacky
267
285
  MSG
268
286
  end
269
287
 
270
- def format_timeout_result(command, stdout, stderr, elapsed, type, timeout)
288
+ def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
271
289
  {
272
290
  command: command,
273
- stdout: stdout,
274
- stderr: stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
291
+ stdout: truncate_output(stdout, max_output_lines),
292
+ stderr: truncate_output(stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr, max_output_lines),
275
293
  exit_code: -1,
276
294
  success: false,
277
295
  state: 'TIMEOUT',
278
- timeout_type: type
296
+ timeout_type: type,
297
+ output_truncated: output_truncated?(stdout, stderr, max_output_lines)
279
298
  }
280
299
  end
281
300
 
301
+ # Truncate output to max_lines, adding a truncation notice if needed
302
+ def truncate_output(output, max_lines)
303
+ return output if output.nil? || output.empty?
304
+
305
+ lines = output.lines
306
+ return output if lines.length <= max_lines
307
+
308
+ truncated_lines = lines.first(max_lines)
309
+ truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
310
+ truncated_lines.join + truncation_notice
311
+ end
312
+
313
+ # Check if output was truncated
314
+ def output_truncated?(stdout, stderr, max_lines)
315
+ stdout_lines = stdout&.lines&.length || 0
316
+ stderr_lines = stderr&.lines&.length || 0
317
+ stdout_lines > max_lines || stderr_lines > max_lines
318
+ end
319
+
282
320
  def format_call(args)
283
321
  cmd = args[:command] || args['command'] || ''
284
322
  cmd_parts = cmd.split
@@ -9,10 +9,10 @@ module Clacky
9
9
  LOGO = <<~'LOGO'
10
10
  ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ █████╗ ██████╗██╗ ██╗██╗ ██╗
11
11
  ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██╔══██╗██╔════╝██║ ██╔╝╚██╗ ██╔╝
12
- ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
13
- ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
14
- ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
15
- ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
12
+ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
13
+ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
14
+ ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
15
+ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
16
16
  LOGO
17
17
 
18
18
  TAGLINE = "[>] AI Coding Assistant & Technical Co-founder"
@@ -72,25 +72,36 @@ module Clacky
72
72
  end
73
73
 
74
74
  # Display task completion summary
75
- def display_task_complete(iterations:, cost:, total_tasks:, total_cost:, cache_stats: {})
75
+ def display_task_complete(iterations:, cost:, total_tasks:, total_cost:, cost_source:, cache_stats: {})
76
76
  puts
77
77
  puts separator("-")
78
78
  puts @pastel.bright_green("[✓] TASK COMPLETED")
79
79
  puts info_line("Iterations", iterations)
80
- puts info_line("Cost", "$#{cost}")
80
+
81
+ # Add cost source indicator
82
+ cost_suffix = case cost_source
83
+ when :api
84
+ " (by API)"
85
+ when :price
86
+ " (by PRICE)"
87
+ when :default
88
+ " (by DEFAULT)"
89
+ else
90
+ " (estimated)"
91
+ end
92
+ puts info_line("Cost", "$#{cost}#{cost_suffix}")
81
93
  puts info_line("Session Total", "#{total_tasks} tasks, $#{total_cost}")
82
-
94
+
83
95
  # Display cache statistics if available
84
96
  if cache_stats[:total_requests] && cache_stats[:total_requests] > 0
85
97
  puts
86
98
  puts @pastel.cyan(" [Prompt Caching]")
87
- puts info_line(" Cache Writes", "#{cache_stats[:cache_creation_input_tokens]} tokens")
88
- puts info_line(" Cache Reads", "#{cache_stats[:cache_read_input_tokens]} tokens")
89
-
99
+ puts info_line(" Cache Reads/Writes", "#{cache_stats[:cache_read_input_tokens]}/#{cache_stats[:cache_creation_input_tokens]} tokens")
100
+
90
101
  hit_rate = (cache_stats[:cache_hit_requests].to_f / cache_stats[:total_requests] * 100).round(1)
91
102
  puts info_line(" Cache Hit Rate", "#{hit_rate}% (#{cache_stats[:cache_hit_requests]}/#{cache_stats[:total_requests]} requests)")
92
103
  end
93
-
104
+
94
105
  puts separator("-")
95
106
  puts
96
107
  end