openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -308,38 +308,69 @@ module Clacky
308
308
  end
309
309
 
310
310
  ext = mime_type.split(";").first.split("/").last.then { |e| e == "mpeg" ? "mp3" : e }
311
- filename = "chunk.#{ext}"
312
- audio_data = Base64.decode64(audio_base64)
313
- boundary = "----FormBoundary#{SecureRandom.hex(8)}"
314
- # A multipart body is a byte stream: build it in binary so UTF-8 text
315
- # parts (e.g. a non-ASCII vocabulary prompt) don't clash with the
316
- # ASCII-8BIT audio bytes.
317
- body = "".b
318
- body << "--#{boundary}\r\n".b
319
- body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n".b
320
- body << "Content-Type: #{mime_type.split(';').first}\r\n\r\n".b
321
- body << audio_data.b
322
- body << "\r\n--#{boundary}\r\n".b
323
- body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n".b
324
- body << @model.to_s.b
325
- unless prompt.to_s.strip.empty?
326
- body << "\r\n--#{boundary}\r\n".b
327
- body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".b
328
- body << prompt.to_s.strip.b
329
- end
330
- body << "\r\n--#{boundary}--\r\n".b
331
311
 
332
- begin
333
- response = stt_connection.post("audio/transcriptions") do |req|
334
- req.headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
335
- req.headers["Authorization"] = "Bearer #{@api_key}"
336
- req.body = body
312
+ # OpenRouter caps multipart uploads at 25 MB and redirects larger files
313
+ # to base64 JSON via input_audio. Plain OpenAI-compatible providers keep
314
+ # multipart (incl. the openclacky gateway, which only parses multipart
315
+ # on its /audio/transcriptions today — enable the JSON flavor there once
316
+ # the server supports it).
317
+ if provider_id == Clacky::Providers::OPENROUTER_ID
318
+ ext = "m4a" if ext == "mp4" # OpenRouter wants the audio format, not the container
319
+ payload = {
320
+ model: @model,
321
+ input_audio: {
322
+ data: audio_base64, # raw base64 bytes, NOT a data URI
323
+ format: ext
324
+ }
325
+ }
326
+ payload[:prompt] = prompt.to_s.strip unless prompt.to_s.strip.empty?
327
+
328
+ begin
329
+ response = stt_json_connection.post("audio/transcriptions") do |req|
330
+ req.headers["Content-Type"] = "application/json"
331
+ req.headers["Authorization"] = "Bearer #{@api_key}"
332
+ req.body = JSON.generate(payload)
333
+ end
334
+ rescue Faraday::Error => e
335
+ return transcription_error_response(
336
+ error: "HTTP request failed: #{e.message}",
337
+ error_type: "network_error", provider: provider_id
338
+ )
339
+ end
340
+ else
341
+ filename = "chunk.#{ext}"
342
+ audio_data = Base64.decode64(audio_base64)
343
+ boundary = "----FormBoundary#{SecureRandom.hex(8)}"
344
+ # A multipart body is a byte stream: build it in binary so UTF-8 text
345
+ # parts (e.g. a non-ASCII vocabulary prompt) don't clash with the
346
+ # ASCII-8BIT audio bytes.
347
+ body = "".b
348
+ body << "--#{boundary}\r\n".b
349
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n".b
350
+ body << "Content-Type: #{mime_type.split(';').first}\r\n\r\n".b
351
+ body << audio_data.b
352
+ body << "\r\n--#{boundary}\r\n".b
353
+ body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n".b
354
+ body << @model.to_s.b
355
+ unless prompt.to_s.strip.empty?
356
+ body << "\r\n--#{boundary}\r\n".b
357
+ body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".b
358
+ body << prompt.to_s.strip.b
359
+ end
360
+ body << "\r\n--#{boundary}--\r\n".b
361
+
362
+ begin
363
+ response = stt_connection.post("audio/transcriptions") do |req|
364
+ req.headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
365
+ req.headers["Authorization"] = "Bearer #{@api_key}"
366
+ req.body = body
367
+ end
368
+ rescue Faraday::Error => e
369
+ return transcription_error_response(
370
+ error: "HTTP request failed: #{e.message}",
371
+ error_type: "network_error", provider: provider_id
372
+ )
337
373
  end
338
- rescue Faraday::Error => e
339
- return transcription_error_response(
340
- error: "HTTP request failed: #{e.message}",
341
- error_type: "network_error", provider: provider_id
342
- )
343
374
  end
