openclacky 1.5.1 → 1.5.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/agent/llm_caller.rb +48 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +7 -0
  5. data/lib/clacky/agent/session_serializer.rb +42 -0
  6. data/lib/clacky/agent/skill_manager.rb +7 -0
  7. data/lib/clacky/agent.rb +58 -1
  8. data/lib/clacky/agent_config.rb +71 -1
  9. data/lib/clacky/brand_config.rb +88 -59
  10. data/lib/clacky/client.rb +13 -2
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +15 -3
  13. data/lib/clacky/extension/packager.rb +17 -0
  14. data/lib/clacky/locales/en.rb +4 -2
  15. data/lib/clacky/locales/zh.rb +5 -3
  16. data/lib/clacky/message_format/anthropic.rb +56 -1
  17. data/lib/clacky/message_format/open_ai.rb +69 -4
  18. data/lib/clacky/message_history.rb +15 -1
  19. data/lib/clacky/providers.rb +73 -6
  20. data/lib/clacky/server/http_server.rb +61 -29
  21. data/lib/clacky/server/session_registry.rb +4 -2
  22. data/lib/clacky/skill.rb +4 -0
  23. data/lib/clacky/tools/invoke_skill.rb +21 -0
  24. data/lib/clacky/tools/security.rb +11 -13
  25. data/lib/clacky/ui_interface.rb +2 -0
  26. data/lib/clacky/utils/model_pricing.rb +8 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +210 -21
  29. data/lib/clacky/web/app.js +8 -3
  30. data/lib/clacky/web/components/code-editor.js +50 -12
  31. data/lib/clacky/web/features/billing/view.js +7 -1
  32. data/lib/clacky/web/features/extensions/store.js +23 -7
  33. data/lib/clacky/web/features/extensions/view.js +8 -4
  34. data/lib/clacky/web/features/new-session/view.js +61 -0
  35. data/lib/clacky/web/features/workspace/store.js +9 -0
  36. data/lib/clacky/web/features/workspace/view.js +8 -1
  37. data/lib/clacky/web/i18n.js +14 -0
  38. data/lib/clacky/web/index.html +4 -2
  39. data/lib/clacky/web/settings.js +49 -14
  40. metadata +2 -2
@@ -103,6 +103,15 @@ module Clacky
103
103
  question: question, context: context, options: options }
104
104
  end
105
105
 
106
+ def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil)
107
+ @events << { type: "subagent_start", session_id: @session_id,
108
+ skill: skill, iterations: iterations, cost_usd: cost_usd }
109
+ end
110
+
111
+ def show_subagent_end
112
+ @events << { type: "subagent_end", session_id: @session_id }
113
+ end
114
+
106
115
  # Ignore all other UI methods (progress, errors, etc.) during history replay
107
116
  def method_missing(name, *args, **kwargs); end
108
117
  def respond_to_missing?(name, include_private = false); true; end
@@ -2267,19 +2276,23 @@ module Clacky
2267
2276
 
2268
2277
  Clacky::Logger.debug("[Brand] api_brand_status: expired=#{brand.expired?} grace_exceeded=#{brand.grace_period_exceeded?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"}")
2269
2278
 
2270
- warning = nil
2279
+ warning = nil
2280
+ warning_type = nil
2271
2281
  if brand.expired?
2272
- warning = "Your #{brand.product_name} license has expired. Please renew to continue."
2282
+ warning = "Your #{brand.product_name} license has expired. Please renew to continue."
2283
+ warning_type = "expired"
2273
2284
  elsif brand.grace_period_exceeded?
2274
- warning = "License server unreachable for more than 3 days. Please check your connection."
2285
+ warning = "License server unreachable for more than 3 days. Please check your connection."
2286
+ warning_type = "unreachable"
2275
2287
  elsif brand.license_expires_at && !brand.expired?
2276
2288
  days_remaining = ((brand.license_expires_at - Time.now.utc) / 86_400).ceil
2277
2289
  if days_remaining <= 7
2278
- warning = "Your #{brand.product_name} license expires in #{days_remaining} day#{"s" if days_remaining != 1}. Please renew soon."
2290
+ warning = "Your #{brand.product_name} license expires in #{days_remaining} day#{"s" if days_remaining != 1}. Please renew soon."
2291
+ warning_type = "expiring"
2279
2292
  end
