openclacky 1.1.2 → 1.1.4

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +27 -31
  3. data/CHANGELOG.md +30 -0
  4. data/Dockerfile +28 -0
  5. data/README.md +4 -0
  6. data/README_CN.md +198 -0
  7. data/docs/engineering-article.md +343 -0
  8. data/lib/clacky/agent/llm_caller.rb +2 -5
  9. data/lib/clacky/agent/session_serializer.rb +4 -0
  10. data/lib/clacky/agent.rb +22 -1
  11. data/lib/clacky/brand_config.rb +87 -5
  12. data/lib/clacky/cli.rb +1 -1
  13. data/lib/clacky/client.rb +15 -11
  14. data/lib/clacky/message_format/anthropic.rb +30 -2
  15. data/lib/clacky/message_format/bedrock.rb +13 -1
  16. data/lib/clacky/message_format/open_ai.rb +5 -1
  17. data/lib/clacky/providers.rb +34 -0
  18. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
  19. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
  20. data/lib/clacky/server/http_server.rb +130 -15
  21. data/lib/clacky/server/session_registry.rb +9 -6
  22. data/lib/clacky/ui2/ui_controller.rb +14 -0
  23. data/lib/clacky/ui_interface.rb +14 -0
  24. data/lib/clacky/utils/model_pricing.rb +96 -25
  25. data/lib/clacky/version.rb +1 -1
  26. data/lib/clacky/web/app.css +1286 -1116
  27. data/lib/clacky/web/brand.js +20 -5
  28. data/lib/clacky/web/i18n.js +42 -0
  29. data/lib/clacky/web/index.html +26 -7
  30. data/lib/clacky/web/onboard.js +6 -0
  31. data/lib/clacky/web/sessions.js +194 -11
  32. data/lib/clacky/web/settings.js +51 -10
  33. data/lib/clacky/web/skills.js +53 -31
  34. data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
  35. data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
  36. data/scripts/build/lib/apt.sh +30 -10
  37. data/scripts/build/lib/network.sh +3 -2
  38. data/scripts/install.sh +30 -9
  39. data/scripts/install_browser.sh +2 -1
  40. data/scripts/install_full.sh +2 -1
  41. data/scripts/install_rails_deps.sh +30 -9
  42. data/scripts/install_system_deps.sh +30 -9
  43. metadata +7 -17
  44. data/docs/HOW-TO-USE-CN.md +0 -96
  45. data/docs/HOW-TO-USE.md +0 -94
  46. data/docs/browser-cdp-native-design.md +0 -195
  47. data/docs/c-end-user-positioning.md +0 -64
  48. data/docs/config.example.yml +0 -27
  49. data/docs/deploy-architecture.md +0 -619
  50. data/docs/deploy_subagent_design.md +0 -540
  51. data/docs/install-script-simplification.md +0 -89
  52. data/docs/memory-architecture.md +0 -343
  53. data/docs/openclacky_cloud_api_reference.md +0 -584
  54. data/docs/security-design.md +0 -109
  55. data/docs/session-management-redesign.md +0 -202
  56. data/docs/system-skill-authoring-guide.md +0 -47
  57. data/docs/why-developer.md +0 -371
  58. data/docs/why-openclacky.md +0 -266
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require "securerandom"
6
7
 
7
8
  module Clacky
8
9
  module Channel
@@ -11,12 +12,31 @@ module Clacky
11
12
  # DingTalk Bot API client — sends messages via session webhook (Stream Mode).
12
13
  class ApiClient
13
14
  OPENAPI_BASE = "https://api.dingtalk.com"
15
+ OAPI_BASE = "https://oapi.dingtalk.com"
16
+
17
+ # File extensions accepted by DingTalk sampleFile message (fileType field).
18
+ # Anything outside this list is rejected by DingTalk's API — we surface
19
+ # a friendly text notice to the user instead of attempting upload.
20
+ #
21
+ # Why 9 entries instead of the 6 the public doc lists? The official
22
+ # doc (open.dingtalk.com / sampleFile) explicitly names only:
23
+ # xlsx, pdf, zip, rar, doc, docx
24
+ # However old-format Office types (xls / ppt / pptx) are accepted in
25
+ # practice and were verified by hand during the C-5597 rollout.
26
+ # We deliberately keep the empirical 9-entry list because
27
+ # downgrading to the doc's 6 would silently reject files users
28
+ # routinely send. If DingTalk ever tightens enforcement and the
29
+ # extra 3 start failing, prefer adding a converter (e.g. xls→xlsx)
30
+ # over shrinking the list — the goal is "things users send arrive".
31
+ SUPPORTED_FILE_EXTS = %w[doc docx xls xlsx ppt pptx pdf zip rar].freeze
14
32
 
