openclacky 1.1.0 → 1.1.2

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +28 -7
  4. data/lib/clacky/agent/llm_caller.rb +23 -1
  5. data/lib/clacky/agent/session_serializer.rb +6 -1
  6. data/lib/clacky/agent/skill_manager.rb +18 -5
  7. data/lib/clacky/agent.rb +14 -5
  8. data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
  9. data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
  10. data/lib/clacky/brand_config.rb +68 -15
  11. data/lib/clacky/cli.rb +18 -19
  12. data/lib/clacky/client.rb +146 -17
  13. data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
  14. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
  15. data/lib/clacky/openai_stream_aggregator.rb +130 -0
  16. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +169 -6
  17. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
  18. data/lib/clacky/server/http_server.rb +9 -3
  19. data/lib/clacky/server/web_ui_controller.rb +8 -4
  20. data/lib/clacky/tools/terminal.rb +11 -0
  21. data/lib/clacky/ui2/components/input_area.rb +10 -1
  22. data/lib/clacky/ui2/components/todo_area.rb +22 -2
  23. data/lib/clacky/ui2/layout_manager.rb +70 -14
  24. data/lib/clacky/ui2/progress_handle.rb +86 -15
  25. data/lib/clacky/ui2/ui_controller.rb +47 -7
  26. data/lib/clacky/utils/logger.rb +7 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +6 -4
  29. data/lib/clacky/web/i18n.js +21 -6
  30. data/lib/clacky/web/index.html +8 -6
  31. data/lib/clacky/web/sessions.js +171 -58
  32. data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
  33. data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  34. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  35. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  36. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  37. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  38. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  39. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  40. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  41. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  42. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  43. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  45. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  46. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  47. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  48. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  49. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  50. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  51. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  52. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  53. data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
  54. data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
  55. data/lib/clacky/web/ws-dispatcher.js +19 -4
  56. data/lib/clacky.rb +3 -0
  57. data/scripts/build/src/install.sh.cc +15 -5
  58. data/scripts/install.ps1 +14 -3
  59. data/scripts/install.sh +15 -5
  60. metadata +28 -2
@@ -44,7 +44,7 @@ module Clacky
44
44
  :license_expires_at, :license_last_heartbeat, :device_id,
45
45
  :logo_url, :support_contact, :license_user_id,
46
46
  :support_qr_url, :theme_color, :homepage_url,
47
- :distribution_last_refreshed_at
47
+ :distribution_last_refreshed_at, :license_last_heartbeat_failure
48
48
 
49
49
  def initialize(attrs = {})
50
50
  @product_name = attrs["product_name"]
@@ -66,6 +66,11 @@ module Clacky
66
66
  # #refresh_distribution!). Persisted to brand.yml so 24h throttling
67
67
  # survives restarts.
68
68
  @distribution_last_refreshed_at = parse_time(attrs["distribution_last_refreshed_at"])
69
+ # Tracks when heartbeats started failing continuously. Set on a failed
70
+ # heartbeat (only if currently nil), cleared on a successful one.
71
+ # grace_period_exceeded? uses this — NOT last_heartbeat — so a user who
72
+ # simply hasn't run the app in days doesn't see a stale "offline" warning.
73
+ @license_last_heartbeat_failure = parse_time(attrs["license_last_heartbeat_failure"])
69
74
 
70
75
  # In-memory decryption key cache: "skill_id:skill_version_id" => { key:, expires_at: }
71
76
  # Never persisted to disk. Survives across multiple skill invocations within one session.
@@ -130,16 +135,19 @@ module Clacky
130
135
  due
131
136
  end
132
137
 
133
- # Returns true when the grace period for missed heartbeats has expired.
138
+ # Returns true when heartbeats have been failing continuously for longer
139
+ # than the grace period. Only considers ACTUAL failure streaks — a user
140
+ # who hasn't launched the app in a week is NOT in violation, since no
141
+ # heartbeat attempt has actually failed.
134
142
  def grace_period_exceeded?
135
- if @license_last_heartbeat.nil?
136
- Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no heartbeat recorded)")
143
+ if @license_last_heartbeat_failure.nil?
144
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no active failure streak)")
137
145
  return false