2280
2293
  end
2281
2294
 
2282
- Clacky::Logger.debug("[Brand] api_brand_status: warning=#{warning.inspect}")
2295
+ Clacky::Logger.debug("[Brand] api_brand_status: warning=#{warning.inspect} warning_type=#{warning_type.inspect}")
2283
2296
 
2284
2297
  json_response(res, 200, {
2285
2298
  branded: true,
@@ -2288,6 +2301,7 @@ module Clacky
2288
2301
  homepage_url: brand.homepage_url,
2289
2302
  logo_url: brand.logo_url,
2290
2303
  warning: warning,
2304
+ warning_type: warning_type,
2291
2305
  test_mode: @brand_test,
2292
2306
  user_licensed: brand.user_licensed?,
2293
2307
  license_user_id: brand.license_user_id
@@ -2478,11 +2492,11 @@ module Clacky
2478
2492
  "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2479
2493
  "hub_active" => market&.dig("hub_active"),
2480
2494
  "download_count" => market&.dig("download_count").to_i,
2481
- # Only mark as unlisted when the extension was published to the
2482
- # marketplace (origin != "self") but is no longer listed there.
2483
- # Self-authored extensions (origin: self) have never been published
2484
- # and should never show the "unlisted" badge.
2485
- "unlisted" => market.nil? && container[:origin].to_s != "self",
2495
+ # Mark as unlisted when:
2496
+ # - market is nil (extension no longer exists on the platform), OR
2497
+ # - platform batch API explicitly returned unlisted:true (brand-private
2498
+ # extension removed from all distributions but not yet soft-deleted).
2499
+ "unlisted" => market.nil? || market["unlisted"] == true,
2486
2500
  "layer" => container[:layer].to_s,
2487
2501
  "installed" => true,
2488
2502
  "removable" => true,
@@ -2541,10 +2555,11 @@ module Clacky
2541
2555
  units
2542
2556
  end
2543
2557
 
2544
- # GET /api/store/extension?id=<slug-or-id>
2558
+ # GET /api/store/extension?id=<slug-or-id>[&source=brand]
2545
2559
  #
2546
- # Public detail for a single marketplace extension (contributes + version
2547
- # history). Anonymous, proxies BrandConfig#extension_detail!.
2560
+ # Detail for a single extension. Pass source=brand to query brand-private
2561
+ # extensions via the license-gated API; omit (or any other value) for the
2562
+ # public marketplace API.
2548
2563
  def api_store_extension_detail(req, res)
2549
2564
  id = req.query["id"].to_s
2550
2565
  if id.strip.empty?
@@ -2553,13 +2568,10 @@ module Clacky
2553
2568
  end
2554
2569
 
2555
2570
  brand = Clacky::BrandConfig.load
2556
- # Always try the public endpoint first — it covers all marketplace
2557
- # extensions regardless of brand status. Only fall back to the
2558
- # license-gated brand endpoint when the public lookup fails (e.g. for
2559
- # origin=self brand-private extensions not exposed on the public API).
2560
- result = brand.extension_detail!(id)
2561
- if !result[:success] && brand.activated?
2562
- result = brand.brand_extension_detail!(id)
2571
+ result = if req.query["source"] == "brand"
2572
+ brand.brand_extension_detail!(id)
2573
+ else
2574
+ brand.extension_detail!(id)
2563
2575
  end
2564
2576
 
2565
2577
  if result[:success] && result[:extension]
@@ -2570,7 +2582,7 @@ module Clacky
2570
2582
  "installed" => !container.nil?,
2571
2583
  "installed_version" => container&.dig(:version),
2572
2584
  "removable" => container && container[:layer] == :installed,
2573
- "disabled" => container ? container[:disabled] == true : false
2585
+ "disabled" => container ? container[:disabled] == true : false,
2574
2586
  )
2575
2587
  json_response(res, 200, { ok: true, extension: ext })
2576
2588
  else
@@ -2597,9 +2609,7 @@ module Clacky
2597
2609
  "homepage" => market ? (market["homepage"] || "") : container[:homepage].to_s,
2598
2610
  "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2599
2611
  "hub_active" => market&.dig("hub_active"),
2600
- # Only mark as unlisted when the extension was published to the
2601
- # marketplace (origin != "self") but is no longer listed there.
2602
- "unlisted" => market.nil? && container[:origin].to_s != "self",
2612
+ "unlisted" => market.nil? || market["unlisted"] == true,
2603
2613
  "installed" => true,
2604
2614
  "removable" => true,
2605
2615
  "disabled" => container[:disabled] == true,
@@ -3773,9 +3783,10 @@ module Clacky
3773
3783
 
3774
3784
  # POST /api/file-action
3775
3785
  # Unified file action endpoint — open locally or download.
3776
- # Body: { path: String, action: "open" | "download" }
3786
+ # Body: { path: String, action: "open" | "download" | "save" }
3777
3787
  # open: opens the file with the OS default handler (local deployments).
3778
3788
  # download: returns the file as a download (remote deployments).
3789
+ # save: writes content back to the file. Body must include { content: String }.
3779
3790
  def api_file_action(req, res)
3780
3791
  body = parse_json_body(req)
3781
3792
  path = body["path"]
@@ -3789,7 +3800,10 @@ module Clacky
3789
3800
  linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
3790
3801
  linux_path = File.expand_path(linux_path)
3791
3802
 
3792
- return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
3803
+ # For save action, the file may not exist yet skip the existence check.
3804
+ unless action == "save"
3805
+ return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
3806
+ end
3793
3807
 
3794
3808
  case action
3795
3809
  when "open"
@@ -3804,8 +3818,14 @@ module Clacky
3804
3818
  when "display-path"
3805
3819
  display = Utils::EnvironmentDetector.linux_to_win_path(linux_path)
3806
3820
  json_response(res, 200, { ok: true, path: display })
3821
+ when "save"
3822
+ content = body["content"]
3823
+ return json_response(res, 400, { error: "content is required" }) if content.nil?
3824
+ FileUtils.mkdir_p(File.dirname(linux_path))
3825
+ File.write(linux_path, content, encoding: "UTF-8")
3826
+ json_response(res, 200, { ok: true })
3807
3827
  else
3808
- json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download' or 'display-path'" })
3828
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download', 'display-path' or 'save'" })
3809
3829
  end
3810
3830
  rescue => e
3811
3831
  json_response(res, 500, { ok: false, error: e.message })
@@ -5398,6 +5418,7 @@ module Clacky
5398
5418
  base_url: m["base_url"],
5399
5419
  api_key_masked: mask_api_key(m["api_key"]),
5400
5420
  anthropic_format: m["anthropic_format"] || false,
5421
+ provider_id: m["provider_id"],
5401
5422
  type: m["type"]
5402
5423
  }
