openclacky 0.9.27 → 0.9.28

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc55d904ebe65b0a5d464481ef0075dcb3271e8ccd56143b3ea2b04600100a41
4
- data.tar.gz: e3aab9ed25ac14fc964eff4b2032322679802bb8f627286e60ab7bdc14d80289
3
+ metadata.gz: 8e9d5f6bf142facc899ab2087b5f245e1e3b7aa5135548a42455fea73e685def
4
+ data.tar.gz: f3d3251db1a6245ff9bdbe37433c9e70409c1975c718f18a68b7af82d17a38a4
5
5
  SHA512:
6
- metadata.gz: 361f37d7bcb4546d0ff92783bdc3018068cb47d411b80595fdcd003c8d62f007e9483ccce46292f7b1fe5a9828ef4e9779f7553ad747e21c388bf39de0b12487
7
- data.tar.gz: 04db6b2d4d939d58e49265dc3c000b82455b857e1634987c4c5218ae716e7ed9d4e7f7950964d56aa484c29ee11290b32721e3000c1bc51022edee14eb417d2b
6
+ metadata.gz: a300098516c8081374fa6d55a21f4884f8d70b43c12b1283643a86e03fdb81d96f765a81bc14e703b6e4584bbe3fec1a7b08eadb5488ff5de2223fda922ca9e8
7
+ data.tar.gz: 64d73189f3852cf6fc064a61d065e762c6ba2e32d4d254736aebdcaff2106be2b55ea3ad349a1898778460e17142a06977250d8053f7db6bfe5525093f701b55
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.28] - 2026-04-10
11
+
12
+ ### Added
13
+ - **Creator menu**: new creator-focused UI for managing brand skills and customizations
14
+ - **Provider fallback system**: automatic fallback to secondary AI providers when primary provider fails
15
+ - **Chinese localization**: full UI translation for skill descriptions and session lists
16
+ - **Session scroll improvements**: better session navigation and scrolling behavior in Web UI
17
+ - **Brand logo support**: custom logos and icons for white-label deployments
18
+
19
+ ### Improved
20
+ - **Browser setup skill**: enhanced browser-setup SKILL with more detailed instructions and error handling
21
+ - **Browser port detection**: more robust detection logic for Chrome/Edge debugging port
22
+
23
+ ### More
24
+ - Test suite improvements and fixes
25
+
10
26
  ## [0.9.27] - 2026-04-07
11
27
 
12
28
  ### Added
@@ -3,17 +3,46 @@
3
3
  module Clacky
4
4
  class Agent
5
5
  # LLM API call management
6
- # Handles API calls with retry logic and progress indication
6
+ # Handles API calls with retry logic, fallback model support, and progress indication
7
7
  module LlmCaller
8
- # Execute LLM API call with progress indicator, retry logic, and cost tracking
9
- # This method is shared by both normal think() and compression flows
8
+ # Number of consecutive RetryableError failures (503/429/5xx) before switching to fallback.
9
+ # Network-level errors (connection failures, timeouts) do NOT trigger fallback they are
10
+ # retried on the primary model for the full max_retries budget, since they are likely
11
+ # transient infrastructure blips rather than a model-level outage.
12
+ RETRIES_BEFORE_FALLBACK = 3
13
+
14
+ # After switching to the fallback model, allow this many retries before giving up.
15
+ # Kept lower than max_retries (10) because we have already exhausted the primary model.
16
+ MAX_RETRIES_ON_FALLBACK = 5
17
+
18
+ # Execute LLM API call with progress indicator, retry logic, and cost tracking.
19
+ #
20
+ # Fallback / probing state machine (driven by AgentConfig):
21
+ #
22
+ # :primary_ok (nil)
23
+ # Normal operation — use the configured model.
24
+ # After RETRIES_BEFORE_FALLBACK consecutive failures → :fallback_active
25
+ #
26
+ # :fallback_active
27
+ # Use fallback model. After FALLBACK_COOLING_OFF_SECONDS (30 min) the
28
+ # config transitions to :probing on the next call_llm entry.
29
+ #
30
+ # :probing
31
+ # Silently attempt the primary model once.
32
+ # Success → config transitions back to :primary_ok, user notified.
33
+ # Failure → renew cooling-off clock, back to :fallback_active, then
34
+ # retry the *same* request with the fallback model so the
35
+ # user experiences no extra delay.
36
+ #
10
37
  # @return [Hash] API response with :content, :tool_calls, :usage, etc.
