openclacky 1.5.4 → 1.5.6

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/lib/clacky/agent/goal_manager.rb +305 -0
  4. data/lib/clacky/agent/goal_state.rb +88 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +4 -1
  6. data/lib/clacky/agent/session_serializer.rb +27 -3
  7. data/lib/clacky/agent.rb +202 -3
  8. data/lib/clacky/agent_config.rb +4 -2
  9. data/lib/clacky/agent_profile.rb +25 -3
  10. data/lib/clacky/cli.rb +15 -15
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  12. data/lib/clacky/default_extensions/ext-studio/ext.yml +4 -4
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +130 -59
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +12 -4
  15. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  16. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  17. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  18. data/lib/clacky/default_searchers/tavily.rb +98 -0
  19. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  20. data/lib/clacky/extension/api_extension.rb +29 -1
  21. data/lib/clacky/extension/loader.rb +1 -0
  22. data/lib/clacky/extension/packager.rb +114 -20
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/idle_compression_timer.rb +1 -3
  25. data/lib/clacky/message_format/anthropic.rb +1 -1
  26. data/lib/clacky/message_format/bedrock.rb +1 -1
  27. data/lib/clacky/message_format/open_ai.rb +8 -0
  28. data/lib/clacky/providers.rb +65 -8
  29. data/lib/clacky/search_config.rb +57 -0
  30. data/lib/clacky/server/http_server.rb +249 -31
  31. data/lib/clacky/server/session_registry.rb +10 -3
  32. data/lib/clacky/server/web_ui_controller.rb +12 -2
  33. data/lib/clacky/session_manager.rb +1 -0
  34. data/lib/clacky/tools/terminal.rb +30 -3
  35. data/lib/clacky/tools/web_search.rb +45 -6
  36. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  37. data/lib/clacky/ui2/components/input_area.rb +26 -2
  38. data/lib/clacky/ui2/ui_controller.rb +17 -0
  39. data/lib/clacky/ui_interface.rb +9 -0
  40. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  41. data/lib/clacky/utils/model_pricing.rb +0 -10
  42. data/lib/clacky/utils/searcher_manager.rb +87 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +530 -197
  45. data/lib/clacky/web/app.js +28 -3
  46. data/lib/clacky/web/components/chat-navigator.js +260 -0
  47. data/lib/clacky/web/features/billing/view.js +2 -2
  48. data/lib/clacky/web/features/brand/view.js +5 -1
  49. data/lib/clacky/web/features/extensions/store.js +214 -9
  50. data/lib/clacky/web/features/extensions/view.js +158 -13
  51. data/lib/clacky/web/features/new-session/store.js +25 -3
  52. data/lib/clacky/web/features/new-session/view.js +337 -140
  53. data/lib/clacky/web/features/skills/store.js +4 -2
  54. data/lib/clacky/web/features/skills/view.js +1 -1
  55. data/lib/clacky/web/i18n.js +72 -18
  56. data/lib/clacky/web/index.html +71 -64
  57. data/lib/clacky/web/projects.js +36 -19
  58. data/lib/clacky/web/sessions.js +173 -8
  59. data/lib/clacky/web/settings.js +85 -0
  60. data/lib/clacky/web/skills.js +24 -4
  61. data/lib/clacky/web/utils.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +63 -3
  63. data/lib/clacky.rb +2 -0
  64. metadata +7 -1
@@ -258,14 +258,31 @@ module Clacky
258
258
  @http_server&.instance_variable_get(:@registry)
259
259
  end
260
260
 
261
+ def project_manager
262
+ @http_server&.instance_variable_get(:@project_manager)
263
+ end
264
+
261
265
  # Create a brand-new session and optionally kick off its first task.
262
266
  # Returns the new session_id. When a prompt is given, the task is
263
267
  # submitted immediately (the session starts running); display_message
264
268
  # controls the user-facing bubble shown in place of the raw prompt.
269
+ # When `project_id` is given, the project's working_dir is inherited
270
+ # (unless an explicit working_dir overrides it) and the session is
271
+ # associated with the project.
265
272
  def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
