openclacky 1.1.6 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. 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]
@@ -385,6 +385,14 @@ module Clacky
385
385
 
386
386
  CLI_DEFAULT_SESSION_NAME = "CLI Session"
387
387
 
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
394
+ end
395
+
388
396
  # Auto-name a CLI session from the first user message, mirroring server-side logic.
389
397
  # Renames when the agent has no history yet (i.e. first message of the session).
390
398
  private def auto_name_session(agent, input)
@@ -395,12 +403,14 @@ module Clacky
395
403
  agent.rename(auto_name)
396
404
  end
397
405
 
398
- def validate_working_directory(path)
406
+ def validate_working_directory(path, config = nil)
399
407
  working_dir = path || Dir.pwd
400
408
 
401
- # If no path specified and currently in home directory, use ~/clacky_workspace
409
+ # If no path specified and currently in home directory, use configured
410
+ # default_working_dir (or ~/clacky_workspace as fallback)
402
411
  if path.nil? && File.expand_path(working_dir) == File.expand_path(Dir.home)
403
- working_dir = File.expand_path("~/clacky_workspace")
412
+ default = config&.default_working_dir || File.expand_path("~/clacky_workspace")
413
+ working_dir = File.expand_path(default)
404
414
 
405
415
  # Create directory if it doesn't exist
406
416
  unless Dir.exist?(working_dir)
@@ -879,6 +889,98 @@ module Clacky
879
889
 
880
890
  end
881
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
+
882
984
  # ── server command ─────────────────────────────────────────────────────────
883
985
  desc "server", "Start the Clacky web UI server"
884
986
  long_desc <<-LONGDESC
@@ -956,8 +1058,8 @@ module Clacky
956
1058
  $stdout = Clacky::Server::EPIPESafeIO.new($stdout)
957
1059
  $stderr = Clacky::Server::EPIPESafeIO.new($stderr)
958
1060
 
959
- fd = ENV["CLACKY_INHERIT_FD"].to_i
960
- master_pid = ENV["CLACKY_MASTER_PID"].to_i
1061
+ fd = ENV["CLACKY_INHERIT_FD"].to_i
1062
+ master_pid = ENV["CLACKY_MASTER_PID"].to_i
961
1063
  # Must use TCPServer.for_fd (not Socket.for_fd) so that accept_nonblock
962
1064
  # returns a single Socket, not [Socket, Addrinfo] — WEBrick expects the former.
963
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+ from:
201
- > - Chrome: https://www.google.com/chrome/
202
- > - Edge: https://www.microsoft.com/edge
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
- - Download latest version: https://www.google.com/chrome/
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
  ```