15
33
  def initialize(client_id:, client_secret:)
16
34
  @client_id = client_id
17
35
  @client_secret = client_secret
18
36
  @token = nil
19
37
  @token_expires_at = 0
38
+ @oapi_token = nil
39
+ @oapi_token_expires_at = 0
20
40
  end
21
41
 
22
42
  # Send a text (or Markdown) message via the session webhook URL.
@@ -67,6 +87,26 @@ module Clacky
67
87
  @token
68
88
  end
69
89
 
90
+ # OAPI access token — required by legacy /media/upload endpoint.
91
+ # Independent token system from /v1.0/oauth2/accessToken.
92
+ def oapi_access_token
93
+ return @oapi_token if @oapi_token && Time.now.to_i < @oapi_token_expires_at - 60
94
+
95
+ uri = URI.parse("#{OAPI_BASE}/gettoken?appkey=#{@client_id}&appsecret=#{@client_secret}")
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.use_ssl = true
98
+ resp = http.request(Net::HTTP::Get.new(uri.request_uri))
99
+ data = JSON.parse(resp.body)
100
+
101
+ unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["access_token"]
102
+ raise "DingTalk OAPI token error (#{resp.code}): #{data["errmsg"] || resp.body}"
103
+ end
104
+
105
+ @oapi_token = data["access_token"]
106
+ @oapi_token_expires_at = Time.now.to_i + (data["expires_in"] || 7200).to_i
107
+ @oapi_token
108
+ end
109
+
70
110
  # Validate credentials by fetching a token.
71
111
  # @return [Hash] { ok: Boolean, error: String? }
72
112
  def test_connection
@@ -75,6 +115,275 @@ module Clacky
75
115
  rescue => e
76
116
  { ok: false, error: e.message }
77
117
  end
