openclacky 1.2.5 → 1.2.7

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +24 -10
  6. data/lib/clacky/agent/llm_caller.rb +25 -3
  7. data/lib/clacky/agent/message_compressor.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  9. data/lib/clacky/agent/session_serializer.rb +23 -4
  10. data/lib/clacky/agent/tool_executor.rb +14 -0
  11. data/lib/clacky/agent/tool_registry.rb +0 -7
  12. data/lib/clacky/agent.rb +43 -10
  13. data/lib/clacky/agent_config.rb +54 -6
  14. data/lib/clacky/billing/billing_store.rb +62 -4
  15. data/lib/clacky/brand_config.rb +5 -0
  16. data/lib/clacky/cli.rb +76 -24
  17. data/lib/clacky/client.rb +59 -4
  18. data/lib/clacky/default_parsers/wps_parser.rb +82 -0
  19. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  20. data/lib/clacky/json_ui_controller.rb +5 -2
  21. data/lib/clacky/message_format/anthropic.rb +13 -3
  22. data/lib/clacky/message_format/bedrock.rb +2 -2
  23. data/lib/clacky/plain_ui_controller.rb +1 -1
  24. data/lib/clacky/platform_http_client.rb +28 -1
  25. data/lib/clacky/providers.rb +11 -29
  26. data/lib/clacky/server/channel/channel_manager.rb +148 -12
  27. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  28. data/lib/clacky/server/http_server.rb +133 -13
  29. data/lib/clacky/server/session_registry.rb +30 -4
  30. data/lib/clacky/server/web_ui_controller.rb +6 -3
  31. data/lib/clacky/tools/browser.rb +4 -13
  32. data/lib/clacky/tools/terminal.rb +23 -27
  33. data/lib/clacky/ui2/ui_controller.rb +1 -1
  34. data/lib/clacky/ui_interface.rb +1 -1
  35. data/lib/clacky/utils/file_processor.rb +3 -0
  36. data/lib/clacky/utils/parser_manager.rb +3 -0
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +659 -75
  39. data/lib/clacky/web/app.js +0 -1
  40. data/lib/clacky/web/billing.js +371 -99
  41. data/lib/clacky/web/i18n.js +48 -2
  42. data/lib/clacky/web/index.html +34 -1
  43. data/lib/clacky/web/sessions.js +213 -82
  44. data/lib/clacky/web/settings.js +59 -17
  45. data/lib/clacky/web/workspace.js +204 -0
  46. data/lib/clacky/web/ws-dispatcher.js +19 -3
  47. data/lib/clacky.rb +9 -3
  48. metadata +4 -5
  49. data/lib/clacky/tools/list_tasks.rb +0 -54
  50. data/lib/clacky/tools/redo_task.rb +0 -41
  51. data/lib/clacky/tools/undo_task.rb +0 -35
@@ -74,10 +74,11 @@ module Clacky
74
74
 
75
75
  # Get summary statistics for a time period
76
76
  # @param period [Symbol] :day, :week, :month, :year, or :all
77
+ # @param model [String, nil] Filter by model name
77
78
  # @return [Hash] Summary with total_cost, total_tokens, by_model, etc.
78
- def summary(period: :month)
79
+ def summary(period: :month, model: nil)
79
80
  from_time = period_start(period)
80
- records = query(from: from_time)
81
+ records = query(from: from_time, model: model)
81
82
 
82
83
  total_cost = records.sum { |r| r.cost_usd || 0 }
83
84
  total_prompt = records.sum { |r| r.prompt_tokens || 0 }
@@ -116,10 +117,11 @@ module Clacky
116
117
 
117
118
  # Get daily cost breakdown for the last N days
118
119
  # @param days [Integer] Number of days to include
120
+ # @param model [String, nil] Filter by model name
119
121
  # @return [Array<Hash>] Daily summaries with date and cost
120
- def daily_breakdown(days: 30)
122
+ def daily_breakdown(days: 30, model: nil)
121
123
  from_time = Time.now - (days * 24 * 60 * 60)
