openclacky 1.3.1 → 1.3.3

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +65 -11
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -16,6 +16,7 @@ require "timeout"
16
16
  require "yaml"
17
17
  require "date"
18
18
  require_relative "session_registry"
19
+ require_relative "git_panel"
19
20
  require_relative "web_ui_controller"
20
21
  require_relative "scheduler"
21
22
  require_relative "../brand_config"
@@ -333,6 +334,14 @@ module Clacky
333
334
  server.mount("/api", servlet_class)
334
335
  server.mount("/ws", servlet_class)
335
336
 
337
+ # Health check endpoint — no auth, minimal overhead.
338
+ # Docker / orchestrators can probe this to decide container health.
339
+ server.mount_proc("/health") do |_req, res|
340
+ res.status = 200
341
+ res["Content-Type"] = "application/json"
342
+ res.body = '{"status":"ok"}'
343
+ end
344
+
336
345
  # Mount static file handler for the entire web directory.
337
346
  # Use mount_proc so we can inject no-cache headers on every response,
338
347
  # preventing stale JS/CSS from being served after a gem update.
@@ -347,12 +356,21 @@ module Clacky
347
356
  server.mount_proc("/") do |req, res|
348
357
  if req.path == "/" || req.path == "/index.html"
349
358
  product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
350
- html = File.read(index_html_path).gsub("{{BRAND_NAME}}", product_name)
359
+ pure = req.query["pure"] == "true"
360
+ html = File.read(index_html_path)
361
+ .gsub("{{BRAND_NAME}}", product_name)
362
+ .gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
351
363
  res.status = 200
352
364
  res["Content-Type"] = "text/html; charset=utf-8"
353
365
  res["Cache-Control"] = "no-store"
354
366
  res["Pragma"] = "no-cache"
355
367
  res.body = html
368
+ elsif req.path.start_with?("/webui_ext/")
369
+ self.send(:serve_webui_ext, req, res)
370
+ elsif req.path.start_with?("/agent_ui/")
371
+ self.send(:serve_agent_ui, req, res)
372
+ elsif req.path.start_with?("/panel_ui/")
373
+ self.send(:serve_panel_ui, req, res)
356
374
  else
357
375
  file_handler.service(req, res)
358
376
  res["Cache-Control"] = "no-store"
@@ -465,6 +483,8 @@ module Clacky
465
483
  when ["POST", "/api/config/test"] then api_test_config(req, res)
466
484
  when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
467
485
  when ["GET", "/api/config/media"] then api_get_media_config(res)
486
+ when ["GET", "/api/config/media-output-dir"] then api_get_media_output_dir(res)
487
+ when ["PATCH", "/api/config/media-output-dir"] then api_update_media_output_dir(req, res)
468
488
  when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
469
489
  when ["PATCH", "/api/config/ocr"] then api_update_ocr_config(req, res)
470
490
  when ["POST", "/api/config/ocr/test"] then api_test_ocr_config(req, res)
@@ -565,6 +585,27 @@ module Clacky
565
585
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
566
586
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
567
587
  api_session_skills(session_id, res)
588
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
589
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
590
+ action = path[%r{/git/([a-z]+)$}, 1]
591
+ api_session_git(session_id, action, req, res)
592
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
593
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
594
+ api_session_git_commit(session_id, req, res)
595
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
596
+ session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
597
+ api_session_time_machine(session_id, res)
598
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/time_machine/switch$})
599
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
600
+ api_session_time_machine_switch(session_id, req, res)
601
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/diff$})
602
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
603
+ task_id = path[%r{/time_machine/(\d+)/diff$}, 1].to_i
604
+ api_session_time_machine_diff(session_id, task_id, req, res)
605
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/restore_preview$})
606
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
607
+ task_id = path[%r{/time_machine/(\d+)/restore_preview$}, 1].to_i
608
+ api_session_time_machine_restore_preview(session_id, task_id, res)
568
609
  elsif method == "GET" && path == "/api/dirs"
569
610
  api_browse_dirs(req, res)
570
611
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
@@ -576,6 +617,9 @@ module Clacky
576
617
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
577
618
  session_id = path.sub("/api/sessions/", "").sub("/messages", "")
