openclacky 1.2.6 → 1.2.8

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +7 -1
  6. data/lib/clacky/agent/message_compressor.rb +2 -1
  7. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  8. data/lib/clacky/agent/session_serializer.rb +23 -4
  9. data/lib/clacky/agent.rb +46 -2
  10. data/lib/clacky/agent_config.rb +54 -6
  11. data/lib/clacky/billing/billing_store.rb +107 -3
  12. data/lib/clacky/brand_config.rb +0 -6
  13. data/lib/clacky/cli.rb +107 -1
  14. data/lib/clacky/client.rb +56 -6
  15. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  17. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  18. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  19. data/lib/clacky/json_ui_controller.rb +5 -2
  20. data/lib/clacky/patch_loader.rb +282 -0
  21. data/lib/clacky/plain_ui_controller.rb +1 -1
  22. data/lib/clacky/providers.rb +11 -2
  23. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +149 -13
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  26. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  27. data/lib/clacky/server/channel.rb +5 -0
  28. data/lib/clacky/server/http_server.rb +135 -14
  29. data/lib/clacky/server/scheduler.rb +1 -4
  30. data/lib/clacky/server/session_registry.rb +30 -4
  31. data/lib/clacky/server/web_ui_controller.rb +6 -3
  32. data/lib/clacky/shell_hook_loader.rb +181 -0
  33. data/lib/clacky/tools/terminal.rb +22 -26
  34. data/lib/clacky/ui2/ui_controller.rb +1 -1
  35. data/lib/clacky/ui_interface.rb +1 -1
  36. data/lib/clacky/version.rb +1 -1
  37. data/lib/clacky/web/app.css +392 -14
  38. data/lib/clacky/web/app.js +0 -1
  39. data/lib/clacky/web/billing.js +117 -22
  40. data/lib/clacky/web/i18n.js +50 -6
  41. data/lib/clacky/web/index.html +33 -0
  42. data/lib/clacky/web/sessions.js +203 -14
  43. data/lib/clacky/web/settings.js +59 -17
  44. data/lib/clacky/web/workspace.js +204 -0
  45. data/lib/clacky/web/ws-dispatcher.js +19 -3
  46. data/lib/clacky.rb +15 -0
  47. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a71c34a2e50f2202679cafd9c31fe7807ea5e9a6a6fad42ba352e19f9ea01282
4
- data.tar.gz: 3baa2b1aed288586bec516576bf8729b630392e4e4b9aeb3b65bca704060b3d1
3
+ metadata.gz: '080944ed788d584c01c97ba01a27a63b2d2ab341ecf88140a63424460dcb105e'
4
+ data.tar.gz: 3c2b51bde81be7c18b3384297609f0163d5e6ed40de7121185a4cc374e576f10
5
5
  SHA512:
6
- metadata.gz: e334e6f5e8726d02933c60d69aed8da4b98f417b1f7d0e0676b3d58bc8a15f9214317a5cde6f3ef8094f69e1fdff8cee6fb822beb076c0eef9e5544770256029
7
- data.tar.gz: 76c8e019789e513382079f942dd7d4036dd68f7b6dac26822e6ec4e0dda410e8d2ad55b1ebaabd58ad388fb2cafa4fa1cb3350fc9a5673fbbb9120893c98ffd6
6
+ metadata.gz: c21e88b443f05ae75979ca4b890142ad15283382ba39fba434a3478f9f3a7f08b13c50fd5760d128662dee26374d69695e6b35e0d25e8f92cdad7351517f564f
7
+ data.tar.gz: fd37b0b3d64bc68ac777c84beef22be2c6f10c260b5e959273b953aae739c1f176bce7dae5c01c738acb332c5eded97eb973250f51576accbce58ee5f9e12139
data/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ 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.8] - 2026-06-01
9
+
10
+ ### Added
11
+ - Extensibility framework: patching, shell hooks, and channel user adapter plugins — customize Clacky behavior without modifying core code
12
+
13
+ ### Improved
14
+ - Billing session list now shows session names, merged deleted sessions, and standardized token breakdown with cache hit/miss color coding
15
+
16
+ ### Fixed
17
+ - Streaming LLM responses automatically retry when connection drops instead of silently truncating
18
+
19
+ ### More
20
+ - Extend openclacky skill with additional extension points
21
+
22
+ ## [1.2.7] - 2026-06-01
23
+
24
+ ### Added
25
+ - Session workspace file explorer — browse and download files in Web UI sidebar
26
+ - Channel manager: new, clear, and skill commands
27
+ - Top-up link in Web UI under billing section
28
+ - Sub-model switching within active sessions
29
+ - Claude 4.8 model support
30
+ - Connection test improvements for custom Claude models
31
+
32
+ ### Fixed
33
+ - Nil error in compress top match parsing
34
+ - Empty 0-token responses causing agent stalls (close #218)
35
+ - Brand config built-in skills loaded without MANIFEST.enc.json (C-5627)
36
+ - Terminal .exe stdin isolation at command-wrap on WSL (close #221)
37
+ - Overly verbose builtin skills installed message during onboarding
38
+
39
+ ### More
40
+ - Add Docker installation section to README
41
+
8
42
  ## [1.2.6] - 2026-05-29
9
43
 
10
44
  ### Added
data/README.md CHANGED
@@ -108,6 +108,40 @@ gem install openclacky
108
108
 
109
109
  see more: https://www.openclacky.com/docs/installation
110
110
 
111
+ ### Docker
112
+
113
+ Build:
114
+
115
+ ```bash
116
+ git clone https://github.com/clacky-ai/openclacky.git
117
+ cd openclacky
118
+ docker build -t openclacky .
119
+ ```
120
+
121
+ **Linux:**
122
+
123
+ ```bash
124
+ docker run -d --network=host -e CLACKY_ACCESS_KEY="" openclacky
125
+ ```
126
+
127
+ `--network=host` is required so the agent inside the container can reach Chrome's remote debugging port running on the host.
128
+
129
+ **macOS / Windows:**
130
+
131
+ ```bash
132
+ docker run -d -p 7070:7070 -e CLACKY_ACCESS_KEY="" openclacky
133
+ ```
134
+
135
+ > **Note:** On macOS/Windows, `--network=host` is not supported — browser automation may be limited.
136
+
137
+ Open **http://localhost:7070** after starting.
138
+
139
+ Environment variables:
140
+
141
+ | Variable | Description |
142
+ |---|---|
143
+ | `CLACKY_ACCESS_KEY` | Protect the Web UI with an access key (empty = public mode) |
144
+
111
145
 
112
146
  ## Quick Start
113
147
 
data/README_CN.md CHANGED
@@ -104,6 +104,40 @@ gem install openclacky
104
104
 
105
105
  详见:https://www.openclacky.com/docs/installation
106
106
 
107
+ ### Docker
108
+
109
+ 构建:
110
+
111
+ ```bash
112
+ git clone https://github.com/clacky-ai/openclacky.git
113
+ cd openclacky
114
+ docker build -t openclacky .
115
+ ```
116
+
117
+ **Linux:**
118
+
119
+ ```bash
120
+ docker run -d --network=host -e CLACKY_ACCESS_KEY="" openclacky
121
+ ```
122
+
123
+ `--network=host` 使容器与宿主机共享网络栈,Agent 可直接访问宿主机上运行的 Chrome 远程调试端口。
124
+
125
+ **macOS / Windows:**
126
+
127
+ ```bash
128
+ docker run -d -p 7070:7070 -e CLACKY_ACCESS_KEY="" openclacky
129
+ ```
130
+
131
+ > **注意:** macOS/Windows 不支持 `--network=host`,浏览器自动化功能可能受限。
132
+
133
+ 启动后访问 **http://localhost:7070**。
134
+
135
+ 环境变量:
136
+
137
+ | 变量 | 说明 |
138
+ |---|---|
139
+ | `CLACKY_ACCESS_KEY` | 设置访问密钥保护 Web UI(留空 = 公开模式) |
140
+
107
141
  ## 快速开始
108
142
 
109
143
  ### 终端(CLI)
@@ -177,7 +177,13 @@ module Clacky
177
177
  else
178
178
  total_tokens - @previous_total_tokens
179
179
  end
180
- @previous_total_tokens = total_tokens # Update for next iteration
180
+
181
+ # Guard: do NOT overwrite @previous_total_tokens with 0 when the upstream
182
+ # returns missing/zero usage (observed when history overflows the model
183
+ # context: response comes back as content="" + finish_reason="stop" +
184
+ # zero usage). Resetting to 0 would disable the compression trigger on
185
+ # subsequent turns and poison the session permanently.
186
+ @previous_total_tokens = total_tokens if total_tokens > 0
181
187
 
182
188
  {
183
189
  delta_tokens: delta_tokens,
@@ -137,7 +137,8 @@ module Clacky
137
137
  # Returns the topics string if found, nil otherwise.
138
138
  # e.g. "<topics>Rails setup, database config</topics>" → "Rails setup, database config"
139
139
  def parse_topics(content)
140
- m = content.match(/<topics>(.*?)<\/topics>/m)
140
+ return nil if content.nil? || content.to_s.empty?
141
+ m = content.to_s.match(/<topics>(.*?)<\/topics>/m)
141
142
  m ? m[1].strip : nil
142
143
  end
143
144
 
@@ -124,8 +124,12 @@ module Clacky
124
124
  # Check if compression is enabled
125
125
  return nil unless @config.enable_compression
126
126
 
127
- # Use actual API-reported tokens from last request
128
- total_tokens = @previous_total_tokens
127
+ # Use the larger of: API-reported tokens from last response, or current
128
+ # estimated history size. The estimate guards the case where a single
129
+ # huge tool result was just appended after the last API call — without
130
+ # this, the next request can ship the bloated history before any
131
+ # compression triggers (issue #218).
132
+ total_tokens = [@previous_total_tokens, @history.estimate_tokens].max
129
133
  message_count = @history.size
130
134
 
131
135
  # Force compression (for idle compression) - use lower threshold
@@ -80,12 +80,28 @@ module Clacky
80
80
  end
81
81
  end
82
82
 
83
+ # Re-apply the per-session sub-model pin (if any). Done AFTER
84
+ # switch_model_by_id so the overlay isn't cleared by the card switch
85
+ # invariant. Validation happens at write-time (the WebUI/API enforces
86
+ # the name belongs to the card's provider) — at restore-time we trust
87
+ # what we previously wrote.
88
+ saved_sub_model = session_data.dig(:config, :sub_model)
89
+ if saved_sub_model && !saved_sub_model.to_s.empty?
90
+ set_session_sub_model(saved_sub_model)
91
+ end
92
+
83
93
  # Rebuild and refresh the system prompt so any newly installed skills
84
94
  # (or other configuration changes since the session was saved) are
85
95
  # reflected immediately — without requiring the user to create a new session.
86
96
  refresh_system_prompt
87
97
  end
88
98
 
99
+ private def persisted_card_field(key)
100
+ card_id = @config.current_model_id
101
+ return nil unless card_id
102
+ @config.models.find { |m| m["id"] == card_id }&.dig(key)
103
+ end
104
+
89
105
  # Generate session data for saving
90
106
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
91
107
  # @param error_message [String] Error message if status is :error
@@ -134,10 +150,13 @@ module Clacky
134
150
  reasoning_effort: @reasoning_effort,
135
151
  # Persist the current model identity so the session can restore its
136
152
  # original model on restart. model_name + model_base_url form a
137
- # composite key to avoid matching a different provider's model of
138
- # the same name. Falls back to default if the model no longer exists.
139
- model_name: @config.current_model&.dig("model"),
140
- model_base_url: @config.current_model&.dig("base_url")
153
+ # composite key that points at the underlying card (NOT the
154
+ # sub-model overlay) overlays are layered on top via :sub_model
155
+ # below so card lookup stays stable when the user toggles
156
+ # sub-models.
157
+ model_name: persisted_card_field("model"),
158
+ model_base_url: persisted_card_field("base_url"),
159
+ sub_model: @config.session_model_overlay_name
141
160
  },
142
161
  stats: stats_data,
143
162
  messages: @history.to_a
data/lib/clacky/agent.rb CHANGED
@@ -137,6 +137,9 @@ module Clacky
137
137
  # Register built-in tools
138
138
  register_builtin_tools
139
139
 
140
+ # Load declarative shell hooks from ~/.clacky/hooks.yml
141
+ ShellHookLoader.load_into(@hooks)
142
+
140
143
  # Ensure user-space parsers are in place (~/.clacky/parsers/)
141
144
  Utils::ParserManager.setup!
142
145
 
@@ -172,6 +175,18 @@ module Clacky
172
175
  true
173
176
  end
174
177
 
178
+ # Pin this session to a sub-model name without changing its underlying
179
+ # card (credentials / base_url stay put). Pass nil or "" to clear and
180
+ # fall back to the card's default model. Validation that the name is
181
+ # listed under the current provider is the caller's job.
182
+ # @param model_name [String, nil]
183
+ # @return [Boolean]
184
+ def set_session_sub_model(model_name)
185
+ @config.session_model_overlay = model_name
186
+ rebuild_client_for_current_model!
187
+ true
188
+ end
189
+
175
190
  # Rebuild the underlying Client (and dependent components) to pick up
176
191
  # credentials/model name from the currently-selected model in @config.
177
192
  private def rebuild_client_for_current_model!
@@ -206,10 +221,16 @@ module Clacky
206
221
  model = @config.current_model
207
222
  return nil unless model
208
223
 
224
+ card_id = @config.current_model_id
225
+ base_entry = card_id ? @config.models.find { |m| m["id"] == card_id } : nil
226
+ sub_model = @config.session_model_overlay_name
227
+
209
228
  {
210
229
  id: model["id"],
211
230
  model: model["model"],
212
- base_url: model["base_url"]
231
+ base_url: model["base_url"],
232
+ card_model: base_entry&.dig("model"),
233
+ sub_model: sub_model
213
234
  }
214
235
  end
215
236
 
@@ -1019,7 +1040,10 @@ module Clacky
1019
1040
  check_stale!
1020
1041
 
1021
1042
  formatted_messages = @client.format_tool_results(response, tool_results, model: current_model)
1022
- formatted_messages.each { |msg| @history.append(msg.merge(task_id: @current_task_id)) }
1043
+ formatted_messages.each do |msg|
1044
+ truncated = truncate_oversized_tool_content(msg)
1045
+ @history.append(truncated.merge(task_id: @current_task_id))
1046
+ end
1023
1047
 
1024
1048
  # Append a follow-up `role:"user"` message for any image payloads that
1025
1049
  # could not be delivered inside the tool message.
@@ -1055,6 +1079,26 @@ module Clacky
1055
1079
  end
1056
1080
  end
1057
1081
 
1082
+ # Cap oversized tool result content to keep a single tool message from
1083
+ # blowing up the prompt budget (issue #218: a 7350-path glob produced a
1084
+ # ~890k-char result that pushed history past the model context window
1085
+ # and poisoned the session). Only string content is truncated — Array
1086
+ # content (multipart/image blocks) is left alone since image payloads
1087
+ # are handled by the image_inject path above.
1088
+ MAX_TOOL_RESULT_CHARS = 80_000
1089
+
1090
+ private def truncate_oversized_tool_content(msg)
1091
+ content = msg[:content]
1092
+ return msg unless content.is_a?(String) && content.length > MAX_TOOL_RESULT_CHARS
1093
+
1094
+ original_len = content.length
1095
+ head = content[0, MAX_TOOL_RESULT_CHARS]
1096
+ truncated = head + "\n\n[Tool result truncated: #{original_len} chars total, " \
1097
+ "showing first #{MAX_TOOL_RESULT_CHARS}. Use a more specific query/limit, " \
1098
+ "or read the raw output via file_reader/grep on the underlying source.]"
1099
+ msg.merge(content: truncated)
1100
+ end
1101
+
1058
1102
  # Enqueue an inline skill injection to be flushed after observe().
1059
1103
  # Called by InvokeSkill#execute to avoid injecting during tool execution,
1060
1104
  # which would break Bedrock's toolUse/toolResult pairing requirement.
@@ -211,6 +211,14 @@ module Clacky
211
211
  # Keys honored: "api_key", "base_url", "model", "anthropic_format".
212
212
  # @return [Hash, nil]
213
213
  @virtual_model_overlay = options[:virtual_model_overlay]
214
+
215
+ # Per-session sub-model override. Persists across restarts via the
216
+ # session file. Independent of @virtual_model_overlay (which is for
217
+ # short-lived subagent forks). Used by the WebUI sub-model switcher
218
+ # to pin a session to e.g. "dsk-deepseek-v4-pro" while the underlying
219
+ # card still says "abs-claude-sonnet-4-6". Only the "model" key is
220
+ # honored — sub-model switching never changes credentials.
221
+ @session_model_overlay = options[:session_model_overlay]
214
222
  end
215
223
 
216
224
  # Load configuration from file
@@ -354,6 +362,9 @@ module Clacky
354
362
  if @virtual_model_overlay
355
363
  copy.instance_variable_set(:@virtual_model_overlay, @virtual_model_overlay.dup)
356
364
  end
365
+ if @session_model_overlay
366
+ copy.instance_variable_set(:@session_model_overlay, @session_model_overlay.dup)
367
+ end
357
368
  copy
358
369
  end
359
370
 
@@ -431,9 +442,12 @@ module Clacky
431
442
  index = @models.find_index { |m| m["id"] == id }
432
443
  return false if index.nil?
433
444
 
445
+ previous_id = @current_model_id
434
446
  @current_model_id = id
435
447
  @current_model_index = index
436
448
 
449
+ @session_model_overlay = nil if previous_id != id
450
+
437
451
  true
438
452
  end
439
453
 
@@ -760,14 +774,18 @@ module Clacky
760
774
  resolved = resolve_current_model_entry
761
775
  return nil unless resolved
762
776
 
763
- # If a virtual overlay is active (e.g. subagent running on lite-model
764
- # credentials), return a *merged copy* so callers see the overlay fields
765
- # but the shared @models hash is never mutated.
777
+ # Merge order (low high): base entry, session-level sub-model override,
778
+ # then short-lived subagent overlay. Both layers are kept separate so
779
+ # a subagent fork can stack its own credentials on top of an active
780
+ # sub-model pin without erasing it.
781
+ merged = resolved
782
+ if @session_model_overlay && !@session_model_overlay.empty?
783
+ merged = merged.merge(@session_model_overlay)
784
+ end
766
785
  if @virtual_model_overlay && !@virtual_model_overlay.empty?
767
- resolved.merge(@virtual_model_overlay)
768
- else
769
- resolved
786
+ merged = merged.merge(@virtual_model_overlay)
770
787
  end
788
+ merged.equal?(resolved) ? resolved : merged
771
789
  end
772
790
 
773
791
  # Internal: resolve the current model entry from @models (no overlay).
@@ -822,6 +840,36 @@ module Clacky
822
840
  @virtual_model_overlay
823
841
  end
824
842
 
843
+ # Apply a session-level sub-model override. Lives on this AgentConfig
844
+ # only (each session deep_copy's its own scalar ivar) and survives a
845
+ # restart through the session file. Pass nil or "" to clear.
846
+ #
847
+ # The override only rewrites the resolved current_model's "model" field —
848
+ # api_key / base_url / anthropic_format come from the underlying card,
849
+ # so the user's credentials and provider identity are untouched.
850
+ #
851
+ # @param model_name [String, nil] sub-model name, e.g. "dsk-deepseek-v4-pro"
852
+ def session_model_overlay=(model_name)
853
+ if model_name.nil? || model_name.to_s.strip.empty?
854
+ @session_model_overlay = nil
855
+ else
856
+ @session_model_overlay = { "model" => model_name.to_s.strip }
857
+ end
858
+ end
859
+
860
+ # @return [Hash, nil] the active session sub-model overlay
861
+ def session_model_overlay
862
+ @session_model_overlay
863
+ end
864
+
865
+ # Convenience accessor: the sub-model name currently pinned on this
866
+ # session, or nil when no override is active. Used by serializers and
867
+ # the WebUI to surface "card · sub-model" two-line displays.
868
+ # @return [String, nil]
869
+ def session_model_overlay_name
870
+ @session_model_overlay && @session_model_overlay["model"]
871
+ end
872
+
825
873
  # Query whether the *current* model supports a given capability.
826
874
  #
827
875
  # This is the single entry-point callers (Agent, downgrade pipeline, UI)
@@ -4,7 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "securerandom"
6
6
  require_relative "billing_record"
7
-
7
+ require_relative "../session_manager"
8
8
  module Clacky
9
9
  module Billing
10
10
  # Persistent storage for billing records using JSONL files
@@ -115,8 +115,112 @@ module Clacky
115
115
  }
116
116
  end
117
117
 
118
- # Get daily cost breakdown for the last N days
119
- # @param days [Integer] Number of days to include
118
+ # Get session-level summary statistics
119
+ # @param period [Symbol] :day, :week, :month, :year, or :all
120
+ # @param model [String, nil] Filter by model name
121
+ # @param limit [Integer] Maximum number of sessions to return
122
+ # @return [Array<Hash>] Session summaries sorted by cost descending
123
+ def session_summary(period: :month, model: nil, limit: 50)
124
+ from_time = period_start(period)
125
+ records = query(from: from_time, model: model)
126
+
127
+ # Load session names from session manager
128
+ session_names = load_session_names
129
+
130
+ # Group by session_id
131
+ by_session = records.group_by { |r| r.session_id || "unknown" }
132
+
133
+ active_sessions = []
134
+ deleted_records = []
135
+
136
+ by_session.each do |session_id, rs|
137
+ total_cost = rs.sum { |r| r.cost_usd || 0 }
138
+ total_prompt = rs.sum { |r| r.prompt_tokens || 0 }
139
+ total_completion = rs.sum { |r| r.completion_tokens || 0 }
140
+ total_cache_read = rs.sum { |r| r.cache_read_tokens || 0 }
141
+ total_cache_write = rs.sum { |r| r.cache_write_tokens || 0 }
142
+ first_record = rs.min_by { |r| r.timestamp }
143
+ last_record = rs.max_by { |r| r.timestamp }
144
+
145
+ entry = {
146
+ session_id: session_id,
147
+ session_name: session_names[session_id],
148
+ total_cost: total_cost.round(6),
149
+ total_tokens: total_prompt + total_completion,
150
+ prompt_tokens: total_prompt,
151
+ completion_tokens: total_completion,
152
+ cache_read_tokens: total_cache_read,
153
+ cache_write_tokens: total_cache_write,
154
+ requests: rs.size,
155
+ first_request: first_record&.timestamp&.iso8601,
156
+ last_request: last_record&.timestamp&.iso8601,
157
+ models: rs.map(&:model).uniq
158
+ }
159
+
160
+ if session_names[session_id]
161
+ active_sessions << entry
162
+ else
163
+ deleted_records << entry
164
+ end
165
+ end
166
+
167
+ # Merge all deleted sessions into a single row
168
+ if deleted_records.any?
169
+ merged = {
170
+ session_id: "_deleted_",
171
+ session_name: nil,
172
+ is_deleted: true,
173
+ total_cost: deleted_records.sum { |r| r[:total_cost] }.round(6),
174
+ total_tokens: deleted_records.sum { |r| r[:total_tokens] },
175
+ prompt_tokens: deleted_records.sum { |r| r[:prompt_tokens] },
176
+ completion_tokens: deleted_records.sum { |r| r[:completion_tokens] },
177
+ cache_read_tokens: deleted_records.sum { |r| r[:cache_read_tokens] },
178
+ cache_write_tokens: deleted_records.sum { |r| r[:cache_write_tokens] },
179
+ requests: deleted_records.sum { |r| r[:requests] },
180
+ first_request: deleted_records.map { |r| r[:first_request] }.compact.min,
181
+ last_request: deleted_records.map { |r| r[:last_request] }.compact.max,
182
+ models: deleted_records.flat_map { |r| r[:models] }.uniq
183
+ }
184
+ active_sessions << merged
185
+ end
186
+
187
+ # Sort by total cost descending
188
+ active_sessions.sort_by! { |s| -s[:total_cost] }
189
+
190
+ # Apply limit
191
+ limit ? active_sessions.first(limit) : active_sessions
192
+ end
193
+
194
+ # Load session names from session manager (including trashed sessions)
195
+ # Returns a hash mapping session_id to session name
196
+ def load_session_names
197
+ names = {}
198
+ begin
199
+ # Load from active sessions
200
+ manager = Clacky::SessionManager.new
201
+ manager.all_sessions.each do |session|
202
+ id = session[:session_id]
203
+ name = session[:name]
204
+ names[id] = name if id && name && !name.to_s.empty?
205
+ end
206
+
207
+ # Also load from trashed sessions
208
+ trash_dir = File.join(Dir.home, ".clacky", "trash", "sessions-trash")
209
+ if Dir.exist?(trash_dir)
210
+ Dir.glob(File.join(trash_dir, "*.json")).each do |filepath|
211
+ session = JSON.parse(File.read(filepath), symbolize_names: true) rescue next
212
+ id = session[:session_id]
213
+ name = session[:name]
214
+ names[id] = name if id && name && !name.to_s.empty?
215
+ end
216
+ end
217
+ rescue => e
218
+ # Silently fail if session manager is not available
219
+ end
220
+ names
221
+ end
222
+
223
+ # Get daily cost breakdown for the last N days # @param days [Integer] Number of days to include
120
224
  # @param model [String, nil] Filter by model name
121
225
  # @return [Array<Hash>] Daily summaries with date and cost
122
226
  def daily_breakdown(days: 30, model: nil)
@@ -783,12 +783,6 @@ module Clacky
783
783
 
784
784
  FileUtils.rm_f(tmp_zip)
785
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
-
792
786
  record_installed_skill(slug, version, skill_info["description"],
793
787
  encrypted: encrypted,
794
788
  description_zh: skill_info["description_zh"],
data/lib/clacky/cli.rb CHANGED
@@ -559,7 +559,8 @@ module Clacky
559
559
  else
560
560
  error_message = format_error(exception)
561
561
  session_manager&.save(agent.to_session_data(status: :error, error_message: error_message))
562
- ui_controller.show_error("Error: #{exception.message}")
562
+ code = exception.is_a?(Clacky::InsufficientCreditError) ? exception.error_code : nil
563
+ ui_controller.show_error("Error: #{exception.message}", code: code)
563
564
  end
564
565
  end
565
566
 
@@ -941,6 +942,111 @@ module Clacky
941
942
  end
942
943
 
943
944
  # ── billing command ────────────────────────────────────────────────────────
945
+ desc "patch_new ID TARGET", "Scaffold a runtime patch for a method (TARGET like Clacky::Tools::WebSearch#execute)"
946
+ long_desc <<-LONGDESC
947
+ Generate a method-override patch under ~/.clacky/patches/ID/. The current
948
+ method fingerprint is computed automatically and stored in meta.yml; you
949
+ only edit the method body in patch.rb. If a future gem version changes the
950
+ targeted method, the fingerprint will no longer match and the patch is
951
+ auto-disabled on next start (rather than applied and risking breakage).
952
+
953
+ Examples:
954
+ $ clacky patch_new fix-search Clacky::Tools::WebSearch#execute -d "bump timeout"
955
+ LONGDESC
956
+ option :desc, type: :string, aliases: "-d", default: "", desc: "Short description"
957
+ def patch_new(id, target)
958
+ require_relative "patch_loader"
959
+ path = Clacky::PatchLoader.scaffold(id, target, description: options[:desc])
960
+ puts "Created patch: #{path}"
961
+ puts "Edit patch.rb, then run: clacky patch_verify"
962
+ rescue ArgumentError, StandardError => e
963
+ warn "Error: #{e.message}"
964
+ exit 1
965
+ end
966
+
967
+ desc "patch_verify", "Load ~/.clacky/patches/ and report applied / disabled / skipped"
968
+ def patch_verify
969
+ require "clacky"
970
+ result = Clacky::PatchLoader.last_result
971
+
972
+ if result.applied.empty? && result.disabled.empty? && result.skipped.empty?
973
+ puts "No patches found in ~/.clacky/patches/"
974
+ return
975
+ end
976
+
977
+ result.applied.each { |id| puts "[OK] #{id}" }
978
+ result.disabled.each { |(id, reason)| puts "[DISABLED] #{id} — #{reason}" }
979
+ result.skipped.each { |(id, reason)| puts "[SKIP] #{id} — #{reason}" }
980
+ exit 1 if result.skipped.any?
981
+ end
982
+
983
+ desc "patch_list", "List patches under ~/.clacky/patches/ and their status"
984
+ def patch_list
985
+ invoke :patch_verify, []
986
+ end
987
+
988
+ desc "hook_new", "Scaffold a starter ~/.clacky/hooks.yml with an example guard script"
989
+ def hook_new
990
+ require_relative "shell_hook_loader"
991
+ path = Clacky::ShellHookLoader.scaffold
992
+ puts "Created hooks config: #{path}"
993
+ puts "Edit it, then run: clacky hook_verify"
994
+ rescue ArgumentError => e
995
+ warn "Error: #{e.message}"
996
+ exit 1
997
+ end
998
+
999
+ desc "hook_verify", "Load ~/.clacky/hooks.yml and report which hooks register"
1000
+ def hook_verify
1001
+ require_relative "agent/hook_manager"
1002
+ require_relative "shell_hook_loader"
1003
+ hm = Clacky::HookManager.new
1004
+ result = Clacky::ShellHookLoader.load_into(hm)
1005
+
1006
+ if result.registered.empty? && result.skipped.empty?
1007
+ puts "No hooks found in ~/.clacky/hooks.yml"
1008
+ return
1009
+ end
1010
+
1011
+ result.registered.each { |(event, name)| puts "[OK] #{event} → #{name}" }
1012
+ result.skipped.each { |(name, reason)| puts "[SKIP] #{name} — #{reason}" }
1013
+ exit 1 if result.skipped.any?
1014
+ end
1015
+
1016
+ desc "channel_new NAME", "Scaffold a custom channel adapter at ~/.clacky/channels/NAME/"
1017
+ long_desc <<-LONGDESC
1018
+ Generate a ready-to-edit channel adapter skeleton. The skeleton already
1019
+ self-registers and implements the full adapter interface with TODO markers —
1020
+ you only fill in the method bodies, then run `clacky channel_verify`.
1021
+
1022
+ Examples:
1023
+ $ clacky channel_new slack
1024
+ LONGDESC
1025
+ def channel_new(name)
1026
+ require_relative "server/channel"
1027
+ path = Clacky::Channel::Adapters::UserAdapterLoader.scaffold(name)
1028
+ puts "Created channel adapter: #{path}"
1029
+ puts "Edit the TODO sections, then run: clacky channel_verify"
1030
+ rescue ArgumentError => e
1031
+ warn "Error: #{e.message}"
1032
+ exit 1
1033
+ end
1034
+
1035
+ desc "channel_verify", "Load user channel adapters and report which are valid"
1036
+ def channel_verify
1037
+ require_relative "server/channel"
1038
+ result = Clacky::Channel::Adapters::UserAdapterLoader.last_result
1039
+
1040
+ if result.loaded.empty? && result.skipped.empty?
1041
+ puts "No custom channel adapters found in ~/.clacky/channels/"
1042
+ return
1043
+ end
1044
+
1045
+ result.loaded.each { |n| puts "[OK] #{n}" }
1046
+ result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
1047
+ exit 1 if result.skipped.any?
1048
+ end
1049
+
944
1050
  desc "billing", "Show billing summary and usage statistics"
945
1051
  long_desc <<-LONGDESC
946
1052
  Display billing summary with token usage and cost breakdown.