122
- records = query(from: from_time)
124
+ records = query(from: from_time, model: model)
123
125
 
124
126
  by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }
125
127
 
@@ -159,6 +161,62 @@ module Clacky
159
161
  deleted
160
162
  end
161
163
 
164
+ # Clear billing records
165
+ # @param scope [Symbol] :today or :all
166
+ # @return [Integer] Number of records/files deleted
167
+ def clear(scope: :today)
168
+ case scope
169
+ when :today
170
+ clear_today
171
+ when :all
172
+ clear_all
173
+ else
174
+ 0
175
+ end
176
+ end
177
+
178
+ private def clear_today
179
+ # Remove today's records from the current month file
180
+ month_file = current_month_file
181
+ return 0 unless File.exist?(month_file)
182
+
183
+ today_start = Time.new(Time.now.year, Time.now.month, Time.now.day)
184
+ kept_lines = []
185
+ deleted_count = 0
186
+
187
+ File.foreach(month_file) do |line|
188
+ next if line.strip.empty?
189
+
190
+ begin
191
+ hash = JSON.parse(line, symbolize_names: true)
192
+ record_time = Time.parse(hash[:timestamp].to_s) rescue nil
193
+
194
+ if record_time && record_time >= today_start
195
+ deleted_count += 1
196
+ else
197
+ kept_lines << line
198
+ end
199
+ rescue JSON::ParserError
200
+ kept_lines << line
201
+ end
202
+ end
203
+
204
+ # Rewrite the file without today's records
205
+ File.open(month_file, "w") do |f|
206
+ kept_lines.each { |line| f.print(line) }
207
+ end
208
+ FileUtils.chmod(0o600, month_file) if File.exist?(month_file)
209
+
210
+ deleted_count
211
+ end
212
+
213
+ private def clear_all
214
+ # Delete all billing files
215
+ files = billing_files
216
+ files.each { |f| File.delete(f) }
217
+ files.size
218
+ end
219
+
162
220
  private def ensure_billing_dir
163
221
  FileUtils.mkdir_p(@billing_dir) unless Dir.exist?(@billing_dir)
164
222
  end
@@ -737,6 +737,9 @@ module Clacky
737
737
  dl = platform_client.download_file(url, tmp_zip)
738
738
  raise dl[:error].to_s unless dl[:success]
739
739
 
740
+ zip_size = File.size?(tmp_zip).to_i
741
+ raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
742
+
740
743
  # Extract into dest_dir (overwrite existing files).
741
744
  # Auto-detect whether the zip has a single root folder to strip.
742
745
  # Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
@@ -787,6 +790,8 @@ module Clacky
787
790
 
788
791
  { success: true, name: slug, version: version }
789
792
  rescue StandardError, ScriptError => e
793
+ FileUtils.rm_f(tmp_zip) if defined?(tmp_zip) && tmp_zip
794
+ FileUtils.rm_rf(dest_dir) if defined?(dest_dir) && dest_dir
790
795
  { success: false, error: e.message }
791
796
  end
792
797
 
data/lib/clacky/cli.rb CHANGED
@@ -403,6 +403,23 @@ module Clacky
403
403
  agent.rename(auto_name)
404
404
  end
405
405
 
406
+ # Format error message and backtrace (first 3 lines) for session saving
407
+ private def format_error(e)
408
+ "#{e.message}\n#{e.backtrace&.first(3)&.join("\n")}"
409
+ end
410
+
411
+ # Validates non-interactive file paths and maps them to hashes with detected MIME types
412
+ private def prepare_non_interactive_files(file_paths)
413
+ file_paths.each do |path|
414
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
415
+ end
416
+ # Convert file paths to file hashes — agent.run decides how to handle each
417
+ file_paths.map do |path|
418
+ mime = Utils::FileProcessor.detect_mime_type(path) rescue "application/octet-stream"
419
+ { name: File.basename(path), mime_type: mime, path: path }
420
+ end
421
+ end
422
+
406
423
  def validate_working_directory(path, config = nil)