266
- source: :manual, display_message: nil, hidden: false)
273
+ source: :manual, display_message: nil, hidden: false, project_id: nil)
267
274
  error!("server not ready", status: 503) unless @http_server
268
275
 
276
+ if project_id
277
+ project_id = project_id.to_s.strip
278
+ project_id = nil if project_id.empty?
279
+ end
280
+
281
+ project = project_id ? project_manager&.find(project_id) : nil
282
+ error!("Project not found", status: 404) if project_id && project.nil?
283
+
284
+ working_dir = File.expand_path(project[:working_dir]) if working_dir.nil? && project && project[:working_dir].to_s.strip != ""
285
+
269
286
  session_id = @http_server.send(
270
287
  :build_session,
271
288
  name: name,
@@ -275,8 +292,19 @@ module Clacky
275
292
  hidden: hidden
276
293
  )
277
294
 
295
+ if project_id
296
+ agent = nil
297
+ registry.with_session(session_id) { |s| agent = s[:agent] }
298
+ if agent
299
+ agent.project_id = project_id
300
+ session_manager&.save(agent.to_session_data)
301
+ end
302
+ end
303
+
278
304
  submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
279
305
 
306
+ @http_server.send(:broadcast_session_update, session_id)
307
+
280
308
  session_id
281
309
  end
282
310
 
@@ -422,6 +422,7 @@ module Clacky
422
422
  "description" => spec["description"].to_s,
423
423
  "description_zh" => spec["description_zh"].to_s,
424
424
  "order" => spec["order"],
425
+ "hidden" => !!spec["hidden"],
425
426
  "author" => container[:author],
426
427
  "homepage" => container[:homepage],
427
428
  "license" => container[:license],
@@ -18,9 +18,20 @@ module Clacky
18
18
  MANIFEST = "ext.yml"
19
19
  MAX_ZIP_SIZE = 50 * 1024 * 1024
20
20
 
21
+ # Network timeouts for the download step. `read_timeout` is an *idle*
22
+ # timeout (resets on every chunk received), so a stalled connection with
23
+ # no new bytes flowing for this long is treated as dead — not a "download
24
+ # still going, just slow" situation, which large files on a slow link
25
+ # would otherwise trigger.
26
+ OPEN_TIMEOUT = 15 # seconds to establish the connection
27
+ READ_TIMEOUT = 60 # seconds of silence before giving up mid-download
28
+
21
29
  # Platform metadata that leaks in from the developer's OS; never ship it.
22
30
  SYSTEM_METADATA = [".DS_Store", "__MACOSX", "Thumbs.db", "desktop.ini"].freeze
23
31
 
32
+ # VCS and IDE metadata that must never ship in a pack, even without a .gitignore.
33
+ NEVER_PACK_DIRS = [".git", ".svn", ".hg", ".idea", ".vscode"].freeze
34
+
24
35
  Result = Struct.new(:ext_id, :path, :units, keyword_init: true)
25
36
 
26
37
  class Error < StandardError; end
@@ -48,6 +59,7 @@ module Clacky
48
59
  File.delete(zip_path) if File.exist?(zip_path)
49
60
 
50
61
  write_zip(container_dir, zip_path)
62
+ enforce_zip_size!(zip_path)
51
63
 
52
64
  Result.new(ext_id: slug, path: zip_path, units: nil)
53
65
  end
@@ -56,12 +68,19 @@ module Clacky
56
68
  # local zip path or an http(s) URL. Validates the archive (single top
57
69
  # container with an ext.yml, no path traversal), extracts it, then runs
58
70
  # verify on the resolved installed layer.