344
375
 
345
376
  unless response.success?
@@ -479,6 +510,15 @@ module Clacky
479
510
  end
480
511
  end
481
512
 
513
+ # STT JSON body carries a large base64 payload; upstream may need longer
514
+ # to decode/transcribe it than the 30s multipart path.
515
+ private def stt_json_connection
516
+ Faraday.new(url: normalized_base_url) do |f|
517
+ f.options.timeout = 120
518
+ f.options.open_timeout = 10
519
+ end
520
+ end
521
+
482
522
  private def vu_connection
483
523
  Faraday.new(url: normalized_base_url) do |f|
484
524
  f.options.timeout = 60
@@ -26,6 +26,8 @@ module Clacky
26
26
  # the same Clacky proxy on a different endpoint. The *model prefix* is
27
27
  # the source of truth for which upstream format the proxy expects:
28
28
  #
29
+ # abs-gpt-* → OpenAI-compatible (POST /chat/completions) — GPT models
30
+ # on Bedrock use the OpenAI endpoint, not Converse
29
31
  # abs-* → Bedrock Converse (POST /model/{id}/converse)
30
32
  # dsk-* → OpenAI-compatible (POST /chat/completions)
31
33
  # or-* → OpenAI-compatible (POST /chat/completions)
@@ -38,7 +40,10 @@ module Clacky
38
40
  # will route correctly without touching this file.
39
41
  def self.bedrock_api_key?(api_key, model)
40
42
  return true if api_key.to_s.start_with?("ABSK")
41
- model.to_s.start_with?("abs-")
43
+ model_str = model.to_s
44
+ # abs-gpt-* is served through Bedrock's OpenAI-compatible endpoint.
45
+ return false if model_str.start_with?("abs-gpt-")
46
+ model_str.start_with?("abs-")
42
47
  end
43
48
 
44
49
  module_function
@@ -3,7 +3,7 @@
3
3
  - Ask clarifying questions if requirements are unclear.
4
4
  - Break down complex tasks into manageable steps.
5
5
  - **USE TOOLS to create/modify files** — don't just return content.
6
- - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply. Use the raw file path as-is (keep non-ASCII characters and spaces literal); never URL-encode it.
6
+ - When the user asks to send/download a file or you generate one for them, append `[filename](file:///absolute/path/to/file)` at the end of your reply. Use the exact absolute path the write tool returned — don't rewrite or abbreviate it with `~` (keep non-ASCII characters and spaces literal); never URL-encode it.
7
7
 
8
8
  ## Tool Usage Rules
9
9
 
@@ -29,8 +29,12 @@ module Clacky
29
29
  "name" => "OpenClacky",
30
30
  "base_url" => "https://api.openclacky.com",
31
31
  "api" => "bedrock",