138
146
  end
139
147
 
140
- elapsed = Time.now.utc - @license_last_heartbeat
148
+ elapsed = Time.now.utc - @license_last_heartbeat_failure
141
149
  exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD
142
- Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
150
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? failing_since=#{@license_last_heartbeat_failure.iso8601} elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
143
151
  exceeded
144
152
  end
145
153
 
@@ -178,6 +186,7 @@ module Clacky
178
186
  @license_user_id = nil
179
187
  @device_id = nil
180
188
  @distribution_last_refreshed_at = nil
189
+ @license_last_heartbeat_failure = nil
181
190
  { success: true }
182
191
  end
183
192
 
@@ -210,9 +219,20 @@ module Clacky
210
219
  data = response[:data]
211
220
  @license_activated_at = Time.now.utc
212
221
  @license_last_heartbeat = Time.now.utc
222
+ @license_last_heartbeat_failure = nil
213
223
  @license_expires_at = parse_time(data["expires_at"])
214
224
  server_device_id = data["device_id"].to_s.strip
215
225
  @device_id = server_device_id unless server_device_id.empty?
226
+
227
+ # Decide whether the new key belongs to the SAME brand as the previously
228
+ # activated one. If yes (e.g. trial → paid), keep the installed brand
229
+ # skills — they are still decryptable and the user shouldn't have to
230
+ # re-download. If no (switching brands), wipe them.
231
+ prev_package_name = @package_name
232
+ prev_product_name = @product_name
233
+ new_dist = data["distribution"].is_a?(Hash) ? data["distribution"] : {}
234
+ same_brand = brand_identity_match?(prev_package_name, prev_product_name, new_dist)
235
+
216
236
  # Clear ALL stale fields first, then apply fresh values from the new key.
217
237
  # Order matters: reset everything before re-assigning so no old value lingers.
218
238
  @product_name = nil
@@ -229,10 +249,10 @@ module Clacky
229
249
  owner_uid = data["owner_user_id"]
230
250
  @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
231
251
  apply_distribution(data["distribution"])
232
- # Clear previously installed brand skills before saving the new license.
233
- # Skills from the old brand are encrypted with that brand's keys — they
234
- # cannot be decrypted with the new license and must be re-downloaded.
235
- clear_brand_skills!
252
+ # Skills from a different brand are encrypted with that brand's keys —
253
+ # they cannot be decrypted with the new license and must be re-downloaded.
254
+ # Same-brand re-activation (trial→paid, key rotation) preserves them.
255
+ clear_brand_skills! unless same_brand
236
256
  save
237
257
  { success: true, message: "License activated successfully!", product_name: @product_name,
238
258
  user_id: @license_user_id, data: data }
@@ -258,14 +278,19 @@ module Clacky
258
278
 
259
279
  # Always derive product_name fresh from the key in mock mode,
260
280
  # so switching keys produces a different brand each time.
261
- user_id = parse_user_id_from_key(@license_key)
262
- @product_name = "Brand#{user_id}"
281
+ user_id = parse_user_id_from_key(@license_key)
282
+ new_product_name = "Brand#{user_id}"
283
+ prev_product_name = @product_name
284
+ same_brand = brand_identity_match?(@package_name, prev_product_name,
285
+ { "product_name" => new_product_name })
286
+ @product_name = new_product_name
263
287
 
264
288
  @license_activated_at = Time.now.utc
265
289
  @license_last_heartbeat = Time.now.utc
290
+ @license_last_heartbeat_failure = nil
266
291
  @license_expires_at = Time.now.utc + (365 * 86_400) # 1 year from now
267
- # Clear old brand skills so stale encrypted files from a previous brand don't linger
268
- clear_brand_skills!
292
+ # Same-brand re-activation preserves installed skills; switching brands wipes them.
293
+ clear_brand_skills! unless same_brand
269
294
  save
270
295
 
