openclacky 1.3.4 → 1.3.5

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/session_serializer.rb +3 -2
  5. data/lib/clacky/agent/tool_executor.rb +0 -12
  6. data/lib/clacky/agent.rb +74 -9
  7. data/lib/clacky/api_extension.rb +81 -0
  8. data/lib/clacky/api_extension_loader.rb +13 -1
  9. data/lib/clacky/client.rb +14 -17
  10. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  11. data/lib/clacky/default_agents/base_prompt.md +1 -0
  12. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  13. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  14. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  15. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  17. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  18. data/lib/clacky/json_ui_controller.rb +1 -1
  19. data/lib/clacky/media/base.rb +60 -0
  20. data/lib/clacky/media/dashscope.rb +385 -21
  21. data/lib/clacky/media/gemini.rb +9 -0
  22. data/lib/clacky/media/generator.rb +52 -0
  23. data/lib/clacky/media/openai_compat.rb +166 -0
  24. data/lib/clacky/null_ui_controller.rb +13 -0
  25. data/lib/clacky/plain_ui_controller.rb +1 -1
  26. data/lib/clacky/providers.rb +50 -2
  27. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  28. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  29. data/lib/clacky/server/http_server.rb +144 -9
  30. data/lib/clacky/server/session_registry.rb +4 -2
  31. data/lib/clacky/server/web_ui_controller.rb +3 -2
  32. data/lib/clacky/skill_loader.rb +14 -2
  33. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  34. data/lib/clacky/tools/terminal.rb +0 -43
  35. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  36. data/lib/clacky/ui2/ui_controller.rb +140 -31
  37. data/lib/clacky/ui_interface.rb +10 -1
  38. data/lib/clacky/utils/encoding.rb +25 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky/web/app.css +145 -22
  41. data/lib/clacky/web/components/onboard.js +1 -14
  42. data/lib/clacky/web/features/brand/view.js +8 -5
  43. data/lib/clacky/web/features/channels/store.js +1 -20
  44. data/lib/clacky/web/features/mcp/store.js +1 -20
  45. data/lib/clacky/web/features/profile/store.js +1 -13
  46. data/lib/clacky/web/features/profile/view.js +16 -4
  47. data/lib/clacky/web/features/skills/store.js +6 -21
  48. data/lib/clacky/web/features/version/store.js +2 -0
  49. data/lib/clacky/web/i18n.js +24 -1
  50. data/lib/clacky/web/index.html +15 -0
  51. data/lib/clacky/web/sessions.js +141 -51
  52. data/lib/clacky/web/settings.js +34 -2
  53. data/lib/clacky/web/ws-dispatcher.js +11 -3
  54. data/lib/clacky.rb +12 -5
  55. metadata +8 -1
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui_interface"
4
+
5
+ module Clacky
6
+ # A UI controller that swallows every event. Used for detached/background
7
+ # subagents whose intermediate output must never reach a real UI stream
8
+ # (e.g. the WebUI chat transcript). All UIInterface methods are inherited
9
+ # as no-ops, so nothing this agent emits is broadcast anywhere.
10
+ class NullUIController
11
+ include Clacky::UIInterface
12
+ end
13
+ end
@@ -117,7 +117,7 @@ module Clacky
117
117
  puts_line("[warn] #{message}")
118
118
  end
119
119
 
120
- def show_error(message, code: nil, top_up_url: nil)
120
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
121
121
  puts_line("[error] #{message}")
122
122
  end
123
123
 
@@ -88,6 +88,30 @@ module Clacky
88
88
  "or-tts-gemini-2-5-pro" => "Gemini 2.5 Pro TTS"
89
89
  },
90
90
  "default_audio_model" => "or-tts-gemini-2-5-flash",