32
- "default_model" => "abs-claude-sonnet-5",
32
+ "default_model" => "or-gemini-3-8-flash",
33
33
  "models" => [
34
+ "abs-gpt-6-astra",
35
+ "abs-gpt-5.6-sol",
36
+ "abs-gpt-5.6-terra",
37
+ "abs-gpt-5.6-luna",
34
38
  "abs-claude-fable-5-1",
35
39
  "abs-claude-fable-5",
36
40
  "abs-claude-opus-5",
@@ -41,6 +45,7 @@ module Clacky
41
45
  "abs-claude-sonnet-4-6",
42
46
  "abs-claude-sonnet-4-5",
43
47
  "abs-claude-haiku-4-5",
48
+ "dsk-deepseek-flash",
44
49
  "dsk-deepseek-v4-pro",
45
50
  "dsk-deepseek-v4-flash",
46
51
  "dsk-deepseek-v4-flash-vision-exp",
@@ -134,7 +139,7 @@ module Clacky
134
139
  # Default OCR sidecar — used when the primary model is text-only.
135
140
  # Candidates are derived from the provider's vision-capable models;
136
141
  # this just picks the cheap+fast default to surface in "auto" mode.
137
- "default_ocr_model" => "dsk-deepseek-v4-flash-vision-exp",
142
+ "default_ocr_model" => "or-gemini-3-8-flash",
138
143
  # Provider-level default: the Claude family served here is vision-capable.
139
144
  "capabilities" => { "vision" => true }.freeze,
140
145
  # Model-level overrides: DeepSeek models routed through this provider
@@ -143,10 +148,17 @@ module Clacky
143
148
  # to disk refs. Gemini 3.1 Pro keeps the provider-default vision=true
144
149
  # (it accepts image/audio/video input natively via OpenRouter).
145
150
  "model_capabilities" => {
151
+ "dsk-deepseek-flash" => { "vision" => true }.freeze,
146
152
  "dsk-deepseek-v4-pro" => { "vision" => false }.freeze,
147
153
  "dsk-deepseek-v4-flash" => { "vision" => false }.freeze,
148
154
  "dsk-deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
149
155
  }.freeze,
156
+ # Bedrock GPT models (abs-gpt-*) are served through the OpenAI
157
+ # Responses API — their Chat Completions endpoint rejects function
158
+ # tools for reasoning models (GPT-6 Astra).
159
+ "model_api_overrides" => {
160
+ /\Aabs-gpt-/ => "openai-responses"
161
+ }.freeze,
150
162
  # Per-primary lite pairing: keys are "strong" primary models, values
151
163
  # are the lite sidekick to auto-inject when that primary is the
152
164
  # default. Lite is consumed by some subagents for cheap/fast work;
@@ -163,6 +175,8 @@ module Clacky
163
175
  "abs-claude-sonnet-5" => "abs-claude-haiku-4-5",
164
176
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
165
177
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
178
+ "abs-gpt-5.6-sol" => "abs-gpt-5.6-luna",
179
+ "abs-gpt-5.6-terra" => "abs-gpt-5.6-luna",
166
180
  "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
167
181
  "or-gemini-3-1-pro" => "or-gemini-3-6-flash"
168
182
  },
@@ -266,14 +280,17 @@ module Clacky
266
280
  # deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
267
281
  # and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
268
282
  "models" => [
283
+ "deepseek-flash",
269
284
  "deepseek-v4-pro",
270
285
  "deepseek-v4-flash",
271
286
  "deepseek-v4-flash-vision-exp",
272
287
  ],
273
288
  # DeepSeek V4 API is text-only across all models, except the
274
- # flash-vision-exp variant which accepts image input.
289
+ # flash-vision-exp variant which accepts image input. V4.1 Flash
290
+ # (deepseek-flash) is natively multimodal.
275
291
  "capabilities" => { "vision" => false }.freeze,
276
292
  "model_capabilities" => {
293
+ "deepseek-flash" => { "vision" => true }.freeze,
277
294
  "deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
278
295
  }.freeze,
279
296
  "website_url" => "https://platform.deepseek.com/api_keys"
@@ -420,6 +437,7 @@ module Clacky
420
437
  "api" => "openai-completions",
421
438
  "default_model" => "gpt-5.5",
422
439
  "models" => [
440
+ "gpt-6-astra",
423
441
  "gpt-5.6-sol",
424
442
  "gpt-5.6-terra",
425
443
  "gpt-5.6-luna",
@@ -703,6 +721,23 @@ module Clacky
703
721
 
704
722
  }.freeze
705
723
 
724
+ # Canonical provider identifiers. Mirror the PRESETS keys above; keep both
725
+ # in sync when adding or renaming a provider.
726
+ OPENCLACKY_ID = "openclacky"
727
+ OPENROUTER_ID = "openrouter"
728
+ DEEPSEEKV4_ID = "deepseekv4"
729
+ GLM_ID = "glm"
730
+ KIMI_ID = "kimi"
731
+ KIMI_CODING_ID = "kimi-coding"
732
+ MINIMAX_ID = "minimax"
733
+ ANTHROPIC_ID = "anthropic"
734
+ OPENAI_ID = "openai"
735
+ QWEN_ID = "qwen"
736
+ MIMO_ID = "mimo"
737
+ VOLCENGINE_ARK_ID = "volcengine-ark"
738
+ OLLAMA_ID = "ollama"
739
+ ORCAROUTER_ID = "orcarouter"
740
+
706
741
  MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
707
742
 
708
743
  # Per-model maximum output token limits.
@@ -496,7 +496,7 @@ module Clacky
496
496
  when :add
497
497
  new_model = show_model_edit_form(nil, test_callback: test_callback)
498
498
  if new_model