271
296
  {
@@ -306,13 +331,16 @@ module Clacky
306
331
 
307
332
  if response[:success]
308
333
  @license_last_heartbeat = Time.now.utc
334
+ @license_last_heartbeat_failure = nil
309
335
  @license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
310
336
  apply_distribution(response[:data]["distribution"])
311
337
  save
312
338
  Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}")
313
339
  { success: true, message: "Heartbeat OK" }
314
340
  else
315
- Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]}")
341
+ @license_last_heartbeat_failure ||= Time.now.utc
342
+ save
343
+ Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]} (failing_since=#{@license_last_heartbeat_failure.iso8601})")
316
344
  { success: false, message: response[:error] || "Heartbeat failed" }
317
345
  end
318
346
  end
@@ -1134,6 +1162,7 @@ module Clacky
1134
1162
  # Persist user_id so user-licensed features remain available across restarts
1135
1163
  data["license_user_id"] = @license_user_id if @license_user_id && !@license_user_id.strip.empty?
1136
1164
  data["distribution_last_refreshed_at"] = @distribution_last_refreshed_at.iso8601 if @distribution_last_refreshed_at
1165
+ data["license_last_heartbeat_failure"] = @license_last_heartbeat_failure.iso8601 if @license_last_heartbeat_failure
1137
1166
  YAML.dump(data)
1138
1167
  end
1139
1168
 
@@ -1155,6 +1184,30 @@ module Clacky
1155
1184
  self.class.version_older?(installed, latest)
1156
1185
  end
1157
1186
 
1187
+ # Decide whether a re-activation key targets the same brand as the
1188
+ # currently-loaded one, so we know whether installed brand skills can stay.
1189
+ #
1190
+ # Identity preference, in order:
1191
+ # 1. package_name — bundle identifier, the strongest brand signal
1192
+ # 2. product_name — display name fallback when package_name is missing
1193
+ #
1194
+ # If neither is present on either side, treat as different brand (safe default:
1195
+ # wipe skills) since we can't confirm continuity.
1196
+ private def brand_identity_match?(prev_package_name, prev_product_name, new_dist)
1197
+ new_dist = {} unless new_dist.is_a?(Hash)
1198
+ new_pkg = new_dist["package_name"].to_s.strip
1199
+ old_pkg = prev_package_name.to_s.strip
1200
+ if !new_pkg.empty? && !old_pkg.empty?
1201
+ return new_pkg == old_pkg
1202
+ end
1203
+
1204
+ new_prod = new_dist["product_name"].to_s.strip
1205
+ old_prod = prev_product_name.to_s.strip
1206
+ return new_prod == old_prod if !new_prod.empty? && !old_prod.empty?
1207
+
1208
+ false
1209
+ end
1210
+
1158
1211
  # Apply distribution fields from API response.
1159
1212
  # Updates product_name, package_name, logo_url, support_contact, support_qr_url,
1160
1213
  # theme_color, and homepage_url from the distribution hash.
data/lib/clacky/cli.rb CHANGED
@@ -163,6 +163,7 @@ module Clacky
163
163
  end
164
164
  ensure
165
165
  Dir.chdir(original_dir)
166
+ Clacky::BrowserManager.instance.stop rescue nil
166
167
  end
167
168
  end
168
169
 
@@ -365,19 +366,12 @@ module Clacky
365
366
  return
366
367
  end
367
368
 
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.
368
374
  if brand.heartbeat_due?
369
- # Fire-and-forget heartbeat in a background thread.
370
- #
371
- # Rationale: a slow/unreachable license server would otherwise block
372
- # CLI startup for up to ~92s (2 hosts × 2 attempts × 23s timeout)
373
- # before the user sees the prompt. Heartbeat is "best-effort" by
374
- # design — its only job is to refresh `last_heartbeat` / `expires_at`
375
- # on disk for the next run's grace-period calculation. Missing a
376
- # single heartbeat is harmless; the next launch will try again.
377
- #
378
- # Consequence: if this run was going to trigger the
379
- # grace_period_exceeded warning, the user will see it on the *next*
380
- # launch instead of this one. Acceptable trade-off.
381
375
  Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
382
376
  Thread.new do
383
377
  begin
@@ -395,10 +389,6 @@ module Clacky
395
389
  Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
396
390
  end
397
391
 
398
- # Surface the grace-period warning based on *already-persisted* state
399
- # (computed from last_heartbeat on disk). This works whether the
400
- # previous run's heartbeat succeeded, failed, or was interrupted —
401
- # grace_period_exceeded? reads last_heartbeat, not this run's result.
402
392
  if brand.grace_period_exceeded?
403
393
  say ""
404
394
  say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
@@ -750,6 +740,9 @@ module Clacky
750
740
  # Inject UI into agent
751
741
  agent.instance_variable_set(:@ui, ui_controller)
752
742
 
743
+ # Inject current session id into UI session bar (parity with WebUI #sib-id)
744
+ ui_controller.update_sessionbar(session_id: agent.session_id)
745
+
753
746
  # Set skill loader for command suggestions, filtered by agent profile whitelist
754
747
  ui_controller.set_skill_loader(agent.skill_loader, agent.agent_profile)
755
748
 
@@ -859,7 +852,7 @@ module Clacky
859
852
  end
860
853
  ui_controller.show_info("Session cleared. Starting fresh.")
861
854
  # Update session bar with reset values
862
- ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
855
+ ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost, session_id: agent.session_id)
863
856
  # Clear todo area display