59
- def install(source, installed_dir: Clacky::ExtensionLoader::INSTALLED_DIR, force: false)
71
+ # on_progress is an optional callable that receives a stage hash, e.g.:
72
+ # { stage: "downloading", progress: 45 }
73
+ # { stage: "extracting" }
74
+ # { stage: "verifying" }
75
+ # Caller can use this for progress reporting without coupling to internals.
76
+ def install(source, installed_dir: Clacky::ExtensionLoader::INSTALLED_DIR, force: false, on_progress: nil)
60
77
  Dir.mktmpdir("clacky-ext-install") do |tmp|
61
- zip_path = local_zip_for(source, tmp)
78
+ on_progress&.call({ stage: "downloading", progress: 0 })
79
+ zip_path = local_zip_for(source, tmp, on_progress: on_progress)
80
+
81
+ on_progress&.call({ stage: "extracting" })
62
82
  extract_root = File.join(tmp, "unpacked")
63
83
  FileUtils.mkdir_p(extract_root)
64
-
65
84
  extract_zip(zip_path, extract_root)
66
85
 
67
86
  ext_id, container_src = locate_container(extract_root)
@@ -74,6 +93,7 @@ module Clacky
74
93
  FileUtils.mkdir_p(target)
75
94
  FileUtils.cp_r(Dir.glob("#{container_src}/*"), target)
76
95
 
96
+ on_progress&.call({ stage: "verifying" })
77
97
  Clacky::ExtensionLoader.invalidate_cache!
78
98
  units = verify_installed(ext_id, installed_dir)
79
99
 
@@ -124,12 +144,9 @@ module Clacky
124
144
  end
125
145
 
126
146
  private def write_zip(container_dir, zip_path)
147
+ gitignore = load_container_gitignore(container_dir)
127
148
  Zip::File.open(zip_path, create: true) do |zip|
128
- files = Dir.glob(File.join(container_dir, "**", "*"), File::FNM_DOTMATCH)
129
- files.each do |abs|
130
- base = File.basename(abs)
131
- next if base == "." || base == ".."
132
-
149
+ each_packable(container_dir, gitignore) do |abs|
133
150
  rel = relative(container_dir, abs)
134
151
  next if system_metadata?(rel)
135
152
 
@@ -143,17 +160,65 @@ module Clacky
143
160
  end
144
161
  end
145
162
 
163
+ # Same budget the installer enforces on download/extract - without it a
164
+ # creator could publish something no one (themselves included) can install.
165
+ private def enforce_zip_size!(zip_path)
166
+ size = File.size(zip_path)
167
+ return if size <= MAX_ZIP_SIZE
168
+
169
+ raise Error, "packed zip is #{size} bytes, exceeds #{MAX_ZIP_SIZE} bytes (#{MAX_ZIP_SIZE / 1024 / 1024}MB) limit"
170
+ end
171
+
172
+ private def load_container_gitignore(container_dir)
173
+ path = File.join(container_dir, ".gitignore")
174
+ Clacky::GitignoreParser.new(path) if File.exist?(path)
175
+ end
176
+
177
+ # Walk the container yielding every path that should ship. Honors .gitignore
178
+ # strictly: ignored files are excluded even when they are config files
179
+ # (.env, secrets) - packing must never leak gitignored content, unlike the
180
+ # search tools which surface config files. Nested .gitignore files are
181
+ # honored per-subtree without leaking rules into sibling directories.
182
+ private def each_packable(dir, gitignore, root: dir)
183
+ Dir.children(dir).sort.each do |name|
184
+ full = File.join(dir, name)
185
+ rel = relative(root, full)
186
+
187
+ if File.directory?(full)
188
+ next if File.symlink?(full)
189
+ next if NEVER_PACK_DIRS.include?(name)
190
+ next if gitignore&.ignored?("#{rel}/")
191
+
192
+ yield full
193
+ each_packable(full, gitignore_for_subdir(full, rel, gitignore), root: root) { |p| yield p }
194
+ else
195
+ next if gitignore&.ignored?(rel)
196
+
197
+ yield full
198
+ end
199
+ end
200
+ end
201
+
202
+ private def gitignore_for_subdir(dir, rel, parent)
203
+ nested = File.join(dir, ".gitignore")
204
+ return parent unless File.exist?(nested)
205
+
206
+ merged = parent ? parent.dup : Clacky::GitignoreParser.new(nil)
207
+ merged.merge!(nested, prefix: rel)
208
+ merged
209
+ end
210
+
146
211
  # True if any path segment is a platform metadata file/dir (e.g. a nested