5403
5424
  end
@@ -5606,7 +5627,8 @@ module Clacky
5606
5627
  "model" => model,
5607
5628
  "base_url" => base_url,
5608
5629
  "api_key" => api_key,
5609
- "anthropic_format" => body["anthropic_format"] || false
5630
+ "anthropic_format" => body["anthropic_format"] || false,
5631
+ "provider_id" => body["provider_id"].to_s.strip.then { |v| v.empty? ? nil : v }
5610
5632
  }
5611
5633
  type = body["type"].to_s
5612
5634
  unless type.empty?
@@ -5667,6 +5689,14 @@ module Clacky
5667
5689
  if body.key?("anthropic_format")
5668
5690
  target["anthropic_format"] = !!body["anthropic_format"]
5669
5691
  end
5692
+ if body.key?("provider_id")
5693
+ v = body["provider_id"].to_s.strip
5694
+ if v.empty?
5695
+ target.delete("provider_id")
5696
+ else
5697
+ target["provider_id"] = v
5698
+ end
5699
+ end
5670
5700
  if body.key?("api_key")
5671
5701
  new_key = body["api_key"].to_s
5672
5702
  # Only store a real, unmasked, non-empty value. This is the
@@ -5973,7 +6003,9 @@ module Clacky
5973
6003
 