118
+
119
+ # Download a file the bot received, given its downloadCode + robotCode
120
+ # from the inbound event. Two-step: exchange downloadCode for a temporary
121
+ # downloadUrl, then persist bytes to UPLOAD_DIR via FileProcessor.save.
122
+ # @param download_code [String]
123
+ # @param robot_code [String]
124
+ # @param prefer_name [String, nil] original filename from inbound event
125
+ # (DingTalk's content.fileName) — used to pick the file extension so
126
+ # downstream consumers (parsers, vision models) route by suffix correctly.
127
+ # @return [Hash, nil] { name:, path:, mime: } or nil on failure
128
+ def download_message_file(download_code, robot_code, prefer_name: nil)
129
+ return nil if download_code.to_s.empty? || robot_code.to_s.empty?
130
+
131
+ url = fetch_download_url(download_code, robot_code)
132
+ return nil unless url
133
+
134
+ download_to_disk(url, prefer_name: prefer_name)
135
+ rescue => e
136
+ Clacky::Logger.warn("[dingtalk] download_message_file failed: #{e.message}")
137
+ nil
138
+ end
139
+
140
+ private def fetch_download_url(download_code, robot_code)
141
+ uri = URI.parse("#{OPENAPI_BASE}/v1.0/robot/messageFiles/download")
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = true
144
+ req = Net::HTTP::Post.new(uri.path,
145
+ "Content-Type" => "application/json",
146
+ "x-acs-dingtalk-access-token" => access_token)
147
+ req.body = JSON.generate(downloadCode: download_code, robotCode: robot_code)
148
+ resp = http.request(req)
149
+ data = JSON.parse(resp.body) rescue {}
150
+ unless resp.code.to_i == 200 && data["downloadUrl"]
151
+ Clacky::Logger.warn("[dingtalk] fetch_download_url rejected (#{resp.code}): #{resp.body}")
152
+ return nil
153
+ end
154
+ data["downloadUrl"]
155
+ end
156
+
157
+ private def download_to_disk(url, prefer_name: nil)
158
+ uri = URI.parse(url)
159
+ http = Net::HTTP.new(uri.host, uri.port)
160
+ http.use_ssl = uri.scheme == "https"
161
+ resp = http.get(uri.request_uri)
162
+ return nil unless resp.code.to_i == 200
163
+
164
+ mime = resp["content-type"].to_s
165
+ # Prefer extension from the original fileName supplied by DingTalk
166
+ # (e.g. report.pdf, notes.txt). Fall back to MIME mapping, then .bin.
167
+ ext = ext_from_name(prefer_name) || guess_ext(mime) || ".bin"
168
+
169
+ base = prefer_name && !prefer_name.to_s.empty? ?
170
+ File.basename(prefer_name.to_s, ".*") : "dingtalk"
171
+ base = sanitize_basename(base)
172
+ filename = "#{base}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(3)}#{ext}"
173
+ saved = Clacky::Utils::FileProcessor.save(body: resp.body, filename: filename)
174
+ saved.merge(mime: mime)
175
+ end
176
+
177
+ private def ext_from_name(name)
178
+ return nil if name.to_s.empty?
179
+ ext = File.extname(name.to_s).downcase
180
+ ext.empty? ? nil : ext
181
+ end
182
+
183
+ private def sanitize_basename(name)
184
+ # Keep ASCII letters/digits/dash/underscore; drop everything else
185
+ # (CJK, spaces, slashes) so the final filename stays filesystem-safe.
186
+ cleaned = name.to_s.gsub(/[^A-Za-z0-9_\-]/, "_").gsub(/_+/, "_").gsub(/^_+|_+$/, "")
187
+ cleaned.empty? ? "dingtalk" : cleaned
188
+ end
189
+
190
+ private def guess_ext(mime)
191
+ case mime.to_s.split(";").first.to_s.strip.downcase
192
+ # Images
193
+ when "image/jpeg", "image/jpg" then ".jpg"
194
+ when "image/png" then ".png"
195
+ when "image/gif" then ".gif"
196
+ when "image/webp" then ".webp"
197
+ when "image/bmp" then ".bmp"
198
+ when "image/svg+xml" then ".svg"
199
+ # Documents
200
+ when "application/pdf" then ".pdf"
201
+ when "application/msword" then ".doc"
202
+ when "application/vnd.openxmlformats-officedocument.wordprocessingml.document" then ".docx"
203
+ when "application/vnd.ms-excel" then ".xls"
204
+ when "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" then ".xlsx"
205
+ when "application/vnd.ms-powerpoint" then ".ppt"
206
+ when "application/vnd.openxmlformats-officedocument.presentationml.presentation" then ".pptx"
207
+ # Archives
208
+ when "application/zip" then ".zip"
209
+ when "application/x-rar-compressed", "application/vnd.rar" then ".rar"
210
+ when "application/x-7z-compressed" then ".7z"
211
+ when "application/x-tar" then ".tar"
212
+ when "application/gzip" then ".gz"
213
+ # Text
214
+ when "text/plain" then ".txt"
215
+ when "text/markdown" then ".md"
216
+ when "text/csv" then ".csv"
217
+ when "text/html" then ".html"
218
+ when "application/json" then ".json"
219
+ when "application/xml", "text/xml" then ".xml"
220
+ when "application/x-yaml", "text/yaml" then ".yml"
221
+ # Audio / video
222
+ when "audio/mpeg" then ".mp3"
223
+ when "audio/wav", "audio/x-wav" then ".wav"
224
+ when "audio/aac" then ".aac"
225
+ when "audio/ogg" then ".ogg"
226
+ when "video/mp4" then ".mp4"
227
+ when "video/quicktime" then ".mov"
228
+ end
229
+ end
230
+
231
+ # Upload a local file to DingTalk and return its media_id.
232
+ # Webhook delivery doesn't support image/file attachments — uploaded
233
+ # mediaId is used by the OAPI sendMessage path below.
234
+ # @param path [String]
235
+ # @param kind [Symbol] :image | :file
236
+ # @return [String, nil] media_id
237
+ def upload_media(path, kind:)
238
+ type_str = kind == :image ? "image" : "file"
239
+ # NB: legacy OAPI /media/upload — the new /v1.0/robot/messageFiles/*
240
+ # path returns 404, this is the only working endpoint as of 2026-05.
241
+ token = oapi_access_token
242
+ uri = URI.parse("#{OAPI_BASE}/media/upload?access_token=#{token}&type=#{type_str}")
243
+ boundary = "----DingTalkBoundary#{rand(1 << 64).to_s(16)}"
244
+ body = build_multipart(path, boundary, type_str)
245
+
246
+ http = Net::HTTP.new(uri.host, uri.port)
247
+ http.use_ssl = true
248
+ req = Net::HTTP::Post.new(uri.request_uri,
249
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}")
250
+ req.body = body
251
+ resp = http.request(req)
252
+ data = JSON.parse(resp.body) rescue {}
253
+ unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["media_id"]
254
+ Clacky::Logger.warn("[dingtalk] upload_media rejected (#{resp.code}): #{resp.body}")
255
+ return nil
256
+ end
257
+ # Operational log: confirm upload succeeded and surface the
258
+ # media_id shape (length + first 4 chars). We keep this at info
259
+ # level because outbound failures correlate strongly with
260
+ # media_id format drift (e.g. DingTalk silently changing the
261
+ # `@` prefix policy). Avoid logging the full body to keep the
262
+ # token / id from leaking into shared log channels.
263
+ mid = data["media_id"].to_s
264
+ Clacky::Logger.info("[dingtalk] upload_media ok type=#{type_str} media_id_len=#{mid.length} media_id_prefix=#{mid[0, 4].inspect}")
265
+ mid
266
+ rescue => e
267
+ Clacky::Logger.warn("[dingtalk] upload_media failed: #{e.message}")
268
+ nil
269
+ end
270
+
271
+ # Send a media message via OAPI (not webhook).
272
+ # DM → /v1.0/robot/oToMessages/batchSend (needs userIds)
273
+ # Group → /v1.0/robot/groupMessages/send (needs openConversationId)
274
+ # @param conv_type [String] "1"=DM, "2"=group
275
+ # @param kind [Symbol] :image | :file
276
+ def send_media(robot_code:, conv_type:, conv_id:, user_id:, media_id:, kind:, file_name: nil)
277
+ msg_key, msg_param = build_media_message(media_id, kind, file_name)
278
+
279
+ if conv_type == "2"
280
+ path = "/v1.0/robot/groupMessages/send"
281
+ body = {
282
+ msgKey: msg_key,
283
+ msgParam: JSON.generate(msg_param),
284
+ openConversationId: conv_id,
285
+ robotCode: robot_code
286
+ }
287
+ else
288
+ path = "/v1.0/robot/oToMessages/batchSend"
289
+ body = {
290
+ msgKey: msg_key,
291
+ msgParam: JSON.generate(msg_param),
292
+ userIds: [user_id],
293
+ robotCode: robot_code
294
+ }
295
+ end
296
+
297
+ uri = URI.parse("#{OPENAPI_BASE}#{path}")
298
+ http = Net::HTTP.new(uri.host, uri.port)
299
+ http.use_ssl = true
300
+ req = Net::HTTP::Post.new(uri.request_uri,
301
+ "Content-Type" => "application/json",
302
+ "x-acs-dingtalk-access-token" => access_token)
303
+ req.body = JSON.generate(body)
304
+ resp = http.request(req)
305
+ data = JSON.parse(resp.body) rescue {}
306
+ if resp.code.to_i != 200
307
+ Clacky::Logger.warn("[dingtalk] send_media rejected (#{resp.code}): #{resp.body}")
308
+ return { ok: false, error: data["message"] || resp.body }
309
+ end
310
+ # Operational log: success path. We log msgKey (image vs file)
311
+ # so the operator can correlate "sampleImageMsg" with image
312
+ # delivery and "sampleFile" with file delivery without parsing
313
+ # the full request body.
314
+ Clacky::Logger.info("[dingtalk] send_media ok kind=#{kind} msgKey=#{msg_key}")
315
+ { ok: true, data: data }
316
+ rescue => e
317
+ Clacky::Logger.warn("[dingtalk] send_media failed: #{e.message}")
318
+ { ok: false, error: e.message }
319
+ end
320
+
321
+ private def build_media_message(media_id, kind, file_name)
322
+ # Images: `sampleImageMsg` with the OAPI-uploaded mediaId (type=image)
323
+ # renders an inline original-resolution image in the chat. The doc
324
+ # field is named `photoURL`, but DingTalk explicitly documents that
325
+ # photoURL accepts either a public URL OR a mediaId — and the
326
+ # mediaId form is what we use (the only way to send local files
327
+ # without standing up a public file server).
328
+ #
329
+ # NOTE — implementation pitfalls hard-won (2026-05-19):
330
+ # 1. Pass the raw mediaId returned by /media/upload AS-IS.
331
+ # Despite the sampleLink doc example showing `picUrl: "@lADO..."`
332
+ # with an `@` prefix, sampleImageMsg.photoURL must NOT be
333
+ # prefixed — the upload response already includes whatever
334
+ # shape DingTalk wants. Adding `@` produces "原图加载失败".
335
+ # 2. msgKey is `sampleImageMsg`, NOT `msgtype: "image"`.
336
+ # `msgtype:image` belongs to the corpconversation/asyncsend_v2
337
+ # work-notification protocol and does NOT work on the group
338
+ # robot endpoint we use here.
339
+ # 3. msgParam must be a JSON-stringified object (handled by the
340
+ # caller), not a nested hash.
341
+ return ["sampleImageMsg", { photoURL: media_id }] if kind == :image
342
+
343
+ # Generic files: sampleFile. fileType must be one of
344
+ # SUPPORTED_FILE_EXTS (see top of file for why we keep the
345
+ # empirically-verified 9-entry list rather than the 6-entry
346
+ # doc list). Upstream `Adapter#send_file` already screens out
347
+ # unsupported extensions before we reach here, so we just pass
348
+ # `ext` through.
349
+ ext = File.extname(file_name.to_s).delete(".")
350
+ ["sampleFile", { mediaId: media_id, fileName: file_name.to_s, fileType: ext }]
351
+ end
352
+
353
+ private def build_multipart(path, boundary, type_str)
354
+ filename = File.basename(path)
355
+ mime = mime_for(filename)
356
+ content = File.binread(path)
357
+
358
+ # All parts must be ASCII-8BIT before joining; mixing UTF-8 (e.g. a
359
+ # filename with CJK chars) with binary file content raises
360
+ # "incompatible character encodings: UTF-8 and BINARY".
361
+ parts = []
362
+ parts << "--#{boundary}\r\n".b
363
+ parts << "Content-Disposition: form-data; name=\"type\"\r\n\r\n#{type_str}\r\n".b
364
+ parts << "--#{boundary}\r\n".b
365
+ parts << "Content-Disposition: form-data; name=\"media\"; filename=\"#{filename}\"\r\n".b
366
+ parts << "Content-Type: #{mime}\r\n\r\n".b
367
+ parts << content.b
368
+ parts << "\r\n--#{boundary}--\r\n".b
369
+ parts.join
370
+ end
371
+
372
+ private def mime_for(filename)
373
+ case File.extname(filename).downcase
374
+ when ".jpg", ".jpeg" then "image/jpeg"
375
+ when ".png" then "image/png"
376
+ when ".gif" then "image/gif"
377
+ when ".webp" then "image/webp"
378
+ when ".txt", ".log" then "text/plain"
379
+ when ".md" then "text/markdown"
380
+ when ".pdf" then "application/pdf"
381
+ when ".json" then "application/json"
382
+ when ".csv" then "text/csv"
383
+ when ".zip" then "application/zip"
384
+ else "application/octet-stream"
385
+ end
386
+ end
78
387
  end