11
38
  private def call_llm
39
+ # Transition :fallback_active → :probing if cooling-off has expired.
40
+ @config.maybe_start_probing
41
+
12
42
  @ui&.show_progress
13
43
 
14
44
  tools_to_send = @tool_registry.all_definitions
15
45
 
16
- # Retry logic for network failures
17
46
  max_retries = 10
18
47
  retry_delay = 5
19
48
  retries = 0
@@ -34,9 +63,23 @@ module Clacky
34
63
  max_tokens: @config.max_tokens,
35
64
  enable_caching: @config.enable_prompt_caching
36
65
  )
66
+
67
+ # Successful response — if we were probing, confirm primary is healthy.
68
+ handle_probe_success if @config.probing?
69
+
37
70
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
38
71
  @ui&.clear_progress
39
72
  retries += 1
73
+
74
+ # Probing failure: primary still down — renew cooling-off and retry with fallback.
75
+ if @config.probing?
76
+ handle_probe_failure
77
+ retry
78
+ end
79
+
80
+ # Network-level errors (timeouts, connection failures) are likely transient
81
+ # infrastructure blips — do NOT trigger fallback. Just retry on the current
82
+ # model (primary or already-active fallback) up to max_retries.
40
83
  if retries <= max_retries
41
84
  @ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
42
85
  sleep retry_delay
@@ -45,29 +88,86 @@ module Clacky
45
88
  @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
46
89
  raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
47
90
  end
91
+
48
92
  rescue RetryableError => e
49
93
  @ui&.clear_progress
50
94
  retries += 1
51
- if retries <= max_retries
52
- @ui&.show_warning("#{e.message} (#{retries}/#{max_retries})")
95
+
96
+ # Probing failure: primary still down — renew cooling-off and retry with fallback.
97
+ if @config.probing?
98
+ handle_probe_failure
99
+ retry
100
+ end
101
+
102
+ # RetryableError (503/429/5xx/ThrottlingException) signals a service-level outage.
103
+ # After RETRIES_BEFORE_FALLBACK attempts, switch to the fallback model and reset the
104
+ # retry counter — but cap fallback retries at MAX_RETRIES_ON_FALLBACK (< max_retries)
105
+ # since we have already confirmed the primary is struggling.
106
+ current_max = @config.fallback_active? ? MAX_RETRIES_ON_FALLBACK : max_retries
107
+
108
+ if retries <= current_max
109
+ if retries == RETRIES_BEFORE_FALLBACK && !@config.fallback_active?
110
+ if try_activate_fallback(current_model)
111
+ retries = 0
112
+ retry
113
+ end
114
+ end
115
+ @ui&.show_warning("#{e.message} (#{retries}/#{current_max})")
53
116
  sleep retry_delay
54
117
  retry
55
118
  else
56
- @ui&.show_error("LLM service unavailable after #{max_retries} retries. Please try again later.")
57
- raise AgentError, "LLM service unavailable after #{max_retries} retries"
119
+ @ui&.show_error("LLM service unavailable after #{current_max} retries. Please try again later.")
120
+ raise AgentError, "LLM service unavailable after #{current_max} retries"
58
121
  end
122
+
59
123
  ensure
60
124
  @ui&.clear_progress
61
125
  end
62
126
 
63
127
  # Track cost and collect token usage data.
64
- # token_data is returned to the caller so it can be displayed
65
- # after show_assistant_message (ensuring correct ordering in WebUI).
66
128
  token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
67
129
  response[:token_usage] = token_data
68
130
 
69
131
  response
70
132
  end
