openclacky 1.5.5 → 1.5.7

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +58 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/goal_manager.rb +305 -0
  15. data/lib/clacky/agent/goal_state.rb +88 -0
  16. data/lib/clacky/agent/hook_manager.rb +14 -3
  17. data/lib/clacky/agent/memory_updater.rb +17 -18
  18. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  19. data/lib/clacky/agent/session_serializer.rb +162 -25
  20. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  21. data/lib/clacky/agent/skill_evolution.rb +11 -7
  22. data/lib/clacky/agent/skill_manager.rb +20 -18
  23. data/lib/clacky/agent/skill_reflector.rb +3 -6
  24. data/lib/clacky/agent.rb +312 -22
  25. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  26. data/lib/clacky/cli.rb +19 -15
  27. data/lib/clacky/client.rb +1 -1
  28. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  29. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
  32. data/lib/clacky/default_searchers/tavily.rb +98 -0
  33. data/lib/clacky/extension/api_extension.rb +37 -3
  34. data/lib/clacky/extension/packager.rb +54 -5
  35. data/lib/clacky/fanout.rb +100 -0
  36. data/lib/clacky/idle_compression_timer.rb +1 -3
  37. data/lib/clacky/locales/en.rb +2 -0
  38. data/lib/clacky/locales/zh.rb +2 -0
  39. data/lib/clacky/mcp/cli_commands.rb +61 -0
  40. data/lib/clacky/mcp/client.rb +18 -2
  41. data/lib/clacky/mcp/connection_manager.rb +85 -0
  42. data/lib/clacky/mcp/http_transport.rb +25 -9
  43. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  44. data/lib/clacky/mcp/oauth/config.rb +51 -0
  45. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  46. data/lib/clacky/mcp/oauth/session.rb +88 -0
  47. data/lib/clacky/message_format/anthropic.rb +27 -13
  48. data/lib/clacky/message_history.rb +32 -1
  49. data/lib/clacky/providers.rb +15 -13
  50. data/lib/clacky/search_config.rb +57 -0
  51. data/lib/clacky/server/http_server.rb +136 -37
  52. data/lib/clacky/server/session_registry.rb +21 -18
  53. data/lib/clacky/server/web_ui_controller.rb +1 -1
  54. data/lib/clacky/session_manager.rb +13 -11
  55. data/lib/clacky/shell_hook_loader.rb +14 -8
  56. data/lib/clacky/tools/invoke_skill.rb +3 -2
  57. data/lib/clacky/tools/terminal.rb +11 -4
  58. data/lib/clacky/tools/web_search.rb +45 -6
  59. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  60. data/lib/clacky/ui2/components/input_area.rb +26 -2
  61. data/lib/clacky/ui2/progress_handle.rb +27 -3
  62. data/lib/clacky/ui2/ui_controller.rb +223 -6
  63. data/lib/clacky/ui_interface.rb +22 -3
  64. data/lib/clacky/utils/arguments_parser.rb +73 -4
  65. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  66. data/lib/clacky/utils/model_pricing.rb +21 -10
  67. data/lib/clacky/utils/searcher_manager.rb +87 -0
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +243 -33
  70. data/lib/clacky/web/app.js +12 -1
  71. data/lib/clacky/web/components/chat-navigator.js +260 -0
  72. data/lib/clacky/web/features/billing/view.js +2 -2
  73. data/lib/clacky/web/features/brand/view.js +5 -1
  74. data/lib/clacky/web/features/extensions/view.js +19 -19
  75. data/lib/clacky/web/features/new-session/view.js +19 -2
  76. data/lib/clacky/web/features/skills/store.js +4 -2
  77. data/lib/clacky/web/i18n.js +64 -14
  78. data/lib/clacky/web/index.html +40 -9
  79. data/lib/clacky/web/projects.js +62 -19
  80. data/lib/clacky/web/sessions.js +329 -115
  81. data/lib/clacky/web/settings.js +86 -0
  82. data/lib/clacky/web/skills.js +24 -4
  83. data/lib/clacky/web/ws-dispatcher.js +86 -35
  84. data/lib/clacky.rb +4 -0
  85. metadata +18 -1
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Clacky
6
+ module Mcp
7
+ module OAuth
8
+ class Config
9
+ class Error < StandardError; end
10
+
11
+ attr_reader :resource
12
+
13
+ def self.from_server_spec(spec)
14
+ spec ||= {}
15
+ auth = spec["auth"]
16
+ enabled = auth.is_a?(Hash) && !auth.empty?
17
+ if enabled && auth["type"].to_s != "oauth"
18
+ raise Error, "unsupported MCP authentication type '#{auth['type']}'"
19
+ end
20
+
21
+ resource = enabled ? (auth["resource"] || spec["url"]) : spec["url"]
22
+ new(enabled: enabled, resource: resource, endpoint: spec["url"])
23
+ end
24
+
25
+ def initialize(enabled:, resource:, endpoint: nil)
26
+ @enabled = enabled
27
+ @resource = resource.to_s
28
+ @endpoint = (endpoint || resource).to_s
29
+ validate! if enabled?
30
+ end
31
+
32
+ def enabled?
33
+ @enabled == true
34
+ end
35
+
36
+ private def validate!
37
+ validate_https_url(@endpoint, "OAuth MCP endpoint")
38
+ validate_https_url(@resource, "OAuth MCP resource")
39
+ end
40
+
41
+ private def validate_https_url(value, label)
42
+ uri = URI.parse(value)
43
+ raise Error, "#{label} must be an HTTPS URL without credentials" unless
44
+ uri.scheme == "https" && uri.host && !uri.host.empty? && !uri.user && !uri.password
45
+ rescue URI::InvalidURIError
46
+ raise Error, "#{label} must be an HTTPS URL"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+
7
+ module Clacky
8
+ module Mcp
9
+ module OAuth
10
+ class CredentialStore
11
+ class Error < StandardError; end
12
+
13
+ attr_reader :path
14
+
15
+ def initialize(server_name:, home: Dir.home)
16
+ original_name = server_name.to_s
17
+ safe_name = original_name.gsub(/[^a-zA-Z0-9_.-]/, "_")
18
+ safe_name = "server" if safe_name.empty?
19
+ safe_name = "#{safe_name}-#{Digest::SHA256.hexdigest(original_name)[0, 12]}" if safe_name != original_name
20
+ @path = File.join(File.expand_path(home), ".clacky", "mcp", "oauth", "#{safe_name}.json")
21
+ end
22
+
23
+ def load
24
+ return nil unless File.file?(@path)
25
+
26
+ with_lock(File::LOCK_SH) do
27
+ value = JSON.parse(File.read(@path))
28
+ raise JSON::ParserError unless value.is_a?(Hash)
29
+ value
30
+ end
31
+ rescue JSON::ParserError
32
+ raise Error, "invalid OAuth credential file; log in again"
33
+ end
34
+
35
+ def save(value)
36
+ ensure_directory
37
+ with_lock(File::LOCK_EX) do
38
+ temporary = "#{@path}.tmp-#{Process.pid}-#{rand(1_000_000)}"
39
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
40
+ file.write(JSON.generate(value))
41
+ file.write("\n")
42
+ file.flush
43
+ file.fsync
44
+ end
45
+ File.rename(temporary, @path)
46
+ File.chmod(0o600, @path)
47
+ ensure
48
+ FileUtils.rm_f(temporary) if defined?(temporary) && temporary
49
+ end
50
+ value
51
+ end
52
+
53
+ def delete
54
+ return unless File.exist?(@path)
55
+
56
+ ensure_directory
57
+ with_lock(File::LOCK_EX) { FileUtils.rm_f(@path) }
58
+ end
59
+
60
+ def inspect
61
+ "#<#{self.class} path=#{@path.inspect}>"
62
+ end
63
+
64
+ private def ensure_directory
65
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
66
+ File.chmod(0o700, File.dirname(@path))
67
+ end
68
+
69
+ private def with_lock(mode)
70
+ ensure_directory
71
+ lock_path = "#{@path}.lock"
72
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
73
+ lock.flock(mode)
74
+ yield
75
+ ensure
76
+ lock.flock(File::LOCK_UN) rescue nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Clacky
6
+ module Mcp
7
+ module OAuth
8
+ class Session
9
+ EXPIRY_SKEW = 60
10
+
11
+ class Error < StandardError; end
12
+ class NotConnectedError < Error; end
13
+
14
+ def initialize(store:, manager:, clock: nil)
15
+ @store = store
16
+ @manager = manager
17
+ @clock = clock || -> { Time.now.to_i }
18
+ @force_refresh = false
19
+ @lock = Monitor.new
20
+ end
21
+
22
+ def authorization_headers
23
+ @lock.synchronize do
24
+ grant = @store.load
25
+ raise NotConnectedError, "OAuth MCP is not connected; run `clacky mcp login SERVER`" unless grant
26
+
27
+ if @force_refresh || expiring?(grant)
28
+ grant = @manager.refresh(grant)
29
+ @store.save(grant)
30
+ @force_refresh = false
31
+ end
32
+ token = grant["access_token"].to_s
33
+ raise Error, "stored OAuth grant has no access token; log in again" if token.empty?
34
+ { "Authorization" => "Bearer #{token}" }
35
+ end
36
+ rescue NotConnectedError, Error
37
+ raise
38
+ rescue StandardError => e
39
+ raise Error, "OAuth session failed (#{e.class}); log in again"
40
+ end
41
+
42
+ def invalidate!
43
+ @lock.synchronize { @force_refresh = true }
44
+ end
45
+
46
+ def login
47
+ @lock.synchronize do
48
+ grant = @manager.login
49
+ @force_refresh = false
50
+ grant
51
+ end
52
+ end
53
+
54
+ def logout
55
+ @lock.synchronize do
56
+ @store.delete
57
+ @force_refresh = false
58
+ end
59
+ true
60
+ end
61
+
62
+ def connected?
63
+ !@store.load.nil?
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def status
69
+ grant = @store.load
70
+ return { "connected" => false } unless grant
71
+ {
72
+ "connected" => true,
73
+ "expires_at" => grant["expires_at"],
74
+ "expired" => grant.fetch("expires_at", 0).to_i <= @clock.call
75
+ }
76
+ end
77
+
78
+ def inspect
79
+ "#<#{self.class} connected=#{connected?}>"
80
+ end
81
+
82
+ private def expiring?(grant)
83
+ grant.fetch("expires_at", 0).to_i <= @clock.call + EXPIRY_SKEW
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -19,6 +19,25 @@ module Clacky
19
19
  module Anthropic