407
424
  working_dir = path || Dir.pwd
408
425
 
@@ -540,9 +557,10 @@ module Clacky
540
557
  session_manager&.save(agent.to_session_data(status: :interrupted))
541
558
  ui_controller.show_warning("Task interrupted by user")
542
559
  else
543
- error_message = "#{exception.message}\n#{exception.backtrace&.first(3)&.join("\n")}"
560
+ error_message = format_error(exception)
544
561
  session_manager&.save(agent.to_session_data(status: :error, error_message: error_message))
545
- ui_controller.show_error("Error: #{exception.message}")
562
+ code = exception.is_a?(Clacky::InsufficientCreditError) ? exception.error_code : nil
563
+ ui_controller.show_error("Error: #{exception.message}", code: code)
546
564
  end
547
565
  end
548
566
 
@@ -553,31 +571,61 @@ module Clacky
553
571
  # Force auto-approve — no one is around to confirm anything
554
572
  agent_config.permission_mode = :auto_approve
555
573
 
556
- # Validate paths up-front so we fail fast with a clear message
557
- file_paths.each do |path|
558
- raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
559
- end
574
+ is_json = !!options[:json]
560
575
 
561
- # Convert file paths to file hashes — agent.run decides how to handle each
562
- files = file_paths.map do |path|
563
- mime = Utils::FileProcessor.detect_mime_type(path) rescue "application/octet-stream"
564
- { name: File.basename(path), mime_type: mime, path: path }
576
+ # Validate and prepare files up-front (DRY)
577
+ begin
578
+ files = prepare_non_interactive_files(file_paths)
579
+ rescue => e
580
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
581
+
582
+ if is_json
583
+ ui = Clacky::JsonUIController.new
584
+ ui.emit("error", message: e.message)
585
+ ui.set_idle_status
586
+ else
587
+ $stderr.puts "Error: #{e.message}"
588
+ end
589
+ exit(1)
565
590
  end
566
591
 
567
- # Wire up plain-text stdout UI so all agent output is visible
568
- plain_ui = Clacky::PlainUIController.new
569
- agent.instance_variable_set(:@ui, plain_ui)
570
592
 
571
- auto_name_session(agent, message)
572
- agent.run(message, files: files)
573
- session_manager&.save(agent.to_session_data(status: :success))
574
- exit(0)
575
- rescue Clacky::AgentInterrupted
576
- $stderr.puts "\nInterrupted."
577
- exit(1)
578
- rescue => e
579
- $stderr.puts "Error: #{e.message}"
580
- exit(1)
593
+ # Wire up the appropriate UI controller and execute
594
+ if is_json
595
+ ui = Clacky::JsonUIController.new
596
+ agent.instance_variable_set(:@ui, ui)
597
+ ui.emit("system", message: "Agent started", model: agent_config.model_name, working_dir: agent.working_dir)
598
+
599
+ status = run_json_task(agent, ui, session_manager) do
600
+ auto_name_session(agent, message)
601
+ agent.run(message, files: files)
602
+ end
603
+
604
+ if status == :success
605
+ ui.emit("done", total_cost: agent.total_cost, total_tasks: agent.total_tasks)
606
+ exit(0)
607
+ else
608
+ exit(1)
609
+ end
610
+ else
611
+ ui = Clacky::PlainUIController.new
612
+ agent.instance_variable_set(:@ui, ui)
613
+
614
+ begin
615
+ auto_name_session(agent, message)
616
+ agent.run(message, files: files)
617
+ session_manager&.save(agent.to_session_data(status: :success))
618
+ exit(0)
619
+ rescue Clacky::AgentInterrupted
620
+ session_manager&.save(agent.to_session_data(status: :interrupted))
621
+ $stderr.puts "\nInterrupted."
622
+ exit(1)
623
+ rescue => e
624
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
625
+ $stderr.puts "Error: #{e.message}"
626
+ exit(1)
627
+ end
628
+ end
581
629
  end
582
630
 