133
+
134
+ # Attempt to activate the provider fallback model for the given primary model.
135
+ # Shows a user-visible warning when switching. Returns true if a fallback was found
136
+ # and activated, false if no fallback is configured.
137
+ # @param failed_model [String] the model name that is currently failing
138
+ # @return [Boolean]
139
+ private def try_activate_fallback(failed_model)
140
+ fallback = @config.fallback_model_for(failed_model)
141
+ return false unless fallback
142
+
143
+ @config.activate_fallback!(fallback)
144
+ @ui&.show_warning(
145
+ "Model #{failed_model} appears unavailable. " \
146
+ "Automatically switching to fallback model: #{fallback}"
147
+ )
148
+ true
149
+ end
150
+
151
+ # Called when a probe attempt (testing primary after cooling-off) succeeds.
152
+ # Resets the state machine to :primary_ok and notifies the user.
153
+ private def handle_probe_success
154
+ primary = @config.model_name
155
+ @config.confirm_fallback_ok!
156
+ @ui&.show_warning("Primary model #{primary} is healthy again. Switched back automatically.")
157
+ end
158
+
159
+ # Called when a probe attempt fails.
160
+ # Renews the cooling-off clock (back to :fallback_active) so the *same*
161
+ # request is immediately retried with the fallback model — no extra delay.
162
+ private def handle_probe_failure
163
+ fallback = @config.instance_variable_get(:@fallback_model)
164
+ primary = @config.model_name
165
+ @config.activate_fallback!(fallback) # renews @fallback_since
166
+ @ui&.show_warning(
167
+ "Primary model #{primary} still unavailable. " \
168
+ "Continuing with fallback model: #{fallback}"
169
+ )
170
+ end
71
171
  end
72
172
  end
73
173
  end
data/lib/clacky/agent.rb CHANGED
@@ -159,9 +159,9 @@ module Clacky
159
159
  }
160
160
  end
161
161
 
162
- # Get current model name
162
+ # Get current model name (respects any active fallback override)
163
163
  private def current_model
164
- @config.model_name
164
+ @config.effective_model_name
165
165
  end
166
166
 
167
167
  # Rename this session. Called by auto-naming (first message) or user explicit rename.
@@ -398,6 +398,81 @@ module Clacky
398
398
  find_model_by_type("lite")
399
399
  end
400
400
 
401
+ # How long to stay on the fallback model before probing the primary again.
402
+ FALLBACK_COOLING_OFF_SECONDS = 30 * 60 # 30 minutes
403
+
404
+ # Look up the fallback model name for the given model name.
405
+ # Uses the provider preset's fallback_models table.
406
+ # Returns nil if no fallback is configured for this model.
407
+ # @param model_name [String] the primary model name (e.g. "abs-claude-sonnet-4-6")
408
+ # @return [String, nil]
409
+ def fallback_model_for(model_name)
410
+ m = current_model
411
+ return nil unless m
412
+
413
+ provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
414
+ return nil unless provider_id
415
+
416
+ Clacky::Providers.fallback_model(provider_id, model_name)
417
+ end
418
+
419
+ # Switch to fallback model and start the cooling-off clock.
420
+ # Idempotent — calling again while already in :fallback_active renews the timestamp.
421
+ # @param fallback_model_name [String] the fallback model to use
422
+ def activate_fallback!(fallback_model_name)
423
+ @fallback_state = :fallback_active
424
+ @fallback_since = Time.now
425
+ @fallback_model = fallback_model_name
426
+ end
427
+
428
+ # Called at the start of every call_llm.
429
+ # If cooling-off has expired, transition from :fallback_active → :probing
430
+ # so the next request will silently test the primary model.
431
+ # No-op in any other state.
432
+ def maybe_start_probing
433
+ return unless @fallback_state == :fallback_active
434
+ return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS
435
+
436
+ @fallback_state = :probing
437
+ end
438
+
439
+ # Called when a successful API response is received.
440
+ # If we were :probing (testing primary after cooling-off), this confirms
441
+ # the primary model is healthy again and resets everything.
442
+ # No-op in :primary_ok or :fallback_active states.
443
+ def confirm_fallback_ok!
444
+ return unless @fallback_state == :probing
445
+
446
+ @fallback_state = nil
447
+ @fallback_since = nil
448
+ @fallback_model = nil
449
+ end
450
+
451
+ # Returns true when a fallback model is currently being used
452
+ # (:fallback_active or :probing states).
453
+ def fallback_active?
454
+ @fallback_state == :fallback_active || @fallback_state == :probing
455
+ end
456
+
457
+ # Returns true only when we are silently probing the primary model.
458
+ def probing?
459
+ @fallback_state == :probing
460
+ end
461
+
462
+ # The effective model name to use for API calls.
463
+ # - :primary_ok / nil → configured model_name (primary)
464
+ # - :fallback_active → fallback model
465
+ # - :probing → configured model_name (trying primary silently)
466
+ def effective_model_name
467
+ case @fallback_state
468
+ when :fallback_active
469
+ @fallback_model || model_name
470
+ else
471
+ # :primary_ok (nil) and :probing both use the primary model
472
+ model_name
473
+ end
474
+ end
475
+
401
476
  # Get current model configuration
