openclacky 1.1.5 → 1.2.0
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 +45 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +113 -63
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +521 -17
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +30 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +41 -6
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +51 -40
- data/lib/clacky/web/i18n.js +248 -24
- data/lib/clacky/web/index.html +362 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +299 -18
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +15 -2
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "billing_record"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
module Billing
|
|
10
|
+
# Persistent storage for billing records using JSONL files
|
|
11
|
+
# Records are stored in monthly files: ~/.clacky/billing/YYYY-MM.jsonl
|
|
12
|
+
class BillingStore
|
|
13
|
+
BILLING_DIR = File.join(Dir.home, ".clacky", "billing")
|
|
14
|
+
|
|
15
|
+
def initialize(billing_dir: nil)
|
|
16
|
+
@billing_dir = billing_dir || BILLING_DIR
|
|
17
|
+
ensure_billing_dir
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Append a billing record to the current month's file
|
|
21
|
+
# @param record [BillingRecord] The record to append
|
|
22
|
+
# @return [String] The record ID
|
|
23
|
+
def append(record)
|
|
24
|
+
record.id ||= SecureRandom.uuid
|
|
25
|
+
record.timestamp ||= Time.now
|
|
26
|
+
|
|
27
|
+
month_file = current_month_file
|
|
28
|
+
File.open(month_file, "a") do |f|
|
|
29
|
+
f.puts(JSON.generate(record.to_h))
|
|
30
|
+
end
|
|
31
|
+
FileUtils.chmod(0o600, month_file)
|
|
32
|
+
|
|
33
|
+
record.id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Query billing records with optional filters
|
|
37
|
+
# @param from [Time, nil] Start time (inclusive)
|
|
38
|
+
# @param to [Time, nil] End time (inclusive)
|
|
39
|
+
# @param model [String, nil] Filter by model name
|
|
40
|
+
# @param session_id [String, nil] Filter by session ID
|
|
41
|
+
# @param limit [Integer, nil] Maximum number of records to return
|
|
42
|
+
# @return [Array<BillingRecord>] Matching records, newest first
|
|
43
|
+
def query(from: nil, to: nil, model: nil, session_id: nil, limit: nil)
|
|
44
|
+
records = []
|
|
45
|
+
|
|
46
|
+
billing_files.each do |file|
|
|
47
|
+
File.foreach(file) do |line|
|
|
48
|
+
next if line.strip.empty?
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
hash = JSON.parse(line, symbolize_names: true)
|
|
52
|
+
record = BillingRecord.from_h(hash)
|
|
53
|
+
|
|
54
|
+
# Apply filters
|
|
55
|
+
next if from && record.timestamp < from
|
|
56
|
+
next if to && record.timestamp > to
|
|
57
|
+
next if model && record.model != model
|
|
58
|
+
next if session_id && record.session_id != session_id
|
|
59
|
+
|
|
60
|
+
records << record
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
# Skip malformed lines
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Sort by timestamp descending (newest first)
|
|
69
|
+
records.sort_by! { |r| r.timestamp }.reverse!
|
|
70
|
+
|
|
71
|
+
# Apply limit
|
|
72
|
+
limit ? records.first(limit) : records
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get summary statistics for a time period
|
|
76
|
+
# @param period [Symbol] :day, :week, :month, :year, or :all
|
|
77
|
+
# @return [Hash] Summary with total_cost, total_tokens, by_model, etc.
|
|
78
|
+
def summary(period: :month)
|
|
79
|
+
from_time = period_start(period)
|
|
80
|
+
records = query(from: from_time)
|
|
81
|
+
|
|
82
|
+
total_cost = records.sum { |r| r.cost_usd || 0 }
|
|
83
|
+
total_prompt = records.sum { |r| r.prompt_tokens || 0 }
|
|
84
|
+
total_completion = records.sum { |r| r.completion_tokens || 0 }
|
|
85
|
+
total_cache_read = records.sum { |r| r.cache_read_tokens || 0 }
|
|
86
|
+
total_cache_write = records.sum { |r| r.cache_write_tokens || 0 }
|
|
87
|
+
|
|
88
|
+
by_model = records.group_by(&:model).transform_values do |rs|
|
|
89
|
+
{
|
|
90
|
+
cost: rs.sum { |r| r.cost_usd || 0 },
|
|
91
|
+
prompt_tokens: rs.sum { |r| r.prompt_tokens || 0 },
|
|
92
|
+
completion_tokens: rs.sum { |r| r.completion_tokens || 0 },
|
|
93
|
+
requests: rs.size
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }.transform_values do |rs|
|
|
98
|
+
rs.sum { |r| r.cost_usd || 0 }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
period: period,
|
|
103
|
+
from: from_time&.iso8601,
|
|
104
|
+
to: Time.now.iso8601,
|
|
105
|
+
total_cost: total_cost.round(6),
|
|
106
|
+
total_tokens: total_prompt + total_completion,
|
|
107
|
+
prompt_tokens: total_prompt,
|
|
108
|
+
completion_tokens: total_completion,
|
|
109
|
+
cache_read_tokens: total_cache_read,
|
|
110
|
+
cache_write_tokens: total_cache_write,
|
|
111
|
+
by_model: by_model,
|
|
112
|
+
by_day: by_day,
|
|
113
|
+
record_count: records.size
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get daily cost breakdown for the last N days
|
|
118
|
+
# @param days [Integer] Number of days to include
|
|
119
|
+
# @return [Array<Hash>] Daily summaries with date and cost
|
|
120
|
+
def daily_breakdown(days: 30)
|
|
121
|
+
from_time = Time.now - (days * 24 * 60 * 60)
|
|
122
|
+
records = query(from: from_time)
|
|
123
|
+
|
|
124
|
+
by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }
|
|
125
|
+
|
|
126
|
+
(0...days).map do |i|
|
|
127
|
+
date = (Time.now - (i * 24 * 60 * 60)).strftime("%Y-%m-%d")
|
|
128
|
+
day_records = by_day[date] || []
|
|
129
|
+
{
|
|
130
|
+
date: date,
|
|
131
|
+
cost: day_records.sum { |r| r.cost_usd || 0 }.round(6),
|
|
132
|
+
tokens: day_records.sum { |r| r.total_tokens },
|
|
133
|
+
prompt_tokens: day_records.sum { |r| r.prompt_tokens || 0 },
|
|
134
|
+
completion_tokens: day_records.sum { |r| r.completion_tokens || 0 },
|
|
135
|
+
cache_read_tokens: day_records.sum { |r| r.cache_read_tokens || 0 },
|
|
136
|
+
cache_write_tokens: day_records.sum { |r| r.cache_write_tokens || 0 },
|
|
137
|
+
requests: day_records.size
|
|
138
|
+
}
|
|
139
|
+
end.reverse
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Delete old billing records
|
|
143
|
+
# @param before [Time] Delete records before this time
|
|
144
|
+
# @return [Integer] Number of files deleted
|
|
145
|
+
def cleanup(before:)
|
|
146
|
+
deleted = 0
|
|
147
|
+
billing_files.each do |file|
|
|
148
|
+
# Parse month from filename (YYYY-MM.jsonl)
|
|
149
|
+
basename = File.basename(file, ".jsonl")
|
|
150
|
+
file_month = Time.parse("#{basename}-01") rescue nil
|
|
151
|
+
next unless file_month
|
|
152
|
+
|
|
153
|
+
# Delete if the entire month is before the cutoff
|
|
154
|
+
if file_month < before - (31 * 24 * 60 * 60)
|
|
155
|
+
File.delete(file)
|
|
156
|
+
deleted += 1
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
deleted
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private def ensure_billing_dir
|
|
163
|
+
FileUtils.mkdir_p(@billing_dir) unless Dir.exist?(@billing_dir)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private def current_month_file
|
|
167
|
+
File.join(@billing_dir, "#{Time.now.strftime('%Y-%m')}.jsonl")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private def billing_files
|
|
171
|
+
Dir.glob(File.join(@billing_dir, "*.jsonl")).sort.reverse
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private def period_start(period)
|
|
175
|
+
now = Time.now
|
|
176
|
+
case period
|
|
177
|
+
when :day
|
|
178
|
+
Time.new(now.year, now.month, now.day)
|
|
179
|
+
when :week
|
|
180
|
+
now - (7 * 24 * 60 * 60)
|
|
181
|
+
when :month
|
|
182
|
+
Time.new(now.year, now.month, 1)
|
|
183
|
+
when :year
|
|
184
|
+
Time.new(now.year, 1, 1)
|
|
185
|
+
when :all
|
|
186
|
+
nil
|
|
187
|
+
else
|
|
188
|
+
Time.new(now.year, now.month, 1)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -101,7 +101,7 @@ module Clacky
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
# Validate and get working directory
|
|
104
|
-
working_dir = validate_working_directory(options[:path])
|
|
104
|
+
working_dir = validate_working_directory(options[:path], agent_config)
|
|
105
105
|
|
|
106
106
|
# Update agent config with CLI options
|
|
107
107
|
agent_config.permission_mode = options[:mode].to_sym if options[:mode]
|
|
@@ -338,25 +338,16 @@ module Clacky
|
|
|
338
338
|
|
|
339
339
|
# ── Brand license check (CLI mode) ──────────────────────────────────────
|
|
340
340
|
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
# not branded -> skip (standard OpenClacky experience)
|
|
346
|
-
# branded, no key -> prompt for license key and activate
|
|
347
|
-
# branded, expired -> warn and continue
|
|
348
|
-
# branded, active -> send heartbeat if interval elapsed (once per day)
|
|
341
|
+
# CLI is a developer-oriented entrypoint: we never block startup with an
|
|
342
|
+
# interactive license prompt. Unactivated installs run in free mode; the
|
|
343
|
+
# WebUI is where end-users activate. This method only surfaces non-blocking
|
|
344
|
+
# warnings (expiry, offline grace period) and dispatches async heartbeats.
|
|
349
345
|
private def check_brand_license_cli
|
|
350
346
|
brand = Clacky::BrandConfig.load
|
|
351
347
|
return unless brand.branded?
|
|
348
|
+
return unless brand.activated?
|
|
352
349
|
|
|
353
|
-
Clacky::Logger.info("[Brand] check_brand_license_cli: activated
|
|
354
|
-
|
|
355
|
-
unless brand.activated?
|
|
356
|
-
Clacky::Logger.info("[Brand] check_brand_license_cli: not activated, prompting user")
|
|
357
|
-
cli_prompt_license_activation(brand)
|
|
358
|
-
return
|
|
359
|
-
end
|
|
350
|
+
Clacky::Logger.info("[Brand] check_brand_license_cli: activated=true expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
|
|
360
351
|
|
|
361
352
|
if brand.expired?
|
|
362
353
|
Clacky::Logger.warn("[Brand] check_brand_license_cli: license expired at #{brand.license_expires_at&.iso8601}")
|
|
@@ -366,11 +357,6 @@ module Clacky
|
|
|
366
357
|
return
|
|
367
358
|
end
|
|
368
359
|
|
|
369
|
-
# Heartbeat is fire-and-forget — startup must never block on the
|
|
370
|
-
# license server. The grace_period_exceeded? check below now keys off
|
|
371
|
-
# license_last_heartbeat_failure (set on a failed heartbeat, cleared
|
|
372
|
-
# on success), so a user who simply hasn't run the app for >3 days
|
|
373
|
-
# no longer sees a false "offline" warning on first launch.
|
|
374
360
|
if brand.heartbeat_due?
|
|
375
361
|
Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
|
|
376
362
|
Thread.new do
|
|
@@ -397,46 +383,16 @@ module Clacky
|
|
|
397
383
|
end
|
|
398
384
|
end
|
|
399
385
|
|
|
400
|
-
|
|
401
|
-
private def cli_prompt_license_activation(brand)
|
|
402
|
-
prompt = TTY::Prompt.new
|
|
403
|
-
|
|
404
|
-
say ""
|
|
405
|
-
say "Welcome to #{brand.product_name}!", :cyan
|
|
406
|
-
say "A license key is required to activate this installation."
|
|
407
|
-
say ""
|
|
408
|
-
|
|
409
|
-
loop do
|
|
410
|
-
key = prompt.ask("Enter your license key (XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX):",
|
|
411
|
-
required: false) { |q| q.modify :strip }
|
|
412
|
-
|
|
413
|
-
if key.nil? || key.empty?
|
|
414
|
-
say "No key entered. You can activate later by re-launching.", :yellow
|
|
415
|
-
say ""
|
|
416
|
-
return
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
unless key.match?(/\A[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}\z/)
|
|
420
|
-
say "Invalid key format. Expected: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX", :red
|
|
421
|
-
next
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
say "Activating..."
|
|
425
|
-
result = brand.activate!(key)
|
|
386
|
+
CLI_DEFAULT_SESSION_NAME = "CLI Session"
|
|
426
387
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
say "(Press Enter to skip activation.)"
|
|
434
|
-
end
|
|
435
|
-
end
|
|
388
|
+
# Format a number with thousand separators for display
|
|
389
|
+
# @param num [Integer, Float] The number to format
|
|
390
|
+
# @return [String] Formatted number string
|
|
391
|
+
private def format_number(num)
|
|
392
|
+
return "0" if num.nil? || num == 0
|
|
393
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
436
394
|
end
|
|
437
395
|
|
|
438
|
-
CLI_DEFAULT_SESSION_NAME = "CLI Session"
|
|
439
|
-
|
|
440
396
|
# Auto-name a CLI session from the first user message, mirroring server-side logic.
|
|
441
397
|
# Renames when the agent has no history yet (i.e. first message of the session).
|
|
442
398
|
private def auto_name_session(agent, input)
|
|
@@ -447,12 +403,14 @@ module Clacky
|
|
|
447
403
|
agent.rename(auto_name)
|
|
448
404
|
end
|
|
449
405
|
|
|
450
|
-
def validate_working_directory(path)
|
|
406
|
+
def validate_working_directory(path, config = nil)
|
|
451
407
|
working_dir = path || Dir.pwd
|
|
452
408
|
|
|
453
|
-
# If no path specified and currently in home directory, use
|
|
409
|
+
# If no path specified and currently in home directory, use configured
|
|
410
|
+
# default_working_dir (or ~/clacky_workspace as fallback)
|
|
454
411
|
if path.nil? && File.expand_path(working_dir) == File.expand_path(Dir.home)
|
|
455
|
-
|
|
412
|
+
default = config&.default_working_dir || File.expand_path("~/clacky_workspace")
|
|
413
|
+
working_dir = File.expand_path(default)
|
|
456
414
|
|
|
457
415
|
# Create directory if it doesn't exist
|
|
458
416
|
unless Dir.exist?(working_dir)
|
|
@@ -931,6 +889,98 @@ module Clacky
|
|
|
931
889
|
|
|
932
890
|
end
|
|
933
891
|
|
|
892
|
+
# ── billing command ────────────────────────────────────────────────────────
|
|
893
|
+
desc "billing", "Show billing summary and usage statistics"
|
|
894
|
+
long_desc <<-LONGDESC
|
|
895
|
+
Display billing summary with token usage and cost breakdown.
|
|
896
|
+
|
|
897
|
+
Period options:
|
|
898
|
+
day - Today's usage
|
|
899
|
+
week - Last 7 days
|
|
900
|
+
month - Current month (default)
|
|
901
|
+
year - Current year
|
|
902
|
+
all - All time
|
|
903
|
+
|
|
904
|
+
Examples:
|
|
905
|
+
$ clacky billing
|
|
906
|
+
$ clacky billing --period week
|
|
907
|
+
$ clacky billing --period all --json
|
|
908
|
+
LONGDESC
|
|
909
|
+
option :period, type: :string, default: "month",
|
|
910
|
+
desc: "Time period: day, week, month, year, all (default: month)"
|
|
911
|
+
option :json, type: :boolean, default: false,
|
|
912
|
+
desc: "Output as JSON"
|
|
913
|
+
option :days, type: :numeric, default: 30,
|
|
914
|
+
desc: "Number of days for daily breakdown (default: 30)"
|
|
915
|
+
option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
|
|
916
|
+
def billing
|
|
917
|
+
if options[:help]
|
|
918
|
+
invoke :help, ["billing"]
|
|
919
|
+
return
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
require_relative "billing/billing_store"
|
|
923
|
+
|
|
924
|
+
store = Clacky::Billing::BillingStore.new
|
|
925
|
+
period = options[:period].to_sym
|
|
926
|
+
summary = store.summary(period: period)
|
|
927
|
+
|
|
928
|
+
if options[:json]
|
|
929
|
+
require "json"
|
|
930
|
+
puts JSON.pretty_generate(summary)
|
|
931
|
+
return
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
# Display formatted billing summary
|
|
935
|
+
puts ""
|
|
936
|
+
puts "📊 Billing Summary (#{period})"
|
|
937
|
+
puts "─" * 50
|
|
938
|
+
puts ""
|
|
939
|
+
|
|
940
|
+
# Total cost
|
|
941
|
+
cost_str = summary[:total_cost] > 0 ? "$#{format('%.4f', summary[:total_cost])}" : "$0.0000"
|
|
942
|
+
puts " 💰 Total Cost: #{cost_str}"
|
|
943
|
+
puts " 📝 Total Tokens: #{format_number(summary[:total_tokens])}"
|
|
944
|
+
puts " 📥 Prompt Tokens: #{format_number(summary[:prompt_tokens])}"
|
|
945
|
+
puts " 📤 Completion: #{format_number(summary[:completion_tokens])}"
|
|
946
|
+
puts " 🗄️ Cache Read: #{format_number(summary[:cache_read_tokens])}"
|
|
947
|
+
puts " 📝 Cache Write: #{format_number(summary[:cache_write_tokens])}"
|
|
948
|
+
puts " 🔢 API Requests: #{summary[:record_count]}"
|
|
949
|
+
puts ""
|
|
950
|
+
|
|
951
|
+
# By model breakdown
|
|
952
|
+
if summary[:by_model] && !summary[:by_model].empty?
|
|
953
|
+
puts "📈 By Model:"
|
|
954
|
+
puts "─" * 50
|
|
955
|
+
summary[:by_model].each do |model, data|
|
|
956
|
+
cost = data.is_a?(Hash) ? data[:cost] : data
|
|
957
|
+
requests = data.is_a?(Hash) ? data[:requests] : "?"
|
|
958
|
+
puts " #{model}"
|
|
959
|
+
puts " Cost: $#{format('%.4f', cost)} | Requests: #{requests}"
|
|
960
|
+
end
|
|
961
|
+
puts ""
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# Daily breakdown (last N days)
|
|
965
|
+
daily = store.daily_breakdown(days: [options[:days], 14].min)
|
|
966
|
+
recent_days = daily.select { |d| d[:cost] > 0 }.last(7)
|
|
967
|
+
|
|
968
|
+
if recent_days.any?
|
|
969
|
+
puts "📅 Recent Daily Usage:"
|
|
970
|
+
puts "─" * 50
|
|
971
|
+
recent_days.each do |day|
|
|
972
|
+
bar_len = [(day[:cost] * 100).to_i, 30].min
|
|
973
|
+
bar = "█" * bar_len
|
|
974
|
+
puts " #{day[:date]} $#{format('%.4f', day[:cost])} #{bar}"
|
|
975
|
+
end
|
|
976
|
+
puts ""
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
puts "─" * 50
|
|
980
|
+
puts " Data stored in: ~/.clacky/billing/"
|
|
981
|
+
puts ""
|
|
982
|
+
end
|
|
983
|
+
|
|
934
984
|
# ── server command ─────────────────────────────────────────────────────────
|
|
935
985
|
desc "server", "Start the Clacky web UI server"
|
|
936
986
|
long_desc <<-LONGDESC
|
|
@@ -1008,8 +1058,8 @@ module Clacky
|
|
|
1008
1058
|
$stdout = Clacky::Server::EPIPESafeIO.new($stdout)
|
|
1009
1059
|
$stderr = Clacky::Server::EPIPESafeIO.new($stderr)
|
|
1010
1060
|
|
|
1011
|
-
fd
|
|
1012
|
-
master_pid
|
|
1061
|
+
fd = ENV["CLACKY_INHERIT_FD"].to_i
|
|
1062
|
+
master_pid = ENV["CLACKY_MASTER_PID"].to_i
|
|
1013
1063
|
# Must use TCPServer.for_fd (not Socket.for_fd) so that accept_nonblock
|
|
1014
1064
|
# returns a single Socket, not [Socket, Addrinfo] — WEBrick expects the former.
|
|
1015
1065
|
socket = TCPServer.for_fd(fd)
|
|
@@ -19,6 +19,25 @@ allowed-tools:
|
|
|
19
19
|
|
|
20
20
|
Configure the browser tool for Clacky. Config is stored at `~/.clacky/browser.yml`.
|
|
21
21
|
|
|
22
|
+
## Region-Aware Download Links
|
|
23
|
+
|
|
24
|
+
Whenever you show the user a link to download or upgrade Chrome/Edge, pick the right one for their region instead of always using google.com.
|
|
25
|
+
|
|
26
|
+
Treat the user as **in China** when any of these is true:
|
|
27
|
+
- The user is talking to you in Chinese
|
|
28
|
+
- The system locale is Chinese (`echo $LANG` contains `zh_CN` / `zh_`)
|
|
29
|
+
- A previous run of `install_browser.sh` reported `Region: china` (visible in its output)
|
|
30
|
+
- `curl -s --max-time 3 https://www.google.com -o /dev/null -w "%{http_code}"` returns `000` while baidu.com works
|
|
31
|
+
|
|
32
|
+
Use these links accordingly:
|
|
33
|
+
|
|
34
|
+
| Region | Chrome | Edge |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| China | https://www.google.cn/chrome/ | https://www.microsoft.com/zh-cn/edge |
|
|
37
|
+
| Global | https://www.google.com/chrome/ | https://www.microsoft.com/edge |
|
|
38
|
+
|
|
39
|
+
When unsure, show **both** lines (label them "China:" and "Global:") so the user can pick.
|
|
40
|
+
|
|
22
41
|
## Command Parsing
|
|
23
42
|
|
|
24
43
|
| User says | Subcommand |
|
|
@@ -197,9 +216,10 @@ Parse the version number:
|
|
|
197
216
|
> ⚠️ Your browser version is v${VERSION}. Version 146+ is recommended for best compatibility.
|
|
198
217
|
> Continuing anyway...
|
|
199
218
|
- **version < 144 or "unknown"** → Stop:
|
|
200
|
-
> ❌ Browser version v${VERSION} is too old. Please upgrade Chrome or Edge to v146
|
|
201
|
-
>
|
|
202
|
-
> -
|
|
219
|
+
> ❌ Browser version v${VERSION} is too old. Please upgrade Chrome or Edge to v146+.
|
|
220
|
+
>
|
|
221
|
+
> Use the download link from the **Region-Aware Download Links** section above
|
|
222
|
+
> (pick `China` or `Global` based on the user's region).
|
|
203
223
|
>
|
|
204
224
|
> After upgrading, run `/browser-setup` again.
|
|
205
225
|
|
|
@@ -363,7 +383,9 @@ Connection
|
|
|
363
383
|
|
|
364
384
|
2. Upgrade your browser:
|
|
365
385
|
- Chrome v142 is too old (need v146+)
|
|
366
|
-
-
|
|
386
|
+
- Pick the download link for the user's region from the
|
|
387
|
+
**Region-Aware Download Links** section at the top of this skill
|
|
388
|
+
(China users → google.cn; others → google.com).
|
|
367
389
|
|
|
368
390
|
After fixing these issues, run `/browser-setup` again to verify.
|
|
369
391
|
```
|