79
388
  end
80
389
  end
@@ -407,7 +407,7 @@ module Clacky
407
407
  when ["GET", "/api/channels"] then api_list_channels(res)
408
408
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
409
409
  when ["POST", "/api/upload"] then api_upload_file(req, res)
410
- when ["POST", "/api/open-file"] then api_open_file(req, res)
410
+ when ["POST", "/api/file-action"] then api_file_action(req, res)
411
411
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
412
412
  when ["GET", "/api/version"] then api_get_version(res)
413
413
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
@@ -448,6 +448,9 @@ module Clacky
448
448
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
449
449
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
450
450
  api_switch_session_model(session_id, req, res)
451
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
452
+ session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
453
+ api_switch_session_reasoning_effort(session_id, req, res)
451
454
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
452
455
  session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
453
456
  api_benchmark_session_models(session_id, req, res)
@@ -752,6 +755,9 @@ module Clacky
752
755
  else
753
756
  Clacky::Logger.debug("[Brand] async distribution refresh skipped/failed — #{result[:message]}")
754
757
  end
758
+ # Free-mode skill sync: branded + unactivated installs need their
759
+ # creator's free skills auto-installed for the "no serial number" UX.
760
+ brand.sync_free_skills_async!
755
761
  rescue StandardError => e
756
762
  Clacky::Logger.warn("[Brand] async distribution refresh raised: #{e.class}: #{e.message}")