402
477
  # Looks for type: default first, falls back to current_model_index
403
478
  def current_model
@@ -165,18 +165,23 @@ module Clacky
165
165
  @license_activated_at = Time.now.utc
166
166
  @license_last_heartbeat = Time.now.utc
167
167
  @license_expires_at = parse_time(data["expires_at"])
168
- # product_name is applied via apply_distribution; no top-level product_name field expected at this level
169
- # Save owner_user_id returned by the server when the license is bound to a specific user.
170
- # Server returns "owner_user_id" for system licenses; plan-based licenses return nil.
171
- owner_uid = data["owner_user_id"]
172
- @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
173
- # Pin the device_id used in this activation request so that future API calls
174
- # (e.g. skill_keys, heartbeat) always send the exact same device_id that was
175
- # recorded in activated_devices on the server side.
176
- # If the server echoes back device_id in the response, prefer that value;
177
- # otherwise keep the one we just sent (@device_id is already set above).
178
168
  server_device_id = data["device_id"].to_s.strip
179
169
  @device_id = server_device_id unless server_device_id.empty?
170
+ # Clear ALL stale fields first, then apply fresh values from the new key.
171
+ # Order matters: reset everything before re-assigning so no old value lingers.
172
+ @product_name = nil
173
+ @package_name = nil
174
+ @logo_url = nil
175
+ @support_contact = nil
176
+ @support_qr_url = nil
177
+ @theme_color = nil
178
+ @homepage_url = nil
179
+ @license_user_id = nil
180
+ # Re-apply owner_user_id from the new activation response.
181
+ # Only system (creator) licenses return a non-nil owner_user_id.
182
+ # Brand-consumer keys return nil → @license_user_id stays nil → user_licensed? = false.
183
+ owner_uid = data["owner_user_id"]
184
+ @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
180
185
  apply_distribution(data["distribution"])
181
186
  # Clear previously installed brand skills before saving the new license.
182
187
  # Skills from the old brand are encrypted with that brand's keys — they
@@ -365,6 +370,41 @@ module Clacky
365
370
  # (not filtered by the authenticated user's own skills).
366
371
  # Returns { success: bool, skills: [], error: }.
367
372
  #
373
+ # Fetch the creator's own published skills from the platform API.
374
+ # Uses GET /api/v1/client/skills (HMAC-signed, system license only).
375
+ # Returns { success: bool, skills: [], error: }.
376
+ def fetch_my_skills!
377
+ return { success: false, error: "License not activated", skills: [] } unless activated?
378
+ return { success: false, error: "User license required", skills: [] } unless user_licensed?
379
+
380
+ user_id = @license_user_id.to_s
381
+ key_hash = Digest::SHA256.hexdigest(@license_key)
382
+ ts = Time.now.utc.to_i.to_s
383
+ nonce = SecureRandom.hex(16)
384
+ message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
385
+ signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
386
+
387
+ query = URI.encode_www_form(
388
+ key_hash: key_hash,
389
+ user_id: user_id,
390
+ device_id: @device_id,
391
+ timestamp: ts,
392
+ nonce: nonce,
393
+ signature: signature
394
+ )
395
+
396
+ response = platform_client.get("/api/v1/client/skills?#{query}")
397
+
398
+ if response[:success]
399
+ skills = response[:data]["skills"] || []
400
+ { success: true, skills: skills }
401
+ else
402
+ { success: false, error: response[:error] || "Failed to fetch skills", skills: [] }
403
+ end
404
+ rescue StandardError => e
405
+ { success: false, error: "Network error: #{e.message}", skills: [] }
406
+ end
407
+
368
408
  # Each skill in the returned array is a hash with at minimum:
369
409
  # "name", "description", "icon", "repo"
370
410
  def fetch_store_skills!
@@ -520,7 +560,7 @@ module Clacky
520
560
  # Record installed version in brand_skills.json (including description for
521
561
  # offline display when the remote API is unreachable).
522
562
  # encrypted: true because the ZIP contains MANIFEST.enc.json + AES-256-GCM encrypted files.
523
- record_installed_skill(slug, version, skill_info["description"], encrypted: true)
563
+ record_installed_skill(slug, version, skill_info["description"], encrypted: true, description_zh: skill_info["description_zh"])
524
564
 
525
565
  { success: true, name: slug, version: version }
526
566
  rescue StandardError, ScriptError => e
@@ -543,11 +583,12 @@ module Clacky
543
583
  # optionally "version" and "emoji".
544
584
  # @return [Hash] { success: bool, name:, version: }
545
585
  def install_mock_brand_skill!(skill_info)
546
- slug = skill_info["name"].to_s.strip
547
- version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] || "1.0.0"
548
- name = slug
549
- description = skill_info["description"] || "A private brand skill."
550
- emoji = skill_info["emoji"] || ""
586
+ slug = skill_info["name"].to_s.strip
587
+ version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] || "1.0.0"
588
+ name = slug
589
+ description = skill_info["description"] || "A private brand skill."
590
+ description_zh = skill_info["description_zh"] || "私有品牌技能。"
591
+ emoji = skill_info["emoji"] || "⭐"
551
592
 
552
593
  return { success: false, error: "Missing skill name" } if slug.empty?
553
594
 
@@ -582,7 +623,7 @@ module Clacky
582
623
  File.binwrite(enc_path, mock_content.encode("UTF-8"))
583
624
 
584
625
  # encrypted: false — mock skills store plain bytes in .enc, no MANIFEST needed.
585
- record_installed_skill(slug, version, description, encrypted: false)
626
+ record_installed_skill(slug, version, description, encrypted: false, description_zh: description_zh)
586
627
  { success: true, name: slug, version: version }
587
628
  rescue StandardError => e
588
629
  { success: false, error: e.message }
@@ -611,6 +652,14 @@ module Clacky
611
652
  result = fetch_brand_skills!
612
653
  next unless result[:success]
613
654
 
655
+ # Remove locally installed skills that have been deleted on the remote.
656
+ # Compare the set of remote skill names against what is installed locally
657
+ # and delete any skill that no longer exists in the remote catalogue.
658
+ remote_skill_names = result[:skills].map { |s| s["name"] }
659
+ installed_brand_skills.each_key do |local_name|
660
+ delete_brand_skill!(local_name) unless remote_skill_names.include?(local_name)
661
+ end
662
+
614
663
  # Auto-sync is intentionally limited to skills the user has already
615
664
  # installed and that have a newer version available.
616
665
  # New skills are never auto-installed — the user must click Install/Update
@@ -646,6 +695,43 @@ module Clacky
646
695
  @decryption_keys.clear if @decryption_keys
647
696
  end
648
697
 
698
+ # Remove a single locally installed brand skill by name.
699
+ #
700
+ # Deletes the skill's directory from disk and removes its entry from
701
+ # brand_skills.json. Also evicts any cached decryption key for that skill
702
+ # so no stale key survives in memory.
703
+ #
704
+ # This is called during background sync when a skill that was previously
705
+ # installed is no longer present in the remote catalogue (i.e. the brand
706
+ # administrator deleted it on the platform side).
707
+ #
708
+ # @param skill_name [String] The slug/name of the skill to remove.
709
+ # @return [void]
710
+ private def delete_brand_skill!(skill_name)
711
+ # Remove files from disk.
712
+ skill_dir = File.join(brand_skills_dir, skill_name)
713
+ FileUtils.rm_rf(skill_dir) if Dir.exist?(skill_dir)
714
+
715
+ # Remove entry from brand_skills.json.
716
+ json_path = File.join(brand_skills_dir, "brand_skills.json")
717
+ if File.exist?(json_path)
718
+ registry = JSON.parse(File.read(json_path))
719
+ registry.delete(skill_name)
720
+ File.write(json_path, JSON.generate(registry))
721
+ end
722
+
723
+ # Evict cached decryption key (keyed by skill_version_id strings).
724
+ # We don't know the exact version id here, but we can drop any key whose
725
+ # associated manifest lives inside the now-deleted directory (they are
726
+ # already gone from disk). The simplest safe approach: clear the whole
727
+ # in-memory cache — keys will be re-fetched on next access for surviving
728
+ # skills.
729
+ @decryption_keys&.clear
730
+ rescue StandardError
731
+ # Deletion errors are non-fatal — a stale skill directory is harmless
732
+ # compared to aborting the entire sync operation.
733
+ end
734
+
649
735
  # Decrypt an encrypted brand skill file and return its content in memory.
