openclacky 1.2.5 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 234f676c7efe1f2803eeb4f262b20014e2a4b967442a03af70dc3e04e81b4f6b
4
- data.tar.gz: 6e9161983ebec82c44467a28e92ff25636718942bec953ebdc7ef6593b515c7e
3
+ metadata.gz: a71c34a2e50f2202679cafd9c31fe7807ea5e9a6a6fad42ba352e19f9ea01282
4
+ data.tar.gz: 3baa2b1aed288586bec516576bf8729b630392e4e4b9aeb3b65bca704060b3d1
5
5
  SHA512:
6
- metadata.gz: afd3471fa15b77e3921aa640fbfd7cf937030478d834c06d0ad2d52faa6ada82561929ce7aa4ac2b075a71b1cf97bd5295ec80af4d2903dbf55c983bb4263e1f
7
- data.tar.gz: 7713e3b3a0aa9775a4a993b651a536552963320787f072ea6f79dbde0bf345a1d680b3cc0a07eec1d5667da69899f770344ba376b52d44d12360ac3ec60402a4
6
+ metadata.gz: e334e6f5e8726d02933c60d69aed8da4b98f417b1f7d0e0676b3d58bc8a15f9214317a5cde6f3ef8094f69e1fdff8cee6fb822beb076c0eef9e5544770256029
7
+ data.tar.gz: 76c8e019789e513382079f942dd7d4036dd68f7b6dac26822e6ec4e0dda410e8d2ad55b1ebaabd58ad388fb2cafa4fa1cb3350fc9a5673fbbb9120893c98ffd6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.6] - 2026-05-29
9
+
10
+ ### Added
11
+ - WPS format support for document processing
12
+ - `--json` flag support with `-m` option
13
+
14
+ ### Improved
15
+ - Billing UI and model filtering experience
16
+ - Brand skill download now retries on failure
17
+ - DeepSeek compatibility handling within OpenRouter provider
18
+
19
+ ### Fixed
20
+ - Claude `tool_use.id` error when switching between models
21
+ - Browser page navigation pageid bug
22
+ - Browser IIFE execution issue
23
+ - WSL UTF-8 command encoding bug
24
+ - File preview path directory resolution
25
+
26
+ ### More
27
+ - Remove list/undo/redo task tool
28
+ - Clean up legacy provider code
29
+ - Improve platform error messages for easier diagnostics
30
+
8
31
  ## [1.2.5] - 2026-05-28
9
32
 
10
33
  ### Fixed
@@ -17,7 +17,8 @@ module Clacky
17
17
  # Updates total cost and displays iteration statistics
18
18
  # @param usage [Hash] Usage data from API response
19
19
  # @param raw_api_usage [Hash, nil] Raw API usage data for debugging
20
- def track_cost(usage, raw_api_usage: nil)
20
+ # @param model [String, nil] Model name to use for billing (defaults to current_model)
21
+ def track_cost(usage, raw_api_usage: nil, model: nil)
21
22
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
22
23
  iteration_cost = nil
23
24
  if usage[:api_cost]
@@ -28,7 +29,10 @@ module Clacky
28
29
  @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
29
30
  else
30
31
  # Priority 2: Calculate from tokens using ModelPricing
31
- result = ModelPricing.calculate_cost(model: current_model, usage: usage)
32
+ # Use provided model name (from API call time) to ensure accurate billing
33
+ # even if the user switches models during the API call
34
+ billing_model = model || current_model
35
+ result = ModelPricing.calculate_cost(model: billing_model, usage: usage)
32
36
  cost = result[:cost]
33
37
  pricing_source = result[:source]
34
38
 
@@ -101,7 +105,7 @@ module Clacky
101
105
 
102
106
  # Persist billing record (skip for subagents to avoid double-counting)
103
107
  unless @is_subagent
104
- persist_billing_record(usage, iteration_cost)
108
+ persist_billing_record(usage, iteration_cost, model: model)
105
109
  end
106
110
 
107
111
  # Return token_data so the caller can display it at the right moment
@@ -111,25 +115,29 @@ module Clacky
111
115
  # Persist a billing record to the billing store
112
116
  # @param usage [Hash] Usage data from API
113
117
  # @param cost [Float, nil] Calculated cost for this iteration
114
- def persist_billing_record(usage, cost)
115
- return if cost.nil? # Skip if cost is unknown
118
+ # @param model [String, nil] Model name to use for billing (defaults to current_model)
119
+ def persist_billing_record(usage, cost, model: nil)
120
+ # Always save billing records for usage tracking, even if cost is unknown (nil).
121
+ # This ensures all API calls are recorded for statistics purposes.
122
+ billing_model = model || current_model
123
+ effective_cost = cost || 0.0 # Use 0 if pricing is unknown
116
124
 