578
619
  api_session_messages(session_id, req, res)
620
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+$})
621
+ session_id = path.sub("/api/sessions/", "")
622
+ api_get_session(session_id, res)
579
623
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
580
624
  session_id = path.sub("/api/sessions/", "")
581
625
  api_rename_session(session_id, req, res)
@@ -630,6 +674,9 @@ module Clacky
630
674
  elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
631
675
  name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
632
676
  api_delete_skill(name, res)
677
+ elsif method == "DELETE" && path.match?(%r{^/api/brand/skills/[^/]+$})
678
+ slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", ""))
679
+ api_delete_brand_skill(slug, res)
633
680
  elsif method == "POST" && path.match?(%r{^/api/brand/skills/[^/]+/install$})
634
681
  slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", "").sub("/install", ""))
635
682
  api_brand_skill_install(slug, req, res)
@@ -680,6 +727,16 @@ module Clacky
680
727
  json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
681
728
  end
682
729
 
730
+ # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
731
+ # Used by the frontend Router when navigating to a session that isn't in
732
+ # the paged sidebar list (search results, URL deep links, share links,
733
+ # browser back/forward, external notifications, etc.).
734
+ def api_get_session(session_id, res)
735
+ row = @registry.snapshot(session_id)
736
+ return json_response(res, 404, { error: "Session not found" }) unless row
737
+ json_response(res, 200, { session: row })
738
+ end
739
+
683
740
  def api_create_session(req, res)
684
741
  body = parse_json_body(req)
685
742
  name = body["name"]
@@ -946,9 +1003,195 @@ module Clacky
946
1003
  @agent_config.save
947
1004
  end
948
1005
 