864
857
  ui_controller.update_todos([])
865
858
  next
@@ -950,8 +943,8 @@ module Clacky
950
943
  $ clacky server
951
944
  $ clacky server --port 8080
952
945
  LONGDESC
953
- option :host, type: :string, default: "127.0.0.1", desc: "Bind host (default: 127.0.0.1)"
954
- option :port, type: :numeric, default: 7070, desc: "Listen port (default: 7070)"
946
+ option :host, type: :string, aliases: ["-b", "--bind"], default: "127.0.0.1", desc: "Bind host (default: 127.0.0.1)"
947
+ option :port, type: :numeric, aliases: "-p", default: 7070, desc: "Listen port (default: 7070)"
955
948
  option :brand_test, type: :boolean, default: false,
956
949
  desc: "Enable brand test mode: mock license activation without calling remote API"
957
950
  option :no_compression, type: :boolean, default: false,
@@ -962,7 +955,13 @@ module Clacky
962
955
  desc: "Disable prompt caching"
963
956
  option :no_skill_evolution, type: :boolean, default: false,
964
957
  desc: "Disable automatic skill evolution"
958
+ option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
965
959
  def server
960
+ if options[:help]
961
+ invoke :help, ["server"]
962
+ return
963
+ end
964
+
966
965
  # ── Security gate ──────────────────────────────────────────────────────
967
966
  # Binding to 0.0.0.0 exposes the server to the public network.
968
967
  # Refuse to start unless CLACKY_ACCESS_KEY env var is set.
data/lib/clacky/client.rb CHANGED
@@ -119,37 +119,59 @@ module Clacky
119
119
  # signal metric — see docs). When we migrate to streaming later, this
120
120
  # same `ttft_ms` field will start carrying the *actual* first-token
121
121
  # latency without any schema change.
122
- def send_messages_with_tools(messages, model:, tools:, max_tokens:, enable_caching: false)
122
+ # @param on_chunk [Proc, nil] optional streaming progress callback.
123
+ # Receives keyword args { input_tokens:, output_tokens: } with cumulative
124
+ # token counts. When nil, behaves exactly as the historical non-streaming
125
+ # path. When given but streaming is not yet wired for the active provider,
126
+ # a single synthetic invocation is fired after the response is received,
127
+ # so UI plumbing can be exercised end-to-end without the proxy work.
128
+ def send_messages_with_tools(messages, model:, tools:, max_tokens:, enable_caching: false, on_chunk: nil)
123
129
  caching_enabled = enable_caching && supports_prompt_caching?(model)
124
130
  cloned = deep_clone(messages)
125
131
 
132
+ streaming_used = false
133
+ first_chunk_at = nil
134
+ wrapped_on_chunk = on_chunk && lambda do |**kwargs|
135
+ first_chunk_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ on_chunk.call(**kwargs)
137
+ end
138
+
126
139
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
140
  response =