757
763
  ensure
@@ -797,12 +803,30 @@ module Clacky
797
803
  refresh_pending = true
798
804
  end
799
805
 
806
+ # Free-mode counts: synchronous fetch is acceptable here because
807
+ # this endpoint is polled lazily and the platform call is cached
808
+ # via http keep-alive. On error we just return zero counts and the
809
+ # banner falls back to the legacy "not activated" message.
810
+ free_count = 0
811
+ paid_count = 0
812
+ begin
813
+ result = brand.fetch_free_skills!
814
+ if result[:success]
815
+ free_count = result[:skills].size
816
+ paid_count = result[:paid_skills_count].to_i
817
+ end
818
+ rescue StandardError
819
+ # Network errors are non-fatal here.
820
+ end
821
+
800
822
  json_response(res, 200, {
801
823
  branded: true,
802
824
  needs_activation: true,
803
825
  product_name: brand.product_name,
804
826
  test_mode: @brand_test,
805
- distribution_refresh_pending: refresh_pending
827
+ distribution_refresh_pending: refresh_pending,
828
+ free_skills_count: free_count,
829
+ paid_skills_count: paid_count
806
830
  })
807
831
  return
808
832
  end
@@ -918,7 +942,30 @@ module Clacky
918
942
  brand = Clacky::BrandConfig.load