91
+ # Speech-to-text models served by the openclacky gateway, which
92
+ # routes them to Vertex AI Gemini (generateContent with inline
93
+ # audio parts). The gateway returns transcription text.
94
+ "stt_models" => [
95
+ "or-stt-gemini-3-5-flash",
96
+ "or-stt-gemini-1-5-pro"
97
+ ],
98
+ "stt_model_aliases" => {
99
+ "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
100
+ "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
101
+ },
102
+ "default_stt_model" => "or-stt-gemini-3-5-flash",
103
+ # Video understanding models served by the openclacky gateway, which
104
+ # routes video frames to Gemini (generateContent with inline image
105
+ # parts). The gateway returns analysis text.
106
+ "video_understanding_models" => [
107
+ "or-gemini-3-5-flash",
108
+ "or-gemini-3-1-pro"
109
+ ],
110
+ "video_understanding_model_aliases" => {
111
+ "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
112
+ "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
113
+ },
114
+ "default_video_understanding_model" => "or-gemini-3-5-flash",
91
115
  # Default OCR sidecar — used when the primary model is text-only.
92
116
  # Candidates are derived from the provider's vision-capable models;
93
117
  # this just picks the cheap+fast default to surface in "auto" mode.
@@ -418,7 +442,7 @@ module Clacky
418
442
 
419
443
  }.freeze
420
444
 
421
- MEDIA_KINDS = %w[image video audio].freeze
445
+ MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
422
446
 
423
447
  class << self
424
448
  # Check if a provider preset exists
@@ -549,11 +573,33 @@ module Clacky
549
573
  preset&.dig("audio_model_aliases") || {}
550
574
  end
551
575
 
576
+ def stt_models(provider_id)
577
+ preset = PRESETS[provider_id]
578
+ preset&.dig("stt_models") || []
579
+ end
580
+
581
+ def stt_model_aliases(provider_id)
582
+ preset = PRESETS[provider_id]
583
+ preset&.dig("stt_model_aliases") || {}
584
+ end
585
+
586
+ def video_understanding_models(provider_id)
587
+ preset = PRESETS[provider_id]
588
+ preset&.dig("video_understanding_models") || []
589
+ end
590
+
591
+ def video_understanding_model_aliases(provider_id)
592
+ preset = PRESETS[provider_id]
593
+ preset&.dig("video_understanding_model_aliases") || {}
594
+ end
595
+
552
596
  def media_model_aliases(provider_id, kind)
553
597
  case kind.to_s
554
598
  when "image" then image_model_aliases(provider_id)
555
599
  when "video" then video_model_aliases(provider_id)
556
600
  when "audio" then audio_model_aliases(provider_id)
601
+ when "stt" then stt_model_aliases(provider_id)
602
+ when "video_understanding" then video_understanding_model_aliases(provider_id)
557
603
  else {}
558
604
  end
559
605
  end
@@ -599,13 +645,15 @@ module Clacky
599
645
 
600
646
  # Unified entry for media model lookup by kind.
601
647
  # @param provider_id [String]
602
- # @param kind [String] one of "image" / "video" / "audio"
648
+ # @param kind [String] one of "image" / "video" / "audio" / "stt"
603
649
  # @return [Array<String>]
604
650
  def media_models(provider_id, kind)
605
651
  case kind.to_s
606
652
  when "image" then image_models(provider_id)
607
653
  when "video" then video_models(provider_id)
608
654
  when "audio" then audio_models(provider_id)
655
+ when "stt" then stt_models(provider_id)
656
+ when "video_understanding" then video_understanding_models(provider_id)
609
657
  else []
610
658
  end
611
659
  end
@@ -331,7 +331,7 @@ module Clacky
331
331
  @shell.add_system_message("Warning: #{message}")
332
332
  end
333
333
 
334
- def show_error(message)
334
+ def show_error(message, **)
335
335
  @shell.add_error_message(message.to_s)
336
336
  end
337
337
 
@@ -145,7 +145,7 @@ module Clacky
145
145
  send_text("Warning: #{message}")
146
146
  end
147
147
 
148
- def show_error(message, code: nil, top_up_url: nil)
148
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
149
149
  text = "Error: #{message}"
150
150
  text += "\n#{top_up_url}" if top_up_url