128
141
  if bedrock?
129
- send_bedrock_request(cloned, model, tools, max_tokens, caching_enabled)
142
+ streaming_used = !on_chunk.nil?
143
+ send_bedrock_request(cloned, model, tools, max_tokens, caching_enabled, on_chunk: wrapped_on_chunk)
130
144
  elsif anthropic_format?
131
- send_anthropic_request(cloned, model, tools, max_tokens, caching_enabled)
145
+ streaming_used = !on_chunk.nil?
146
+ send_anthropic_request(cloned, model, tools, max_tokens, caching_enabled, on_chunk: wrapped_on_chunk)
132
147
  else
133
- send_openai_request(cloned, model, tools, max_tokens, caching_enabled)
148
+ streaming_used = !on_chunk.nil?
149
+ send_openai_request(cloned, model, tools, max_tokens, caching_enabled, on_chunk: wrapped_on_chunk)
134
150
  end
135
151
  t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
152
 
153
+ if on_chunk && !streaming_used
154
+ usage = response[:usage] || {}
155
+ safe_invoke_on_chunk(
156
+ on_chunk,
157
+ input_tokens: usage[:prompt_tokens].to_i,
158
+ output_tokens: usage[:completion_tokens].to_i
159
+ )
160
+ end
161
+
137
162
  duration_ms = ((t1 - t0) * 1000).round
138
- # Throughput is only meaningful with a reasonable output size; below ~10
139
- # tokens the sample is too small to be informative and the result is
140
- # wildly high (e.g. 1 token / 50ms → 20 tok/s is meaningless).
141
- # Canonical usage hashes from message_format/* all use :completion_tokens.
163
+ ttft_ms = first_chunk_at ? ((first_chunk_at - t0) * 1000).round : duration_ms
142
164
  output_tokens = response[:usage]&.dig(:completion_tokens).to_i
143
165
  tps = (output_tokens >= 10 && duration_ms > 0) ? (output_tokens * 1000.0 / duration_ms).round(1) : nil
144
166
 
145
167
  response[:latency] = {
146
- ttft_ms: duration_ms, # non-streaming: TTFT == full duration
168
+ ttft_ms: ttft_ms,
147
169
  duration_ms: duration_ms,
148
170
  output_tokens: output_tokens,
149
171
  tps: tps,
150
172
  model: model,
151
173
  measured_at: Time.now.to_f,
152
- streaming: false # future flag — true when we migrate
174
+ streaming: streaming_used
153
175
  }
154
176
  response
155
177
  end
@@ -195,8 +217,10 @@ module Clacky
195
217
 
196
218
  # ── Bedrock Converse request / response ───────────────────────────────────
197
219
 
198
- def send_bedrock_request(messages, model, tools, max_tokens, caching_enabled)
199
- body = MessageFormat::Bedrock.build_request_body(messages, model, tools, max_tokens, caching_enabled)
220
+ def send_bedrock_request(messages, model, tools, max_tokens, caching_enabled, on_chunk: nil)
221
+ body = MessageFormat::Bedrock.build_request_body(messages, model, tools, max_tokens, caching_enabled)
222
+ return send_bedrock_stream_request(body, model, on_chunk) if on_chunk
223
+
200
224
  response = bedrock_connection.post(bedrock_endpoint(model)) { |r| r.body = body.to_json }
201
225
 
202
226
  raise_error(response) unless response.status == 200
@@ -205,6 +229,29 @@ module Clacky
205
229
  MessageFormat::Bedrock.parse_response(parsed_body)
206
230
  end
207
231
 