1006
+ # Absolute path to the user's WebUI extension directory.
1007
+ WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
1008
+
1009
+ # Build the full <script> payload injected at {{EXT_SCRIPTS}}:
1010
+ # 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
1011
+ # 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
1012
+ # 3. official panels — default_agents/_panels/<id>/**/*.js (data-panel)
1013
+ # Prefixed with a registerPanelAgents() call so the client knows which
1014
+ # agent profiles reference each official panel. Never raises.
1015
+ private def webui_ext_script_tags
1016
+ [
1017
+ panel_agents_script,
1018
+ global_ext_script_tags,
1019
+ agent_webui_script_tags,
1020
+ official_panel_script_tags,
1021
+ ].reject(&:empty?).join("\n")
1022
+ end
1023
+
1024
+ # Inline script registering the panel→agents map (which agent profiles
1025
+ # reference each official panel, from their profile.yml `panels:`).
1026
+ private def panel_agents_script
1027
+ map = panel_agents_map
1028
+ return "" if map.empty?
1029
+
1030
+ "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1031
+ end
1032
+
1033
+ # { "git" => ["coding", ...], ... } — scan every agent profile.yml for a
1034
+ # `panels:` list and invert it into panel → referencing agents.
1035
+ private def panel_agents_map
1036
+ result = Hash.new { |h, k| h[k] = [] }
1037
+ agent_profile_dirs.each do |name, dir|
1038
+ yml = File.join(dir, "profile.yml")
1039
+ next unless File.file?(yml)
1040
+
1041
+ data = begin
1042
+ YAML.safe_load(File.read(yml)) || {}
1043
+ rescue StandardError
1044
+ {}
1045
+ end
1046
+ Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
1047
+ end
1048
+ result
1049
+ end
1050
+
1051
+ # { agent_name => resolved_dir } across built-in and user agent dirs,
1052
+ # user dir winning on name collision (matches AgentProfile lookup order).
1053
+ private def agent_profile_dirs
1054
+ dirs = {}
1055
+ [Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
1056
+ next unless Dir.exist?(root)
1057
+
1058
+ Dir.children(root).each do |name|
1059
+ next if name.start_with?("_") # _panels and other non-agent dirs
1060
+ full = File.join(root, name)
1061
+ next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
1062
+
1063
+ dirs[name] = full
1064
+ end
1065
+ end
1066
+ dirs
1067
+ end
1068
+
1069
+ # Global extensions — visible for all agents (unchanged legacy behavior).
1070
+ private def global_ext_script_tags
1071
+ ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
1072
+ end
1073
+
1074
+ # Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
1075
+ # its owning agent so the client only mounts it for that profile.
1076
+ private def agent_webui_script_tags
1077
+ agent_profile_dirs.filter_map do |name, dir|
1078
+ webui = File.join(dir, "webui")
1079
+ next unless Dir.exist?(webui)
1080
+
1081
+ ext_script_block(webui, "/agent_ui/#{name}", id_prefix: "agent/#{name}/", agents: [name])
1082
+ end.reject(&:empty?).join("\n")
1083
+ end
1084
+
1085
+ # Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
1086
+ # the panel→agents map (registerPanelAgents), so a panel only mounts for
1087
+ # agents whose profile.yml references it.
1088
+ private def official_panel_script_tags
1089
+ root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
1090
+ return "" unless Dir.exist?(root)
1091
+
1092
+ Dir.children(root).sort.filter_map do |panel|
1093
+ dir = File.join(root, panel)
1094
+ next unless File.directory?(dir)
1095
+
1096
+ ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
1097
+ end.reject(&:empty?).join("\n")
1098
+ end
1099
+
1100
+ # Emit begin/script/end triples for every *.js under `root`, served from
1101
+ # `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
1102
+ # client can decide visibility. Returns "" when the dir is absent.
1103
+ private def ext_script_block(root, url_base, id_prefix: "", agents: nil, panel: nil)
1104
+ return "" unless Dir.exist?(root)
1105
+
1106
+ Dir.glob(File.join(root, "**", "*.js")).sort.map do |abs|
1107
+ rel = abs.delete_prefix(root + "/")
1108
+ ext_id = id_prefix + rel.delete_suffix(".js")
1109
+ src = "#{url_base}/#{rel}"
1110
+ # Bracket the extension's own <script> with begin/end markers so that
1111
+ # registrations made during its synchronous evaluation are attributed
1112
+ # to it (for crash attribution / disable). Synchronous src scripts run
1113
+ # in document order, so the surrounding inline scripts run immediately
1114
+ # before and after it.
1115
+ "<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{agents.to_json}, #{panel.to_json})</script>" \
1116
+ "<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
1117
+ "<script>Clacky.ext._extEnd()</script>"
1118
+ end.join("\n")
1119
+ end
1120
+
1121
+ # Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
1122
+ # with strict path containment so a crafted path cannot escape the dir.
1123
+ private def serve_webui_ext(req, res)
1124
+ rel = req.path.delete_prefix("/webui_ext/")
1125
+ abs = File.expand_path(File.join(WEBUI_EXT_ROOT, rel))
1126
+
1127
+ unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
1128
+ res.status = 404
1129
+ res.body = "not found"
1130
+ return
1131
+ end
1132
+
1133
+ ext = File.extname(abs)
1134
+ ctype = { ".js" => "application/javascript", ".css" => "text/css",
1135
+ ".html" => "text/html; charset=utf-8" }[ext]
1136
+ unless ctype
1137
+ res.status = 415
1138
+ res.body = "unsupported media type"
1139
+ return
1140
+ end
1141
+
1142
+ res.status = 200
1143
+ res["Content-Type"] = ctype
1144
+ res["Cache-Control"] = "no-store"
1145
+ res["Pragma"] = "no-cache"
1146
+ res.body = File.read(abs)
1147
+ end
1148
+
1149
+ # Serve agents/<name>/webui/<file> from built-in or user agent dir.
1150
+ # Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
1151
+ private def serve_agent_ui(req, res)
1152
+ rest = req.path.delete_prefix("/agent_ui/")
1153
+ name, _, rel = rest.partition("/")
1154
+ dir = agent_profile_dirs[name]
1155
+ return (res.status = 404; res.body = "not found") unless dir && !rel.empty?
949
1156
 
950
- # GET /api/browser/status
951
- # Returns real daemon liveness from BrowserManager (not just yml read).
1157
+ serve_static_under(File.join(dir, "webui"), rel, res)
1158
+ end
1159
+
1160
+ # Serve official panel assets: /panel_ui/<panel>/<rel>.
1161
+ private def serve_panel_ui(req, res)
1162
+ rest = req.path.delete_prefix("/panel_ui/")
1163
+ panel, _, rel = rest.partition("/")
1164
+ return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
1165
+
1166
+ root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
1167
+ serve_static_under(root, rel, res)
1168
+ end
1169
+
1170
+ # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
1171
+ # strict path containment so a crafted rel cannot escape `root`.
1172
+ private def serve_static_under(root, rel, res)
1173
+ root = File.expand_path(root)
1174
+ abs = File.expand_path(File.join(root, rel))
1175
+ unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
1176
+ res.status = 404
1177
+ res.body = "not found"
1178
+ return
1179
+ end
1180
+
1181
+ ctype = { ".js" => "application/javascript", ".css" => "text/css",
1182
+ ".html" => "text/html; charset=utf-8" }[File.extname(abs)]
1183
+ unless ctype
1184
+ res.status = 415
1185
+ res.body = "unsupported media type"
1186
+ return
1187
+ end
1188
+
1189
+ res.status = 200
1190
+ res["Content-Type"] = ctype
1191
+ res["Cache-Control"] = "no-store"
1192
+ res["Pragma"] = "no-cache"
1193
+ res.body = File.read(abs)
1194
+ end
952
1195
  def api_browser_status(res)
953
1196
  json_response(res, 200, @browser_manager.status)
954
1197
  end
@@ -1057,13 +1300,18 @@ module Clacky
1057
1300
 
1058
1301
  aspect_ratio = body["aspect_ratio"].to_s
1059
1302
  aspect_ratio = "landscape" if aspect_ratio.empty?
1060
- output_dir = body["output_dir"].to_s
1061
- output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1303
+ output_dir = Clacky::Media::OutputDir.resolve(
1304
+ param: body["output_dir"],
1305
+ configured: @agent_config.media_output_dir,
1306
+ fallback: @agent_config.default_working_dir || Dir.pwd
1307
+ )
1062
1308
 
1063
1309
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1064
1310
  prompt: prompt,
1065
1311
  aspect_ratio: aspect_ratio,
1066
- output_dir: output_dir
1312
+ output_dir: output_dir,
1313
+ image: body["image"],
1314
+ images: body["images"]
1067
1315
  )