151
151
  send_text(text)
@@ -368,6 +368,8 @@ module Clacky
368
368
  res.body = html
369
369
  elsif req.path.start_with?("/webui_ext/")
370
370
  self.send(:serve_webui_ext, req, res)
371
+ elsif req.path.start_with?("/builtin_ext/")
372
+ self.send(:serve_builtin_ext, req, res)
371
373
  elsif req.path.start_with?("/agent_ui/")
372
374
  self.send(:serve_agent_ui, req, res)
373
375
  elsif req.path.start_with?("/panel_ui/")
@@ -462,6 +464,10 @@ module Clacky
462
464
  600
463
465
  elsif path == "/api/media/audio/speech"
464
466
  120
467
+ elsif path == "/api/media/audio/transcriptions"
468
+ 30
469
+ elsif path == "/api/media/video/understand"
470
+ 60
465
471
  elsif path.start_with?("/api/backup/download") || path == "/api/backup/run"
466
472
  # Building a tar.gz of ~/.clacky (with session history) can take a while.
467
473
  120
@@ -549,6 +555,8 @@ module Clacky
549
555
  when ["POST", "/api/media/image"] then api_media_image(req, res)
550
556
  when ["POST", "/api/media/video"] then api_media_video(req, res)
551
557
  when ["POST", "/api/media/audio/speech"] then api_media_audio_speech(req, res)
558
+ when ["POST", "/api/media/audio/transcriptions"] then api_media_audio_transcriptions(req, res)
559
+ when ["POST", "/api/media/video/understand"] then api_media_video_understand(req, res)
552
560
  when ["GET", "/api/media/types"] then api_media_types(res)
553
561
  when ["GET", "/api/version"] then api_get_version(res)
554
562
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
@@ -677,7 +685,7 @@ module Clacky
677
685
  elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
678
686
  id = path.sub("/api/config/models/", "")
679
687
  api_delete_model(id, res)
680
- elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
688
+ elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio|stt|video_understanding)$})
681
689
  kind = path.sub("/api/config/media/", "")
682
690
  api_update_media_config(kind, req, res)
683
691
  elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
@@ -1033,6 +1041,9 @@ module Clacky
1033
1041
  # Absolute path to the user's WebUI extension directory.
1034
1042
  WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
1035
1043
 
1044
+ # Built-in extensions bundled with the gem (default_extensions/*/**.js).
1045
+ BUILTIN_EXT_ROOT = File.expand_path("../default_extensions", __dir__)
1046
+
1036
1047
  # Build the full <script> payload injected at {{EXT_SCRIPTS}}:
1037
1048
  # 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
1038
1049
  # 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
@@ -1042,6 +1053,7 @@ module Clacky
1042
1053
  private def webui_ext_script_tags