117
125
  record = Billing::BillingRecord.new(
118
126
  session_id: @session_id,
119
127
  timestamp: Time.now,
120
- model: current_model,
128
+ model: billing_model,
121
129
  prompt_tokens: usage[:prompt_tokens] || 0,
122
130
  completion_tokens: usage[:completion_tokens] || 0,
123
131
  cache_read_tokens: usage[:cache_read_input_tokens] || 0,
124
132
  cache_write_tokens: usage[:cache_creation_input_tokens] || 0,
125
- cost_usd: cost,
126
- cost_source: @cost_source
133
+ cost_usd: effective_cost,
134
+ cost_source: cost.nil? ? :unknown : @cost_source
127
135
  )
128
136
 
129
137
  billing_store.append(record)
130
138
  rescue => e
131
139
  # Billing persistence is non-critical; log and continue
132
- @ui&.log("Failed to persist billing record: #{e.message}", level: :debug) if @config&.verbose
140
+ Clacky::Logger.warn("billing.persist_error", error: e.message, model: billing_model)
133
141
  end
134
142
 
135
143
  # Estimate token count for a message content
@@ -88,6 +88,11 @@ module Clacky
88
88
  # the error propagate.
89
89
  context_overflow_retry_attempted = false
90
90
 
91
+ # Capture model name at API call time for accurate billing tracking.
92
+ # If the user switches models while the API call is in progress, we still
93
+ # want to bill under the model that actually handled the request.
94
+ api_call_model = current_model
95
+
91
96
  begin
92
97
  begin
93
98
  # Use active_messages (Time Machine) when undone, otherwise send full history.
@@ -100,7 +105,7 @@ module Clacky
100
105
 
101
106
  response = @client.send_messages_with_tools(
102
107
  messages_to_send,
103
- model: current_model,
108
+ model: api_call_model,
104
109
  tools: tools_to_send,
105
110
  max_tokens: @config.max_tokens,
106
111
  enable_caching: @config.enable_prompt_caching,
@@ -124,8 +129,22 @@ module Clacky
124
129
  # block below handles retry + fallback identically to 5xx/429.
125
130
  detect_upstream_truncation!(response)
126
131
 
132
+ # Empty response detector: model returned nothing (no content, no
133
+ # tool_calls, finish_reason != "stop"). DeepSeek via OpenRouter
134
+ # occasionally does this. Treat as transient failure and retry.
135
+ if response[:content].to_s.strip.empty? &&
136
+ (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
137
+ response[:finish_reason].to_s != "stop" &&
138
+ response[:finish_reason].to_s != "length"
139
+ Clacky::Logger.warn("llm.empty_response_detected",
140
+ model: api_call_model,
141
+ finish_reason: response[:finish_reason].to_s,
142
+ completion_tokens: response.dig(:token_usage, :completion_tokens)
143
+ )
144
+ raise RetryableError, "[LLM] Model returned empty response (no content, no tool_calls), retrying..."
145
+ end
146
+
127
147
  rescue Faraday::TimeoutError => e
128
- # ── Read-timeout path (distinct from connection-level failures) ──
129
148
  # Faraday::TimeoutError on our non-streaming POST almost always means
130
149
  # the *response* took longer than the 300s read-timeout to come back —
131
150
  # i.e. the model is trying to produce a huge output in one shot
@@ -210,6 +229,7 @@ module Clacky
210
229
  if retries <= current_max
211
230
  if retries == RETRIES_BEFORE_FALLBACK && !@config.fallback_active?
212
231
  if try_activate_fallback(current_model)
232
+ api_call_model = current_model
213
233
  retries = 0
214
234
  retry
215
235
  end
@@ -299,7 +319,9 @@ module Clacky
299
319
  end
300
320
 
301
321
  # Track cost and collect token usage data.
302
- token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
322
+ # Pass the model name captured at API call time to ensure accurate billing
323
+ # even if the user switched models during the (potentially long) API call.
324
+ token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage], model: api_call_model)
303
325
  response[:token_usage] = token_data
304
326
 
305
327
  # [DIAG] Log raw client response shape. Only emit when we see the
@@ -108,6 +108,15 @@ module Clacky
108
108
  preview_error
109
109
  rescue JSON::ParserError
110
110
  nil
111
+ rescue StandardError => e
112
+ @debug_logs << {
113
+ timestamp: Time.now.iso8601,
114
+ event: "tool_preview_error",
115
+ tool_name: call[:name],
116
+ error_class: e.class.name,
117
+ error_message: e.message
118
+ }
119
+ nil
111
120
  end
112
121
  end
113
122
 
@@ -397,6 +406,11 @@ module Clacky
397
406
  return { error: "File not found: #{path}", path: path }
398
407
  end