20
20
  module_function
21
21
 
22
+ # Normalise a /v1/messages usage block to the codebase's canonical
23
+ # (OpenAI-style) prompt_tokens: total input including cache_read but
24
+ # excluding cache_creation.
25
+ #
26
+ # Anthropic reports input_tokens as the post-breakpoint tail only, with
27
+ # the cache buckets disjoint from it. Some /v1/messages-compatible
28
+ # gateways instead report input_tokens as the FULL input with both cache
29
+ # buckets already folded in; adding cache_read there would bill the whole
30
+ # cached prefix twice. Detect that by whether the cache buckets already
31
+ # fit inside input_tokens.
32
+ def normalise_prompt_tokens(input_tokens, cache_read, cache_creation)
33
+ cached_input = cache_read + cache_creation
34
+ if cached_input.positive? && input_tokens >= cached_input
35
+ input_tokens - cache_creation
36
+ else
37
+ input_tokens + cache_read
38
+ end
39
+ end
40
+
22
41
  # ── Message type identification ───────────────────────────────────────────
23
42
 
24
43
  # Returns true if the message is an Anthropic-native tool result stored in
@@ -169,22 +188,16 @@ module Clacky
169
188
  else data["stop_reason"]
170
189
  end
171
190
 
172
- # Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
173
- # input — cache_read_input_tokens and cache_creation_input_tokens are
174
- # reported separately and are disjoint from input_tokens.
175
- #
176
191
  # Normalise to the codebase's canonical shape (OpenAI-style) so downstream