232
+ # Streaming variant for Bedrock Converse.
233
+ # Posts to /model/{m}/converse-stream with stream:true; the proxy returns
234
+ # SSE frames whose `event` is the Bedrock event-type and whose `data` is
235
+ # the raw Bedrock event JSON. We accumulate frames into a synthetic
236
+ # non-streaming response and feed it back through the existing parser so
237
+ # downstream code is identical.
238
+ private def send_bedrock_stream_request(body, model, on_chunk)
239
+ stream_body = body.merge(stream: true)
240
+ aggregator = BedrockStreamAggregator.new(on_chunk: on_chunk)
241
+ sse_buf = +""
242
+
243
+ response = bedrock_connection.post(bedrock_stream_endpoint(model)) do |req|
244
+ req.body = stream_body.to_json
245
+ req.options.on_data = proc do |chunk, _bytes_received, _env|
246
+ sse_buf << chunk
247
+ drain_sse_frames(sse_buf) { |event, data| aggregator.handle(event, data) }
248
+ end
249
+ end
250
+
251
+ raise_error(response) unless response.status == 200
252
+ MessageFormat::Bedrock.parse_response(aggregator.to_h)
253
+ end
254
+
208
255
  def parse_simple_bedrock_response(response)
209
256
  raise_error(response) unless response.status == 200
210
257
  data = safe_json_parse(response.body, context: "LLM response")
@@ -216,11 +263,13 @@ module Clacky
216
263
 
217
264
  # ── Anthropic request / response ──────────────────────────────────────────
218
265
 
219
- def send_anthropic_request(messages, model, tools, max_tokens, caching_enabled)
266
+ def send_anthropic_request(messages, model, tools, max_tokens, caching_enabled, on_chunk: nil)
220
267
  # Apply cache_control to the message that marks the cache breakpoint
221
268
  messages = apply_message_caching(messages) if caching_enabled
222
269
 
223
- body = MessageFormat::Anthropic.build_request_body(messages, model, tools, max_tokens, caching_enabled)
270
+ body = MessageFormat::Anthropic.build_request_body(messages, model, tools, max_tokens, caching_enabled)
271
+ return send_anthropic_stream_request(body, on_chunk) if on_chunk
272
+
224
273
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
225
274
 
226
275
  raise_error(response) unless response.status == 200
@@ -229,6 +278,24 @@ module Clacky
229
278
  MessageFormat::Anthropic.parse_response(parsed_body)
230
279
  end
231
280
 
281
+ private def send_anthropic_stream_request(body, on_chunk)
282
+ stream_body = body.merge(stream: true)
283
+ aggregator = AnthropicStreamAggregator.new(on_chunk: on_chunk)
284
+ sse_buf = +""
285
+
286
+ response = anthropic_connection.post(anthropic_messages_path) do |req|
287
+ req.headers["Accept"] = "text/event-stream"
288
+ req.body = stream_body.to_json
289
+ req.options.on_data = proc do |chunk, _bytes_received, _env|
290
+ sse_buf << chunk
291
+ drain_sse_frames(sse_buf) { |event, data| aggregator.handle(event, data) }
292
+ end
293
+ end
294
+
295
+ raise_error(response) unless response.status == 200
296
+ MessageFormat::Anthropic.parse_response(aggregator.to_h)
297
+ end
298
+
232
299
  def parse_simple_anthropic_response(response)
233
300
  raise_error(response) unless response.status == 200
234
301
  data = safe_json_parse(response.body, context: "LLM response")
@@ -237,24 +304,47 @@ module Clacky
237
304
 
238
305
  # ── OpenAI request / response ─────────────────────────────────────────────
239
306
 
240
- def send_openai_request(messages, model, tools, max_tokens, caching_enabled)
307
+ def send_openai_request(messages, model, tools, max_tokens, caching_enabled, on_chunk: nil)
241
308
  # Apply cache_control markers to messages when caching is enabled.
242
309
  # OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
243
310
  messages = apply_message_caching(messages) if caching_enabled
244
311
 
