openclacky 0.9.16 → 0.9.18

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/lib/clacky/agent/cost_tracker.rb +0 -1
  4. data/lib/clacky/agent/hook_manager.rb +0 -1
  5. data/lib/clacky/agent/message_compressor.rb +0 -1
  6. data/lib/clacky/agent/message_compressor_helper.rb +0 -1
  7. data/lib/clacky/agent/session_serializer.rb +0 -1
  8. data/lib/clacky/agent/skill_manager.rb +0 -1
  9. data/lib/clacky/brand_config.rb +0 -1
  10. data/lib/clacky/client.rb +0 -1
  11. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +0 -2
  12. data/lib/clacky/plain_ui_controller.rb +0 -1
  13. data/lib/clacky/providers.rb +10 -5
  14. data/lib/clacky/server/browser_manager.rb +0 -1
  15. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +0 -1
  16. data/lib/clacky/server/channel/adapters/feishu/bot.rb +0 -1
  17. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +0 -1
  18. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +27 -6
  19. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +0 -1
  20. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +31 -6
  21. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +0 -1
  22. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +0 -1
  23. data/lib/clacky/server/channel/channel_manager.rb +0 -1
  24. data/lib/clacky/server/channel/channel_ui_controller.rb +0 -1
  25. data/lib/clacky/server/http_server.rb +2 -2
  26. data/lib/clacky/server/server_master.rb +0 -1
  27. data/lib/clacky/server/session_registry.rb +0 -1
  28. data/lib/clacky/server/web_ui_controller.rb +0 -1
  29. data/lib/clacky/session_manager.rb +0 -1
  30. data/lib/clacky/skill.rb +0 -1
  31. data/lib/clacky/skill_loader.rb +4 -1
  32. data/lib/clacky/tools/browser.rb +44 -6
  33. data/lib/clacky/tools/file_reader.rb +0 -1
  34. data/lib/clacky/tools/grep.rb +0 -1
  35. data/lib/clacky/tools/run_project.rb +0 -1
  36. data/lib/clacky/tools/todo_manager.rb +0 -1
  37. data/lib/clacky/ui2/components/command_suggestions.rb +0 -1
  38. data/lib/clacky/ui2/components/inline_input.rb +0 -1
  39. data/lib/clacky/ui2/components/input_area.rb +0 -1
  40. data/lib/clacky/ui2/components/message_component.rb +0 -1
  41. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  42. data/lib/clacky/ui2/components/todo_area.rb +0 -1
  43. data/lib/clacky/ui2/components/tool_component.rb +0 -1
  44. data/lib/clacky/ui2/components/welcome_banner.rb +0 -1
  45. data/lib/clacky/ui2/markdown_renderer.rb +0 -1
  46. data/lib/clacky/ui2/progress_indicator.rb +0 -1
  47. data/lib/clacky/ui2/screen_buffer.rb +0 -1
  48. data/lib/clacky/ui2/theme_manager.rb +0 -1
  49. data/lib/clacky/ui2/themes/base_theme.rb +0 -1
  50. data/lib/clacky/ui2/ui_controller.rb +0 -1
  51. data/lib/clacky/utils/arguments_parser.rb +0 -1
  52. data/lib/clacky/utils/gitignore_parser.rb +0 -1
  53. data/lib/clacky/utils/limit_stack.rb +0 -1
  54. data/lib/clacky/utils/model_pricing.rb +0 -1
  55. data/lib/clacky/version.rb +1 -1
  56. data/lib/clacky/web/i18n.js +4 -0
  57. data/lib/clacky/web/index.html +4 -1
  58. data/lib/clacky/web/onboard.js +11 -0
  59. data/lib/clacky/web/settings.js +14 -1
  60. data/scripts/install.ps1 +81 -34
  61. data/scripts/install.sh +215 -494
  62. data/scripts/install_full.sh +891 -0
  63. data/scripts/install_simple.sh +3 -2
  64. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e4f19a4e5cd22ab1ecc81bfb9c62db1b458a9823699ce853d6094a24b4d3496
4
- data.tar.gz: b0916deb0e5cce5d68f2454ddf35e267409869c19c26e7c203adfd8215aeabe4
3
+ metadata.gz: 538f6014a8386fcddf69dbf69efd162a00ad594d776b812eba5ac04f98953995
4
+ data.tar.gz: 4d9952dbbf2e20d1a598b136a0f01e821a1e53391240c197afe60e3570d4bfce
5
5
  SHA512:
6
- metadata.gz: fe1aeaeac77dddfbb06bfc702c4d09adbffb338b0815481902b3490ab8610448f38ec0921b8e7a6885154b722a89528f911995bb473025339890554399611094
7
- data.tar.gz: ca2505ac215e2cf9430b2b5c7e9629f581108daa6798d6a379e61e8446c199def9537813218b835304c3bf412d8df452767e78fff1a39a05b7d125c93ec0a8f3
6
+ metadata.gz: 8ed52fb94f805bc83c8ab996c9a1f01635665b054750900623002edc29a0197c9ab99be08d192e2f1f1040c8665451e155d9878fec5a71c213d7c3130fbbb956
7
+ data.tar.gz: 248569c522a59c37c3bda8342141be750609ca4fca996a8d563fcb953e6dcb1446fbc379082691b5503679d26d9168aa8e2503f7b169b3b4daeafa9428b7cf5b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.18] - 2026-03-28
11
+
12
+ ### Fixed
13
+ - **Brand skill config now reloads from disk on every `load_all`**: brand skills installed or activated after the initial startup were previously invisible until restart — the skill loader now refreshes `BrandConfig` each time it loads skills, so newly installed brand skills take effect immediately
14
+
15
+ ### More
16
+ - Remove `private` keyword from all internal classes to improve Ruby 2.6 compatibility
17
+ - Rename `install.sh` → `install_full.sh`; promote `install_simple.sh` → `install.sh` as the default entry point
18
+
19
+ ## [0.9.17] - 2026-03-27
20
+
21
+ ### Added
22
+ - **Browser screenshots now saved to disk**: every screenshot action automatically saves both the original full-resolution PNG and the compressed (800px) version to disk — the agent reports both file paths so you can reference, open, or pass the screenshots to other tools
23
+ - **Provider "Get API Key" links in onboarding**: the setup wizard now shows a direct link to the provider's website when you select a provider that has a `website_url` — making it easier to sign up and get your API key without leaving the flow
24
+
25
+ ### Fixed
26
+ - **WebSocket auto-reconnect for Feishu and WeCom channels**: the WebSocket clients for Feishu and WeCom now automatically retry the connection after failures — channels stay online without manual intervention after a network hiccup
27
+ - **Brand command in simple install script**: the `clacky` brand command was incorrectly invoked in `install_simple.sh` — now fixed so the post-install branding step runs correctly
28
+ - **Windows WSL2 and Hyper-V detection in PowerShell installer**: improved detection logic for WSL2 and Hyper-V environments in `install.ps1`, reducing false negatives on Windows machines with non-standard configurations
29
+
10
30
  ## [0.9.16] - 2026-03-27
11
31
 
12
32
  ### Fixed
@@ -84,7 +84,6 @@ module Clacky
84
84
  # Simple approximation: characters / 4 (English text)
85
85
 
86
86
 
87
- private
88
87
 
89
88
  # Collect token usage data for current iteration and return it.
90
89
  # Does NOT call @ui directly — the caller is responsible for displaying
@@ -51,7 +51,6 @@ module Clacky
51
51
  end
52
52
  end
53
53
 
54
- private
55
54
 
56
55
  def validate_event!(event)
57
56
  return if HOOK_EVENTS.include?(event)
@@ -108,7 +108,6 @@ module Clacky
108
108
  [system_msg, *parsed_messages, *safe_recent].compact
109
109
  end
110
110
 
111
- private
112
111
 
113
112
  def parse_compressed_result(result, chunk_path: nil)
114
113
  # Return the compressed result as a single assistant message
@@ -214,7 +214,6 @@ module Clacky
214
214
  end
215
215
  end
216
216
 
217
- private
218
217
 
219
218
  # Returns true if msg is a tool result, regardless of storage format.
220
219
  # Canonical: role:"tool" | Legacy Anthropic-native: role:"user" + tool_result blocks
@@ -193,7 +193,6 @@ module Clacky
193
193
  { has_more: has_more }
194
194
  end
195
195
 
196
- private
197
196
 
198
197
  # Render a single non-user message into the UI.
