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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +24 -10
- data/lib/clacky/agent/llm_caller.rb +25 -3
- data/lib/clacky/agent/message_compressor.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +6 -2
- data/lib/clacky/agent/session_serializer.rb +23 -4
- data/lib/clacky/agent/tool_executor.rb +14 -0
- data/lib/clacky/agent/tool_registry.rb +0 -7
- data/lib/clacky/agent.rb +43 -10
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/billing/billing_store.rb +62 -4
- data/lib/clacky/brand_config.rb +5 -0
- data/lib/clacky/cli.rb +76 -24
- data/lib/clacky/client.rb +59 -4
- data/lib/clacky/default_parsers/wps_parser.rb +82 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/message_format/anthropic.rb +13 -3
- data/lib/clacky/message_format/bedrock.rb +2 -2
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/platform_http_client.rb +28 -1
- data/lib/clacky/providers.rb +11 -29
- data/lib/clacky/server/channel/channel_manager.rb +148 -12
- data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
- data/lib/clacky/server/http_server.rb +133 -13
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/tools/browser.rb +4 -13
- data/lib/clacky/tools/terminal.rb +23 -27
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- 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 +659 -75
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/billing.js +371 -99
- data/lib/clacky/web/i18n.js +48 -2
- data/lib/clacky/web/index.html +34 -1
- data/lib/clacky/web/sessions.js +213 -82
- data/lib/clacky/web/settings.js +59 -17
- data/lib/clacky/web/workspace.js +204 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -3
- data/lib/clacky.rb +9 -3
- metadata +4 -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
|
@@ -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
|
|
@@ -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 =
|
|
560
|
+
error_message = format_error(exception)
|
|
544
561
|
session_manager&.save(agent.to_session_data(status: :error, error_message: error_message))
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
- en: `✅ Installed N builtin skills
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|