245
- body = MessageFormat::OpenAI.build_request_body(
312
+ body = MessageFormat::OpenAI.build_request_body(
246
313
  messages, model, tools, max_tokens, caching_enabled,
247
314
  vision_supported: @vision_supported
248
315
  )
316
+ return send_openai_stream_request(body, on_chunk) if on_chunk
317
+
249
318
  response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
250
319
 
251
320
  raise_error(response) unless response.status == 200
252
321
  check_html_response(response)
253
-
322
+
254
323
  parsed_body = safe_json_parse(response.body, context: "LLM response")
255
324
  MessageFormat::OpenAI.parse_response(parsed_body)
256
325
  end
257
326
 
327
+ # Streaming variant for OpenAI-compatible chat completions (DeepSeek/OpenRouter
328
+ # via platform/llm_proxy). Uses Faraday's on_data hook to consume SSE frames,
329
+ # accumulates them, and reconstructs the non-streaming JSON response shape so
330
+ # MessageFormat::OpenAI.parse_response works unchanged.
331
+ private def send_openai_stream_request(body, on_chunk)
332
+ stream_body = body.merge(stream: true, stream_options: { include_usage: true })
333
+ aggregator = OpenAIStreamAggregator.new(on_chunk: on_chunk)
334
+ sse_buf = +""
335
+
336
+ response = openai_connection.post("chat/completions") do |req|
337
+ req.body = stream_body.to_json
338
+ req.options.on_data = proc do |chunk, _bytes_received, _env|
339
+ sse_buf << chunk
340
+ drain_sse_frames(sse_buf) { |_event, data| aggregator.handle(data) }
341
+ end
342
+ end
343
+
344
+ raise_error(response) unless response.status == 200
345
+ MessageFormat::OpenAI.parse_response(aggregator.to_h)
346
+ end
347
+
258
348
  def parse_simple_openai_response(response)
259
349
  raise_error(response) unless response.status == 200
260
350
  parsed_body = safe_json_parse(response.body, context: "LLM response")
@@ -320,6 +410,33 @@ module Clacky
320
410
  "/model/#{model}/converse"
321
411
  end
322
412
 
413
+ # Bedrock Converse streaming endpoint path.
414
+ private def bedrock_stream_endpoint(model)
415
+ "/model/#{model}/converse-stream"
416
+ end
417
+
418
+ # Pull complete SSE frames out of a buffer and yield them as (event, data).
419
+ # An SSE frame ends at a blank line ("\n\n"); incomplete trailing data
420
+ # stays in the buffer for the next chunk. Frames without an explicit
421
+ # `event:` line use the default "message" type per the SSE spec.
422
+ private def drain_sse_frames(buf)
423
+ while (sep = buf.index("\n\n"))
424
+ frame = buf.slice!(0, sep + 2)
425
+ event = "message"
426
+ data_lines = []
427
+ frame.each_line do |line|
428
+ line = line.chomp
429
+ if line.start_with?("event:")
430
+ event = line.sub(/^event:\s*/, "")
431
+ elsif line.start_with?("data:")
432
+ data_lines << line.sub(/^data:\s*/, "")
433
+ end
434
+ end
435
+ next if data_lines.empty?
436
+ yield event, data_lines.join("\n")
437
+ end
438
+ end
439
+
323
440
  def bedrock_connection
324
441
  @bedrock_connection ||= Faraday.new(url: @base_url) do |conn|
325
442
  conn.headers["Content-Type"] = "application/json"
@@ -477,6 +594,18 @@ module Clacky
477
594
  "The request will be retried automatically."
478
595
  end
479
596
 
597
+ # ── Streaming helpers ─────────────────────────────────────────────────────
598
+
599
+ # Invoke the user's on_chunk callback in a way that never lets a callback
600
+ # error tear down the LLM request. Streaming chunks are best-effort UI
601
+ # updates; a buggy progress renderer must not abort an in-flight call.
602
+ private def safe_invoke_on_chunk(on_chunk, **kwargs)
603
+ return unless on_chunk
604
+ on_chunk.call(**kwargs)
605
+ rescue => e
606
+ Clacky::Logger.warn("[on_chunk] callback raised #{e.class}: #{e.message}")
607
+ end
608
+
480
609
  # ── Utilities ─────────────────────────────────────────────────────────────
481
610
 
482
611
  def deep_clone(obj)
@@ -221,8 +221,12 @@ then parse the last stdout line as JSON and read `installed` as N.
221
221
 
222
222
  ### A.10. Import external skills (optional)
223
223
 
224
- Run `test -d ~/.openclaw && echo yes || echo no`. If `no`, skip silently.
225
- If `yes`:
224
+ Check if OpenClaw is installed:
225
+ - Run `test -d ~/.openclaw && echo yes || echo no`
226
+ - If `no` and on WSL (i.e. `/proc/version` contains `microsoft`), also run:
227
+ `powershell.exe -NoProfile -Command '$env:USERPROFILE' 2>/dev/null | tr -d '\r'` to get the Windows home, then check `test -d "$(wslpath '<win_home>')/.openclaw" && echo yes || echo no`
228
+ - If all checks return `no`, skip silently.
229
+ If any check returns `yes`:
226
230
  1. `ruby "SKILL_DIR/scripts/import_external_skills.rb" --source openclaw --dry-run`
227
231
  2. Parse the skill count N.
228
232
  3. Ask via `request_user_feedback`:
@@ -172,7 +172,7 @@ class OpenClawImporter < ExternalSkillsImporter
172
172
  end
173
173
 
174
174
  private def source_available?
175
- @openclaw_dir.exist?
175
+ openclaw_dirs.any?(&:exist?)
176
176
  end
177
177
 
178
178
  # Returns all directories that may contain OpenClaw skills.
@@ -182,12 +182,56 @@ class OpenClawImporter < ExternalSkillsImporter
182
182
  # - ~/.openclaw/workspace/skills/ (workspace skills)
183
183
  # - ~/.openclaw/skills/ (managed/shared skills)
184
184
  # - ~/.openclaw/workspace/.agents/skills/ (project-level shared skills)
185
+ #
186
+ # On WSL, also scans the Windows-native %USERPROFILE%\.openclaw directory.
185
187
  private def source_dirs
186
- [
187
- @openclaw_dir.join('workspace', 'skills'),
188
- @openclaw_dir.join('skills'),
189
- @openclaw_dir.join('workspace', '.agents', 'skills')
190
- ].select(&:exist?)
188
+ openclaw_dirs.flat_map do |root|
189
+ [
190
+ root.join('workspace', 'skills'),
191
+ root.join('skills'),
192
+ root.join('workspace', '.agents', 'skills')
193
+ ]
194
+ end.select(&:exist?)
195
+ end
196
+
197
+ # All candidate OpenClaw root directories.
198
+ # On WSL, includes both ~/.openclaw and the Windows-native path.
199
+ private def openclaw_dirs
200
+ dirs = [@openclaw_dir]
201
+ win_home = windows_home
202
+ dirs << win_home.join('.openclaw') if win_home && win_home.join('.openclaw') != @openclaw_dir
203
+ dirs
204
+ end
205
+
206
+ # True when running inside WSL.
207
+ # Mirrors EnvironmentDetector#wsl? — reads /proc/version for "microsoft".
208
+ private def wsl?
209
+ return @wsl if defined?(@wsl)
210
+
211
+ @wsl = File.exist?('/proc/version') &&
212
+ File.read('/proc/version').downcase.include?('microsoft')
213
+ rescue StandardError
214
+ @wsl = false
215
+ end
216
+
217
+ # Resolve the Windows %USERPROFILE% as a WSL-accessible Pathname.
218
+ # Uses powershell.exe (standard in WSL) then wslpath for conversion,
219
+ # mirroring the approach in EnvironmentDetector#wsl_desktop_path.
220
+ # Returns nil when not on WSL or when the path cannot be resolved.
221
+ private def windows_home
222
+ return nil unless wsl?
223
+ return nil if `which powershell.exe 2>/dev/null`.strip.empty?
224
+
225
+ win_path = `powershell.exe -NoProfile -Command '$env:USERPROFILE' 2>/dev/null`.strip.tr("\r\n", '')
226
+ return nil if win_path.empty?
227
+
228
+ linux_path = `wslpath '#{win_path}' 2>/dev/null`.strip
229
+ return nil if linux_path.empty?
230
+
231
+ path = Pathname.new(linux_path)
232
+ path.exist? ? path : nil
233
+ rescue StandardError
234
+ nil
191
235
  end
192
236
 
193
237
  private def discover_skills