openclacky 1.5.12 → 1.5.13

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 858730306a6e6647d79a5ebb35160c52fba7ed26c62ec97043373e7f052332e2
4
- data.tar.gz: b412bc405daae81f1b6d71dd4dd2dffc197a00ac97540ecfd4916f7daf58843d
3
+ metadata.gz: ca259b2f17df9695e21c4d4c4f18ee83affec0fc41d67db829d903e1c3bb008d
4
+ data.tar.gz: b0bfd31378c6b452a7aef246d3d316242a91fa6366f543c45edcff8c3f44ac6c
5
5
  SHA512:
6
- metadata.gz: 685d67556e68efd81a8bc446edd473a57939f0404b754e4432c7d29e073d2c47adc0c939fc5da3ea32144f3c6026aa17d73ff4766420399515089a3a8a4bc98d
7
- data.tar.gz: dc85168d4b103185d20087635cfc5972c4e3e23b8dee95cb2ba28017d276dda425d8d92d23159dad463bd6cdcc29b8e02404d50dd4a79e5e6345193e8b4167c8
6
+ metadata.gz: cfcb5b28264aeb2acb6b121b89c395e4e9241b3a3135c2e9fb7d565365ed263c701f939448a2c01cb229359cca11402b36b081d4edff6231ce8e46e72d253498
7
+ data.tar.gz: 95cca310ebd8e0a23b1f1e5aed241b6e5fbc19aa206fdb16301dfd3b90f07e33424a44686c10b2d6cf4d3344c75eab2c033d7b3e4e7ef6c9a40b7879dd07a88b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.13] - 2026-09-03
8
+
9
+ ### Added
10
+ - Add Gemini 3.8 Flash model and set it as the trial default
11
+ - Add or-stt-gemini-3-8-flash speech-to-text model; default STT and video understanding to 3.8
12
+ - Add OpenAI Responses API as a fourth API format (#490 - @kylezhang)
13
+ - Add access-key API and config-reload API
14
+ - Add Claude Fable 5.1, GLM-5.3-Flash and qwen3.8-max models with pricing
15
+ - Default the Web UI to the OS/browser language on first visit
16
+ - Make the workspace file tree resizable
17
+ - Expose mounted drives in the directory picker
18
+ - Personalize the new-session page title and empty-chat greeting with the AI's name
19
+ - Localise built-in setup session titles
20
+
21
+ ### Improved
22
+ - Replace native selects with shared CustomSelect dropdowns across settings
23
+ - Overhaul the git & time-machine panel
24
+ - Improve the directory picker (#521)
25
+
26
+ ### Fixed
27
+ - Merge OpenClacky billing across all configured API keys
28
+ - Preserve custom provider and API format when editing a model
29
+ - Reclaim PTY file descriptors when a session shell dies
30
+ - Wrap ANSI-coloured CJK text without IndexError
31
+ - Prevent history edits while a session is running (#520)
32
+ - Preserve attachment badges after compression (#516)
33
+ - Flag hidden agents instead of dropping them
34
+ - Install upgrades next to the running gem's base directory
35
+ - Skip extension auto-sync for administrators (#515)
36
+ - Support the mention menu with Windows IME (#513)
37
+ - Validate extension version input (#514)
38
+ - Keep mention chips anchored and on one line while typing
39
+ - Fall back to uncolored markdown for CJK tables
40
+ - Stabilise billing toolbar layout (#517)
41
+ - Light the group unread dot for backend-created sessions (#518)
42
+ - Translate maximum reasoning effort (#522)
43
+ - Harden WSL Windows profile detection in the directory picker
44
+ - Wrap download errors so the CDN fallback fires
45
+ - Surface the git tab badge eagerly
46
+ - Show the meeting panel only in general sessions
47
+ - Whitelist the config top-level key in the extension verifier
48
+
49
+ ### More
50
+ - Extract shared ModelPicker model dropdown component
51
+ - Refine dropdown chevrons and submenu selection
52
+ - Adjust sidebar panel order and labels
53
+ - Update trial model copy to Gemini 3.7 Flash
54
+
7
55
  ## [1.5.12] - 2026-08-27
8
56
 
9
57
  ### Added
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module Clacky
7
+ # Access key for server-mode authentication.
8
+ #
9
+ # Two sources, in priority order:
10
+ # 1. ~/.clacky/access_key — a file holding the raw key, writable from
11
+ # outside the process (e.g. a sandbox host pushing a key into a
12
+ # pre-started container). This is what makes rotation possible:
13
+ # ENV is a boot-time snapshot the running process can never change.
14
+ # 2. CLACKY_ACCESS_KEY env var — the original bootstrap mechanism.
15
+ module AccessKey
16
+ ENV_VAR = "CLACKY_ACCESS_KEY"
17
+
18
+ def self.resolve
19
+ from_file || from_env
20
+ end
21
+
22
+ def self.from_file
23
+ return nil unless File.exist?(key_file)
24
+
25
+ key = File.read(key_file).strip
26
+ key.empty? ? nil : key
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def self.from_env
32
+ key = ENV.fetch(ENV_VAR, "").strip
33
+ key.empty? ? nil : key
34
+ end
35
+
36
+ def self.write(key)
37
+ key = key.to_s.strip
38
+ raise ArgumentError, "access key must not be blank" if key.empty?
39
+
40
+ FileUtils.mkdir_p(File.dirname(key_file))
41
+ File.write(key_file, "#{key}\n")
42
+ FileUtils.chmod(0o600, key_file)
43
+ key
44
+ end
45
+
46
+ def self.generate
47
+ SecureRandom.hex(32)
48
+ end
49
+
50
+ def self.configured?
51
+ !resolve.nil?
52
+ end
53
+
54
+ # Resolved lazily so specs that stub Dir.home in a before-hook still work.
55
+ def self.key_file
56
+ File.join(Dir.home, ".clacky", "access_key")
57
+ end
58
+ end
59
+ end
@@ -624,10 +624,12 @@ module Clacky
624
624
 
625
625
  case role
626
626
  when "user"
627
+ display_files = display_files_for_archive(msg)
627
628
  lines << (msg[:task_id] ? "## User [Task #{msg[:task_id]}]" : "## User")
628
629
  lines << ""
629
- lines << format_message_content(content)
630
+ lines << format_archived_user_content(content, display_files)
630
631
  lines << ""
632
+ append_display_files_line(lines, display_files)
631
633
  append_ext_events_line(lines, msg)
632
634
  when "assistant"
633
635
  # If this message is itself a compressed summary, annotate the header
@@ -676,6 +678,55 @@ module Clacky
676
678
  lines
677
679
  end
678
680
 
681
+ # Serialize only the lightweight UI metadata needed to reconstruct file
682
+ # badges after compression. File paths, previews, sizes, MIME types, and
683
+ # contents deliberately stay out of the chunk archive.
684
+ def display_files_for_archive(msg)
685
+ files = Array(msg[:display_files]).dup
686
+ Array(msg[:content]).each do |block|
687
+ next unless block.is_a?(Hash)
688
+ next unless %w[image image_url].include?((block[:type] || block["type"]).to_s)
689
+
690
+ name = block[:image_name] || block["image_name"]
691
+ if name.nil? || name.to_s.strip.empty?
692
+ path = block[:image_path] || block["image_path"]
693
+ name = File.basename(path.to_s).sub(/\A[0-9a-f]{16}_/, "") unless path.to_s.empty?
694
+ end
695
+ name = "image" if name.nil? || name.to_s.strip.empty?
696
+ files << { name: name, type: "image" }
697
+ end
698
+
699
+ files.filter_map do |file|
700
+ next unless file.is_a?(Hash)
701
+
702
+ name = file[:name] || file["name"]
703
+ next if name.nil? || name.to_s.strip.empty?
704
+
705
+ type = file[:type] || file["type"] || "file"
706
+ type = "file" if type.to_s.strip.empty?
707
+ { name: name.to_s, type: type.to_s }
708
+ end.uniq { |file| [file[:name], file[:type]] }
709
+ end
710
+
711
+ # Once an image has a badge in the archive, omit the generic [image_url]
712
+ # placeholder from the visible replay text. Text blocks remain unchanged.
713
+ def format_archived_user_content(content, display_files)
714
+ return format_message_content(content) unless content.is_a?(Array)
715
+ return format_message_content(content) unless display_files.any? { |file| file[:type] == "image" }
716
+
717
+ visible_blocks = content.reject do |block|
718
+ block.is_a?(Hash) && %w[image image_url].include?((block[:type] || block["type"]).to_s)
719
+ end
720
+ format_message_content(visible_blocks)
721
+ end
722
+
723
+ def append_display_files_line(lines, files)
724
+ return if files.empty?
725
+
726
+ lines << "_Display files: #{files.to_json}_"
727
+ lines << ""
728
+ end
729
+
679
730
  # Serialize persisted extension events into the chunk MD so they survive
680
731
  # compression and can be replayed in place. Mirrors the "_Tool calls:"
681
732
  # convention; parse_chunk_md_to_rounds reads it back.
@@ -534,6 +534,7 @@ module Clacky
534
534
  sections.each do |sec|
535
535
  text = sec[:lines].join.strip
536
536
  text, sec_ext_events = extract_ext_events_from_text(text)
537
+ text, sec_display_files = extract_display_files_from_text(text)
537
538
 
538
539
  # Nested chunk: expand it recursively, prepend before current rounds
539
540
  if sec[:nested_chunk]
@@ -546,7 +547,7 @@ module Clacky
546
547
  next
547
548
  end
548
549
 
549
- next if text.empty? && sec_ext_events.empty?
550
+ next if text.empty? && sec_ext_events.empty? && sec_display_files.empty?
550
551
 
551
552
  if sec[:role] == "user"
552
553
  round_index += 1
@@ -560,6 +561,7 @@ module Clacky
560
561
  _from_chunk: true
561
562
  }
562
563
  user_msg[:task_id] = sec[:task_id] if sec[:task_id]
564
+ user_msg[:display_files] = sec_display_files unless sec_display_files.empty?
563
565
  current_round = {
564
566
  user_msg: user_msg,
565
567
  events: [],
@@ -618,6 +620,35 @@ module Clacky
618
620
  end
619
621
 
620
622
 
623
+ # Pull lightweight attachment badge metadata out of a chunk section.
624
+ # The marker is internal archive data and must not appear in replayed text.
625
+ # Only name + type are accepted; paths and file contents are never restored.
626
+ def extract_display_files_from_text(text)
627
+ return [text, []] unless text.include?("_Display files:")
628
+
629
+ files = []
630
+ kept = text.each_line.reject do |line|
631
+ match = line.strip.match(/\A_Display files:\s*(\[.*\])_?\z/i)
632
+ next false unless match
633
+
634
+ parsed = JSON.parse(match[1]) rescue []
635
+ Array(parsed).each do |file|
636
+ next unless file.is_a?(Hash)
637
+
638
+ name = file["name"] || file[:name]
639
+ next if name.nil? || name.to_s.strip.empty?
640
+
641
+ type = file["type"] || file[:type] || "file"
642
+ type = "file" if type.to_s.strip.empty?
643
+ files << { name: name.to_s, type: type.to_s }
644
+ end
645
+ true
646
+ end
647
+
648
+ [kept.join.strip, files]
649
+ end
650
+
651
+
621
652
  # Pull the "_Ext events: type | {json}; ..._" line out of a chunk section,
622
653
  # returning the remaining text plus the parsed events. Counterpart to
623
654
  # MessageCompressorHelper#append_ext_events_line.
@@ -948,7 +979,9 @@ module Clacky
948
979
 
949
980
  mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
950
981
  ext = mime_type.split("/").last
951
- { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
982
+ name = block[:image_name] || block["image_name"]
983
+ name = "image_#{idx + 1}.#{ext}" if name.nil? || name.to_s.strip.empty?
984
+ { name: name, mime_type: mime_type, data_url: url, path: path }
952
985
  end
953
986
  end
954
987
 
@@ -529,7 +529,15 @@ module Clacky
529
529
  private def looks_binary?(path)
530
530
  return false if path.nil? || !File.exist?(path)
531
531
  sample = File.binread(path, 8000)
532
- sample.include?("\x00") || !sample.dup.force_encoding("UTF-8").valid_encoding?
532
+ return true if sample.include?("\x00")
533
+ sample.force_encoding("UTF-8")
534
+ # The 8000-byte window can cut a multibyte char in half; drop up to
535
+ # three dangling tail bytes before judging the encoding.
536
+ 3.times do
537
+ break if sample.valid_encoding?
538
+ sample = sample.byteslice(0, sample.bytesize - 1)
539
+ end
540
+ !sample.valid_encoding?
533
541
  rescue StandardError
534
542
  true
535
543
  end
data/lib/clacky/agent.rb CHANGED
@@ -568,9 +568,12 @@ module Clacky
568
568
  all_disk_files = disk_files + downgraded
569
569
 
570
570
  # Format user message — text + inline vision images
571
- # Store the tmp path alongside the data_url so the history replay can
572
- # reconstruct the image if the base64 was stripped (e.g. after compression).
573
- user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } })
571
+ # Store the tmp path and original name alongside the data_url: the path supports
572
+ # normal replay, while the name becomes the lightweight badge after compression.
573
+ user_content = format_user_content(
574
+ user_input,
575
+ vision_images.map { |v| { url: v[:url], path: v[:path], name: v[:name] } }
576
+ )
574
577
 
575
578
  # Parse disk files — agent's responsibility, not the upload layer.
576
579
  # process_path runs the parser script and returns a FileRef with preview_path or parse_error.
@@ -2351,10 +2354,10 @@ module Clacky
2351
2354
 
2352
2355
  # Build user message content for LLM.
2353
2356
  # Returns plain String when no vision images; Array of content parts otherwise.
2354
- # Build user message content for LLM.
2355
- # vision_images: Array of String (plain url) OR Hash { url:, path: }
2356
- # path is stored in the block so history replay can reconstruct the image
2357
- # from the tmp file when the base64 data_url is no longer available.
2357
+ # vision_images: Array of String (plain url) OR Hash { url:, path:, name: }
2358
+ # path is stored so normal history replay can reconstruct the image; name is
2359
+ # lightweight metadata used for an archived badge after compression. Both
2360
+ # fields are stripped by MessageHistory before the content reaches the API.
2358
2361
  private def format_user_content(text, vision_images)
2359
2362
  vision_images ||= []
2360
2363
 
@@ -2366,6 +2369,7 @@ module Clacky
2366
2369
  if img.is_a?(Hash)
2367
2370
  block = { type: "image_url", image_url: { url: img[:url] } }
2368
2371
  block[:image_path] = img[:path] if img[:path]
2372
+ block[:image_name] = img[:name] if img[:name]
2369
2373
  content << block
2370
2374
  else
2371
2375
  content << { type: "image_url", image_url: { url: img } }
@@ -402,6 +402,46 @@ module Clacky
402
402
  Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
403
403
  end
404
404
 
405
+ # Re-read config.yml from disk and apply it to this instance in place.
406
+ #
407
+ # Two invariants make this safe for a running server:
408
+ # 1. @models is mutated with #replace, never reassigned — every session
409
+ # built via #deep_copy shares that array by design (see #deep_copy).
410
+ # 2. Model ids are regenerated on every load and are not persisted, so
411
+ # fresh entries inherit the id of the matching old entry (same model
412
+ # name + base_url). Otherwise every session's @current_model_id
413
+ # would dangle after a reload.
414
+ #
415
+ # Returns true when applied, false when the file could not be parsed —
416
+ # in-memory config is left untouched rather than wiped.
417
+ def reload!(config_file = CONFIG_FILE)
418
+ fresh = self.class.load(config_file)
419
+
420
+ fresh.models.each do |m|
421
+ previous = find_model_by_name_and_url(m["model"], m["base_url"])
422
+ m["id"] = previous["id"] if previous
423
+ end
424
+
425
+ @models.replace(fresh.models)
426
+
427
+ CONFIG_SETTINGS_KEYS.each do |key|
428
+ send("#{key}=", fresh.send(key))
429
+ end
430
+
431
+ # A session may have been pinned to a model the user just deleted.
432
+ unless @models.any? { |m| m["id"] == @current_model_id }
433
+ fallback = find_model_by_type("default") || @models.first
434
+ @current_model_id = fallback && fallback["id"]
435
+ @current_model_index = fallback ? @models.index(fallback).to_i : 0
436
+ end
437
+
438
+ Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
439
+ true
440
+ rescue StandardError => e
441
+ Clacky::Logger.warn("[AgentConfig] reload! failed: #{e.class}: #{e.message}") if defined?(Clacky::Logger)
442
+ false
443
+ end
444
+
405
445
  # Convert to YAML format (top-level array)
406
446
  # Auto-injected lite models (auto_injected: true) are excluded from persistence —
407
447
  # they are regenerated at load time from the provider preset.
@@ -88,11 +88,11 @@ module Clacky
88
88
  # @param recency [Hash<String, Integer>] agent id => epoch of last use,
89
89
  # used to order third-party extension agents by most-recently-used
90
90
  # instead of their static `order` field.
91
- # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar: }
91
+ # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar:, hidden: }
92
92
  def self.all(recency: {})
93
93
  out = {}
94
94
 
95
- add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar|
95
+ add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar, hidden|
96
96
  next if id.nil? || id.empty?
97
97
  out[id] = {
98
98
  id: id,
@@ -105,13 +105,13 @@ module Clacky
105
105
  layer: layer,
106
106
  author: author,
107
107
  avatar: avatar,
108
+ hidden: hidden,
108
109
  }
109
110
  end
110
111
 
111
112
  ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
112
113
  ext_result&.agents&.each do |unit|
113
114
  spec = unit.spec || {}
114
- next if spec["hidden"]
115
115
  title = spec["title"].to_s
116
116
  title = unit.id if title.empty?
117
117
  avatar = spec["avatar_abs"].to_s.empty? ? nil : "/agent_avatar/#{unit.id}"
@@ -119,7 +119,7 @@ module Clacky
119
119
  unit.id, title, spec["title_zh"].to_s,
120
120
  spec["description"].to_s, spec["description_zh"].to_s,
121
121
  "extension", spec["order"], unit.layer.to_s,
122
- spec["author"].to_s, avatar
122
+ spec["author"].to_s, avatar, !!spec["hidden"]
123
123
  )
124
124
  end
125
125
 
@@ -134,7 +134,7 @@ module Clacky
134
134
  id, meta["title"] || meta["name"] || id, meta["title_zh"].to_s,
135
135
  meta["description"].to_s, meta["description_zh"].to_s,
136
136
  "user", meta["order"], "user",
137
- meta["author"].to_s.empty? ? "You" : meta["author"].to_s, user_avatar
137
+ meta["author"].to_s.empty? ? "You" : meta["author"].to_s, user_avatar, false
138
138
  )
139
139
  end
140
140
 
@@ -21,6 +21,7 @@ module Clacky
21
21
  "dsk-deepseek-v4-flash" => "deepseek-v4-flash",
22
22
  "dsk-deepseek-v4-flash-vision-exp" => "deepseek-v4-flash-vision-exp",
23
23
  # claude via bedrock (abs-)
24
+ "abs-claude-fable-5-1" => "global.anthropic.claude-fable-5-1",
24
25
  "abs-claude-fable-5" => "global.anthropic.claude-fable-5",
25
26
  "abs-claude-opus-5" => "global.anthropic.claude-opus-5",
26
27
  "abs-claude-opus-4-8" => "global.anthropic.claude-opus-4-8",
@@ -31,7 +32,8 @@ module Clacky
31
32
  "abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
32
33
  "abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
33
34
  # gemini chat (or-)
34
- "or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
35
+ "or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
36
+ "or-gemini-3-8-flash" => "gemini-3.8-flash",
35
37
  "or-gemini-3-7-flash" => "gemini-3.7-flash",
36
38
  "or-gemini-3-6-flash" => "gemini-3.6-flash",
37
39
  "or-gemini-3-5-flash" => "gemini-3.5-flash",
@@ -48,6 +50,7 @@ module Clacky
48
50
  "or-tts-gemini-2-5-flash" => "gemini-2.5-flash-tts",
49
51
  "or-tts-gemini-2-5-pro" => "gemini-2.5-pro-tts",
50
52
  # speech-to-text (or-)
53
+ "or-stt-gemini-3-8-flash" => "gemini-3.8-flash",
51
54
  "or-stt-gemini-3-7-flash" => "gemini-3.7-flash",
52
55
  "or-stt-gemini-3-6-flash" => "gemini-3.6-flash",
53
56
  "or-stt-gemini-3-5-flash" => "gemini-3.5-flash",
@@ -62,6 +65,7 @@ module Clacky
62
65
  "google/gemini-3-pro-image-preview" => "or-gemini-3-pro-image",
63
66
  # STT aliases reuse the chat real id; Hash#invert keeps the later STT
64
67
  # key, so pin these back to the chat alias for display.
68
+ "gemini-3.8-flash" => "or-gemini-3-8-flash",
65
69
  "gemini-3.7-flash" => "or-gemini-3-7-flash",
66
70
  "gemini-3.6-flash" => "or-gemini-3-6-flash",
67
71
  "gemini-3.5-flash" => "or-gemini-3-5-flash"
@@ -80,6 +84,26 @@ module Clacky
80
84
  ALIAS_TO_REAL[alias_name]
81
85
  end
82
86
 
87
+ # Fetch and merge usage summaries across multiple openclacky keys
88
+ # (e.g. several accounts). The platform bills per key, so summing
89
+ # per-key results yields the combined total. Failing keys
90
+ # (invalid/revoked) are skipped; returns nil only when every key fails.
91
+ def fetch_summary_merged(api_keys, period:, model: nil)
92
+ summaries = Array(api_keys).uniq.map { |key| fetch_summary(key, period: period, model: model) }.compact
93
+ return nil if summaries.empty?
94
+
95
+ summaries.reduce { |acc, summary| merge_summaries(acc, summary) }
96
+ end
97
+
98
+ # Fetch and merge daily breakdowns across multiple openclacky keys.
99
+ # Same skip-on-failure semantics as fetch_summary_merged.
100
+ def fetch_daily_merged(api_keys, days:, model: nil)
101
+ dailies = Array(api_keys).uniq.map { |key| fetch_daily(key, days: days, model: model) }.compact
102
+ return nil if dailies.empty?
103
+
104
+ { days: merge_daily_entries(dailies.flat_map { |daily| daily[:days] }) }
105
+ end
106
+
83
107
  # Fetch authoritative usage summary for the platform (openclacky).
84
108
  # Returns a hash shaped like BillingStore#summary, or nil on failure.
85
109
  def fetch_summary(api_key, period:, model: nil)
@@ -116,6 +140,65 @@ module Clacky
116
140
  nil
117
141
  end
118
142
 
143
+ private def merge_summaries(a, b)
144
+ merged = {}
145
+ (a.keys | b.keys).each do |key|
146
+ merged[key] =
147
+ case key
148
+ when :by_model then merge_by_model(a[:by_model], b[:by_model])
149
+ when :by_day then merge_by_day(a[:by_day], b[:by_day])
150
+ else merge_value(a[key], b[key])
151
+ end
152
+ end
153
+ merged
154
+ end
155
+
156
+ private def merge_by_model(a, b)
157
+ merged = {}
158
+ (a || {}).each { |model, entry| merged[model] = entry }
159
+ (b || {}).each do |model, entry|
160
+ merged[model] = merged.key?(model) ? merge_entries(merged[model], entry) : entry
161
+ end
162
+ merged
163
+ end
164
+
165
+ private def merge_by_day(a, b)
166
+ merged = {}
167
+ (a || {}).each { |date, cost| merged[date] = cost.to_f }
168
+ (b || {}).each { |date, cost| merged[date] = (merged[date] || 0.0) + cost.to_f }
169
+ merged
170
+ end
171
+
172
+ private def merge_daily_entries(entries)
173
+ by_date = {}
174
+ entries.each do |entry|
175
+ next unless entry.is_a?(Hash)
176
+
177
+ date = entry[:date] || entry["date"]
178
+ next if date.nil?
179
+
180
+ by_date[date] = by_date.key?(date) ? merge_entries(by_date[date], entry) : entry
181
+ end
182
+ by_date.values.sort_by { |entry| entry[:date].to_s }
183
+ end
184
+
185
+ private def merge_entries(a, b)
186
+ merged = {}
187
+ (a.keys | b.keys).each { |key| merged[key] = merge_value(a[key], b[key]) }
188
+ merged
189
+ end
190
+
191
+ # Sum numeric leaf fields. Non-numeric fields (period, from/to,
192
+ # source) are identical across keys — they come from the same query
193
+ # shape — so the first value wins.
194
+ private def merge_value(a, b)
195
+ return b if a.nil?
196
+ return a if b.nil?
197
+ return a + b if a.is_a?(Numeric) && b.is_a?(Numeric)
198
+
199
+ a
200
+ end
201
+
119
202
  # Normalise the platform summary (string keys) to the same shape as
120
203
  # BillingStore#summary (symbol keys, symbol-keyed by_model entries).
121
204
  private def normalize_summary(data)
@@ -912,14 +912,15 @@ module Clacky
912
912
  { success: false, error: e.message }
913
913
  end
914
914
 
915
- # Synchronise brand extensions in the background for activated installs.
915
+ # Synchronise brand extensions in the background for activated consumer
916
+ # installs. Brand administrators manage extensions through ext-studio and
917
+ # must not auto-install the extensions they publish for consumers.
916
918
  # Mirrors sync_brand_skills_async! but installs into the extension layer.
917
- # Unlike brand skills, new extensions are auto-installed because a bundled
918
- # extension is chosen by the brand administrator, not the end user.
919
919
  #
920
920
  # @return [Thread, nil]
921
921
  def sync_brand_extensions_async!(on_complete: nil)
922
922
  return nil unless activated?
923
+ return nil if user_licensed?
923
924
  return nil if ENV["CLACKY_TEST"] == "1"
924
925
 
925
926
  Clacky::ThreadRegistry.spawn(name: "brand-fetch-extensions") do
data/lib/clacky/cli.rb CHANGED
@@ -1349,8 +1349,8 @@ module Clacky
1349
1349
 
1350
1350
  # ── Security gate ──────────────────────────────────────────────────────
1351
1351
  # Binding to 0.0.0.0 exposes the server to the public network.
1352
- # Refuse to start unless CLACKY_ACCESS_KEY env var is set.
1353
- if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY")
1352
+ # Refuse to start unless an access key is available.
1353
+ if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY") && Clacky::AccessKey.from_file.nil?
1354
1354
  puts <<~MSG
1355
1355
  ╔══════════════════════════════════════════════════════════════╗
1356
1356
  ║ ⚠️ Security Warning: Refusing to start ║
@@ -1365,6 +1365,8 @@ module Clacky
1365
1365
  ║ Then export it: ║
1366
1366
  ║ export CLACKY_ACCESS_KEY=<your-generated-key> ║
1367
1367
  ║ ║
1368
+ ║ Or write it to ~/.clacky/access_key ║
1369
+ ║ ║
1368
1370
  ╚══════════════════════════════════════════════════════════════╝
1369
1371
  MSG
1370
1372
  exit(1)