399
408
 
409
+ if File.directory?(expanded_path)
410
+ @ui&.show_file_error("Path is a directory, not a file: #{path}")
411
+ return { error: "Path is a directory, not a file: #{path}", path: path }
412
+ end
413
+
400
414
  if old_string.empty?
401
415
  @ui&.show_file_error("No old_string provided (nothing to replace)")
402
416
  return { error: "No old_string provided (nothing to replace)" }
@@ -59,13 +59,6 @@ module Clacky
59
59
  "ask_user" => "request_user_feedback",
60
60
  "user_feedback" => "request_user_feedback",
61
61
  "ask" => "request_user_feedback",
62
- # undo_task aliases
63
- "undo" => "undo_task",
64
- # redo_task aliases
65
- "redo" => "redo_task",
66
- # list_tasks aliases
67
- "tasks" => "list_tasks",
68
- "task_history" => "list_tasks",
69
62
  # trash_manager aliases
70
63
  "trash" => "trash_manager",
71
64
  "delete" => "trash_manager",
data/lib/clacky/agent.rb CHANGED
@@ -902,11 +902,6 @@ module Clacky
902
902
  args[:skill_loader] = @skill_loader
903
903
  end
904
904
 
905
- # Special handling for Time Machine tools: inject agent
906
- if ["undo_task", "redo_task", "list_tasks"].include?(call[:name])
907
- args[:agent] = self
908
- end
909
-
910
905
  # Inject working_dir so tools don't rely on Dir.chdir global state
911
906
  args[:working_dir] = @working_dir if @working_dir
912
907
 
@@ -1183,9 +1178,6 @@ module Clacky
1183
1178
  @tool_registry.register(Tools::TodoManager.new)
1184
1179
  @tool_registry.register(Tools::RequestUserFeedback.new)
1185
1180
  @tool_registry.register(Tools::InvokeSkill.new)
1186
- @tool_registry.register(Tools::UndoTask.new)
1187
- @tool_registry.register(Tools::RedoTask.new)
1188
- @tool_registry.register(Tools::ListTasks.new)
1189
1181
  @tool_registry.register(Tools::Browser.new)
1190
1182
  end
1191
1183
 
@@ -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
@@ -780,6 +783,12 @@ module Clacky
780
783
 
781
784
  FileUtils.rm_f(tmp_zip)
782
785
 
786
+ if encrypted
787
+ manifest_path = File.join(dest_dir, "MANIFEST.enc.json")
788
+ raise "MANIFEST.enc.json missing after extraction" unless File.exist?(manifest_path)
789
+ JSON.parse(File.read(manifest_path))
790
+ end
791
+
783
792
  record_installed_skill(slug, version, skill_info["description"],
784
793
  encrypted: encrypted,
785
794
  description_zh: skill_info["description_zh"],
@@ -787,6 +796,8 @@ module Clacky
787
796
 
788
797
  { success: true, name: slug, version: version }
789
798
  rescue StandardError, ScriptError => e
799
+ FileUtils.rm_f(tmp_zip) if defined?(tmp_zip) && tmp_zip
800
+ FileUtils.rm_rf(dest_dir) if defined?(dest_dir) && dest_dir
790
801
  { success: false, error: e.message }
791
802
  end
792
803
 
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,7 +557,7 @@ 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
562
  ui_controller.show_error("Error: #{exception.message}")
546
563
  end
@@ -553,31 +570,61 @@ module Clacky
553
570
  # Force auto-approve — no one is around to confirm anything
554
571
  agent_config.permission_mode = :auto_approve
555
572
 
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
573
+ is_json = !!options[:json]
560
574
 
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 }
575
+ # Validate and prepare files up-front (DRY)
576
+ begin
577
+ files = prepare_non_interactive_files(file_paths)
578
+ rescue => e
579
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
580
+
581
+ if is_json
582
+ ui = Clacky::JsonUIController.new
583
+ ui.emit("error", message: e.message)
584
+ ui.set_idle_status
585
+ else
586
+ $stderr.puts "Error: #{e.message}"
587
+ end
588
+ exit(1)
565
589
  end