1068
1316
  if result["success"]
1069
1317
  log_media_usage(result, prompt: prompt)
@@ -1087,8 +1335,11 @@ module Clacky
1087
1335
  aspect_ratio = "landscape" if aspect_ratio.empty?
1088
1336
  duration = body["duration_seconds"]
1089
1337
  image = body["image"]
1090
- output_dir = body["output_dir"].to_s
1091
- output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1338
+ output_dir = Clacky::Media::OutputDir.resolve(
1339
+ param: body["output_dir"],
1340
+ configured: @agent_config.media_output_dir,
1341
+ fallback: @agent_config.default_working_dir || Dir.pwd
1342
+ )
1092
1343
 
1093
1344
  result = Clacky::Media::Generator.new(@agent_config).generate_video(
1094
1345
  prompt: prompt,
@@ -1116,8 +1367,11 @@ module Clacky
1116
1367
  end
1117
1368
 
1118
1369
  voice = body["voice"]
1119
- output_dir = body["output_dir"].to_s
1120
- output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1370
+ output_dir = Clacky::Media::OutputDir.resolve(
1371
+ param: body["output_dir"],
1372
+ configured: @agent_config.media_output_dir,
1373
+ fallback: @agent_config.default_working_dir || Dir.pwd
1374
+ )
1121
1375
 
1122
1376
  result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1123
1377
  input: input,
@@ -1942,6 +2196,23 @@ module Clacky
1942
2196
  json_response(res, 500, { ok: false, error: e.message })
1943
2197
  end
1944
2198
 
2199
+ # DELETE /api/brand/skills/:slug
2200
+ # Uninstalls a brand skill by removing its files and metadata.
2201
+ def api_delete_brand_skill(slug, res)
2202
+ brand = Clacky::BrandConfig.load
2203
+ installed = brand.installed_brand_skills
2204
+ unless installed.key?(slug)
2205
+ json_response(res, 404, { ok: false, error: "Brand skill '#{slug}' is not installed" })
2206
+ return
2207
+ end
2208
+
2209
+ brand.delete_brand_skill!(slug)
2210
+ @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
2211
+ json_response(res, 200, { ok: true })
2212
+ rescue StandardError => e
2213
+ json_response(res, 500, { ok: false, error: e.message })
2214
+ end
2215
+
1945
2216
  # GET /api/brand
1946
2217
  # Returns brand metadata consumed by the WebUI on boot
1947
2218
  # to dynamically replace branding strings.
@@ -2895,10 +3166,13 @@ module Clacky
2895
3166
  result = Utils::EnvironmentDetector.open_file(linux_path)
2896
3167
  return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
2897
3168
  json_response(res, 200, { ok: true })
3169
+ when "reveal"
3170
+ Utils::EnvironmentDetector.reveal_file(linux_path)
3171
+ json_response(res, 200, { ok: true })
2898
3172
  when "download"
2899
3173
  serve_file_download(res, linux_path)
2900
3174
  else
2901
- json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
3175
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal' or 'download'" })
2902
3176
  end
2903
3177
  rescue => e
2904
3178
  json_response(res, 500, { ok: false, error: e.message })
@@ -3346,7 +3620,147 @@ module Clacky
3346
3620
  json_response(res, 200, { skills: skill_data })
3347
3621
  end
3348
3622
 
3349
- # GET /api/sessions/:id/files?path=<relative dir>
3623
+ # GET /api/sessions/:id/git/<action> — read-only git info for the session's
3624
+ # working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
3625
+ # log accepts ?limit=.
3626
+ def api_session_git(session_id, action, req, res)
3627
+ dir = git_session_dir(session_id, res)
3628
+ return unless dir
3629
+
3630
+ unless Clacky::Server::GitPanel.repo?(dir)
3631
+ return json_response(res, 200, { repo: false })
3632
+ end
3633
+
3634
+ query = URI.decode_www_form(req.query_string.to_s).to_h
3635
+ case action
3636
+ when "status"
3637
+ json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
3638
+ when "diff"
3639
+ json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
3640
+ when "log"
3641
+ json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
3642
+ when "branches"
3643
+ json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
3644
+ else
3645
+ json_response(res, 404, { error: "Unknown git action" })
3646
+ end
3647
+ end
3648
+
3649
+ # POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
3650
+ def api_session_git_commit(session_id, req, res)
3651
+ dir = git_session_dir(session_id, res)
3652
+ return unless dir
3653
+
3654
+ unless Clacky::Server::GitPanel.repo?(dir)
3655
+ return json_response(res, 400, { error: "Not a git repository" })
3656
+ end
3657
+
3658
+ body = parse_json_body(req)
3659
+ result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
3660
+ if result[:ok]
3661
+ json_response(res, 200, result)
3662
+ else
3663
+ json_response(res, 422, { error: result[:error] })
3664
+ end
3665
+ end
3666
+
3667
+ # GET /api/sessions/:id/time_machine — task history for the Time Machine
3668
+ # panel. Mirrors the CLI menu: each entry carries id, summary, status
3669
+ # (current/past/undone) and whether it branches.
3670
+ def api_session_time_machine(session_id, res)
3671
+ agent = time_machine_agent(session_id, res)
3672
+ return unless agent
3673
+
3674
+ history = agent.get_task_history(limit: 20)
3675
+ json_response(res, 200, { tasks: history })
3676
+ end
3677
+
3678
+ # POST /api/sessions/:id/time_machine/switch — body: { task_id: }.
3679
+ # Restores the working tree to the end-of-task state of task_id.
3680
+ def api_session_time_machine_switch(session_id, req, res)
3681
+ agent = time_machine_agent(session_id, res)
3682
+ return unless agent
3683
+
3684
+ body = parse_json_body(req)
3685
+ task_id = body["task_id"].to_i
3686
+ result = agent.switch_to_task(task_id)
3687
+ if result[:success]
3688
+ @session_manager.save(agent.to_session_data(status: :success))
3689
+ broadcast_session_update(session_id)
3690
+ json_response(res, 200, { ok: true, message: result[:message], task_id: result[:task_id] })
3691
+ else
3692
+ json_response(res, 422, { ok: false, error: result[:message] })
3693
+ end
3694
+ end
3695
+
3696
+ # GET /api/sessions/:id/time_machine/:task_id/diff
3697
+ # Without ?path: returns the file list this task touched.
3698
+ # With ?path=<rel>: returns the unified diff of that file.
3699
+ def api_session_time_machine_diff(session_id, task_id, req, res)
3700
+ agent = time_machine_agent(session_id, res)
3701
+ return unless agent
3702
+
3703
+ rel = req.query["path"].to_s
3704
+ if rel.empty?
3705
+ json_response(res, 200, { ok: true, task_id: task_id, files: agent.task_diff_files(task_id) })
3706
+ else
3707
+ diff = agent.task_file_diff(task_id, rel)
3708
+ if diff.nil?
3709
+ json_response(res, 404, { ok: false, error: "No diff for #{rel}" })
3710
+ else
3711
+ json_response(res, 200, { ok: true, task_id: task_id }.merge(diff))
3712
+ end
3713
+ end
3714
+ end
3715
+
3716
+ # GET /api/sessions/:id/time_machine/:task_id/restore_preview
3717
+ # Returns the file-level effect of switching back to this task without
3718
+ # actually performing the switch. Lets the UI render an honest
3719
+ # confirmation listing the files that would be overwritten/created/deleted.
3720
+ def api_session_time_machine_restore_preview(session_id, task_id, res)
3721
+ agent = time_machine_agent(session_id, res)
3722
+ return unless agent
3723
+
3724
+ changes = agent.preview_restore_to_task(task_id)
3725
+ json_response(res, 200, { ok: true, task_id: task_id, changes: changes })
3726
+ end
3727
+
3728
+ # Resolve a session's agent for time-machine ops; writes the error
3729
+ # response and returns nil on failure.
3730
+ private def time_machine_agent(session_id, res)
3731
+ unless @registry.ensure(session_id)
3732
+ json_response(res, 404, { error: "Session not found" })
3733
+ return nil
3734
+ end
3735
+ session = @registry.get(session_id)
3736
+ agent = session && session[:agent]
3737
+ unless agent
3738
+ json_response(res, 404, { error: "Session not found" })
3739
+ return nil
3740
+ end
3741
+ agent
3742
+ end
3743
+
3744
+ # Resolve a session's working_dir for git ops; writes the error response
3745
+ # and returns nil on any failure.
3746
+ private def git_session_dir(session_id, res)
3747
+ unless @registry.ensure(session_id)
3748
+ json_response(res, 404, { error: "Session not found" })
3749
+ return nil
3750
+ end
3751
+ session = @registry.get(session_id)
3752
+ agent = session && session[:agent]
3753
+ unless agent
3754
+ json_response(res, 404, { error: "Session not found" })
3755
+ return nil
3756
+ end
3757
+ dir = File.expand_path(agent.working_dir.to_s)
3758
+ unless Dir.exist?(dir)
3759
+ json_response(res, 404, { error: "Working directory not found" })
3760
+ return nil
3761
+ end
3762
+ dir
3763
+ end
3350
3764
  # Lists one directory level inside the session's working_dir (lazy, per-layer).
3351
3765
  # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
3352
3766
  IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
@@ -4324,7 +4738,83 @@ module Clacky
4324
4738
  json_response(res, 422, { error: e.message })
4325
4739
  end
4326
4740
 
4327
- # POST /api/config — save updated model list
4741
+ # GET /api/config/media-output-dir
4742
+ # Returns the user-configured directory under which generated media
4743
+ # files (images / videos / audio) are persisted, plus the default
4744
+ # the system would use if the value is empty. The frontend uses
4745
+ # `default` as a placeholder hint.
4746
+ def api_get_media_output_dir(res)
4747
+ json_response(res, 200, {
4748
+ ok: true,
4749
+ value: @agent_config.media_output_dir.to_s,
4750
+ default: default_media_output_dir
4751
+ })
4752
+ end
4753
+
4754
+ # PATCH /api/config/media-output-dir
4755
+ # Body: { "value": "<absolute or ~-prefixed path, or empty to clear>" }
4756
+ # Empty / blank value clears the override, restoring the legacy
4757
+ # fallback (default_working_dir → Dir.pwd) for new generations.
4758
+ def api_update_media_output_dir(req, res)
4759
+ body = parse_json_body(req)
4760
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
4761
+
4762
+ raw = body["value"].to_s.strip
4763
+ if raw.empty?
4764
+ @agent_config.media_output_dir = nil
4765
+ @agent_config.save
4766
+ return json_response(res, 200, {
4767
+ ok: true,
4768
+ value: "",
4769
+ default: default_media_output_dir
4770
+ })
4771
+ end
4772
+
4773
+ expanded = File.expand_path(raw)
4774
+
4775
+ # Reject anything that's not an absolute path after `~` expansion.
4776
+ # Relative paths would silently resolve against the server's CWD,
4777
+ # which is exactly the source of confusion this setting exists to fix.
4778
+ unless expanded.start_with?("/")
4779
+ return json_response(res, 422, {
4780
+ error: "media_output_dir must be an absolute path or start with ~"
4781
+ })
4782
+ end
4783
+
4784
+ # Create the directory if missing; surface filesystem errors plainly
4785
+ # so the user can fix permissions / typo without reading server logs.
4786
+ begin
4787
+ FileUtils.mkdir_p(expanded)
4788
+ rescue Errno::EACCES, Errno::EROFS, Errno::ENOSPC, Errno::ENOTDIR => e
4789
+ return json_response(res, 422, {
4790
+ error: "cannot create directory: #{e.message}"
4791
+ })
4792
+ end
4793
+
4794
+ unless File.writable?(expanded)
4795
+ return json_response(res, 422, {
4796
+ error: "directory is not writable: #{expanded}"
4797
+ })
4798
+ end
4799
+
4800
+ @agent_config.media_output_dir = expanded
4801
+ @agent_config.save
4802
+ json_response(res, 200, {
4803
+ ok: true,
4804
+ value: expanded,
4805
+ default: default_media_output_dir
4806
+ })
4807
+ rescue => e
4808
+ json_response(res, 422, { error: e.message })
4809
+ end
4810
+
4811
+ # The path the resolver would pick if media_output_dir is blank.
4812
+ # Mirrors the fallback chain inside Clacky::Media::OutputDir.resolve so
4813
+ # the frontend can render it as a placeholder hint (no second source
4814
+ # of truth — both call sites read default_working_dir).
4815
+ private def default_media_output_dir
4816
+ @agent_config.default_working_dir.to_s.empty? ? Dir.pwd : @agent_config.default_working_dir
4817
+ end
4328
4818
  # DEPRECATED: this endpoint previously accepted the entire models array
4329
4819
  # and replaced @models in place. That design was fragile — any missing
4330
4820
  # or stale field on ANY row could wipe other rows' api_keys. It has
@@ -5062,6 +5552,10 @@ module Clacky
5062
5552
  conn.send_json(type: "error", message: "Session not found: #{session_id}")
5063
5553
  end
5064
5554
 
5555
+ when "edit_message"
5556
+ session_id = msg["session_id"] || conn.session_id
5557
+ handle_edit_message(session_id, msg["content"].to_s, msg["created_at"].to_s)
5558
+
5065
5559
  when "message"
5066
5560
  session_id = msg["session_id"] || conn.session_id
5067
5561
  # Merge legacy images array into files as { data_url:, name:, mime_type: } entries
@@ -5112,6 +5606,20 @@ module Clacky
5112
5606
 
5113
5607
  # ── Session actions ───────────────────────────────────────────────────────
5114
5608
 
5609
+ def handle_edit_message(session_id, content, created_at)
5610
+ return unless @registry.exist?(session_id)
5611
+
5612
+ agent = nil
5613
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
5614
+ return unless agent
5615
+
5616
+ if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
5617
+ agent.history.truncate_from_created_at(created_at)
5618
+ end
5619
+
5620
+ handle_user_message(session_id, content)
5621
+ end
5622
+
5115
5623
  def handle_user_message(session_id, content, files = [])
5116
5624
  return unless @registry.exist?(session_id)
5117
5625