199
198
  # Used by both the normal round-based replay and the compressed-session fallback.
@@ -258,7 +258,6 @@ module Clacky
258
258
  @ui&.show_info("Injected skill content for /#{skill.identifier}")
259
259
  end
260
260
 
261
- private
262
261
 
263
262
  # Find skills whose identifiers are similar to the given name.
264
263
  # Uses substring matching first, then character overlap as a fallback.
@@ -862,7 +862,6 @@ module Clacky
862
862
  }
863
863
  end
864
864
 
865
- private
866
865
 
867
866
  def to_yaml
868
867
  data = {}
data/lib/clacky/client.rb CHANGED
@@ -121,7 +121,6 @@ module Clacky
121
121
  model_str.match?(/claude(?:-3[-.]?[5-9]|-[4-9]|-sonnet-[34])/)
122
122
  end
123
123
 
124
- private
125
124
 
126
125
  # ── Bedrock Converse request / response ───────────────────────────────────
127
126
 
@@ -104,7 +104,6 @@ class ToolClient
104
104
  raise "ToolClient connection failed: #{e.message}"
105
105
  end
106
106
 
107
- private
108
107
 
109
108
  def http
110
109
  return @http if @http
@@ -286,7 +285,6 @@ class FeishuApiClient
286
285
  get_json("#{FEISHU_API_BASE}/app/#{app_id}")
287
286
  end
288
287
 
289
- private
290
288
 
291
289
  # Execute a GET fetch in the browser page context.
292
290
  # Uses window.csrfToken — required by all /developers/v1/ endpoints.
@@ -135,7 +135,6 @@ module Clacky
135
135
  def set_input_tips(message, type: :info); end
136
136
  def stop; end
137
137
 
138
- private
139
138
 
140
139
  def puts_line(text)
141
140
  @mutex.synchronize do
@@ -17,7 +17,8 @@ module Clacky
17
17
  "base_url" => "https://openrouter.ai/api/v1",
18
18
  "api" => "openai-responses",
19
19
  "default_model" => "anthropic/claude-sonnet-4-6",
20
- "models" => [] # Dynamic - fetched from API
20
+ "models" => [], # Dynamic - fetched from API
21
+ "website_url" => "https://openrouter.ai/keys"
21
22
  }.freeze,
22
23
 
23
24
  "minimax" => {
@@ -25,7 +26,8 @@ module Clacky
25
26
  "base_url" => "https://api.minimaxi.com/v1",
26
27
  "api" => "openai-completions",
27
28
  "default_model" => "MiniMax-M2.7",
28
- "models" => ["MiniMax-M2.5", "MiniMax-M2.7"]
29
+ "models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
30
+ "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
29
31
  }.freeze,
30
32
 
31
33
  "kimi" => {
@@ -33,7 +35,8 @@ module Clacky
33
35
  "base_url" => "https://api.moonshot.cn/v1",
34
36
  "api" => "openai-completions",
35
37
  "default_model" => "kimi-k2.5",
36
- "models" => ["kimi-k2.5"]
38
+ "models" => ["kimi-k2.5"],
39
+ "website_url" => "https://platform.moonshot.cn/console/api-keys"
37
40
  }.freeze,
38
41
 
39
42
  "anthropic" => {
@@ -41,7 +44,8 @@ module Clacky
41
44
  "base_url" => "https://api.anthropic.com",
42
45
  "api" => "anthropic-messages",
43
46
  "default_model" => "claude-sonnet-4.6",
44
- "models" => ["claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"]
47
+ "models" => ["claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
48
+ "website_url" => "https://console.anthropic.com/settings/keys"
45
49
  }.freeze,
46
50
 
47
51
  "bedrock-jp" => {
@@ -52,7 +56,8 @@ module Clacky
52
56
  "models" => [
53
57
  "jp.anthropic.claude-sonnet-4-6",
54
58
  "jp.anthropic.claude-haiku-4-6"
55
- ]
59
+ ],
60
+ "website_url" => "https://console.aws.amazon.com/iam/home#/security_credentials"
56
61
  }.freeze
57
62
 
58
63
  }.freeze
@@ -190,7 +190,6 @@ module Clacky
190
190
  # ---------------------------------------------------------------------------
191
191
  # Private
192
192
  # ---------------------------------------------------------------------------
193
- private
194
193
 