650
736
  #
651
737
  # Security model:
@@ -829,6 +915,42 @@ module Clacky
829
915
  {}
830
916
  end
831
917
 
918
+ # Path to the upload_meta.json file that tracks which local skills have been
919
+ # published to the platform and what version they were uploaded as.
920
+ #
921
+ # Format:
922
+ # {
923
+ # "commit" => { "platform_version" => "1.2.0", "uploaded_at" => "2026-04-09T..." },
924
+ # "nss-upload" => { "platform_version" => "1.0.0", "uploaded_at" => "..." }
925
+ # }
926
+ UPLOAD_META_FILE = File.join(Dir.home, ".clacky", "skills", "upload_meta.json").freeze
927
+
928
+ # Load upload metadata for all published local skills.
929
+ # @return [Hash{String => Hash}]
930
+ def self.load_upload_meta
931
+ return {} unless File.exist?(UPLOAD_META_FILE)
932
+
933
+ JSON.parse(File.read(UPLOAD_META_FILE))
934
+ rescue StandardError
935
+ {}
936
+ end
937
+
938
+ # Persist a single skill's upload record.
939
+ # @param skill_name [String]
940
+ # @param platform_version [String]
941
+ def self.record_upload!(skill_name, platform_version)
942
+ meta = load_upload_meta
943
+ meta[skill_name] = {
944
+ "platform_version" => platform_version,
945
+ "uploaded_at" => Time.now.utc.iso8601
946
+ }
947
+ dir = File.dirname(UPLOAD_META_FILE)
948
+ FileUtils.mkdir_p(dir)
949
+ File.write(UPLOAD_META_FILE, JSON.generate(meta))
950
+ rescue StandardError
951
+ # Non-fatal — metadata write failure should not break the upload flow
952
+ end
953
+
832
954
  # Returns a hash representation for JSON serialization (e.g. /api/brand).
833
955
  def to_h
834
956
  {
@@ -947,18 +1069,19 @@ module Clacky
947
1069
  # 1. name already valid → use name as-is
948
1070
  # 2. name invalid — sanitize → downcase, spaces→hyphens, strip illegal chars
949
1071
  # 3. still invalid after sanitize → raise, caller gets { success: false }
950
- private def record_installed_skill(name, version, description = nil, encrypted: true)
1072
+ private def record_installed_skill(name, version, description = nil, encrypted: true, description_zh: nil)
951
1073
  safe_name = sanitize_skill_name(name)
952
1074
 
953
1075
  FileUtils.mkdir_p(brand_skills_dir)
954
1076
  path = File.join(brand_skills_dir, "brand_skills.json")
955
1077
  installed = installed_brand_skills
956
1078
  installed[safe_name] = {
957
- "version" => version,
958
- "name" => safe_name,
959
- "description" => description.to_s,
960
- "encrypted" => encrypted,
961
- "installed_at" => Time.now.utc.iso8601
1079
+ "version" => version,
1080
+ "name" => safe_name,
1081
+ "description" => description.to_s,
1082
+ "description_zh" => description_zh.to_s,
1083
+ "encrypted" => encrypted,
1084
+ "installed_at" => Time.now.utc.iso8601
962
1085
  }
963
1086
  File.write(path, JSON.generate(installed))
964
1087
  end