499
- anthropic_format = new_model[:provider] == "anthropic"
499
+ anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
500
500
  current_config.add_model(
501
501
  model: new_model[:model],
502
502
  api_key: new_model[:api_key],
@@ -6,10 +6,10 @@ require "fileutils"
6
6
  module Clacky
7
7
  # Search provider selection, backed by ~/.clacky/search.yml.
8
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
9
+ # The built-in Parallel/DuckDuckGo/Bing providers need no configuration and
10
+ # stay the default. Pointing `provider` at a script in ~/.clacky/searchers/ routes
11
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.
12
+ # want a provider-specific API or policy controls reach for.
13
13
  #
14
14
  # search.yml schema:
15
15
  # provider: tavily — basename of a script in ~/.clacky/searchers/;
@@ -272,7 +272,7 @@ module Clacky
272
272
  end
273
273
 
274
274
  # Handle built-in commands
275
- if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
275
+ if text&.match?(KNOWN_COMMAND)
276
276
  handle_command(adapter, event, text)
277
277
  return
278
278
  end
@@ -366,7 +366,7 @@ module Clacky
366
366
  key = channel_key(event)
367
367
 
368
368
  case text
369
- when /\A([\?h]|help)\z/i
369
+ when %r{\A/([??]|h|help)\z}i
370
370
  adapter.send_text(chat_id, COMMAND_HELP)
371
371
 
372
372
  when "/new", "/clear"
@@ -506,15 +506,18 @@ module Clacky
506
506
  list_sessions(adapter, chat_id)
507
507
 
508
508
  else
509
- adapter.send_text(chat_id, "Unknown command. Type ? for help.")
509
+ adapter.send_text(chat_id, "Unknown command. Type /? for help.")
510
510
  end
511
511
  end
512
512
 
513
- KNOWN_COMMAND = %r{\A/(new|clear|model|skills|bind|stop|unbind|status|list)\b}i
513
+ # "/?" is anchored with \z instead of \b because \b never matches after
514
+ # "?" (not a word character), which would let "/?extra" through.
515
+ # "?" is accepted too since Chinese IMEs produce the full-width form.
516
+ KNOWN_COMMAND = %r{\A/(?:[??]\z|(?:h|help|new|clear|model|skills|bind|stop|unbind|status|list)\b)}i
514
517
 
515
518
  COMMAND_HELP = <<~HELP.strip
516
519
  Commands:
517
- ? / h / help - show this help
520
+ /? / /h / /help - show this help
518
521
  /new / /clear - start a new session
519
522
  /model - show current model, cards & quick-switch list
520
523
  /model <n> - switch card by number
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "open3"
4
4
 
5
+ require_relative "../utils/encoding"
6
+
5
7
  module Clacky
6
8
  module Server
7
9
  # Read-mostly git operations scoped to a session's working directory, backing
@@ -14,9 +16,15 @@ module Clacky
14
16
 
15
17
  # Run a git subcommand in `dir` with argv-style args (no shell). Returns
16
18
  # [stdout, stderr, success_bool]. Never raises on git failure.
19
+ #
20
+ # `core.quotePath=false` keeps git from octal-escaping non-ASCII paths, so
21
+ # the parsers below (and the path comparisons in `restore`) see the same
22
+ # bytes the caller passed in. Output is then normalised to UTF-8 because
23
+ # git inherits the locale, and under a non-UTF-8 locale the raw bytes come
24
+ # back tagged US-ASCII, where String#split would raise on multibyte paths.
17
25
  def git(dir, *args)
18
- out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
19
- [out, err, status.success?]
26
+ out, err, status = Open3.capture3("git", "-C", dir.to_s, "-c", "core.quotePath=false", *args)
27
+ [Utils::Encoding.to_utf8(out), Utils::Encoding.to_utf8(err), status.success?]
20
28
  rescue StandardError => e
21
29
  ["", e.message, false]
22
30
  end
@@ -20,6 +20,7 @@ require_relative "session_registry"
20
20
  require_relative "project_manager"
21
21
  require_relative "git_panel"
22
22
  require_relative "dir_picker"
23
+ require_relative "preview"
23
24
  require_relative "web_ui_controller"
24
25
  require_relative "model_prices"
25
26
  require_relative "scheduler"
@@ -102,7 +103,7 @@ module Clacky
102
103
 
103
104
  # Rewrite local image paths to /api/local-image proxy URLs for browser rendering
104
105
  rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
105
- ev = { type: "assistant_message", session_id: @session_id, content: rewritten }
106
+ ev = { type: "assistant_message", session_id: @session_id, content: rewritten, interim: interim }
106
107
  ev[:created_at] = created_at if created_at
107
108
  @events << stamp(ev)
108
109
  end
@@ -168,6 +169,7 @@ module Clacky
168
169
  # GET /** → static files served from lib/clacky/web/ directory
169
170
  class HttpServer
170
171
  include DirPicker
172
+ include Preview
171
173
 
172
174
  WEB_ROOT = File.expand_path("../web", __dir__)
173
175
  # How long shutdown waits for each agent thread to unwind before falling
@@ -496,6 +498,12 @@ module Clacky
496
498
  self.send(:serve_agent_avatar, req, res)
497
499
  elsif req.path.start_with?("/ext_ui/")
498
500
  self.send(:serve_ext_ui, req, res)
501
+ elsif req.path.start_with?("/preview/p/")
502
+ self.send(:serve_preview_proxy, req, res)
503
+ elsif req.path.start_with?("/preview/f/")
504
+ self.send(:serve_preview_local, req, res)
505
+ elsif req.path == "/preview" || req.path.start_with?("/preview/")
506
+ self.send(:serve_preview, req, res)
499
507
  else
500
508
  file_handler.service(req, res)
501
509
  res["Cache-Control"] = "no-store"
@@ -722,6 +730,9 @@ module Clacky
722
730
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
723
731
  when ["POST", "/api/upload"] then api_upload_file(req, res)
724
732
  when ["POST", "/api/file-action"] then api_file_action(req, res)
733
+ when ["GET", "/api/file/apps"] then api_file_apps(req, res)
734
+ when ["GET", "/api/file/default-app"] then api_file_default_app(req, res)
735
+ when ["GET", "/api/app-icon"] then api_app_icon(req, res)
725
736
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
726
737
  when ["POST", "/api/media/image"] then api_media_image(req, res)
727
738
  when ["POST", "/api/media/video"] then api_media_video(req, res)
@@ -3471,7 +3482,7 @@ module Clacky
3471
3482
  keys = []
3472
3483
  @agent_config.models.each do |m|
3473
3484
  next unless m.is_a?(Hash)
3474
- next unless @agent_config.provider_id_for(m) == "openclacky"
3485
+ next unless @agent_config.provider_id_for(m) == Clacky::Providers::OPENCLACKY_ID
3475
3486
 
3476
3487
  key = m["api_key"].to_s.strip
3477
3488
  keys << key unless key.empty?
@@ -4423,12 +4434,87 @@ module Clacky
4423
4434
  json_response(res, 500, { ok: false, error: e.message })
4424
4435
  end
4425
4436
 
4437
+ # GET /api/file/apps?path=/path/to/file.pptx
4438
+ # Lists installed applications that declare support for the file's
4439
+ # extension, so the Web UI can offer "open with" choices for formats it
4440
+ # cannot preview inline. macOS uses MacAppDetector, WSL uses
4441
+ # WindowsAppDetector; other OSes answer with an empty list.
4442
+ def api_file_apps(req, res)
4443
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
4444
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4445
+
4446
+ path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4447
+ ext = File.extname(path).downcase.sub(/\A\./, "")
4448
+
4449
+ apps = if ext.empty?
4450
+ []
4451
+ elsif Utils::EnvironmentDetector.os_type == :wsl
4452
+ Utils::WindowsAppDetector.apps_for_ext(ext)
4453
+ else
4454
+ Utils::MacAppDetector.apps_for_ext(ext)
4455
+ end
4456
+ json_response(res, 200, { ok: true, apps: apps })
4457
+ rescue => e
4458
+ json_response(res, 500, { ok: false, error: e.message })
4459
+ end
4460
+
4461
+ # GET /api/file/default-app?path=...
4462
+ # Returns the application the OS would use to open the file.
4463
+ def api_file_default_app(req, res)
4464
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
4465
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4466
+
4467
+ path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4468
+ app = if File.extname(path).empty?
4469
+ nil
4470
+ elsif Utils::EnvironmentDetector.os_type == :wsl
4471
+ Utils::WindowsAppDetector.default_app_for(path)
4472
+ else
4473
+ Utils::MacAppDetector.default_app_for(path)
4474
+ end
4475
+ json_response(res, 200, { ok: true, app: app })
4476
+ rescue => e
4477
+ json_response(res, 500, { ok: false, error: e.message })
4478
+ end
4479
+
4480
+ # GET /api/app-icon?path=/Applications/Keynote.app (macOS)
4481
+ # GET /api/app-icon?path=C:\...\app.exe (WSL)
4482
+ # Serves an application's icon as a PNG. macOS converts the bundle .icns
4483
+ # via sips; WSL extracts the .exe associated icon via PowerShell. Both are
4484
+ # cached on disk.
4485
+ def api_app_icon(req, res)
4486
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
4487
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4488
+
4489
+ png = if Utils::EnvironmentDetector.os_type == :wsl
4490
+ Utils::WindowsAppDetector.icon_png(raw_path)
4491
+ else
4492
+ Utils::MacAppDetector.icon_png(Utils::EnvironmentDetector.resolve_local_path(raw_path))
4493
+ end
4494
+ unless png
4495
+ # Never let a failed lookup stick in the browser cache — it would keep
4496
+ # serving a 404 even after the detector is fixed/updated.
4497
+ res["Cache-Control"] = "no-store"
4498
+ return json_response(res, 404, { error: "icon not found" })
4499
+ end
4500
+
4501
+ res.status = 200
4502
+ res["Content-Type"] = "image/png"
4503
+ res["Cache-Control"] = "public, max-age=604800"
4504
+ res["Content-Length"] = File.size(png).to_s
4505
+ res.body = File.binread(png)
4506
+ rescue => e
4507
+ json_response(res, 500, { error: e.message })
4508
+ end
4509
+
4426
4510
  # POST /api/file-action
4427
4511
  # Unified file action endpoint — open locally or download.
4428
- # Body: { path: String, action: "open" | "download" | "save" }
4429
- # open: opens the file with the OS default handler (local deployments).
4430
- # download: returns the file as a download (remote deployments).
4431
- # save: writes content back to the file. Body must include { content: String }.
4512
+ # Body: { path: String, action: "open" | "open_with" | "download" | "save" }
4513
+ # open: opens the file with the OS default handler (local deployments).
4514
+ # open_with: opens the file with the app named in body.app (the app must
4515
+ # be one returned by /api/file/apps).
4516
+ # download: returns the file as a download (remote deployments).
4517
+ # save: writes content back to the file. Body must include { content: String }.
4432
4518
  def api_file_action(req, res)
4433
4519
  body = parse_json_body(req)
4434
4520
  path = body["path"]
@@ -4452,6 +4538,15 @@ module Clacky
4452
4538
  result = Utils::EnvironmentDetector.open_file(linux_path)
4453
4539
  return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
4454
4540
  json_response(res, 200, { ok: true })
4541
+ when "open_with"
4542
+ result = if Utils::EnvironmentDetector.os_type == :wsl
4543
+ Utils::WindowsAppDetector.open_with(linux_path, body["app"].to_s)
4544
+ else
4545
+ Utils::MacAppDetector.open_with(linux_path, body["app"].to_s)
4546
+ end
4547
+ return json_response(res, 400, { error: "unknown application" }) if result.nil?
4548
+ return json_response(res, 500, { error: "failed to open with application" }) unless result
4549
+ json_response(res, 200, { ok: true })
4455
4550
  when "reveal"
4456
4551
  Utils::EnvironmentDetector.reveal_file(linux_path)
4457
4552
  json_response(res, 200, { ok: true })
@@ -4467,7 +4562,7 @@ module Clacky
4467
4562
  File.write(linux_path, content, encoding: "UTF-8")
4468
4563
  json_response(res, 200, { ok: true })
4469
4564
  else
4470
- json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download', 'display-path' or 'save'" })
4565
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'open_with', 'reveal', 'download', 'display-path' or 'save'" })
4471
4566
  end
4472
4567
  rescue => e
4473
4568
  json_response(res, 500, { ok: false, error: e.message })
@@ -5226,7 +5321,10 @@ module Clacky
5226
5321
  next unless File.exist?(full)
5227
5322
  {
5228
5323
  name: name,
5229
- path: "#{rel}/#{name}".gsub(%r{/+}, "/"),
5324
+ # Root-level entries must NOT get a leading "/" — the client treats
5325
+ # slash-prefixed paths as absolute and passes them to file-action
5326
+ # verbatim, which then fails to find "<root>/<name>".
5327
+ path: rel.empty? ? name : "#{rel}/#{name}".gsub(%r{/+}, "/"),
5230
5328
  type: is_dir ? "dir" : "file",
5231
5329
  size: is_dir ? nil : (File.size(full) rescue nil)
5232
5330
  }
@@ -5358,11 +5456,22 @@ module Clacky
5358
5456
  return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
5359
5457
  end
5360
5458
 
5459
+ raw = File.read(skill_md)
5460
+ frontmatter_match = raw.match(/\A---\n.*?\n---[ \t]*\n?/m)
5461
+ body = frontmatter_match ? raw[frontmatter_match.end(0)..].sub(/\A\n+/, "") : raw
5462
+
5361
5463
  json_response(res, 200, {
5362
5464
  ok: true,
5363
5465
  name: skill.identifier,
5364
- content: File.read(skill_md),
5365
- path: skill_md
5466
+ content: raw,
5467
+ path: skill_md,
5468
+ fields: {
5469
+ name: skill.identifier,
5470
+ name_zh: skill.name_zh.to_s,
5471
+ description: skill.description.to_s,
5472
+ description_zh: skill.description_zh.to_s,
5473
+ body: body
5474
+ }
5366
5475
  })
5367
5476
  end
5368
5477
 
@@ -5375,13 +5484,19 @@ module Clacky
5375
5484
  return json_response(res, 403, { ok: false, error: "System skills cannot be edited" })
5376
5485
  end
5377
5486
 
5378
- data = parse_json_body(req)
5379
- content = data["content"].to_s
5487
+ data = parse_json_body(req)
5380
5488
  skill_md = File.join(skill.directory.to_s, "SKILL.md")
5381
5489
  unless File.exist?(skill_md)
5382
5490
  return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
5383
5491
  end
5384
5492
 
5493
+ fields = data["fields"]
5494
+ content = if fields.is_a?(Hash)
5495
+ Clacky::Skill.update_frontmatter_fields(File.read(skill_md), fields)
5496
+ else
5497
+ data["content"].to_s
5498
+ end
5499
+
5385
5500
  File.write(skill_md, content)
5386
5501
  @skill_loader.load_all
5387
5502
  json_response(res, 200, { ok: true, name: skill.identifier })
@@ -6761,15 +6876,47 @@ module Clacky
6761
6876
 
6762
6877
  unless agent
6763
6878
  Clacky::Logger.warn("[messages] agent is nil", session_id: session_id)
6879
+ if query["navigation"] == "1"
6880
+ return json_response(res, 200, { sources: [], total: 0 })
6881
+ end
6882
+ if query["previews"]
6883
+ return json_response(res, 200, { previews: [] })
6884
+ end
6885
+ if query["preview"]
6886
+ return json_response(res, 409, { error: "History location is no longer available" })
6887
+ end
6764
6888
  return json_response(res, 200, { events: [], has_more: false })
6765
6889
  end
6766
6890
 
6767
6891
  # Collect events emitted by replay_history via a lightweight collector UI
6892
+ if query["navigation"] == "1"
6893
+ return json_response(res, 200, agent.history_navigation)
6894
+ end
6895
+ if query["previews"]
6896
+ ids = JSON.parse(query["previews"])
6897
+ raise ArgumentError, "History locations must be an array" unless ids.is_a?(Array)
6898
+
6899
+ return json_response(res, 200, { previews: agent.history_navigation_previews(ids: ids) })
6900
+ end
6901
+ if query["preview"]
6902
+ return json_response(res, 200, agent.history_navigation_preview(id: query["preview"]))
6903
+ end
6768
6904
  collected = []
6769
6905
  collector = HistoryCollector.new(session_id, collected)
6770
- result = agent.replay_history(collector, limit: limit, before: before)
6906
+ if query["window"] == "1"
6907
+ result = agent.replay_history_window(collector, limit: limit, around: query["around"],
6908
+ before_id: query["before_id"], after_id: query["after_id"])
6909
+ ids = result.delete(:round_ids)
6910
+ collected.select { |event| event[:type] == "history_user_message" }.each_with_index do |event, index|
6911
+ event[:round_id] = ids[index]
6912
+ end
6913
+ else
6914
+ result = agent.replay_history(collector, limit: limit, before: before)
6915
+ end
6771
6916
 
6772
- json_response(res, 200, { events: collected, has_more: result[:has_more] })
6917
+ json_response(res, 200, { events: collected }.merge(result))
6918
+ rescue ArgumentError, JSON::ParserError => e
6919
+ json_response(res, 409, { error: e.message })
6773
6920
  end
6774
6921
 
6775
6922
  # ── Project API ───────────────────────────────────────────────────────────