583
631
  # Run agent with JSON (NDJSON) output mode — persistent process.
@@ -620,6 +668,7 @@ module Clacky
620
668
  next
621
669
  end
622
670
 
671
+
623
672
  # Handle built-in commands
624
673
  case content.downcase
625
674
  when "/exit", "/quit"
@@ -658,12 +707,15 @@ module Clacky
658
707
  yield
659
708
  session_manager&.save(agent.to_session_data(status: :success))
660
709
  json_ui.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
710
+ :success
661
711
  rescue Clacky::AgentInterrupted
662
712
  session_manager&.save(agent.to_session_data(status: :interrupted))
663
713
  json_ui.emit("interrupted")
714
+ :interrupted
664
715
  rescue => e
665
- session_manager&.save(agent.to_session_data(status: :error, error_message: e.message))
716
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
666
717
  json_ui.emit("error", message: e.message)
718
+ :error
667
719
  ensure
668
720
  json_ui.set_idle_status
669
721
  end
data/lib/clacky/client.rb CHANGED
@@ -301,7 +301,12 @@ module Clacky
301
301
  end
302
302
  end
303
303
 
304
- raise_error(response) unless response.status == 200
304
+ unless response.status == 200
305
+ recovered_body = response.body.to_s
306
+ recovered_body = sse_buf.to_s if recovered_body.empty?
307
+ recovered = Struct.new(:status, :body).new(response.status, recovered_body)
308
+ raise_error(recovered)
309
+ end
305
310
  MessageFormat::Anthropic.parse_response(aggregator.to_h)
306
311
  end
307
312
 
@@ -351,7 +356,10 @@ module Clacky
351
356
  end
352
357
  end
353
358
 
354
- raise_error(response) unless response.status == 200
359
+ unless response.status == 200
360
+ response.env.body = sse_buf if response.body.to_s.empty?
361
+ raise_error(response)
362
+ end
355
363
  MessageFormat::OpenAI.parse_response(aggregator.to_h)
356
364
  end
357
365
 
@@ -533,6 +541,22 @@ module Clacky
533
541
  def raise_error(response)
534
542
  error_body = JSON.parse(response.body) rescue nil
535
543
  error_message = extract_error_message(error_body, response.body)
544
+ error_code = extract_error_code(error_body)
545
+
546
+ Clacky::Logger.warn("client.raise_error",
547
+ status: response.status,
548
+ body: response.body.to_s[0, 2000],
549
+ error_message: error_message.to_s[0, 500],
550
+ error_code: error_code
551
+ )
552
+
553
+ if error_code == "insufficient_credit" || response.status == 402
554
+ raise InsufficientCreditError.new(
555
+ "[LLM] Insufficient credit: #{error_message}",
556
+ error_code: "insufficient_credit",
557
+ provider_id: @provider_id
558
+ )
559
+ end
536
560
 
537
561
  case response.status
538
562
  when 400
@@ -548,7 +572,6 @@ module Clacky
548
572
  # broken message is not replayed on the next user turn.
549
573
  raise BadRequestError, "[LLM] Client request error: #{error_message}"
550
574
  when 401 then raise AgentError, "[LLM] Invalid API key"
551
- when 402 then raise AgentError, "[LLM] Billing or payment issue (possibly out of credits): #{error_message}"
552
575
  when 403 then raise AgentError, "[LLM] Access denied: #{error_message}"
553
576
  when 404 then raise AgentError, "[LLM] API endpoint not found: #{error_message}"
554
577
  when 429 then raise RetryableError, "[LLM] Rate limit exceeded, please wait a moment"
@@ -565,19 +588,51 @@ module Clacky
565
588
  end
566
589
  end
567
590
 
591
+ private def extract_error_code(error_body)
592
+ return nil unless error_body.is_a?(Hash)
593
+ err = error_body["error"]
594
+ return err["code"] if err.is_a?(Hash) && err["code"].is_a?(String)
595
+ nil
596
+ end
597
+
568
598
  def extract_error_message(error_body, raw_body)
