openclacky 0.5.1 → 0.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8253fadf5425c27f9b8ebfecfe195c4a21c0f0d944e0c22030dc77411dda41e
4
- data.tar.gz: 345301c2cc266afab03564ea08c3801bc1adae239e129d7fc9ac99280e47ec07
3
+ metadata.gz: 8c1d065edf333a21a538aff43effdcefbc1cb9fa9a3c816d2da3943418f502fb
4
+ data.tar.gz: d891f1b03ff43bcd237eb510990b74145522404dd4fa3b2bc4b3741f3e495a32
5
5
  SHA512:
6
- metadata.gz: 2fe927a2d4c86be0ecd45e4defe9ae8646ded14153ac05178bf751a29f108b4ed9744f2e7fd64714437e70e4be1dcc092640154400e9c639deb29d558c42fa0e
7
- data.tar.gz: 25f7e33a14d03a1a03e5a0947dab559a5e27244cd7333342bd1014033cce6fac141ae3794eb0fc6dd435260045a695c161bbe07f39f0a253974939cda048edb9
6
+ metadata.gz: 5190473333b6c0259ee7ec76bf3127986b03fcbbaeba8face1e0f5bd5ebc60d40b9937df7ef129737eb6364789a214a515970728cfd71b80770cc671e5440004
7
+ data.tar.gz: 0a7858fe351e34810bca0b28ab91b3974041758608fe8c446e6f0c555402c990418bb6d3bc4ac071fe61fe3d881a5c190f191258287541b57508a940c2643752
data/.clackyrules CHANGED
@@ -22,7 +22,9 @@ It provides chat functionality and autonomous AI agent capabilities with tool us
22
22
  - Use frozen string literals: `# frozen_string_literal: true`
23
23
  - Keep classes focused and single-responsibility
24
24
  - Use meaningful variable and method names
25
+ - **IMPORTANT**: All code comments must be written in English
25
26
  - Add descriptive comments for complex logic
27
+ - Use clear, self-documenting code with English naming
26
28
 
27
29
  ### Architecture Patterns
28
30
  - Tools inherit from `Clacky::Tools::Base`
data/README.md CHANGED
@@ -12,6 +12,7 @@ A command-line interface for interacting with AI models. OpenClacky supports Ope
12
12
  - šŸŽØ Colorful terminal output
13
13
  - 🌐 OpenAI-compatible API support (OpenAI, Gitee AI, DeepSeek, etc.)
14
14
  - šŸ› ļø Rich built-in tools: file operations, web search, code execution, and more
15
+ - ⚔ Prompt caching support for Claude models (reduces costs up to 90%)
15
16
 
16
17
  ## Installation
17
18
 
data/lib/clacky/agent.rb CHANGED
@@ -8,7 +8,8 @@ require_relative "utils/arguments_parser"
8
8
 
9
9
  module Clacky
10
10
  class Agent
11
- attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos
11
+ attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
12
+ :cache_stats
12
13
 
13
14
  # Pricing per 1M tokens (approximate - adjust based on actual model)