177
192
  # (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
178
193
  # provider-agnostic:
179
194
  #
180
- # prompt_tokens = non_cached + cache_read (OpenAI convention:
181
- # includes cache_read
182
- # but NOT cache_write;
183
- # ModelPricing does
184
- # `regular_input = prompt_tokens - cache_read`.)
195
+ # prompt_tokens = total input incl. cache_read, excl. cache_write
196
+ # (ModelPricing does
197
+ # `regular_input = prompt_tokens - cache_read`.)
185
198
  # completion_tokens = output
186
199
  # total_tokens = THIS TURN'S new compute volume
187
- # = raw_input + cache_creation + output
200
+ # = non_cached + cache_creation + output
188
201
  # (cache_read is excluded because hits are ~free /
189
202
  # already-paid-for; cache_creation IS new work this
190
203
  # turn even though it's billed at write_rate.)
@@ -199,14 +212,15 @@ module Clacky
199
212
  cache_creation = usage["cache_creation_input_tokens"].to_i
200
213
  output_tokens = usage["output_tokens"].to_i
201
214
 
202
- prompt_tokens = raw_input_tokens + cache_read
215
+ prompt_tokens = normalise_prompt_tokens(raw_input_tokens, cache_read, cache_creation)
203
216
 
204
217
  usage_data = {
205
218
  prompt_tokens: prompt_tokens,
206
219
  completion_tokens: output_tokens,
207
220
  # Per-turn new compute: what the server freshly processed this request.
208
- # Excludes cache_read (nearly free, already-paid-for).
209
- total_tokens: raw_input_tokens + cache_creation + output_tokens,
221
+ # Excludes cache_read (nearly free, already-paid-for). Derived from the
222
+ # normalised prompt_tokens so it stays correct under both conventions.
223
+ total_tokens: (prompt_tokens - cache_read) + cache_creation + output_tokens,
210
224
  # Signal to CostTracker: total_tokens above is already the per-turn
211
225
  # delta (not a running cumulative like OpenAI's). CostTracker should
212
226
  # NOT subtract previous_total when this flag is truthy.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Clacky
4
6
  # MessageHistory wraps the conversation message list and exposes
5
7
  # business-meaningful operations instead of raw array manipulation.
@@ -12,9 +14,18 @@ module Clacky
12
14
  task_id created_at system_injected session_context memory_update
13
15
  subagent_instructions subagent_result subagent_transcript token_usage
14
16
  compressed_summary chunk_path truncated transient
15
- chunk_index chunk_count
17
+ chunk_index chunk_count ext_events
16
18
  ].freeze