569
599
  if raw_body.is_a?(String) && raw_body.strip.start_with?("<!DOCTYPE", "<html")
570
600
  return "Invalid API endpoint or server error (received HTML instead of JSON)"
571
601
  end
572
602
 
603
+ return "(empty response body)" if raw_body.to_s.strip.empty? && !error_body.is_a?(Hash)
573
604
  return raw_body unless error_body.is_a?(Hash)
574
605
 
575
606
  error_body["upstreamMessage"]&.then { |m| return m unless m.empty? }
576
- error_body.dig("error", "message")&.then { |m| return m } if error_body["error"].is_a?(Hash)
607
+
608
+ if error_body["error"].is_a?(Hash)
609
+ upstream_msg = extract_upstream_error(error_body["error"])
610
+ return upstream_msg if upstream_msg
611
+ end
612
+
577
613
  error_body["message"]&.then { |m| return m }
578
614
  error_body["error"].is_a?(String) ? error_body["error"] : (raw_body.to_s[0..200] + (raw_body.to_s.length > 200 ? "..." : ""))
579
615
  end
580
616
 
617
+ # OpenRouter nests the real provider error inside metadata.raw as a JSON string.
618
+ private def extract_upstream_error(error_hash)
619
+ raw = error_hash.dig("metadata", "raw")
620
+ if raw.is_a?(String) && !raw.empty?
621
+ nested = JSON.parse(raw) rescue nil
622
+ if nested.is_a?(Hash)
623
+ details = nested.dig("error", "details")
624
+ if details.is_a?(String) && !details.empty?
625
+ innermost = JSON.parse(details) rescue nil
626
+ if innermost.is_a?(Hash) && innermost.dig("error", "message")
627
+ return innermost.dig("error", "message")
628
+ end
629
+ end
630
+ return nested.dig("error", "message") if nested.dig("error", "message")
631
+ end
632
+ end
633
+ error_hash["message"]
634
+ end
635
+
581
636
  # Parse JSON with user-friendly error messages.
582
637
  # @param json_string [String] the JSON string to parse
583
638
  # @param context [String] a description of what's being parsed (e.g., "LLM response")
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # Clacky WPS Parser — CLI interface
5
+ #
6
+ # Handles WPS Office formats:
7
+ # .wps — WPS Writer (word processor)
8
+ # .et — WPS Spreadsheet
9
+ # .dps — WPS Presentation
10
+ #
11
+ # Usage:
12
+ # ruby wps_parser.rb <file_path>
13
+ #
14
+ # Output:
15
+ # stdout — extracted text content (UTF-8)
16
+ # stderr — error messages
17
+ # exit 0 — success
18
+ # exit 1 — failure
19
+ #
20
+ # VERSION: 1
21
+
22
+ require "open3"
23
+ require "tmpdir"
24
+ require "fileutils"
25
+
26
+ MIN_CONTENT_BYTES = 20
27
+
28
+ # Convert WPS formats to text using LibreOffice headless mode.
29
+ # .et (spreadsheet) → csv for structured output; .wps/.dps → txt.
30
+ def try_libreoffice(path, ext)
31
+ Dir.mktmpdir("clacky-wps") do |dir|
32
+ output_ext = ext == ".et" ? "csv" : "txt"
33
+ _stdout, _stderr, status = Open3.capture3(
34
+ "libreoffice", "--headless", "--convert-to", output_ext,
35
+ "--outdir", dir, path
36
+ )
37
+ return nil unless status.success?
38
+
39
+ output_file = Dir.glob(File.join(dir, "*.#{output_ext}")).first
40
+ return nil unless output_file && File.exist?(output_file)
41
+
42
+ text = File.read(output_file).strip
43
+ return nil if text.bytesize < MIN_CONTENT_BYTES
44
+ text
45
+ end
46
+ rescue Errno::ENOENT
47
+ nil
48
+ end
49
+
50
+ # --- main ---
51
+
52
+ path = ARGV[0]
53
+
54
+ if path.nil? || path.empty?
55
+ warn "Usage: ruby wps_parser.rb <file_path>"
56
+ exit 1
57
+ end
58
+
59
+ unless File.exist?(path)
60
+ warn "File not found: #{path}"
61
+ exit 1
62
+ end
63
+
64
+ ext = File.extname(path).downcase
65
+
66
+ unless %w[.wps .et .dps].include?(ext)
67
+ warn "Unsupported WPS format: #{ext}"
68
+ exit 1
69
+ end
70
+
71
+ text = try_libreoffice(path, ext)
72
+
73
+ if text
74
+ print text
75
+ exit 0
76
+ else
77
+ warn "Could not extract text from #{ext} file."
78
+ warn "Tip: install LibreOffice to enable WPS format support."
79
+ warn " macOS: brew install --cask libreoffice"
80
+ warn " Linux: apt install libreoffice"
81
+ exit 1
82
+ end
@@ -216,8 +216,8 @@ Silently run `ruby "SKILL_DIR/scripts/install_builtin_skills.rb"`,
216
216
  then parse the last stdout line as JSON and read `installed` as N.