5974
6004
  if model_name && !model_name.empty?
5975
6005
  info = agent.current_model_info
5976
- provider_id = info && Clacky::Providers.find_by_base_url(info[:base_url])
6006
+ # Prefer explicitly saved provider_id, fall back to base_url lookup
6007
+ provider_id = info&.dig(:provider_id).to_s.strip.then { |v| v.empty? ? nil : v }
6008
+ provider_id ||= (info && Clacky::Providers.find_by_base_url(info[:base_url]))
5977
6009
  allowed = provider_id ? Clacky::Providers.models(provider_id) : []
5978
6010
  if allowed.empty?
5979
6011
  return json_response(res, 400, { error: "Current model has no provider preset; sub-model switching unavailable" })
@@ -376,8 +376,10 @@ module Clacky
376
376
  # in any preset (e.g. self-hosted custom endpoints) — the WebUI
377
377
  # treats that as "no sub-model switcher available".
378
378
  private def sub_model_options_for(model_info)
379
- return [] unless model_info && model_info[:base_url]
380
- provider_id = Clacky::Providers.find_by_base_url(model_info[:base_url])
379
+ return [] unless model_info
380
+ # Prefer explicitly saved provider_id, fall back to base_url lookup
381
+ provider_id = model_info[:provider_id].to_s.strip.then { |v| v.empty? ? nil : v }
382
+ provider_id ||= (model_info[:base_url] && Clacky::Providers.find_by_base_url(model_info[:base_url]))
381
383
  return [] unless provider_id
382
384
  Clacky::Providers.models(provider_id)
383
385
  end
data/lib/clacky/skill.rb CHANGED
@@ -537,6 +537,10 @@ module Clacky
537
537
  name_invalid = !valid_slug.call(@name) || @name.length > 64
538
538
 
539
539
  if name_invalid
540
+ # Preserve the original non-slug name as name_zh (display name) if not already set.
541
+ # e.g. name: "中英翻译" → name_zh = "中英翻译", name falls back to dir slug.
542
+ @name_zh ||= @name
543
+
540
544
  if valid_slug.call(dir_slug)
541
545
  # Recoverable: fall back to directory name, record a warning
542
546
  @warnings << "Invalid name '#{@name}' in metadata; using directory name '#{dir_slug}' instead."
@@ -77,6 +77,27 @@ module Clacky
77
77
  "InvokeSkill(#{skill})"
78
78
  end
79
79
 
80
+ # Format the tool result for the LLM.
81
+ # Converts the raw Hash into a markdown string so the LLM can directly
82
+ # read the subagent output without parsing a JSON wrapper. JSON-encoding
83
+ # the result as a nested string caused models to miss the "result" field
84
+ # and hallucinate that the subagent returned nothing.
85
+ # @param result [Hash, String] Tool execution result
86
+ # @return [String] LLM-friendly formatted result
87
+ def format_result_for_llm(result)
88
+ if result.is_a?(String)
89
+ result
90
+ elsif result[:error]
91
+ "Error: #{result[:error]}"
92
+ elsif result[:skill_type] == "subagent"
93
+ subagent_result = result[:result].to_s.strip
94
+ subagent_result = "(subagent produced no output)" if subagent_result.empty?
95
+ "Subagent executed successfully.\n\n#{subagent_result}"
96
+ else
97
+ "Skill content expanded"
98
+ end
99
+ end
100
+
80
101
  # Format the tool result for display
81
102
  # @param result [Hash] Tool execution result
82
103
  # @return [String] Formatted result summary
@@ -19,17 +19,18 @@ module Clacky
19
19
  # Responsibilities (applied to the `command` string BEFORE it is handed
20
20
  # to a shell / PTY for execution):
21
21
  #
22
- # 1. Block hard-dangerous commands: sudo, pkill clacky, eval, exec,
22
+ # 1. Block hard-dangerous commands: pkill clacky, eval, exec,
23
23
  # `...`, | sh, | bash,
24
24
  # redirect to /etc /usr /bin.
25
25
  # 2. Rewrite `curl ... | bash` → save script to a file for manual
26
26
  # review instead of exec.