17
19
 
20
+ # Cap on persisted ext_events per message. These are milestone events
21
+ # (progress chatter is transient), so a handful per message is the norm —
22
+ # the cap only exists to stop a runaway extension. Oldest are dropped first.
23
+ MAX_EXT_EVENTS_PER_MESSAGE = 50
24
+
25
+ # Oversized payloads belong in a file, not in session.json (which is
26
+ # rewritten in full on every save).
27
+ MAX_EXT_EVENT_BYTES = 8 * 1024
28
+
18
29
  def initialize(messages = [])
19
30
  @messages = messages.dup
20
31
  end
@@ -85,6 +96,26 @@ module Clacky
85
96
  self
86
97
  end
87
98
 
99
+ # Append a custom extension event onto the most recent message so it is
100
+ # persisted with the session and can be replayed after a reload.
101
+ # Events are anchored to the last message: on replay they are re-emitted
102
+ # right after that message, preserving chronological order.
103
+ # No-op when the history is still empty (nothing to anchor to).
104
+ def append_ext_event(event)
105
+ msg = @messages.last
106
+ return self unless msg
107
+
108
+ if JSON.generate(event).bytesize > MAX_EXT_EVENT_BYTES
109
+ Clacky::Logger.warn("ext event #{event[:type].inspect} dropped: payload exceeds #{MAX_EXT_EVENT_BYTES} bytes")
110
+ return self
111
+ end
112
+
113
+ list = (msg[:ext_events] ||= [])
114
+ list.shift while list.size >= MAX_EXT_EVENTS_PER_MESSAGE
115
+ list << deep_sanitize_utf8(event)
116
+ self
117
+ end
118
+
88
119
  # Mutate the last message matching the predicate lambda in-place.