195
194
  def load_config
196
195
  return {} unless File.exist?(BROWSER_CONFIG_PATH)
@@ -143,7 +143,6 @@ module Clacky
143
143
  errors
144
144
  end
145
145
 
146
- private
147
146
 
148
147
  # Handle incoming WebSocket event
149
148
  # @param raw_event [Hash] Raw event data
@@ -160,7 +160,6 @@ module Clacky
160
160
  end
161
161
  end
162
162
 
163
- private
164
163
 
165
164
  # Build message content and type based on text content.
166
165
  # Uses interactive card (schema 2.0) for code blocks and tables,
@@ -42,7 +42,6 @@ module Clacky
42
42
  end
43
43
  end
44
44
 
45
- private
46
45
 
47
46
  # Parse message.receive event
48
47
  # @return [Hash, nil]
@@ -51,7 +51,11 @@ module Clacky
51
51
  @ws_socket&.close rescue nil
52
52
  end
53
53
 
54
- private
54
+
55
+ # Timeout for IO.select on the read loop. Feishu server sends pings every
56
+ # @ping_interval seconds (default 90s). Allow two missed pings before
57
+ # treating the connection as dead.
58
+ READ_TIMEOUT_MULTIPLIER = 2.5
55
59
 
56
60
  def connect_and_listen
57
61
  Clacky::Logger.info("[feishu-ws] Fetching WebSocket endpoint...")
@@ -92,9 +96,22 @@ module Clacky
92
96
 
93
97
  start_ping_thread
94
98
 
99
+ # read_timeout is based on the server-provided ping interval so it
100
+ # automatically adapts if Feishu changes the cadence.
101
+ read_timeout = (@ping_interval * READ_TIMEOUT_MULTIPLIER).ceil
102
+
95
103
  loop do
96
104
  break unless @running
97
- data = socket.readpartial(4096)
105
+
106
+ # Use IO.select with a timeout to detect silent connection drops
107
+ # (NAT expiry, firewall idle-kill) that never send a TCP FIN/RST.
108
+ ready = IO.select([socket], nil, nil, read_timeout)
109
+ unless ready
110
+ Clacky::Logger.warn("[feishu-ws] read timeout (#{read_timeout}s), reconnecting...")
111
+ return
112
+ end
113
+
114
+ data = socket.read_nonblock(4096)
98
115
  @incoming << data
99
116
  while (frame = @incoming.next)
100
117
  case frame.type
@@ -106,13 +123,14 @@ module Clacky
106
123
  when :ping
107
124
  send_raw_frame(:pong, frame.data)
108
125
  when :close
109
- Clacky::Logger.info("[feishu-ws] WebSocket closed, will reconnect")
126
+ Clacky::Logger.info("[feishu-ws] WebSocket closed by server, will reconnect")
110
127
  return
111
128
  end
112
129
  end
113
130
  end
114
- rescue EOFError, Errno::ECONNRESET
115
- Clacky::Logger.warn("[feishu-ws] Connection lost, reconnecting in #{RECONNECT_DELAY}s...")
131
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
132
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
133
+ Clacky::Logger.warn("[feishu-ws] Connection lost (#{e.class}: #{e.message}), reconnecting in #{RECONNECT_DELAY}s...")
116
134
  ensure
117
135
  @ws_open = false
118
136
  @ws_socket = nil
@@ -254,7 +272,10 @@ module Clacky
254
272
  headers: { "type" => "ping" }
255
273
  )
256
274
  rescue => e
257
- warn "[feishu-ws] ping failed: #{e.message}"
275
+ Clacky::Logger.warn("[feishu-ws] ping failed (#{e.class}: #{e.message}), forcing reconnect")
276
+ # Close the socket so IO.select in the read loop immediately
277
+ # returns nil / read_nonblock raises IOError, triggering reconnect.
278
+ @ws_socket&.close rescue nil
258
279
  break
259
280
  end
260
281
  end
@@ -74,7 +74,6 @@ module Clacky
74
74
  errors
75
75
  end
76
76
 
77
- private
78
77
 
79
78
  def handle_raw_message(raw)
80
79
  msgtype = raw["msgtype"]
@@ -115,7 +115,13 @@ module Clacky
115
115
  raise
116
116
  end
117
117
 