919
943
 
920
944
  unless brand.activated?
921
- json_response(res, 403, { ok: false, error: "License not activated" })
945
+ # Free-mode: branded but no license. Return the unencrypted skills
946
+ # available to anonymous installs so the Brand Skills tab is not
947
+ # empty and the user can install/use them without a serial number.
948
+ # Each skill is tagged is_free=true so the UI can show a "Free" badge.
949
+ result = brand.fetch_free_skills!
950
+
951
+ if result[:success]
952
+ free_skills = result[:skills].map { |s| s.merge("is_free" => true) }
953
+ json_response(res, 200, {
954
+ ok: true,
955
+ skills: free_skills,
956
+ free_mode: true,
957
+ paid_skills_count: result[:paid_skills_count].to_i
958
+ })
959
+ else
960
+ json_response(res, 200, {
961
+ ok: true,
962
+ skills: [],
963
+ free_mode: true,
964
+ paid_skills_count: 0,
965
+ warning_code: "remote_unavailable",
966
+ warning: result[:error] || "Could not reach the license server."
967
+ })
968
+ end
922
969
  return
923
970
  end
924
971
 
@@ -963,8 +1010,29 @@ module Clacky
963
1010
  def api_brand_skill_install(slug, req, res)
964
1011
  brand = Clacky::BrandConfig.load
965
1012
 
1013
+ # Free-mode: branded but not activated. Fall back to the public free
1014
+ # skills endpoint and install with encrypted: false. Paid (encrypted)
1015
+ # skills still require activation and will return 404 here.
966
1016
  unless brand.activated?
967
- json_response(res, 403, { ok: false, error: "License not activated" })
1017
+ fetch_result = brand.fetch_free_skills!
1018
+ unless fetch_result[:success]
1019
+ json_response(res, 422, { ok: false, error: fetch_result[:error] })
1020
+ return
1021
+ end
1022
+
1023
+ skill_info = fetch_result[:skills].find { |s| s["name"] == slug }
1024
+ unless skill_info
1025
+ json_response(res, 404, { ok: false, error: "Skill '#{slug}' is not a free skill — activate your license to access it." })
1026
+ return
1027
+ end
1028
+
1029
+ result = brand.install_free_skill!(skill_info)
1030
+ if result[:success]
1031
+ @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
1032
+ json_response(res, 200, { ok: true, name: result[:name], version: result[:version] })
1033
+ else
1034
+ json_response(res, 422, { ok: false, error: result[:error] })
1035
+ end
968
1036
  return
