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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +28 -7
- data/lib/clacky/agent/llm_caller.rb +23 -1
- data/lib/clacky/agent/session_serializer.rb +6 -1
- data/lib/clacky/agent/skill_manager.rb +18 -5
- data/lib/clacky/agent.rb +14 -5
- data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
- data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
- data/lib/clacky/brand_config.rb +68 -15
- data/lib/clacky/cli.rb +18 -19
- data/lib/clacky/client.rb +146 -17
- data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
- data/lib/clacky/openai_stream_aggregator.rb +130 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +169 -6
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
- data/lib/clacky/server/http_server.rb +9 -3
- data/lib/clacky/server/web_ui_controller.rb +8 -4
- data/lib/clacky/tools/terminal.rb +11 -0
- data/lib/clacky/ui2/components/input_area.rb +10 -1
- data/lib/clacky/ui2/components/todo_area.rb +22 -2
- data/lib/clacky/ui2/layout_manager.rb +70 -14
- data/lib/clacky/ui2/progress_handle.rb +86 -15
- data/lib/clacky/ui2/ui_controller.rb +47 -7
- data/lib/clacky/utils/logger.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +6 -4
- data/lib/clacky/web/i18n.js +21 -6
- data/lib/clacky/web/index.html +8 -6
- data/lib/clacky/web/sessions.js +171 -58
- data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
- data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -4
- data/lib/clacky.rb +3 -0
- data/scripts/build/src/install.sh.cc +15 -5
- data/scripts/install.ps1 +14 -3
- data/scripts/install.sh +15 -5
- metadata +28 -2
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -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
|
|
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 @
|
|
136
|
-
Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no
|
|
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 - @
|
|
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
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
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
|
|
262
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|