14
15
  PRICING = {
@@ -65,6 +66,12 @@ module Clacky
65
66
  @todos = [] # Store todos in memory
66
67
  @iterations = 0
67
68
  @total_cost = 0.0
69
+ @cache_stats = {
70
+ cache_creation_input_tokens: 0,
71
+ cache_read_input_tokens: 0,
72
+ total_requests: 0,
73
+ cache_hit_requests: 0
74
+ }
68
75
  @start_time = nil
69
76
  @working_dir = working_dir || Dir.pwd
70
77
  @created_at = Time.now.iso8601
@@ -90,6 +97,14 @@ module Clacky
90
97
  @working_dir = session_data[:working_dir]
91
98
  @created_at = session_data[:created_at]
92
99
  @total_tasks = session_data.dig(:stats, :total_tasks) || 0
100
+
101
+ # Restore cache statistics if available
102
+ @cache_stats = session_data.dig(:stats, :cache_stats) || {
103
+ cache_creation_input_tokens: 0,
104
+ cache_read_input_tokens: 0,
105
+ total_requests: 0,
106
+ cache_hit_requests: 0
107
+ }
93
108
 
94
109
  # Check if the session ended with an error
95
110
  last_status = session_data.dig(:stats, :last_status)
@@ -103,7 +118,7 @@ module Clacky
103
118
  @messages = @messages[0...last_user_index]
104
119
 
105
120
  # Trigger a hook to notify about the rollback
106
- trigger_hook(:session_rollback, {
121
+ @hooks.trigger(:session_rollback, {
107
122
  reason: "Previous session ended with error",
108
123
  error_message: last_error,
109
124
  rolled_back_message_index: last_user_index
@@ -122,7 +137,14 @@ module Clacky
122
137
  # Add system prompt as the first message if this is the first run
123
138
  if @messages.empty?
124
139
  system_prompt = build_system_prompt
125
- @messages << { role: "system", content: system_prompt }
140
+ system_message = { role: "system", content: system_prompt }
141
+
142
+ # Enable caching for system prompt if configured and model supports it
143
+ if @config.enable_prompt_caching
144
+ system_message[:cache_control] = { type: "ephemeral" }
145
+ end
146
+
147
+ @messages << system_message
126
148
  end
127
149
 
128
150
  @messages << { role: "user", content: user_input }
@@ -222,7 +244,8 @@ module Clacky
222
244
  total_iterations: @iterations,
223
245
  total_cost_usd: @total_cost.round(4),
224
246
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
225
- last_status: status.to_s
247
+ last_status: status.to_s,
248
+ cache_stats: @cache_stats
226
249
  }
227
250
 
228
251
  # Add error message if status is error
@@ -240,6 +263,7 @@ module Clacky
240
263
  max_iterations: @config.max_iterations,
241
264
  max_cost_usd: @config.max_cost_usd,
242
265
  enable_compression: @config.enable_compression,
266
+ enable_prompt_caching: @config.enable_prompt_caching,
243
267
  keep_recent_messages: @config.keep_recent_messages,
244
268
  max_tokens: @config.max_tokens,
245
269
  verbose: @config.verbose
@@ -366,7 +390,8 @@ module Clacky
366
390
  model: @config.model,
367
391
  tools: tools_to_send,
368
392
  max_tokens: @config.max_tokens,
369
- verbose: @config.verbose
393
+ verbose: @config.verbose,
394
+ enable_caching: @config.enable_prompt_caching
370
395
  )
371
396
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
372
397
  retries += 1
@@ -386,6 +411,54 @@ module Clacky
386
411
 
387
412
  track_cost(response[:usage])
388
413
 
414
+ # Handle truncated responses (when max_tokens limit is reached)
415
+ if response[:finish_reason] == "length"
416
+ # Count recent truncations to prevent infinite loops
417
+ recent_truncations = @messages.last(5).count { |m|
418
+ m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
419
+ }
420
+
421
+ if recent_truncations >= 2
422
+ # Too many truncations - task is too complex
423
+ progress.finish
424
+ puts "\nāš ļø Response truncated multiple times. Task is too complex for a single response." if @config.verbose
425
+
426
+ # Create a response that tells the user to break down the task
427
+ error_response = {
428
+ content: "I apologize, but this task is too complex to complete in a single response. " \
429
+ "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
430
+ "For example, when creating a long document:\n" \
431
+ "1. First create the file with a basic structure\n" \
432
+ "2. Then use edit() to add content section by section",
433
+ finish_reason: "stop",
434
+ tool_calls: nil
435
+ }
436
+
437
+ # Add this as an assistant message so it appears in conversation
438
+ @messages << {
439
+ role: "assistant",
440
+ content: error_response[:content]
441
+ }
442
+
443
+ return error_response
444
+ end
445
+
446
+ # Insert system message to guide LLM to retry with smaller steps
447
+ @messages << {
448
+ role: "user",
449
+ content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
450
+ "- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
451
+ "- Break down large tasks into multiple smaller steps\n" \
452
+ "- Avoid putting more than 2000 characters in a single tool call argument\n" \
453
+ "- Use multiple tool calls instead of one large call"
454
+ }
455
+
456
+ puts "āš ļø Response truncated due to length limit. Retrying with smaller steps..." if @config.verbose
457
+
458
+ # Recursively retry
459
+ return think(&block)
460
+ end
461
+
389
462
  # Add assistant response to messages
390
463
  msg = { role: "assistant" }
391
464
  # Always include content field (some APIs require it even with tool_calls)
@@ -543,6 +616,18 @@ module Clacky
543
616
  input_cost = (usage[:prompt_tokens] / 1_000_000.0) * PRICING[:input]
544
617
  output_cost = (usage[:completion_tokens] / 1_000_000.0) * PRICING[:output]
545
618
  @total_cost += input_cost + output_cost
619
+
620
+ # Track cache usage statistics
621
+ @cache_stats[:total_requests] += 1
622
+
623
+ if usage[:cache_creation_input_tokens]
624
+ @cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
625
+ end
626
+
627
+ if usage[:cache_read_input_tokens]
628
+ @cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
629
+ @cache_stats[:cache_hit_requests] += 1
630
+ end
546
631
  end
547
632
 
548
633
  def compress_messages_if_needed
@@ -582,7 +667,15 @@ module Clacky
582
667
  summary = summarize_messages(messages_to_compress)
583
668
 
584
669
  # Rebuild messages array: [system, summary, recent_messages]
585
- @messages = [system_msg, summary, *recent_messages].compact
670
+ # Preserve cache_control on system message if it exists
671
+ rebuilt_messages = [system_msg, summary, *recent_messages].compact
672
+
673
+ # Re-apply cache control to system message if caching is enabled
674
+ if @config.enable_prompt_caching && rebuilt_messages.first&.dig(:role) == "system"
675
+ rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
676
+ end
677
+
678
+ @messages = rebuilt_messages
586
679
 
587
680
  final_size = @messages.size
588
681
 
@@ -978,6 +1071,7 @@ module Clacky
978
1071
  iterations: @iterations,
979
1072
  duration_seconds: Time.now - @start_time,
980
1073
  total_cost_usd: @total_cost.round(4),
1074
+ cache_stats: @cache_stats,
981
1075
  messages: @messages,
982
1076
  error: error
983
1077
  }
@@ -7,7 +7,8 @@ module Clacky
7
7
 
8
8
  attr_accessor :model, :max_iterations, :max_cost_usd, :timeout_seconds,
9
9
  :permission_mode, :allowed_tools, :disallowed_tools,
10
- :max_tokens, :verbose, :enable_compression, :keep_recent_messages
10
+ :max_tokens, :verbose, :enable_compression, :keep_recent_messages,
11
+ :enable_prompt_caching
11
12
 
12
13
  def initialize(options = {})
13
14
  @model = options[:model] || "gpt-3.5-turbo"
@@ -21,6 +22,8 @@ module Clacky
21
22
  @verbose = options[:verbose] || false
22
23
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
23
24
  @keep_recent_messages = options[:keep_recent_messages] || 20
25
+ # Enable prompt caching by default for cost savings
26
+ @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
24
27
  end
25
28
 
26
29
 
data/lib/clacky/cli.rb CHANGED
@@ -4,6 +4,10 @@ require "thor"
4
4
  require "tty-prompt"
5
5
  require "tty-spinner"
6
6
  require "readline"
7
+ require_relative "ui/banner"
8
+ require_relative "ui/prompt"
9
+ require_relative "ui/statusbar"
10
+ require_relative "ui/formatter"
7
11
 
8
12
  module Clacky
9
13
  class CLI < Thor
@@ -140,7 +144,7 @@ module Clacky
140
144
  # Report the error
141
145
  say "\nāŒ Error: #{e.message}", :red
142
146
  say e.backtrace.first(5).join("\n"), :red if options[:verbose]
143
-
147
+
144
148
  # Show session saved message
145
149
  if session_manager&.last_saved_path
146
150
  say "\nšŸ“‚ Session saved: #{session_manager.last_saved_path}", :yellow
@@ -149,7 +153,7 @@ module Clacky
149
153
  # Guide user to recover
150
154
  say "\nšŸ’” To recover and retry, run:", :yellow
151
155
  say " clacky agent -c", :cyan
152
-
156
+
153
157
  exit 1
154
158
  ensure
155
159
  Dir.chdir(original_dir)
@@ -214,12 +218,14 @@ module Clacky
214
218
  end
215
219
 
216
220
  def display_agent_event(event)
221
+ formatter = ui_formatter
222
+
217
223
  case event[:type]
218
224
  when :thinking
219
- print "šŸ’­ "
225
+ formatter.thinking
220
226
  when :assistant_message
221
227
  # Display assistant's thinking/explanation before tool calls
222
- say "\nšŸ’¬ #{event[:data][:content]}", :white if event[:data][:content] && !event[:data][:content].empty?
228
+ formatter.assistant_message(event[:data][:content])
223
229
  when :tool_call
224
230
  display_tool_call(event[:data])
225
231
  when :observation
@@ -227,15 +233,15 @@ module Clacky
227
233
  # Auto-display TODO status if exists
228
234
  display_todo_status_if_exists
229
235
  when :answer
230
- say "\nāŗ #{event[:data][:content]}", :white if event[:data][:content] && !event[:data][:content].empty?
236
+ formatter.assistant_message(event[:data][:content])
231
237
  when :tool_denied
232
- say "\nāŗ Tool denied: #{event[:data][:name]}\n\n", :yellow
238
+ formatter.tool_denied(event[:data][:name])
233
239
  when :tool_planned
234
- say "\nāŗ Planned: #{event[:data][:name]}", :blue
240
+ formatter.tool_planned(event[:data][:name])
235
241
  when :tool_error
236
- say "\nāŗ Error: #{event[:data][:error].message}", :red
242
+ formatter.tool_error(event[:data][:error].message)
237
243
  when :on_iteration
238
- say "\n--- Iteration #{event[:data][:iteration]} ---", :yellow if options[:verbose]
244
+ formatter.iteration(event[:data][:iteration]) if options[:verbose]
239
245
  end
240
246
  end
241
247
 
@@ -249,14 +255,14 @@ module Clacky
249
255
  begin
250
256
  args = JSON.parse(args_json, symbolize_names: true)
251
257
  formatted = tool.format_call(args)
252
- say "\nāŗ #{formatted}", :cyan
258
+ ui_formatter.tool_call(formatted)
253
259
  rescue JSON::ParserError, StandardError => e
254
260
  say "āš ļø Warning: Failed to format tool call: #{e.message}", :yellow
255
- say "\nāŗ #{tool_name}(...)", :cyan
261
+ ui_formatter.tool_call("#{tool_name}(...)")
256
262
  end
257
263
  else
258
264
  say "āš ļø Warning: Tool instance not found for '#{tool_name}'", :yellow
259
- say "\nāŗ #{tool_name}(...)", :cyan
265
+ ui_formatter.tool_call("#{tool_name}(...)")
260
266
  end
261
267
 
262
268
  # Show verbose details if requested
@@ -274,15 +280,15 @@ module Clacky
274
280
  if tool
275
281
  begin
276
282
  summary = tool.format_result(result)
277
- say " āŽæ #{summary}", :white
283
+ ui_formatter.tool_result(summary)
278
284
  rescue StandardError => e
279
- say " āŽæ Done", :white
285
+ ui_formatter.tool_result("Done")
280
286
  end
281
287
  else
282
288
  # Fallback for unknown tools
283
289
  result_str = result.to_s
284
290
  summary = result_str.length > 100 ? "#{result_str[0..100]}..." : result_str
285
- say " āŽæ #{summary}", :white
291
+ ui_formatter.tool_result(summary)
286
292
  end
287
293
 
288
294
  # Show verbose details if requested
@@ -313,42 +319,7 @@ module Clacky
313
319
  todos = @current_agent.todos
314
320
  return if todos.empty?
315
321
 
316
- # Count statuses
317
- completed = todos.count { |t| t[:status] == "completed" }
318
- total = todos.size
319
-
320
- # Build progress bar
321
- progress_bar = todos.map { |t| t[:status] == "completed" ? "āœ“" : "ā—‹" }.join
322
-
323
- # Check if all completed
324
- if completed == total
325
- say "\nšŸ“‹ Tasks [#{completed}/#{total}]: #{progress_bar} šŸŽ‰ All completed!", :green
326
- return
327
- end
328
-
329
- # Find current and next tasks
330
- current_task = todos.find { |t| t[:status] == "pending" }
331
- next_task_index = todos.index(current_task)
332
- next_task = next_task_index && todos[next_task_index + 1]
333
-
334
- say "\nšŸ“‹ Tasks [#{completed}/#{total}]: #{progress_bar}", :yellow
335
- if current_task
336
- say " → Next: ##{current_task[:id]} - #{current_task[:task]}", :white
337
- end
338
- if next_task && next_task[:status] == "pending"
339
- say " ⇢ After that: ##{next_task[:id]} - #{next_task[:task]}", :white
340
- end
341
- end
342
-
343
- def display_agent_result(result)
344
- say "\n" + ("=" * 60), :cyan
345
- say "Agent Session Complete", :green
346
- say "=" * 60, :cyan
347
- say "Status: #{result[:status]}", :green
348
- say "Iterations: #{result[:iterations]}", :yellow
349
- say "Duration: #{result[:duration_seconds].round(2)}s", :yellow
350
- say "Total Cost: $#{result[:total_cost_usd]}", :yellow
351
- say "=" * 60, :cyan
322
+ ui_formatter.todo_status(todos)
352
323
  end
353
324
 
354
325
  def validate_working_directory(path)
@@ -387,26 +358,37 @@ module Clacky
387
358
  # Store agent as instance variable for access in display methods
388
359
  @current_agent = agent
389
360
 
361
+ # Initialize UI components
362
+ banner = ui_banner
363
+ prompt = ui_prompt
364
+ statusbar = ui_statusbar
365
+
366
+ # Show startup banner for new session
367
+ if agent.total_tasks == 0
368
+ banner.display_startup
369
+ end
370
+
390
371
  # Show session info if continuing
391
372
  if agent.total_tasks > 0
392
- say "Continuing session: #{agent.session_id[0..7]}", :cyan
393
- say "Created: #{Time.parse(agent.created_at).strftime('%Y-%m-%d %H:%M')}", :cyan
394
- say "Tasks completed: #{agent.total_tasks}", :cyan
395
- say "Total cost: $#{agent.total_cost.round(4)}", :cyan
396
- say ""
373
+ banner.display_session_continue(
374
+ session_id: agent.session_id[0..7],
375
+ created_at: Time.parse(agent.created_at).strftime('%Y-%m-%d %H:%M'),
376
+ tasks: agent.total_tasks,
377
+ cost: agent.total_cost.round(4)
378
+ )
397
379
 
398
380
  # Show recent conversation history
399
381
  display_recent_messages(agent.messages, limit: 5)
382
+ else
383
+ # Show welcome info for new session
384
+ banner.display_agent_welcome(
385
+ working_dir: working_dir,
386
+ mode: agent_config.permission_mode,
387
+ max_iterations: agent_config.max_iterations,
388
+ max_cost: agent_config.max_cost_usd
389
+ )
400
390
  end
401
391
 
402
- say "šŸ¤– Starting interactive agent mode...", :green
403
- say "Working directory: #{working_dir}", :cyan
404
- say "Mode: #{agent_config.permission_mode}", :yellow
405
- say "Max iterations: #{agent_config.max_iterations} per task", :yellow
406
- say "Max cost: $#{agent_config.max_cost_usd} per task", :yellow
407
- say "\nType 'exit' or 'quit' to end the session.\n", :yellow
408
-
409
- prompt = TTY::Prompt.new
410
392
  total_tasks = agent.total_tasks
411
393
  total_cost = agent.total_cost
412
394
 
@@ -418,15 +400,26 @@ module Clacky
418
400
  unless current_message && !current_message.strip.empty?
419
401
  say "\n" if total_tasks > 0
420
402
 
421
- # Use Readline for better Unicode/CJK support
422
- current_message = Readline.readline("You: ", true)
403
+ # Show status bar before input
404
+ statusbar.display(
405
+ working_dir: working_dir,
406
+ mode: agent_config.permission_mode.to_s,
407
+ model: agent_config.model,
408
+ tasks: total_tasks,
409
+ cost: total_cost
410
+ )
411
+
412
+ # Use enhanced prompt with "You:" prefix
413
+ current_message = prompt.read_input(prefix: "You:")
423
414
 
424
415
  break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
425
416
  next if current_message.strip.empty?
417
+
418
+ # Display user's message after input
419
+ ui_formatter.user_message(current_message)
426
420
  end
427
421
 
428
422
  total_tasks += 1
429
- say "\n"
430
423
 
431
424
  begin
432
425
  result = agent.run(current_message) do |event|
@@ -441,17 +434,18 @@ module Clacky
441
434
  end
442
435
 
443
436
  # Show brief task completion
444
- say "\n" + ("-" * 60), :cyan
445
- say "āœ“ Task completed", :green
446
- say " Iterations: #{result[:iterations]}", :white
447
- say " Cost: $#{result[:total_cost_usd].round(4)}", :white
448
- say " Session total: #{total_tasks} tasks, $#{total_cost.round(4)}", :yellow
449
- say "-" * 60, :cyan
437
+ banner.display_task_complete(
438
+ iterations: result[:iterations],
439
+ cost: result[:total_cost_usd].round(4),
440
+ total_tasks: total_tasks,
441
+ total_cost: total_cost.round(4),
442
+ cache_stats: result[:cache_stats]
443
+ )
450
444
  rescue Clacky::AgentInterrupted
451
445
  # Save session on interruption
452
446
  if session_manager
453
447
  session_manager.save(agent.to_session_data(status: :interrupted))
454
- say "\n\nāš ļø Task interrupted by user (Ctrl+C)", :yellow
448
+ ui_formatter.warning("Task interrupted by user (Ctrl+C)")
455
449
  say "You can start a new task or type 'exit' to quit.\n", :yellow
456
450
  end
457
451
  rescue StandardError => e
@@ -461,17 +455,15 @@ module Clacky
461
455
  end
462
456
 
463
457
  # Report the error
464
- say "\nāŒ Error: #{e.message}", :red
465
- say e.backtrace.first(3).join("\n"), :white if options[:verbose]
466
-
458
+ banner.display_error(e.message, details: options[:verbose] ? e.backtrace.first(3).join("\n") : nil)
459
+
467
460
  # Show session saved message
468
461
  if session_manager&.last_saved_path
469
- say "\nšŸ“‚ Session saved: #{session_manager.last_saved_path}", :yellow
462
+ ui_formatter.info("Session saved: #{session_manager.last_saved_path}")
470
463
  end
471
464
 
472
465
  # Guide user to recover
473
- say "\nšŸ’” To recover and retry, run:", :yellow
474
- say " clacky agent -c", :cyan
466
+ ui_formatter.info("To recover and retry, run: clacky agent -c")
475
467
  say "\nOr you can continue with a new task or type 'exit' to quit.", :yellow
476
468
  end
477
469
 
@@ -484,9 +476,10 @@ module Clacky
484
476
  session_manager.save(agent.to_session_data)
485
477
  end
486
478
 
487
- say "\nšŸ‘‹ Agent session ended", :green
488
- say "Total tasks completed: #{total_tasks}", :cyan
489
- say "Total cost: $#{total_cost.round(4)}", :cyan
479
+ banner.display_goodbye(
480
+ total_tasks: total_tasks,
481
+ total_cost: total_cost.round(4)
482
+ )
490
483
  end
491
484
 
492
485
  def list_sessions
@@ -582,21 +575,23 @@ module Clacky
582
575
  return
583
576
  end
584
577
 
585
- say "šŸ“œ Recent conversation history:\n", :yellow
586
- say "-" * 60, :white
578
+ formatter = ui_formatter
579
+ formatter.separator("─")
580
+ say pastel.dim("Recent conversation history:"), :yellow
581
+ formatter.separator("─")
587
582
 
588
583
  recent.each do |msg|
589
584
  case msg[:role]
590
585
  when "user"
591
586
  content = truncate_message(msg[:content], 150)
592
- say "\nšŸ‘¤ You: #{content}", :cyan
587
+ say " #{pastel.blue('[>>]')} You: #{content}"
593
588
  when "assistant"
594
589
  content = truncate_message(msg[:content], 200)
595
- say "šŸ¤– Assistant: #{content}", :green
590
+ say " #{pastel.green('[<<]')} Assistant: #{content}"
596
591
  end
597
592
  end
598
593
 
599
- say "\n" + ("-" * 60), :white
594
+ formatter.separator("─")
600
595
  say ""
601
596
  end
602
597
 
@@ -612,6 +607,27 @@ module Clacky
612
607
  cleaned
613
608
  end
614
609
  end
610
+
611
+ # UI component accessors
612
+ def ui_banner
613
+ @ui_banner ||= UI::Banner.new
614
+ end
615
+
616
+ def ui_prompt
617
+ @ui_prompt ||= UI::Prompt.new
618
+ end
619
+
620
+ def ui_statusbar
621
+ @ui_statusbar ||= UI::StatusBar.new
622
+ end
623
+
624
+ def ui_formatter
625
+ @ui_formatter ||= UI::Formatter.new
626
+ end
627
+
628
+ def pastel
629
+ @pastel ||= Pastel.new
630
+ end
615
631
  end
616
632
 
617
633
  private
data/lib/clacky/client.rb CHANGED
@@ -43,7 +43,9 @@ module Clacky
43
43
  end
44
44
 
45
45
  # Send messages with function calling (tools) support
46
- def send_messages_with_tools(messages, model:, tools:, max_tokens:, verbose: false)
46
+ # Options:
47
+ # - enable_caching: Enable prompt caching for system prompt and tools (default: false)
48
+ def send_messages_with_tools(messages, model:, tools:, max_tokens:, verbose: false, enable_caching: false)
47
49
  body = {
48
50
  model: model,
49
51
  max_tokens: max_tokens,
@@ -51,7 +53,18 @@ module Clacky
51
53
  }
52
54
 
53
55
  # Add tools if provided
54
- body[:tools] = tools if tools&.any?
56
+ # For Claude API with caching: mark the last tool definition with cache_control
57
+ if tools&.any?
58
+ if enable_caching && supports_prompt_caching?(model)
59
+ # Deep clone tools to avoid modifying original
60
+ cached_tools = tools.map { |tool| deep_clone(tool) }
61
+ # Mark the last tool for caching (Claude caches from cache breakpoint to end)
62
+ cached_tools.last[:cache_control] = { type: "ephemeral" }
63
+ body[:tools] = cached_tools
64
+ else
65
+ body[:tools] = tools
66
+ end
67
+ end
55
68
 
56
69
  # Debug output
57
70
  if verbose || ENV["CLACKY_DEBUG"]
@@ -77,6 +90,31 @@ module Clacky
77
90
 
78
91
  private
79
92
 
93
+ # Check if the model supports prompt caching
94
+ # Currently only Claude 3.5 Sonnet and newer Claude models support this
95
+ def supports_prompt_caching?(model)
96
+ 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")
102
+ end
103
+
104
+ # Deep clone a hash/array structure (for tool definitions)
105
+ def deep_clone(obj)
106
+ case obj
107
+ when Hash
108
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_clone(v) }
109
+ when Array
110
+ obj.map { |item| deep_clone(item) }
111
+ when String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass
112
+ obj
113
+ else
114
+ obj.dup rescue obj
115
+ end
116
+ end
117
+
80
118
  def connection
81
119
  @connection ||= Faraday.new(url: @base_url) do |conn|
82
120
  conn.headers["Content-Type"] = "application/json"
@@ -117,15 +155,26 @@ module Clacky
117
155
  puts " content length: #{message["content"]&.length || 0}"
118
156
  end
119
157
 
158
+ # Parse usage with cache information
159
+ usage_data = {
160
+ prompt_tokens: usage["prompt_tokens"],
161
+ completion_tokens: usage["completion_tokens"],
162
+ total_tokens: usage["total_tokens"]
163
+ }
164
+
165
+ # Add cache metrics if present (Claude API with prompt caching)
166
+ if usage["cache_creation_input_tokens"]
167
+ usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"]
168
+ end
169
+ if usage["cache_read_input_tokens"]
170
+ usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"]
171
+ end
172
+
120
173
  {
121
174
  content: message["content"],
122
175
  tool_calls: parse_tool_calls(message["tool_calls"]),
123
176
  finish_reason: data["choices"].first["finish_reason"],
124
- usage: {
125
- prompt_tokens: usage["prompt_tokens"],
126
- completion_tokens: usage["completion_tokens"],
127
- total_tokens: usage["total_tokens"]
128
- }
177
+ usage: usage_data
129
178
  }
130
179
  when 401
131
180
  raise Error, "Invalid API key"