147
212
  # agents/.DS_Store or a whole __MACOSX/ tree).
148
213
  private def system_metadata?(rel)
149
214
  rel.split(File::SEPARATOR).any? { |seg| SYSTEM_METADATA.include?(seg) }
150
215
  end
151
216
 
152
- private def local_zip_for(source, tmp)
217
+ private def local_zip_for(source, tmp, on_progress: nil)
153
218
  src = source.to_s
154
219
  if src.match?(%r{\Ahttps?://})
155
220
  dest = File.join(tmp, "download.zip")
156
- download(src, dest)
221
+ download(src, dest, on_progress: on_progress)
157
222
  dest
158
223
  else
159
224
  path = File.expand_path(src)
@@ -162,19 +227,48 @@ module Clacky
162
227
  end
163
228
  end
164
229
 
165
- private def download(url, dest)
166
- total = 0
167
- URI.open(url, "rb") do |io| # rubocop:disable Security/Open
168
- File.open(dest, "wb") do |out|
169
- while (chunk = io.read(64 * 1024))
170
- total += chunk.bytesize
171
- raise Error, "download exceeds #{MAX_ZIP_SIZE} bytes" if total > MAX_ZIP_SIZE
172
- out.write(chunk)
173
- end
174
- end
230
+ # Download url → dest file.
231
+ # IMPORTANT: OpenURI's block form (`URI.open(url) { |io| ... }`) buffers
232
+ # the *entire* response into a StringIO/Tempfile via Net::HTTP's
233
+ # read_body before ever yielding `io` — so reading `io` in chunks after
234
+ # the fact reports progress only after the download has already
235
+ # finished. To get progress callbacks that fire while bytes are still
236
+ # arriving on the socket, we hook OpenURI's own `progress_proc` /
237
+ # `content_length_proc`, which run inside read_body as each chunk lands.
238
+ # Respects whatever proxy env vars the user has configured (some CDN
239
+ # domains are only reachable at usable speed through a proxy).
240
+ private def download(url, dest, on_progress: nil)
241
+ content_length = nil
242
+
243
+ content_length_proc = lambda do |len|
244
+ content_length = len if len&.positive?
245
+ end
246
+
247
+ progress_proc = lambda do |size|
248
+ raise Error, "download exceeds #{MAX_ZIP_SIZE} bytes" if size > MAX_ZIP_SIZE
249
+ next unless on_progress
250
+
251
+ pct = if content_length&.positive?
252
+ [(size * 100 / content_length).floor, 99].min
253
+ else
254
+ (90 - 90 * Math.exp(-size.to_f / (1024 * 1024))).floor
255
+ end
256
+ on_progress.call({ stage: "downloading", progress: pct })
257
+ end
258
+
259
+ URI.open(
260
+ url, "rb",
261
+ open_timeout: OPEN_TIMEOUT,
262
+ read_timeout: READ_TIMEOUT,
263
+ content_length_proc: content_length_proc,
264
+ progress_proc: progress_proc
265
+ ) do |io| # rubocop:disable Security/Open
266
+ File.open(dest, "wb") { |out| out.write(io.read) }
175
267
  end
176
268
  rescue OpenURI::HTTPError, SocketError => e
177
269
  raise Error, "failed to download #{url}: #{e.message}"
270
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
271
+ raise Error, "download timed out: #{e.message}"
178
272
  end
179
273
 
180
274
  private def extract_zip(zip_path, dest_root)
@@ -22,7 +22,7 @@ module Clacky
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
23
23
  API_KEYS = %w[id handler].freeze
24
24
  SKILL_KEYS = %w[id dir protected].freeze
25
- AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar].freeze
25
+ AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar hidden].freeze
26
26
  CHANNEL_KEYS = %w[id platform adapter].freeze