118
- private
118
+
119
+ # Timeout for IO.select on the read loop. If no data arrives within this
120
+ # window we treat the connection as dead and reconnect. This catches the
121
+ # silent-drop case where the TCP stack never delivers a FIN/RST (e.g.
122
+ # NAT timeout, firewall idle-kill). The WeCom server sends pings every
123
+ # ~30 s, so 75 s gives two missed pings before we give up.
124
+ READ_TIMEOUT_S = 75
119
125
 
120
126
  def connect_and_listen
121
127
  uri = URI.parse(@ws_url)
@@ -151,7 +157,17 @@ module Clacky
151
157
 
152
158
  loop do
153
159
  break unless @running
154
- data = ssl.readpartial(4096)
160
+
161
+ # Use IO.select with a timeout so we detect silent connection drops
162
+ # (e.g. NAT expiry) that never deliver a TCP FIN/RST. Without this,
163
+ # readpartial blocks forever and the thread hangs permanently.
164
+ ready = IO.select([ssl], nil, nil, READ_TIMEOUT_S)
165
+ unless ready
166
+ Clacky::Logger.warn("[WecomWSClient] read timeout (#{READ_TIMEOUT_S}s), reconnecting...")
167
+ return
168
+ end
169
+
170
+ data = ssl.read_nonblock(4096)
155
171
  @incoming << data
156
172
  while (frame = @incoming.next)
157
173
  case frame.type
@@ -160,13 +176,14 @@ module Clacky
160
176
  when :ping
161
177
  send_raw_frame(:pong, frame.data)
162
178
  when :close
163
- Clacky::Logger.info("[WecomWSClient] connection closed")
179
+ Clacky::Logger.info("[WecomWSClient] connection closed by server")
164
180
  return
165
181
  end
166
182
  end
167
183
  end
168
- rescue EOFError, Errno::ECONNRESET
169
- Clacky::Logger.info("[WecomWSClient] connection lost, reconnecting...")
184
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
185
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
186
+ Clacky::Logger.info("[WecomWSClient] connection lost (#{e.class}: #{e.message}), reconnecting...")
170
187
  ensure
171
188
  @ws_open = false
172
189
  @ws_socket = nil
@@ -257,7 +274,15 @@ module Clacky
257
274
  loop do
258
275
  sleep HEARTBEAT_INTERVAL
259
276
  break unless @running
260
- send_frame(cmd: "ping", req_id: generate_req_id("ping"))
277
+ begin
278
+ send_frame(cmd: "ping", req_id: generate_req_id("ping"))
279
+ rescue => e
280
+ Clacky::Logger.warn("[WecomWSClient] ping failed (#{e.class}: #{e.message}), forcing reconnect")
281
+ # Close the socket so IO.select in the read loop immediately
282
+ # returns nil / read_nonblock raises IOError, triggering reconnect.
283
+ @ws_socket&.close rescue nil
284
+ break
285
+ end
261
286
  end
262
287
  end
263
288
  end
@@ -177,7 +177,6 @@ module Clacky
177
177
  false
178
178
  end
179
179
 
180
- private
181
180
 
182
181
  def process_message(msg)
183
182
  # Only process inbound USER messages (message_type 1 = USER)
@@ -173,7 +173,6 @@ module Clacky
173
173
  aes_ecb_decrypt(encrypted_bytes, raw_aes_key)
174
174
  end
175
175
 
176
- private
177
176
 
178
177
  # Full upload pipeline: encrypt → getuploadurl → CDN PUT → return CDNMedia hash.
179
178
  def upload_media(raw_bytes:, file_name:, media_type:, to_user_id:)
@@ -96,7 +96,6 @@ module Clacky
96
96
  end
97
97
  end
98
98
 
99
- private
100
99
 
101
100
  def start_adapter(platform)
102
101
  klass = Adapters.find(platform)
@@ -166,7 +166,6 @@ module Clacky
166
166
  def set_input_tips(message, type: :info); end
167
167
  def stop; end
168
168
 
169
- private
170
169
 
171
170
  def send_text(text)
172
171
  text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip
@@ -299,7 +299,6 @@ module Clacky
299
299
  server.start
300
300
  end
301
301
 
302
- private
303
302
 
304
303
  # ── Router ────────────────────────────────────────────────────────────────
305
304
 