969
1037
  end
970
1038
 
@@ -1304,7 +1372,11 @@ module Clacky
1304
1372
  sleep 0.5 # Let WEBrick flush the HTTP response
1305
1373
 
1306
1374
  if @master_pid
1307
- # Worker mode: tell master to hot-restart, then exit cleanly.
1375
+ # Worker mode: tell master to hot-restart. Master will TERM us after the
1376
+ # new worker boots; our trap("TERM") then runs shutdown_proc, which detaches
1377
+ # the inherited listen socket before WEBrick shutdown. Do NOT exit(0) here —
1378
+ # that bypasses trap handlers and lets the OS close(fd) on a socket shared
1379
+ # with master+new worker, corrupting the listener on Linux/WSL.
1308
1380
  Clacky::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
1309
1381
  begin
1310
1382
  Process.kill("USR1", @master_pid)
@@ -1312,7 +1384,6 @@ module Clacky
1312
1384
  Clacky::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
1313
1385
  standalone_exec_restart
1314
1386
  end
1315
- exit(0)
1316
1387
  else
1317
1388
  # Standalone mode (no master): fall back to the original exec approach.
1318
1389
  standalone_exec_restart
@@ -1549,12 +1620,16 @@ module Clacky
1549
1620
  json_response(res, 500, { ok: false, error: e.message })
1550
1621
  end
1551
1622
 
1552
- # POST /api/open-file
1553
- # Opens a local file or directory using the OS default handler.
1554
- # Used by the Web UI to handle file:// links — browsers block direct
1555
- # file:// navigation from http:// pages for security reasons.
1556
- def api_open_file(req, res)
1557
- path = parse_json_body(req)["path"]
1623
+ # POST /api/file-action
1624
+ # Unified file action endpoint open locally or download.
1625
+ # Body: { path: String, action: "open" | "download" }
1626
+ # open: opens the file with the OS default handler (local deployments).
1627
+ # download: returns the file as a download (remote deployments).
1628
+ def api_file_action(req, res)
1629
+ body = parse_json_body(req)
1630
+ path = body["path"]
1631
+ action = body["action"] || "open"
1632
+
1558
1633
  return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
1559
1634
 
1560
1635
  # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
@@ -1567,13 +1642,33 @@ module Clacky
1567
1642
 
1568
1643
  return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
1569
1644
 
1570
- result = Utils::EnvironmentDetector.open_file(linux_path)
1571
- return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
1572
- json_response(res, 200, { ok: true })
1645
+ case action
1646
+ when "open"
1647
+ result = Utils::EnvironmentDetector.open_file(linux_path)
1648
+ return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
1649
+ json_response(res, 200, { ok: true })
1650
+ when "download"
1651
+ serve_file_download(res, linux_path)
1652
+ else
1653
+ json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
1654
+ end
1573
1655
  rescue => e
1574
1656
  json_response(res, 500, { ok: false, error: e.message })
1575
1657
  end
1576
1658
 
1659
+ # Stream a file to the client as a download.
1660
+ # Content-Type is always application/octet-stream — the browser determines
1661
+ # file type and handling from the filename extension in Content-Disposition.
1662
+ def serve_file_download(res, path)
1663
+ filename = File.basename(path)
1664
+
1665
+ res.status = 200
1666
+ res["Content-Type"] = "application/octet-stream"
1667
+ res["Content-Disposition"] = "attachment; filename=\"#{filename}\""
1668
+ res["Content-Length"] = File.size(path).to_s
1669
+ res.body = File.binread(path)
1670
+ end
1671
+
1577
1672
  # GET /api/local-image?path=file:///path/to/image.png
1578
1673
  # GET /api/local-image?path=/path/to/image.png
1579
1674
  #
@@ -2999,6 +3094,26 @@ module Clacky
2999
3094
  json_response(res, 500, { error: e.message })
3000
3095
  end
3001
3096
 
3097
+ # PATCH /api/sessions/:id/reasoning_effort
3098
+ # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
3099
+ def api_switch_session_reasoning_effort(session_id, req, res)
3100
+ body = parse_json_body(req)
3101
+ raw = body["reasoning_effort"]
3102
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
3103
+
3104
+ agent = nil
3105
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
3106
+ return json_response(res, 404, { error: "Session not found" }) unless agent
3107
+
3108
+ agent.reasoning_effort = raw
3109
+ @session_manager.save(agent.to_session_data)
3110
+ broadcast_session_update(session_id)
3111
+
3112
+ json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
3113
+ rescue => e
3114
+ json_response(res, 500, { error: e.message })
3115
+ end
3116
+
3002
3117
  # POST /api/sessions/:id/benchmark