1043
1054
  [
1044
1055
  panel_agents_script,
1056
+ builtin_ext_script_tags,
1045
1057
  global_ext_script_tags,
1046
1058
  agent_webui_script_tags,
1047
1059
  official_panel_script_tags,
@@ -1093,6 +1105,21 @@ module Clacky
1093
1105
  dirs
1094
1106
  end
1095
1107
 
1108
+ # Built-in extensions from default_extensions/*/*.js — loaded before user
1109
+ # extensions so user ~/.clacky/webui_ext/ can override by ext id.
1110
+ private def builtin_ext_script_tags
1111
+ return "" unless Dir.exist?(BUILTIN_EXT_ROOT)
1112
+
1113
+ Dir.glob(File.join(BUILTIN_EXT_ROOT, "*", "**", "*.js")).sort.map do |abs|
1114
+ rel = abs.delete_prefix(BUILTIN_EXT_ROOT + "/")
1115
+ ext_id = "builtin/" + rel.delete_suffix(".js")
1116
+ src = "/builtin_ext/#{rel}"
1117
+ "<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{nil.to_json})</script>" \
1118
+ "<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
1119
+ "<script>Clacky.ext._extEnd()</script>"
1120
+ end.join("\n")
1121
+ end
1122
+
1096
1123
  # Global extensions — visible for all agents (unchanged legacy behavior).
1097
1124
  private def global_ext_script_tags
1098
1125
  ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
@@ -1173,6 +1200,31 @@ module Clacky
1173
1200
  res.body = File.read(abs)
1174
1201
  end
1175
1202
 
1203
+ private def serve_builtin_ext(req, res)
1204
+ rel = req.path.delete_prefix("/builtin_ext/")
1205
+ abs = File.expand_path(File.join(BUILTIN_EXT_ROOT, rel))
1206
+
1207
+ unless abs.start_with?(BUILTIN_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
1208
+ res.status = 404
1209
+ res.body = "not found"
1210
+ return
1211
+ end
1212
+
1213
+ ext = File.extname(abs)
1214
+ ctype = { ".js" => "application/javascript", ".css" => "text/css" }[ext]
1215
+ unless ctype
1216
+ res.status = 415
1217
+ res.body = "unsupported media type"
1218
+ return
1219
+ end
1220
+
1221
+ res.status = 200
1222
+ res["Content-Type"] = ctype
1223
+ res["Cache-Control"] = "no-store"
1224
+ res["Pragma"] = "no-cache"
1225
+ res.body = File.read(abs)
1226
+ end
1227
+
1176
1228
  # Serve agents/<name>/webui/<file> from built-in or user agent dir.
1177
1229
  # Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
1178
1230
  private def serve_agent_ui(req, res)
@@ -1405,6 +1457,59 @@ module Clacky
1405
1457
  json_response(res, 500, { error: e.message })
1406
1458
  end
1407
1459
 
1460
+ def api_media_audio_transcriptions(req, res)
1461
+ body = parse_json_body(req)
1462
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
1463
+
1464
+ audio_b64 = body["audio_base64"].to_s
1465
+ if audio_b64.empty?
1466
+ return json_response(res, 422, { error: "audio_base64 is required" })
1467
+ end
1468
+
1469
+ mime_type = body["mime_type"].to_s
1470
+ mime_type = "audio/webm" if mime_type.empty?
1471
+
1472
+ result = Clacky::Media::Generator.new(@agent_config).generate_transcription(
1473
+ audio_base64: audio_b64,
1474
+ mime_type: mime_type
1475
+ )
1476
+ if result["success"]
1477
+ Clacky::Logger.info("[Media] stt generated model=#{result["model"]} provider=#{result["provider"]} cost_usd=#{result["cost_usd"].to_f}")
1478
+ end
1479
+ status = result["success"] ? 200 : 422
1480
+ json_response(res, status, result)
1481
+ rescue StandardError => e
1482
+ json_response(res, 500, { error: e.message })
1483
+ end
1484
+
1485
+ def api_media_video_understand(req, res)
1486
+ body = parse_json_body(req)
1487
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
1488
+
1489
+ video_b64 = body["video_base64"].to_s
1490
+ if video_b64.empty?
1491
+ return json_response(res, 422, { error: "video_base64 is required" })
1492
+ end
1493
+
1494
+ mime_type = body["mime_type"].to_s
1495
+ mime_type = "image/png" if mime_type.empty?
1496
+
1497
+ prompt = body["prompt"].to_s
1498
+
1499
+ result = Clacky::Media::Generator.new(@agent_config).understand_video(
1500
+ video_base64: video_b64,
1501
+ mime_type: mime_type,
1502
+ prompt: prompt
1503
+ )
1504
+ if result["success"]
1505
+ Clacky::Logger.info("[Media] video_understanding generated model=#{result["model"]} provider=#{result["provider"]} cost_usd=#{result["cost_usd"].to_f}")
1506
+ end
1507
+ status = result["success"] ? 200 : 422
1508
+ json_response(res, status, result)
1509
+ rescue StandardError => e
1510
+ json_response(res, 500, { error: e.message })
1511
+ end
1512
+
1408
1513
  private def log_media_usage(result, prompt:)
1409
1514
  usage = result["usage"]
1410
1515
  cost = result["cost_usd"]
@@ -1967,6 +2072,7 @@ module Clacky
1967
2072
  product_name: brand.product_name,
1968
2073
  homepage_url: brand.homepage_url,
1969
2074
  logo_url: brand.logo_url,
2075
+ theme_color: brand.theme_color,
1970
2076
  test_mode: @brand_test,
1971
2077
  distribution_refresh_pending: refresh_pending
1972
2078
  })