27
- # 3. Protect credential/secret files: .env, .ssh/, .aws/ — block
27
+ # 3. Protect credential/secret files: .ssh/, .aws/ — block
28
28
  # writes to these only. Other
29
29
  # "project" files (Gemfile,
30
- # README.md, package.json, …)
31
- # are NOT protected — editing
32
- # them is a normal dev task.
30
+ # README.md, package.json,
31
+ # .env, …) are NOT protected —
32
+ # editing them is a normal dev
33
+ # task.
33
34
  #
34
35
  # Note on `rm`:
35
36
  # `rm` is NOT rewritten here — it's intercepted at runtime by a shell
@@ -135,7 +136,10 @@ module Clacky
135
136
  when /^curl.*\|\s*(sh|bash)/
136
137
  replace_curl_pipe_command(command)
137
138
  when /^sudo\s+/
138
- block_sudo_command(command)
139
+ # sudo is allowed — the user is in control and takes responsibility.
140
+ # Still log it for audit trail.
141
+ log_warning("sudo command executed: #{command}")
142
+ command
139
143
  when />\s*\/dev\/null\s*$/
140
144
  allow_dev_null_redirect(command)
141
145
  when /^(mv|cp|mkdir|touch|echo)\s+/
@@ -175,10 +179,6 @@ module Clacky
175
179
  end
176
180
  end
177
181
 
178
- def block_sudo_command(_command)
179
- raise SecurityError, "sudo commands are not allowed for security reasons"
180
- end
181
-
182
182
  def allow_dev_null_redirect(command)
183
183
  command
184
184
  end
@@ -273,8 +273,6 @@ module Clacky
273
273
  SECRET_WRITE_PATTERNS = [
274
274
  %r{(?:\A|/)\.ssh/},
275
275
  %r{(?:\A|/)\.aws/},
276
- /(?:\A|\/)\.env(?:\.|\z)/,
277
- /\.env\z/
278
276
  ].freeze
279
277
 
280
278
  def validate_secret_write(path)
@@ -319,7 +317,7 @@ module Clacky
319
317
  end
320
318
 
321
319
  private :replace_chmod_command,
322
- :replace_curl_pipe_command, :block_sudo_command,
320
+ :replace_curl_pipe_command,
323
321
  :allow_dev_null_redirect, :validate_and_allow,
324
322
  :validate_general_command,
325
323
  :validate_file_path, :validate_secret_write,
@@ -11,6 +11,8 @@ module Clacky
11
11
  # @param files [Array<Hash>] extracted file refs: [{ name:, path:, inline: }]
12
12
  def show_assistant_message(content, files:); end
13
13
  def show_feedback_request(question, context, options); end
14
+ def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil); end
15
+ def show_subagent_end; end
14
16
  def show_tool_call(name, args); end
15
17
  def show_tool_result(result); end
16
18
  def show_tool_stdout(lines); end
@@ -472,6 +472,12 @@ module Clacky
472
472
  # endpoints don't charge separately for cache writes (Z.ai's page lists
473
473
  # "Cached Input Storage: Limited-time Free"), so bill writes at the
474
474
  # regular input miss rate for safe "displayed ≤ actual" behaviour.
475
+ "glm-5.2" => {
476
+ input: { default: 1.40, over_200k: 1.40 },
477
+ output: { default: 4.40, over_200k: 4.40 },
478
+ cache: { write: 1.40, read: 0.26 }
479
+ },
480
+
475
481
  "glm-5.1" => {
476
482
  input: { default: 1.40, over_200k: 1.40 },
477
483
  output: { default: 4.40, over_200k: 4.40 },
@@ -786,6 +792,8 @@ module Clacky
786
792
  # (mainland bigmodel.cn vs intl z.ai) the user configured.
787
793
  # Strict anchored match so unrelated strings like "glm-5-x-foo"
788
794
  # don't silently borrow a nearby model's rate.
795
+ when /^glm-5\.2$/i
796
+ "glm-5.2"
789
797
  when /^glm-5\.1$/i
790
798
  "glm-5.1"
791
799
  when /^glm-5v-turbo$/i
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.1"
4
+ VERSION = "1.5.3"
5
5
  end