3003
3118
  #
3004
3119
  # Speed-test every configured model in one shot so the user can pick the
@@ -165,11 +165,12 @@ module Clacky
165
165
  model_info = s[:agent]&.current_model_info
166
166
  live_name = s[:agent]&.name
167
167
  live_name = nil if live_name&.empty?
168
- live_cost_source = s[:agent]&.cost_source
169
- { status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
170
- total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
171
- cost_source: live_cost_source,
172
- latest_latency: s[:agent]&.latest_latency }
168
+ live_cost_source = s[:agent]&.cost_source
169
+ { status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
170
+ total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
171
+ cost_source: live_cost_source,
172
+ reasoning_effort: s[:agent]&.reasoning_effort,
173
+ latest_latency: s[:agent]&.latest_latency }
173
174
  end
174
175
  end
175
176
 
@@ -241,6 +242,7 @@ module Clacky
241
242
  { status: s[:status], error: s[:error], model: model_info&.dig(:model),
242
243
  name: live_name, total_tasks: s[:agent]&.total_tasks,
243
244
  total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
245
+ reasoning_effort: s[:agent]&.reasoning_effort,
244
246
  latest_latency: s[:agent]&.latest_latency }
245
247
  end
246
248
 
@@ -271,6 +273,7 @@ module Clacky
271
273
  # per-assistant-message `latency` fields in messages[]. Reloaded
272
274
  # sessions start with nil and get populated on the next LLM call.
273
275
  latest_latency: ls&.dig(:latest_latency),
276
+ reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
274
277
  pinned: s[:pinned] || false,
275
278
  }
276
279
  end
@@ -382,7 +385,7 @@ module Clacky
382
385
  return nil unless agent
383
386
 
384
387
  model_info = agent.current_model_info
385
-
388
+
386
389
  {
387
390
  id: session[:id],
388
391
  name: agent.name,
@@ -795,6 +795,20 @@ module Clacky
795
795
  @legacy_progress_handles[type] = start_progress(message: display, style: style)
796
796
  end
797
797
 
798
+ # Stream-only update for the live thinking progress. Unlike
799
+ # +show_progress(progress_type: "thinking", phase: "active")+, this
800
+ # NEVER creates a new handle — if no thinking handle is currently
801
+ # alive (e.g. we're inside an idle-compression call_llm where only
802
+ # the quiet "Compressing..." handle is on the stack), the streamed
803
+ # token counts are silently dropped instead of spawning a primary
804
+ # spinner that would push the compression progress off-screen.
805
+ def stream_thinking_progress(input_tokens:, output_tokens:)
806
+ @legacy_progress_handles ||= {}
807
+ existing = @legacy_progress_handles["thinking"]
808
+ return unless existing&.running?
809
+ existing.update(metadata: { input_tokens: input_tokens, output_tokens: output_tokens })
810
+ end
811
+
798
812
  # ---------------------------------------------------------------------
799
813
  # (Legacy dead-code removed: the old imperative show_progress body
800
814
  # used to live here and is now superseded by the shim + owner
@@ -36,6 +36,20 @@ module Clacky
36
36
  # metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
37
37
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
38
38
 
39
+ # Update the live "thinking" progress with streamed token counts.
40
+ # This is *purely decorative*: it must NEVER start a new progress
41
+ # indicator. If no thinking progress is currently active (e.g. during
42
+ # idle compression, where only a quiet "Compressing..." progress is
43
+ # live), the call is a no-op. UI2 overrides this; other UIs delegate
44
+ # to show_progress.
45
+ def stream_thinking_progress(input_tokens:, output_tokens:)
46
+ show_progress(
47
+ progress_type: "thinking",
48
+ phase: "active",
49
+ metadata: { input_tokens: input_tokens, output_tokens: output_tokens }
50
+ )
51
+ end
52
+
39
53
  # === Progress (v2: owned handles) ===
40
54
  #
41
55
  # Start a new progress indicator and return an owned handle. The caller