27
27
  PATCH_KEYS = %w[target file fingerprint on_mismatch].freeze
28
28
  HOOK_KEYS = %w[event file].freeze
@@ -121,9 +121,7 @@ module Clacky
121
121
  success = @agent.trigger_idle_compression
122
122
 
123
123
  if success && @session_manager
124
- existing = @session_manager.load(@agent.session_id)
125
- original_updated_at = existing&.dig(:updated_at) ? Time.parse(existing[:updated_at].to_s) : nil
126
- @session_manager.save(@agent.to_session_data(status: :success, updated_at: original_updated_at))
124
+ @session_manager.save(@agent.to_session_data(status: :success))
127
125
  end
128
126
 
129
127
  @on_compress&.call(success)
@@ -114,7 +114,7 @@ module Clacky
114
114
  private_class_method def self.normalized_effort(effort)
115
115
  return nil if effort.nil? || effort.to_s.empty?
116
116
  s = effort.to_s
117
- %w[low medium high].include?(s) ? s : nil
117
+ %w[low medium high xhigh max].include?(s) ? s : nil
118
118
  end
119
119
 
120
120
  # Merge consecutive tool_result user messages into a single user message.
@@ -91,7 +91,7 @@ module Clacky
91
91
 
92
92
  private_class_method def self.additional_fields_for_effort(effort)
93
93
  return nil if effort.nil? || effort.to_s.empty?
94
- return nil unless %w[low medium high].include?(effort.to_s)
94
+ return nil unless %w[low medium high xhigh max].include?(effort.to_s)
95
95
  {
96
96
  thinking: { type: "adaptive" },
97
97
  output_config: { effort: effort.to_s }
@@ -279,6 +279,14 @@ module Clacky
279
279
  elsif !effort_str.empty?
280
280
  body[:thinking] = { type: "adaptive" }
281
281
  end
282
+ elsif model.to_s.match?(/deepseek/i)
283
+ # DeepSeek V4 reasoning_effort only accepts low/high/max.
284
+ # xhigh is not recognized — map it to max.
285
+ effort = case effort_str
286
+ when "xhigh" then "max"
287
+ else effort_str
288
+ end
289
+ body[:reasoning_effort] = effort unless effort.empty?
282
290
  elsif reasoning_effort && !effort_str.empty?
283
291
  body[:reasoning_effort] = effort_str
284
292
  end
@@ -427,13 +427,11 @@ module Clacky
427
427
  "name_key" => "provider.name.volcengine_ark",
428
428
  "base_url" => "https://ark.cn-beijing.volces.com/api/v3",
429
429
  "api" => "openai-completions",
430
- "default_model" => "doubao-seed-2.0-pro",
430
+ "default_model" => "doubao-seed-2.1-pro",
431
431
  "models" => [
432
432
  "doubao-seed-evolving",
433
433
  "doubao-seed-2.1-pro",
434
434
  "doubao-seed-2.1-turbo",
435
- "doubao-seed-2.0-code",
436
- "doubao-seed-2.0-pro",
437
435
  "doubao-seed-2.0-lite",
438
436
  "minimax-m3",
439
437
  "minimax-m2.7",
@@ -460,9 +458,7 @@ module Clacky
460
458
  "deepseek-v4-flash" => "deepseek-v4-flash-260425",
461
459
  "doubao-seed-2.1-pro" => "doubao-seed-2-1-pro-260628",
462
460
  "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"
461
+ "doubao-seed-2.0-lite" => "doubao-seed-2-0-lite-260428"
466
462
  }.freeze
467
463
  }.freeze,
468
464
  # Most Doubao/multimodal models accept image input; GLM-5.2 and
@@ -477,6 +473,67 @@ module Clacky
477
473
  "website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
478
474
  }.freeze,
479
475
 