89
120
  # Used by execute_skill_with_subagent to update instruction messages.
90
121
  def mutate_last_matching(predicate, &block)
@@ -43,6 +43,7 @@ module Clacky
43
43
  "dsk-deepseek-v4-pro",
44
44
  "dsk-deepseek-v4-flash",
45
45
  "or-gemini-3-1-pro",
46
+ "or-gemini-3-6-flash",
46
47
  "or-gemini-3-5-flash"
47
48
  ],
48
49
  # Image generation models served by the openclacky platform
@@ -94,30 +95,34 @@ module Clacky
94
95
  # routes them to Vertex AI Gemini (generateContent with inline
95
96
  # audio parts). The gateway returns transcription text.
96
97
  "stt_models" => [
98
+ "or-stt-gemini-3-6-flash",
97
99
  "or-stt-gemini-3-5-flash",
98
100
  "or-stt-gemini-1-5-pro"
99
101
  ],
100
102
  "stt_model_aliases" => {
103
+ "or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
101
104
  "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
102
105
  "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
103
106
  },
104
- "default_stt_model" => "or-stt-gemini-3-5-flash",
107
+ "default_stt_model" => "or-stt-gemini-3-6-flash",
105
108
  # Video understanding models served by the openclacky gateway, which
106
109
  # routes video frames to Gemini (generateContent with inline image
107
110
  # parts). The gateway returns analysis text.
108
111
  "video_understanding_models" => [
112
+ "or-gemini-3-6-flash",
109
113
  "or-gemini-3-5-flash",
110
114
  "or-gemini-3-1-pro"
111
115
  ],
112
116
  "video_understanding_model_aliases" => {
117
+ "or-gemini-3-6-flash" => "Gemini 3.6 Flash",
113
118
  "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
114
119
  "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
115
120
  },
116
- "default_video_understanding_model" => "or-gemini-3-5-flash",
121
+ "default_video_understanding_model" => "or-gemini-3-6-flash",
117
122
  # Default OCR sidecar — used when the primary model is text-only.
118
123
  # Candidates are derived from the provider's vision-capable models;
119
124
  # this just picks the cheap+fast default to surface in "auto" mode.
120
- "default_ocr_model" => "or-gemini-3-5-flash",
125
+ "default_ocr_model" => "or-gemini-3-6-flash",
121
126
  # Provider-level default: the Claude family served here is vision-capable.
122
127
  "capabilities" => { "vision" => true }.freeze,
123
128
  # Model-level overrides: DeepSeek models routed through this provider
@@ -144,7 +149,7 @@ module Clacky
144
149
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
145
150
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
146
151
  "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
147
- "or-gemini-3-1-pro" => "or-gemini-3-5-flash"
152
+ "or-gemini-3-1-pro" => "or-gemini-3-6-flash"
148
153
  },
149
154
  # Fallback chain: if a model is unavailable, try the next one in order.
150
155
  # Keys are primary model names; values are the fallback model to use instead.
@@ -427,13 +432,11 @@ module Clacky
427
432
  "name_key" => "provider.name.volcengine_ark",
428
433
  "base_url" => "https://ark.cn-beijing.volces.com/api/v3",
429
434
  "api" => "openai-completions",
430
- "default_model" => "doubao-seed-2.0-pro",
435
+ "default_model" => "doubao-seed-2.1-pro",
431
436
  "models" => [
432
437
  "doubao-seed-evolving",
433
438
  "doubao-seed-2.1-pro",
434
439
  "doubao-seed-2.1-turbo",
435
- "doubao-seed-2.0-code",
436
- "doubao-seed-2.0-pro",
437
440
  "doubao-seed-2.0-lite",
438
441
  "minimax-m3",
439
442
  "minimax-m2.7",
@@ -460,9 +463,7 @@ module Clacky
460
463
  "deepseek-v4-flash" => "deepseek-v4-flash-260425",
461
464
  "doubao-seed-2.1-pro" => "doubao-seed-2-1-pro-260628",
462
465
  "doubao-seed-2.1-turbo" => "doubao-seed-2-1-turbo-260628",
463
- "doubao-seed-2.0-pro" => "doubao-seed-2-0-pro-260215",
464
- "doubao-seed-2.0-lite" => "doubao-seed-2-0-lite-260428",
465
- "doubao-seed-2.0-code" => "doubao-seed-2-0-code-preview-260215"
466
+ "doubao-seed-2.0-lite" => "doubao-seed-2-0-lite-260428"
466
467
  }.freeze
