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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -9
- data/lib/clacky/agent/llm_caller.rb +25 -3
- data/lib/clacky/agent/tool_executor.rb +14 -0
- data/lib/clacky/agent/tool_registry.rb +0 -7
- data/lib/clacky/agent.rb +0 -8
- data/lib/clacky/billing/billing_store.rb +62 -4
- data/lib/clacky/brand_config.rb +11 -0
- data/lib/clacky/cli.rb +74 -23
- data/lib/clacky/client.rb +36 -2
- data/lib/clacky/default_parsers/wps_parser.rb +82 -0
- data/lib/clacky/message_format/anthropic.rb +13 -3
- data/lib/clacky/message_format/bedrock.rb +2 -2
- data/lib/clacky/platform_http_client.rb +28 -1
- data/lib/clacky/providers.rb +0 -27
- data/lib/clacky/server/http_server.rb +24 -4
- data/lib/clacky/tools/browser.rb +4 -13
- data/lib/clacky/tools/terminal.rb +2 -2
- data/lib/clacky/utils/file_processor.rb +3 -0
- data/lib/clacky/utils/parser_manager.rb +3 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +422 -74
- data/lib/clacky/web/billing.js +371 -99
- data/lib/clacky/web/i18n.js +24 -2
- data/lib/clacky/web/index.html +1 -1
- data/lib/clacky/web/sessions.js +10 -68
- data/lib/clacky.rb +0 -3
- metadata +3 -5
- data/lib/clacky/tools/list_tasks.rb +0 -54
- data/lib/clacky/tools/redo_task.rb +0 -41
- data/lib/clacky/tools/undo_task.rb +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a71c34a2e50f2202679cafd9c31fe7807ea5e9a6a6fad42ba352e19f9ea01282
|
|
4
|
+
data.tar.gz: 3baa2b1aed288586bec516576bf8729b630392e4e4b9aeb3b65bca704060b3d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|