@@ -2038,7 +2144,8 @@ module Clacky
2038
2144
  ok: true,
2039
2145
  product_name: result[:product_name] || brand.product_name,
2040
2146
  user_id: result[:user_id] || brand.license_user_id,
2041
- user_licensed: brand.user_licensed?
2147
+ user_licensed: brand.user_licensed?,
2148
+ theme_color: brand.theme_color
2042
2149
  })
2043
2150
  else
2044
2151
  json_response(res, 422, { ok: false, error: result[:message] })
@@ -5820,6 +5927,22 @@ module Clacky
5820
5927
  Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
5821
5928
  end
5822
5929
 
5930
+ # Run a task in a session immediately in the background, without waiting
5931
+ # for the client to subscribe. The user bubble is persisted via
5932
+ # display_text (Agent#run → history → replay_history), so the frontend
5933
+ # only needs to navigate over and load history — no realtime broadcast,
5934
+ # no subscribe-timing race. This is the stable entry point for
5935
+ # programmatic "create session + run now" flows (spawn, extensions).
5936
+ def run_session_task(session_id, prompt, display_message: nil)
5937
+ return unless @registry.exist?(session_id)
5938
+
5939
+ agent = nil
5940
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
5941
+ return unless agent
5942
+
5943
+ run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
5944
+ end
5945
+
5823
5946
  # Start the pending task for a session.
5824
5947
  # Called when the client sends "run_task" over WS — by that point the
5825
5948
  # client has already subscribed, so every broadcast will be delivered.
@@ -5827,18 +5950,29 @@ module Clacky
5827
5950
  return unless @registry.exist?(session_id)
5828
5951
 
5829
5952
  session = @registry.get(session_id)
5830
- prompt = session[:pending_task]
5831
- working_dir = session[:pending_working_dir]
5953
+ prompt = session[:pending_task]
5954
+ working_dir = session[:pending_working_dir]
5955
+ display_message = session[:pending_display_message]
5832
5956
  return unless prompt # nothing pending
5833
5957
 
5834
5958
  # Clear the pending fields so a re-connect doesn't re-run
5835
- @registry.update(session_id, pending_task: nil, pending_working_dir: nil)
5959
+ @registry.update(session_id, pending_task: nil, pending_working_dir: nil, pending_display_message: nil)
5836
5960
 
5837
5961
  agent = nil
5838
5962
  @registry.with_session(session_id) { |s| agent = s[:agent] }
5839
5963
  return unless agent
5840
5964
 
5841
- run_agent_task(session_id, agent) { agent.run(prompt) }
5965
+ # Surface a user message on screen before the agent starts thinking, so
5966
+ # programmatically-submitted tasks (e.g. meeting summarization) don't
5967
+ # appear as a thinking spinner with no preceding message. When a short
5968
+ # display_message is provided we show that instead of the full prompt.
5969
+ if display_message
5970
+ web_ui = nil
5971
+ @registry.with_session(session_id) { |s| web_ui = s[:ui] }
5972
+ web_ui&.show_user_message(display_message, source: :web)
5973
+ end
5974
+
5975
+ run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
5842
5976
  end
5843
5977
 
5844
5978
  # Interrupt every running agent thread and persist its session state.
@@ -5921,10 +6055,11 @@ module Clacky
5921
6055
  top_up_url = preset && preset["website_url"]
5922
6056
  end
5923
6057
  user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
5924
- @registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url)
6058
+ raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
6059
+ @registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
5925
6060
  broadcast_session_update(session_id)