217
217
 
218
218
  - If N > 0, show one line:
219
- - zh: `✅ 已为你内置 N 个技能,输入 /skills 随时查看。`
220
- - en: `✅ Installed N builtin skills. Type /skills anytime to view them.`
219
+ - zh: `✅ 已为你内置 N 个技能。`
220
+ - en: `✅ Installed N builtin skills.`
221
221
 
222
222
  ### A.10. Import external skills (optional)
223
223
 
@@ -101,8 +101,11 @@ module Clacky
101
101
  emit("warning", message: message)
102
102
  end
103
103
 
104
- def show_error(message)
105
- emit("error", message: message)
104
+ def show_error(message, code: nil, top_up_url: nil)
105
+ payload = { message: message }
106
+ payload[:code] = code if code
107
+ payload[:top_up_url] = top_up_url if top_up_url
108
+ emit("error", **payload)
106
109
  end
107
110
 
108
111
  def show_success(message)
@@ -39,6 +39,17 @@ module Clacky
39
39
  msg[:content].select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
40
40
  end
41
41
 
42
+ # Anthropic requires tool_use.id to match ^[a-zA-Z0-9_-]+$ (max 128 chars).
43
+ # Some OpenAI-compatible upstreams (e.g. kimi-k2.6) return ids like "tool_name:0"
44
+ # — fine for OpenAI, rejected by Anthropic. We replace illegal chars with "_"
45
+ # at the format boundary so ids stay self-consistent across use/result pairs
46
+ # (pure function → same input maps to same output in both directions).
47
+ def sanitize_tool_use_id(id)
48
+ s = id.to_s
49
+ s = s.gsub(/[^a-zA-Z0-9_-]/, "_")
50
+ s.length > 128 ? s[0, 128] : s
51
+ end
52
+
42
53
  # ── Request building ──────────────────────────────────────────────────────
43
54
 
44
55
  # Convert canonical @messages + tools into an Anthropic API request body.
@@ -156,7 +167,6 @@ module Clacky
156
167
  end
157
168
 
158
169
  # ── Tool result formatting ────────────────────────────────────────────────
159
-
160
170
  # Format tool results into canonical messages to append to @messages.
161
171
  # Input: response (canonical, has :tool_calls), tool_results array
162
172
  # Output: canonical messages: [{ role: "tool", tool_call_id:, content: }]
@@ -211,7 +221,7 @@ module Clacky
211
221
  else
212
222
  raw_args
213
223
  end
214
- blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
224
+ blocks << { type: "tool_use", id: sanitize_tool_use_id(tc[:id]), name: name, input: input || {} }
215
225
  end
216
226
 
217
227
  return { role: "assistant", content: blocks }
@@ -250,7 +260,7 @@ module Clacky
250
260
  else
251
261
  raw_content
252
262
  end