467
468
  }.freeze,
468
469
  # Most Doubao/multimodal models accept image input; GLM-5.2 and
@@ -619,7 +620,8 @@ module Clacky
619
620
  { pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
620
621
  { pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
621
622
  { pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
622
- { pattern: /mimo/i, limit: 32_768 } # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
623
+ { pattern: /mimo/i, limit: 32_768 }, # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
624
+ { pattern: /gemini/i, limit: 65_536 } # Gemini 3.x: 64K max output ceiling
623
625
  ].freeze
624
626
 
625
627
  class << self
@@ -991,8 +993,8 @@ module Clacky
991
993
  # Translate a display model name into the real model id the target
992
994
  # endpoint expects. Some providers expose the same model under different
993
995
  # ids per billing endpoint (e.g. Volcengine Ark's pay-as-you-go API needs
994
- # versioned ids like "doubao-seed-2-0-pro-260215" while the Coding/Agent
995
- # Plan endpoints accept the short "doubao-seed-2.0-pro"). Users always see
996
+ # versioned ids like "doubao-seed-2-1-pro-260628" while the Coding/Agent
997
+ # Plan endpoints accept the short "doubao-seed-2.1-pro"). Users always see
996
998
  # and pick the short name; this swaps it just before the request goes out.
997
999
  #
998
1000
  # Returns the original model unchanged when no alias applies (unknown
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Clacky
7
+ # Search provider selection, backed by ~/.clacky/search.yml.
8
+ #
9
+ # The built-in DuckDuckGo/Bing scrapers need no configuration and stay the
10
+ # default. Pointing `provider` at a script in ~/.clacky/searchers/ routes
11
+ # web_search through an API-backed engine instead, which is what users who
12
+ # want higher-quality results (or China-reachable ones) reach for.
13
+ #
14
+ # search.yml schema:
15
+ # provider: tavily — basename of a script in ~/.clacky/searchers/;
16
+ # blank or missing means built-in engines only
17
+ # key: tvly-xxx — credential handed to that script via env
18
+ #
19
+ # The directory is the registry: dropping my_engine.rb into
20
+ # ~/.clacky/searchers/ and setting `provider: my_engine` is all it takes,
21
+ # mirroring how ~/.clacky/skills/ and ~/.clacky/parsers/ work.
22
+ module SearchConfig
23
+ CONFIG_PATH = File.expand_path("~/.clacky/search.yml").freeze
24
+
25
+ class << self
26
+ # @return [Hash] { "provider" => String, "key" => String }
27
+ def load
28
+ raw = File.exist?(CONFIG_PATH) ? YAML.load_file(CONFIG_PATH) : nil
29
+ return { "provider" => "", "key" => "" } unless raw.is_a?(Hash)
30
+
31
+ { "provider" => raw["provider"].to_s.strip, "key" => raw["key"].to_s.strip }
32
+ rescue StandardError
33
+ { "provider" => "", "key" => "" }
34
+ end
35
+
36
+ def save(provider:, key:)
37
+ FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
38
+ File.write(CONFIG_PATH, { "provider" => provider.to_s.strip, "key" => key.to_s.strip }.to_yaml)
39
+ File.chmod(0o600, CONFIG_PATH)
40
+ end
41
+
42
+ # Absolute path of the configured searcher, or nil when unset/missing.
43
+ # A provider naming a script that isn't installed is treated as unset so
44
+ # web_search silently falls back instead of erroring on every search.
45
+ def script_path
46
+ name = load["provider"]
47
+ return nil if name.empty?
48
+
49
+ Clacky::Utils::SearcherManager.path_for(name)
50
+ end
51
+
52
+ def key
53
+ load["key"]
54
+ end
55
+ end
56
+ end
57
+ end