@@ -1528,7 +1527,8 @@ module Clacky
1528
1527
  name: preset["name"],
1529
1528
  base_url: preset["base_url"],
1530
1529
  default_model: preset["default_model"],
1531
- models: preset["models"] || []
1530
+ models: preset["models"] || [],
1531
+ website_url: preset["website_url"]
1532
1532
  }
1533
1533
  end
1534
1534
  json_response(res, 200, { providers: providers })
@@ -106,7 +106,6 @@ module Clacky
106
106
  remove_pid_file
107
107
  end
108
108
 
109
- private
110
109
 
111
110
  # Spawn a fresh Ruby process that loads the (possibly updated) gem from disk.
112
111
  # The listen socket is inherited via its file descriptor number.
@@ -187,7 +187,6 @@ module Clacky
187
187
  end
188
188
  end
189
189
 
190
- private
191
190
 
192
191
  # Normalize source field from a disk session hash.
193
192
  # "system" is a legacy value renamed to "setup" — treat them as equivalent.
@@ -288,7 +288,6 @@ module Clacky
288
288
  emit("server_stop")
289
289
  end
290
290
 
291
- private
292
291
 
293
292
  # Generate a short human-readable summary for a tool call display.
294
293
  # Delegates to each tool's own format_call method when available.
@@ -108,7 +108,6 @@ module Clacky
108
108
  end.size
109
109
  end
110
110
 
111
- private
112
111
 
113
112
  def ensure_sessions_dir
114
113
  FileUtils.mkdir_p(@sessions_dir) unless Dir.exist?(@sessions_dir)
data/lib/clacky/skill.rb CHANGED
@@ -327,7 +327,6 @@ module Clacky
327
327
  end
328
328
  end
329
329
 
330
- private
331
330
 
332
331
  def load_skill
333
332
  if @brand_skill
@@ -42,6 +42,10 @@ module Clacky
42
42
  # Clears previously loaded skills before loading to ensure idempotency
43
43
  # @return [Array<Skill>] Loaded skills
44
44
  def load_all
45
+ # Always refresh brand_config from disk so newly installed/activated brand
46
+ # skills are visible even if this SkillLoader was created before the change.
47
+ @brand_config = Clacky::BrandConfig.load
48
+
45
49
  # Clear existing skills to ensure idempotent reloading
46
50
  clear
47
51
 
@@ -297,7 +301,6 @@ module Clacky
297
301
  true
298
302
  end
299
303
 
300
- private
301
304
 
302
305
  def load_skills_from_directory(dir, source_type)
303
306
  return [] unless dir.exist?
@@ -6,6 +6,9 @@ require "timeout"
6
6
  require "tmpdir"
7
7
  require "shellwords"
8
8
  require "yaml"
9
+ require "base64"
10
+ require "fileutils"
11
+ require "securerandom"
9
12
  require_relative "base"
10
13
 
11
14
  module Clacky
@@ -159,11 +162,20 @@ module Clacky
159
162
  action = result[:action].to_s
160
163
 
161
164
  if action == "screenshot" && result[:image_data]
162
- mime_type = result[:mime_type] || "image/jpeg"
163
- image_data = result[:image_data]
164
- data_url = "data:#{mime_type};base64,#{image_data}"
165
+ mime_type = result[:mime_type] || "image/png"
166
+ image_data = result[:image_data]
167
+ data_url = "data:#{mime_type};base64,#{image_data}"
168
+ original_path = result[:original_path]
169
+ compressed_path = result[:compressed_path]
170
+
171
+ text = "Screenshot captured."
172
+ if original_path || compressed_path
173
+ text += "\n- Original (full resolution): #{original_path || 'unavailable'}" \
174
+ "\n- Compressed (800px, sent to AI): #{compressed_path || 'unavailable'}"
175
+ end
176
+
165
177
  return [
166
- { type: "text", text: "Screenshot captured." },
178
+ { type: "text", text: text },
167
179
  { type: "image_url", image_url: { url: data_url } }
168
180
  ]
169
181
  end
@@ -180,7 +192,6 @@ module Clacky
180
192
  }.compact
181
193
  end
182
194
 
183
- private
184
195
 
185
196
  BROWSER_CONFIG_PATH = File.expand_path("~/.clacky/browser.yml").freeze
186
197
 