253
- block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: tool_content }
263
+ block = { type: "tool_result", tool_use_id: sanitize_tool_use_id(msg[:tool_call_id]), content: tool_content }
254
264
  block[:cache_control] = hoisted_cache_control if hoisted_cache_control
255
265
  return { role: "user", content: [block] }
256
266
  end
@@ -187,7 +187,7 @@ module Clacky
187
187
  name = func[:name] || tc[:name]
188
188
  raw_args = func[:arguments] || tc[:arguments]
189
189
  input = raw_args.is_a?(String) ? (JSON.parse(raw_args) rescue {}) : (raw_args || {})
190
- blocks << { toolUse: { toolUseId: tc[:id], name: name, input: input } }
190
+ blocks << { toolUse: { toolUseId: Anthropic.sanitize_tool_use_id(tc[:id]), name: name, input: input } }
191
191
  end
192
192
 
193
193
  return { role: "assistant", content: blocks }
@@ -208,7 +208,7 @@ module Clacky
208
208
  end
209
209
  return {
210
210
  role: "user",
211
- content: [{ toolResult: { toolUseId: msg[:tool_call_id], content: result_blocks } }]
211
+ content: [{ toolResult: { toolUseId: Anthropic.sanitize_tool_use_id(msg[:tool_call_id]), content: result_blocks } }]
212
212
  }
213
213
  end
214
214
 
@@ -117,7 +117,7 @@ module Clacky
117
117
  puts_line("[warn] #{message}")
118
118
  end
119
119
 
120
- def show_error(message)
120
+ def show_error(message, code: nil, top_up_url: nil)
121
121
  puts_line("[error] #{message}")
122
122
  end
123
123
 
@@ -228,12 +228,17 @@ module Clacky
228
228
  h.request(req) do |resp|
229
229
  case resp.code.to_i
230
230
  when 200
231
+ expected_len = resp["content-length"]&.to_i
231
232
  File.open(dest, "wb") do |f|
232
233
  resp.read_body do |chunk|
233
234
  f.write(chunk)
234
235
  written += chunk.bytesize
235
236
  end
236
237
  end
238
+ if expected_len && expected_len > 0 && written != expected_len
239
+ raise RetryableNetworkError,
240
+ "Truncated download: got #{written} bytes, expected #{expected_len}"
241
+ end
237
242
  when 301, 302, 303, 307, 308
238
243
  location = resp["location"]
239
244
  raise RetryableNetworkError, "Redirect with no Location header" if location.nil? || location.empty?
@@ -354,13 +359,35 @@ module Clacky
354
359
  { success: true, data: body["data"] || body }
355
360
  else
356
361
  error_code = body["code"]
362
+ server_msg = extract_server_error_message(body)
357
363
  error_msg = API_ERROR_MESSAGES[error_code] ||
358
- body["error"] ||
364
+ server_msg ||
359
365
  "Request failed (HTTP #{code}#{error_code ? ", code: #{error_code}" : ""}). Please contact support."
360
366
  { success: false, error: error_msg, data: body }
361
367
  end
362
368
  end
363
369
 
370
+ # Server error messages can come back under different keys / shapes:
371
+ # { "error": "msg" } — single string
372
+ # { "errors": ["msg1", "msg2"] } — array of strings (Rails .errors.full_messages)
373
+ # { "errors": "msg" } — string (less common)
374
+ # { "message": "msg" } — alternative key
375
+ # Returns the first non-blank human-readable string, or nil if none.
376
+ private def extract_server_error_message(body)
377
+ return nil unless body.is_a?(Hash)
378
+
379
+ [body["error"], body["errors"], body["message"]].each do |val|
380
+ case val
381
+ when String
382
+ return val unless val.strip.empty?
383
+ when Array
384
+ joined = val.compact.map(&:to_s).reject(&:empty?).join("; ")
385
+ return joined unless joined.empty?
386
+ end
387
+ end
388
+ nil
389
+ end
390
+
364
391
  # Raised for transient failures that should be retried (timeouts, conn resets, SSL errors).
365
392
  class RetryableNetworkError < StandardError; end
366
393
  end