566
590
 
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
591
 
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)
592
+ # Wire up the appropriate UI controller and execute
593
+ if is_json
594
+ ui = Clacky::JsonUIController.new
595
+ agent.instance_variable_set(:@ui, ui)
596
+ ui.emit("system", message: "Agent started", model: agent_config.model_name, working_dir: agent.working_dir)
597
+
598
+ status = run_json_task(agent, ui, session_manager) do
599
+ auto_name_session(agent, message)
600
+ agent.run(message, files: files)
601
+ end
602
+
603
+ if status == :success
604
+ ui.emit("done", total_cost: agent.total_cost, total_tasks: agent.total_tasks)
605
+ exit(0)
606
+ else
607
+ exit(1)
608
+ end
609
+ else
610
+ ui = Clacky::PlainUIController.new
611
+ agent.instance_variable_set(:@ui, ui)
612
+
613
+ begin
614
+ auto_name_session(agent, message)
615
+ agent.run(message, files: files)
616
+ session_manager&.save(agent.to_session_data(status: :success))
617
+ exit(0)
618
+ rescue Clacky::AgentInterrupted
619
+ session_manager&.save(agent.to_session_data(status: :interrupted))
620
+ $stderr.puts "\nInterrupted."
621
+ exit(1)
622
+ rescue => e
623
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
624
+ $stderr.puts "Error: #{e.message}"
625
+ exit(1)
626
+ end
627
+ end
581
628
  end
582
629
 
583
630
  # Run agent with JSON (NDJSON) output mode — persistent process.
@@ -620,6 +667,7 @@ module Clacky
620
667
  next
621
668
  end
622
669
 
670
+
623
671
  # Handle built-in commands
624
672
  case content.downcase
625
673
  when "/exit", "/quit"
@@ -658,12 +706,15 @@ module Clacky
658
706
  yield
659
707
  session_manager&.save(agent.to_session_data(status: :success))
660
708
  json_ui.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
709
+ :success
661
710
  rescue Clacky::AgentInterrupted
662
711
  session_manager&.save(agent.to_session_data(status: :interrupted))
663
712
  json_ui.emit("interrupted")
713
+ :interrupted
664
714
  rescue => e
665
- session_manager&.save(agent.to_session_data(status: :error, error_message: e.message))
715
+ session_manager&.save(agent.to_session_data(status: :error, error_message: format_error(e)))
666
716
  json_ui.emit("error", message: e.message)
717
+ :error
667
718
  ensure
668
719
  json_ui.set_idle_status
669
720
  end
data/lib/clacky/client.rb CHANGED
@@ -351,7 +351,10 @@ module Clacky
351
351
  end
352
352
  end
353
353
 
354
- raise_error(response) unless response.status == 200
354
+ unless response.status == 200
355
+ response.env.body = sse_buf if response.body.to_s.empty?
356
+ raise_error(response)
357
+ end
355
358
  MessageFormat::OpenAI.parse_response(aggregator.to_h)
356
359
  end
357
360
 
@@ -534,6 +537,12 @@ module Clacky
534
537
  error_body = JSON.parse(response.body) rescue nil
535
538
  error_message = extract_error_message(error_body, response.body)
536
539
 
540
+ Clacky::Logger.warn("client.raise_error",
541
+ status: response.status,
542
+ body: response.body.to_s[0, 2000],
543
+ error_message: error_message.to_s[0, 500]
544
+ )
545
+
537
546
  case response.status
538
547
  when 400
539
548
  # Well-behaved APIs (Anthropic, OpenAI) never put quota/availability issues in 400.
@@ -570,14 +579,39 @@ module Clacky
570
579
  return "Invalid API endpoint or server error (received HTML instead of JSON)"
571
580
  end
572
581
 
582
+ return "(empty response body)" if raw_body.to_s.strip.empty? && !error_body.is_a?(Hash)
573
583
  return raw_body unless error_body.is_a?(Hash)
574
584
 
575
585
  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)
586
+
587
+ if error_body["error"].is_a?(Hash)
588
+ upstream_msg = extract_upstream_error(error_body["error"])
589
+ return upstream_msg if upstream_msg
590
+ end
591
+
577
592
  error_body["message"]&.then { |m| return m }
578
593
  error_body["error"].is_a?(String) ? error_body["error"] : (raw_body.to_s[0..200] + (raw_body.to_s.length > 200 ? "..." : ""))
579
594
  end
580
595
 
596
+ # OpenRouter nests the real provider error inside metadata.raw as a JSON string.
597
+ private def extract_upstream_error(error_hash)
598
+ raw = error_hash.dig("metadata", "raw")
599
+ if raw.is_a?(String) && !raw.empty?
600
+ nested = JSON.parse(raw) rescue nil
601
+ if nested.is_a?(Hash)
602
+ details = nested.dig("error", "details")
603
+ if details.is_a?(String) && !details.empty?
604
+ innermost = JSON.parse(details) rescue nil
605
+ if innermost.is_a?(Hash) && innermost.dig("error", "message")
606
+ return innermost.dig("error", "message")
607
+ end
608
+ end
609
+ return nested.dig("error", "message") if nested.dig("error", "message")
610
+ end
611
+ end
612
+ error_hash["message"]
613
+ end
614
+
581
615
  # Parse JSON with user-friendly error messages.
582
616
  # @param json_string [String] the JSON string to parse
583
617
  # @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