5926
- web_ui&.show_error(user_message, code: code, top_up_url: top_up_url)
5927
- @session_manager.save(agent.to_session_data(status: :error, error_message: user_message))
6061
+ web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
6062
+ @session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
5928
6063
  end
5929
6064
  @registry.with_session(session_id) { |s| s[:thread] = thread }
5930
6065
  end
@@ -44,6 +44,7 @@ module Clacky
44
44
  error: nil,
45
45
  error_code: nil,
46
46
  top_up_url: nil,
47
+ raw_message: nil,
47
48
  updated_at: nil,
48
49
  agent: nil,
49
50
  ui: nil,
@@ -169,7 +170,7 @@ module Clacky
169
170
  live_name = s[:agent]&.name
170
171
  live_name = nil if live_name&.empty?
171
172
  live_cost_source = s[:agent]&.cost_source
172
- { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
173
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url], raw_message: s[:raw_message],
173
174
  updated_at: s[:updated_at]&.iso8601,
174
175
  model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
175
176
  total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
@@ -269,7 +270,7 @@ module Clacky
269
270
  model_info = s[:agent]&.current_model_info
270
271
  live_name = s[:agent]&.name
271
272
  live_name = nil if live_name&.empty?
272
- { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
273
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url], raw_message: s[:raw_message],
273
274
  updated_at: s[:updated_at]&.iso8601,
274
275
  model: model_info&.dig(:model), model_id: model_info&.dig(:id),
275
276
  name: live_name, total_tasks: s[:agent]&.total_tasks,
@@ -296,6 +297,7 @@ module Clacky
296
297
  error: ls ? ls[:error] : nil,
297
298
  error_code: ls&.dig(:error_code),
298
299
  top_up_url: ls&.dig(:top_up_url),
300
+ raw_message: ls&.dig(:raw_message),
299
301
  model: ls&.dig(:model),
300
302
  model_id: ls&.dig(:model_id),
301
303
  card_model: ls&.dig(:card_model),
@@ -230,12 +230,13 @@ module Clacky
230
230
  @broadcaster.call(@session_id, event)
231
231
  end
232
232
 
233
- def show_error(message, code: nil, top_up_url: nil)
233
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
234
234
  payload = { message: message }
235
235
  payload[:code] = code if code
236
236
  payload[:top_up_url] = top_up_url if top_up_url
237
+ payload[:raw_message] = raw_message if raw_message
237
238
  emit("error", **payload)
238
- forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url) }
239
+ forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url, raw_message: raw_message) }
239
240
  end
240
241
 
241
242
  def show_success(message)
@@ -441,13 +441,11 @@ module Clacky
441
441
 
442
442
  # Load default skills from gem's default_skills directory
443
443
  private def load_default_skills
444
- # Get the gem's lib directory
445
444
  gem_lib_dir = File.expand_path("../", __dir__)
446
445
  default_skills_dir = File.join(gem_lib_dir, "clacky", "default_skills")
447
446
 
448
447
  return unless Dir.exist?(default_skills_dir)
449
448
 
450
- # Load each skill directory
451
449
  Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
452
450
  skill_dir = File.dirname(skill_file)
453
451
  skill_name = File.basename(skill_dir)
@@ -459,6 +457,20 @@ module Clacky
459
457
  @errors << "Failed to load default skill #{skill_name}: #{e.message}"
460
458
  end
461
459
  end
460
+
461
+ # Also load skills bundled inside default_extensions/*/skills/
462
+ ext_skills_dir = File.join(gem_lib_dir, "clacky", "default_extensions")
463
+ Dir.glob(File.join(ext_skills_dir, "*/skills/*/SKILL.md")).each do |skill_file|
464
+ skill_dir = File.dirname(skill_file)
465
+ skill_name = File.basename(skill_dir)
466
+
467
+ begin
468
+ skill = Skill.new(Pathname.new(skill_dir))
469
+ register_skill(skill, source: :default)
470
+ rescue StandardError => e
471
+ @errors << "Failed to load default skill #{skill_name}: #{e.message}"
472
+ end
473
+ end
462
474
  end