476
+ "ollama" => {
477
+ "name" => "Ollama (Cloud)",
478
+ # Ollama Cloud API — OpenAI-compatible endpoint serving both local and
479
+ # cloud models. Cloud models are available immediately without pulling.
480
+ # Authentication: API key via Authorization: Bearer <key> header.
481
+ # Create API keys at: https://ollama.com/settings/keys
482
+ "base_url" => "https://ollama.com",
483
+ "api" => "openai-completions",
484
+ "default_model" => "deepseek-v4-flash",
485
+ # Curated list of cloud-enabled models from the Ollama library.
486
+ # Users can type any Ollama model name manually; this list seeds the picker.
487
+ "models" => [
488
+ "glm-5.2",
489
+ "kimi-k3",
490
+ "gemma4",
491
+ "qwen3.5",
492
+ "glm-5.1",
493
+ "minimax-m2.7",
494
+ "nemotron-3-super",
495
+ "minimax-m3",
496
+ "kimi-k2.7-code",
497
+ "kimi-k2.6",
498
+ "deepseek-v4-flash",
499
+ "deepseek-v4-pro",
500
+ "nemotron-3-ultra",
501
+ "gemini-3-flash-preview",
502
+ "nemotron-3-nano",
503
+ "mistral-large-3",
504
+ "gpt-oss"
505
+ ],
506
+ # Provider-level default: many Ollama cloud models are vision-capable.
507
+ "capabilities" => { "vision" => true }.freeze,
508
+ # Model-level overrides: text-only models that don't accept image input.
509
+ "model_capabilities" => {
510
+ "glm-5.2" => { "vision" => false }.freeze,
511
+ "glm-5.1" => { "vision" => false }.freeze,
512
+ "minimax-m2.7" => { "vision" => false }.freeze,
513
+ "deepseek-v4-pro" => { "vision" => false }.freeze,
514
+ "deepseek-v4-flash" => { "vision" => false }.freeze,
515
+ "nemotron-3-super" => { "vision" => false }.freeze,
516
+ "nemotron-3-ultra" => { "vision" => false }.freeze,
517
+ "nemotron-3-nano" => { "vision" => false }.freeze,
518
+ "gpt-oss" => { "vision" => false }.freeze
519
+ }.freeze,
520
+ # Per-primary lite pairing: subagents use smaller models for cheap/fast work.
521
+ "lite_models" => {
522
+ "glm-5.2" => "glm-5.1",
523
+ "kimi-k3" => "kimi-k2.6",
524
+ "gemma4" => "nemotron-3-nano",
525
+ "qwen3.5" => "gemini-3-flash-preview",
526
+ "minimax-m3" => "minimax-m2.7",
527
+ "kimi-k2.7-code" => "kimi-k2.6",
528
+ "deepseek-v4-pro" => "deepseek-v4-flash",
529
+ "mistral-large-3" => "nemotron-3-nano",
530
+ "gpt-oss" => "nemotron-3-nano",
531
+ "gemini-3-flash-preview" => "nemotron-3-nano"
532
+ },
533
+ "default_ocr_model" => "kimi-k2.7-code",
534
+ "website_url" => "https://ollama.com/settings/keys"
535
+ }.freeze,
536
+
480
537
  "openai" => {
481
538
  "name" => "OpenAI (GPT)",
482
539
  "base_url" => "https://api.openai.com/v1",
@@ -930,8 +987,8 @@ module Clacky
930
987
  # Translate a display model name into the real model id the target
931
988
  # endpoint expects. Some providers expose the same model under different
932
989
  # ids per billing endpoint (e.g. Volcengine Ark's pay-as-you-go API needs
933
- # versioned ids like "doubao-seed-2-0-pro-260215" while the Coding/Agent
934
- # Plan endpoints accept the short "doubao-seed-2.0-pro"). Users always see
990
+ # versioned ids like "doubao-seed-2-1-pro-260628" while the Coding/Agent
991
+ # Plan endpoints accept the short "doubao-seed-2.1-pro"). Users always see
935
992
  # and pick the short name; this swaps it just before the request goes out.
936
993
  #
937
994
  # 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