@@ -416,6 +427,9 @@ module Clacky
416
427
  output: text.empty? ? "Screenshot captured." : text }
417
428
  end
418
429
 
430
+ # Save original (full-resolution) PNG to disk before any downscaling
431
+ original_path = save_screenshot_to_disk(image_block["data"], suffix: "original")
432
+
419
433
  image_data = png_downscale_base64(image_block["data"], SCREENSHOT_MAX_WIDTH)
420
434
 
421
435
  if image_data.bytesize > SCREENSHOT_MAX_BASE64_BYTES
@@ -424,8 +438,13 @@ module Clacky
424
438
  output: "Screenshot too large after resize (#{size_kb}KB). Use action=snapshot instead." }
425
439
  end
426
440
 
441
+ # Save compressed (800px) PNG for AI reference
442
+ compressed_path = save_screenshot_to_disk(image_data, suffix: "compressed")
443
+
427
444
  { action: "screenshot", success: true, profile: "user",
428
- image_data: image_data, mime_type: "image/png", output: "Screenshot captured." }
445
+ image_data: image_data, mime_type: "image/png",
446
+ original_path: original_path, compressed_path: compressed_path,
447
+ output: "Screenshot captured." }
429
448
  end
430
449
 
431
450
  private def png_downscale_base64(b64, max_width)
@@ -443,6 +462,25 @@ module Clacky
443
462
  result
444
463
  end
445
464
 
465
+ # Save a base64-encoded PNG screenshot to disk and return the file path.
466
+ # suffix: "original" or "compressed" — embedded in filename for clarity.
467
+ # Uses the same upload directory as other image files so the agent can
468
+ # reference, read, or pass the path to other tools.
469
+ private def save_screenshot_to_disk(base64_data, suffix: nil)
470
+ upload_dir = File.join(Dir.tmpdir, "clacky-uploads")
471
+ FileUtils.mkdir_p(upload_dir)
472
+ ts = Time.now.strftime("%Y%m%d_%H%M%S")
473
+ hex = SecureRandom.hex(4)
474
+ label = suffix ? "_#{suffix}" : ""
475
+ filename = "screenshot_#{ts}_#{hex}#{label}.png"
476
+ path = File.join(upload_dir, filename)
477
+ File.binwrite(path, Base64.strict_decode64(base64_data))
478
+ path
479
+ rescue => e
480
+ Clacky::Logger.error("screenshot_save_failed", error: e.message)
481
+ nil
482
+ end
483
+
446
484
  # -----------------------------------------------------------------------
447
485
  # Chrome MCP
448
486
  # -----------------------------------------------------------------------
@@ -313,7 +313,6 @@ module Clacky
313
313
  end
314
314
  end
315
315
 
316
- private
317
316
 
318
317
  # List first-level directory contents (files and directories)
319
318
  private def list_directory_contents(path)
@@ -267,7 +267,6 @@ module Clacky
267
267
  compact
268
268
  end
269
269
 
270
- private
271
270
 
272
271
  def search_file(file, regex, context_lines, max_matches)
273
272
  matches = []
@@ -100,7 +100,6 @@ module Clacky
100
100
  end
101
101
  end
102
102
 
103
- private
104
103
 
105
104
  def start_project
106
105
  config = load_project_config
@@ -98,7 +98,6 @@ module Clacky
98
98
  end
99
99
  end
100
100
 
101
- private
102
101
 
103
102
  def load_todos
104
103
  @todos
@@ -185,7 +185,6 @@ module Clacky
185
185
  flush
186
186
  end
187
187
 
188
- private
189
188
 
190
189
  # Update the complete commands list (system + skills)
191
190
  private def update_commands
@@ -176,7 +176,6 @@ module Clacky
176
176
  @result_queue = nil
177
177
  end
178
178
 
179
- private
180
179
 
181
180
  def handle_enter
182
181
  result = expand_placeholders(current_line)
@@ -624,7 +624,6 @@ module Clacky
624
624
  end
625
625
  end
626
626
 
627
- private
628
627
 
629
628
  # Update command suggestions based on current input
630
629
  # Shows suggestions when input starts with /
@@ -32,7 +32,6 @@ module Clacky
32
32
  end
33
33
  end
34
34
 
35
- private
36
35
 
37
36
  # Render user message
38
37
  # @param content [String] Message content