463
475
  end
464
476
  end
@@ -35,9 +35,7 @@ module Clacky
35
35
  def clean(raw)
36
36
  return "" if raw.nil? || raw.empty?
37
37
 
38
- s = raw.dup
39
- s.force_encoding(Encoding::UTF_8)
40
- s = s.scrub("?") unless s.valid_encoding?
38
+ s = Clacky::Utils::Encoding.pty_to_utf8(raw)
41
39
 
42
40
  s = s.gsub(CSI_REGEX, "")
43
41
  s = s.gsub(OSC_REGEX, "")
@@ -340,10 +340,6 @@ module Clacky
340
340
  project_root: cwd || Dir.pwd
341
341
  )
342
342
 
343
- # PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
344
- # UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
345
- safe_command = force_powershell_utf8(safe_command)
346
-
347
343
  # Background / dedicated path — never reuse the persistent shell,
348
344
  # because these commands stay running and would occupy the slot.
349
345
  if background
@@ -1522,45 +1518,6 @@ module Clacky
1522
1518
  return "" if lines.empty?
1523
1519
  lines.last(DISPLAY_TAIL_LINES).join("\n")
1524
1520
  end
1525
-
1526
- # PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
1527
- # to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
1528
- # `???`. Inject UTF-8 setup into the user's PowerShell command so the
1529
- # shell emits UTF-8 bytes regardless of host locale.
1530
- POWERSHELL_PREAMBLE =
1531
- "[Console]::OutputEncoding=[Text.Encoding]::UTF8;"
1532
-
1533
- # Only rewrites simple `powershell[.exe]` / `pwsh[.exe]` invocations.
1534
- # Skips -File / -EncodedCommand / commands already handling encoding /
1535
- # pipelines (anything risky to splice).
1536
- private def force_powershell_utf8(command)
1537
- cmd = command.to_s
1538
- return command unless cmd =~ /\A\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b/i
1539
- return command if cmd =~ /OutputEncoding/i
1540
- return command if cmd =~ /\s-(?:File|EncodedCommand|enc|f)\b/i
1541
-
1542
- # `-Command "..."` form: pipeline / chain characters inside the
1543
- # quoted body are PowerShell-internal, not shell-level, so we splice
1544
- # safely into the quoted string.
1545
- if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+(?:[^"'\s]+\s+)*?-(?:Command|c)\s+)(["'])(.*)\2(\s*(?:<\s*\S+\s*)?)\z/i))
1546
- head, quote, body, tail = m[1], m[2], m[3], m[4].to_s
1547
- return "#{head}#{quote}#{POWERSHELL_PREAMBLE}#{body}#{quote}#{tail}"
1548
- end
1549
-
1550
- # Outside the quoted-Command form, refuse to splice if there's any
1551
- # shell-level pipe / chain — too risky to get the boundaries right.
1552
- return command if cmd =~ /[|&;]/
1553
-
1554
- if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?))(.*)\z/i))
1555
- exe, rest = m[1], m[2].to_s.strip
1556
- return command if rest.start_with?("-") && rest !~ /\A-(?:Command|c)\b/i
1557
- rest = rest.sub(/\A-(?:Command|c)\b\s*/i, "")
1558
- inner = rest.empty? ? POWERSHELL_PREAMBLE.chomp(";") : "#{POWERSHELL_PREAMBLE}#{rest}"
1559
- return %Q{#{exe} -Command "#{inner}"}
1560
- end
1561
-
1562
- command
1563
- end
1564
1521
  end
1565
1522
  end
1566
1523
  end
@@ -326,7 +326,7 @@ module Clacky
326
326
  print "\e[?25l"
327
327
  return :cancelled
328
328
  when "\u0015" # Ctrl+U - clear line
329
- buffer = ''
329
+ buffer = String.new
330
330
  cursor_pos = 0
331
331
  else
332
332
  # Regular character input