openclacky 1.2.18 → 1.3.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/clacky/agent/time_machine.rb +256 -74
  4. data/lib/clacky/agent/tool_executor.rb +12 -0
  5. data/lib/clacky/agent.rb +15 -20
  6. data/lib/clacky/agent_config.rb +18 -0
  7. data/lib/clacky/cli.rb +55 -3
  8. data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
  9. data/lib/clacky/media/base.rb +93 -0
  10. data/lib/clacky/media/gemini.rb +10 -0
  11. data/lib/clacky/media/generator.rb +57 -0
  12. data/lib/clacky/media/openai_compat.rb +160 -0
  13. data/lib/clacky/message_history.rb +12 -7
  14. data/lib/clacky/providers.rb +29 -1
  15. data/lib/clacky/rich_ui_controller.rb +3 -1
  16. data/lib/clacky/server/backup_manager.rb +200 -0
  17. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  18. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  19. data/lib/clacky/server/channel/channel_manager.rb +65 -50
  20. data/lib/clacky/server/http_server.rb +356 -14
  21. data/lib/clacky/server/scheduler.rb +19 -0
  22. data/lib/clacky/server/session_registry.rb +8 -4
  23. data/lib/clacky/session_manager.rb +40 -2
  24. data/lib/clacky/tools/trash_manager.rb +14 -0
  25. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  26. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  27. data/lib/clacky/ui2/ui_controller.rb +150 -19
  28. data/lib/clacky/utils/file_processor.rb +75 -4
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +2283 -1277
  31. data/lib/clacky/web/app.js +73 -1
  32. data/lib/clacky/web/backup.js +119 -0
  33. data/lib/clacky/web/billing.js +224 -11
  34. data/lib/clacky/web/channels.js +81 -11
  35. data/lib/clacky/web/design-sample.css +247 -0
  36. data/lib/clacky/web/design-sample.html +127 -0
  37. data/lib/clacky/web/favicon.svg +16 -0
  38. data/lib/clacky/web/i18n.js +167 -31
  39. data/lib/clacky/web/index.html +176 -55
  40. data/lib/clacky/web/logo_nav_dark.png +0 -0
  41. data/lib/clacky/web/onboard.js +121 -28
  42. data/lib/clacky/web/sessions.js +447 -192
  43. data/lib/clacky/web/settings.js +21 -1
  44. data/lib/clacky/web/skills.js +34 -1
  45. data/lib/clacky/web/tasks.js +129 -61
  46. data/lib/clacky/web/utils.js +72 -0
  47. data/lib/clacky/web/ws-dispatcher.js +6 -0
  48. data/lib/clacky.rb +1 -0
  49. metadata +9 -8
  50. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
@@ -3,6 +3,7 @@
3
3
  require "webrick"
4
4
  require "websocket"
5
5
  require "socket"
6
+ require "digest"
6
7
  require "json"
7
8
  require "net/http"
8
9
  require "faraday"
@@ -366,6 +367,17 @@ module Clacky
366
367
  @scheduler.start
367
368
  puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
368
369
 
370
+ # Reclaim orphaned Time Machine snapshots (sessions deleted earlier
371
+ # without snapshot cleanup). Runs off-thread so startup stays fast.
372
+ Thread.new do
373
+ begin
374
+ n = Clacky::SessionManager.cleanup_orphan_snapshots
375
+ puts " Snapshots: reclaimed #{n} orphan dir(s)" if n.positive?
376
+ rescue StandardError => e
377
+ Clacky::Logger.error("snapshot_cleanup_error", error: e)
378
+ end
379
+ end
380
+
369
381
  # Start IM channel adapters (non-blocking — each platform runs in its own thread)
370
382
  @channel_manager.start
371
383
 
@@ -411,6 +423,16 @@ module Clacky
411
423
  # with modalities:["image"]); end-to-end latency is commonly
412
424
  # 20-60s and can exceed 2 minutes for or-gpt-image-2 under load.
413
425
  300
426
+ elsif path == "/api/media/video"
427
+ # Video generation (Veo via the gateway) runs an async submit+poll
428
+ # cycle that routinely takes 1-3 minutes and can approach the
429
+ # gateway's 8-minute ceiling. Give the local handler headroom.
430
+ 600
431
+ elsif path == "/api/media/audio/speech"
432
+ 120
433
+ elsif path.start_with?("/api/backup/download") || path == "/api/backup/run"
434
+ # Building a tar.gz of ~/.clacky (with session history) can take a while.
435
+ 120
414
436
  else
415
437
  10
416
438
  end
@@ -435,7 +457,7 @@ module Clacky
435
457
  when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
436
458
  when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
437
459
  when ["GET", "/api/skills"] then api_list_skills(res)
438
- when ["GET", "/api/config"] then api_get_config(res)
460
+ when ["GET", "/api/config"] then api_get_config(req, res)
439
461
  when ["GET", "/api/config/settings"] then api_get_settings(res)
440
462
  when ["GET", "/api/exchange-rate"] then api_exchange_rate(req, res)
441
463
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
@@ -449,10 +471,16 @@ module Clacky
449
471
  when ["POST", "/api/internal/ocr-image"] then api_internal_ocr_image(req, res)
450
472
  when ["GET", "/api/providers"] then api_list_providers(res)
451
473
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
474
+ when ["POST", "/api/onboard/device/start"] then api_onboard_device_start(req, res)
475
+ when ["POST", "/api/onboard/device/poll"] then api_onboard_device_poll(req, res)
452
476
  when ["GET", "/api/browser/status"] then api_browser_status(res)
453
477
  when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
454
478
  when ["POST", "/api/browser/reload"] then api_browser_reload(res)
455
479
  when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
480
+ when ["GET", "/api/backup/status"] then api_backup_status(res)
481
+ when ["POST", "/api/backup/run"] then api_backup_run(res)
482
+ when ["GET", "/api/backup/download"] then api_backup_download(res)
483
+ when ["PATCH", "/api/backup/config"] then api_backup_config(req, res)
456
484
  when ["POST", "/api/telemetry"] then api_telemetry(req, res)
457
485
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
458
486
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
@@ -480,6 +508,8 @@ module Clacky
480
508
  when ["POST", "/api/file-action"] then api_file_action(req, res)
481
509
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
482
510
  when ["POST", "/api/media/image"] then api_media_image(req, res)
511
+ when ["POST", "/api/media/video"] then api_media_video(req, res)
512
+ when ["POST", "/api/media/audio/speech"] then api_media_audio_speech(req, res)
483
513
  when ["GET", "/api/media/types"] then api_media_types(res)
484
514
  when ["GET", "/api/version"] then api_get_version(res)
485
515
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
@@ -535,6 +565,8 @@ module Clacky
535
565
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
536
566
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
537
567
  api_session_skills(session_id, res)
568
+ elsif method == "GET" && path == "/api/dirs"
569
+ api_browse_dirs(req, res)
538
570
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
539
571
  session_id = path.sub("/api/sessions/", "").sub("/files", "")
540
572
  api_session_files(session_id, req, res)
@@ -595,6 +627,9 @@ module Clacky
595
627
  elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
596
628
  name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
597
629
  api_toggle_skill(name, req, res)
630
+ elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
631
+ name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
632
+ api_delete_skill(name, res)
598
633
  elsif method == "POST" && path.match?(%r{^/api/brand/skills/[^/]+/install$})
599
634
  slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", "").sub("/install", ""))
600
635
  api_brand_skill_install(slug, req, res)
@@ -822,6 +857,96 @@ module Clacky
822
857
  end
823
858
  end
824
859
 
860
+ # POST /api/onboard/device/start
861
+ # Kicks off a device-authorization flow against the platform. Returns the
862
+ # device_code (held by the client for polling) plus the user-facing
863
+ # verification URL the browser should open.
864
+ def api_onboard_device_start(req, res)
865
+ client = Clacky::PlatformHttpClient.new
866
+ result = client.post("/api/v1/device/authorize", {
867
+ device_id: onboard_device_id,
868
+ device_info: { os: RUBY_PLATFORM, hostname: Socket.gethostname, app_version: Clacky::VERSION }
869
+ })
870
+
871
+ if result[:success]
872
+ data = result[:data]
873
+ json_response(res, 200, {
874
+ ok: true,
875
+ device_code: data["device_code"],
876
+ user_code: data["user_code"],
877
+ verification_uri: data["verification_uri"],
878
+ verification_uri_complete: data["verification_uri_complete"],
879
+ interval: data["interval"] || 5
880
+ })
881
+ else
882
+ json_response(res, 502, { ok: false, error: result[:error] })
883
+ end
884
+ end
885
+
886
+ # POST /api/onboard/device/poll { device_code }
887
+ # Polls the platform once. While pending, returns { status: "pending" }.
888
+ # On approval, persists the issued key into agent_config and returns
889
+ # { status: "approved" } so the frontend can proceed to the onboard session.
890
+ def api_onboard_device_poll(req, res)
891
+ body = parse_json_body(req) || {}
892
+ device_code = body["device_code"].to_s
893
+ if device_code.empty?
894
+ return json_response(res, 422, { ok: false, error: "device_code is required" })
895
+ end
896
+
897
+ client = Clacky::PlatformHttpClient.new
898
+ result = client.post("/api/v1/device/token", { device_code: device_code })
899
+ data = result[:data] || {}
900
+ status = data["status"]
901
+
902
+ if result[:success] && status == "approved"
903
+ persist_onboard_model(
904
+ api_key: data["api_key"],
905
+ base_url: data["base_url"],
906
+ model: data["default_model"]
907
+ )
908
+ json_response(res, 200, {
909
+ ok: true,
910
+ status: "approved",
911
+ default_model: data["default_model"]
912
+ })
913
+ elsif status == "pending"
914
+ json_response(res, 200, { ok: true, status: "pending" })
915
+ else
916
+ # denied / expired / consumed / network error — surface to the client.
917
+ json_response(res, 200, {
918
+ ok: false,
919
+ status: status || "error",
920
+ error: result[:error]
921
+ })
922
+ end
923
+ end
924
+
925
+ # Stable per-machine id for the onboarding device flow. Independent of the
926
+ # brand/license device_id — onboarding can happen before any license.
927
+ private def onboard_device_id
928
+ components = [Socket.gethostname, ENV["USER"] || ENV["USERNAME"] || "", RUBY_PLATFORM]
929
+ Digest::SHA256.hexdigest(components.join(":"))
930
+ end
931
+
932
+ # Persist a device-flow-issued model as the default and re-anchor current_*.
933
+ private def persist_onboard_model(api_key:, base_url:, model:)
934
+ @agent_config.models.each { |m| m.delete("type") if m["type"] == "default" }
935
+ entry = {
936
+ "id" => SecureRandom.uuid,
937
+ "model" => model,
938
+ "base_url" => base_url,
939
+ "api_key" => api_key,
940
+ "anthropic_format" => false,
941
+ "type" => "default"
942
+ }
943
+ @agent_config.models << entry
944
+ @agent_config.current_model_id = entry["id"]
945
+ @agent_config.current_model_index = @agent_config.models.length - 1
946
+ @agent_config.save
947
+ end
948
+
949
+
825
950
  # GET /api/browser/status
826
951
  # Returns real daemon liveness from BrowserManager (not just yml read).
827
952
  def api_browser_status(res)
@@ -860,6 +985,52 @@ module Clacky
860
985
  json_response(res, 500, { ok: false, error: e.message })
861
986
  end
862
987
 
988
+ # GET /api/backup/status
989
+ def api_backup_status(res)
990
+ json_response(res, 200, BackupManager.status)
991
+ rescue StandardError => e
992
+ json_response(res, 500, { ok: false, error: e.message })
993
+ end
994
+
995
+ # POST /api/backup/run — run a backup immediately.
996
+ def api_backup_run(res)
997
+ result = BackupManager.run!
998
+ json_response(res, 200, { ok: true, archive: File.basename(result[:archive]),
999
+ size: result[:size], dest_dir: result[:dest_dir],
1000
+ status: BackupManager.status })
1001
+ rescue StandardError => e
1002
+ json_response(res, 500, { ok: false, error: e.message })
1003
+ end
1004
+
1005
+ # GET /api/backup/download — build a one-off archive and stream it
1006
+ # directly to the browser. Not written to dest_dir nor recorded.
1007
+ def api_backup_download(res)
1008
+ result = BackupManager.build_download!
1009
+ res.status = 200
1010
+ res["Content-Type"] = "application/gzip"
1011
+ res["Content-Disposition"] = %(attachment; filename="#{result[:filename]}")
1012
+ res["Cache-Control"] = "no-store"
1013
+ res.body = File.binread(result[:path])
1014
+ rescue StandardError => e
1015
+ json_response(res, 500, { ok: false, error: e.message })
1016
+ ensure
1017
+ FileUtils.rm_f(result[:path]) if result && result[:path]
1018
+ end
1019
+ # Body: { enabled?, cron?, dest_dir?, keep?, include_sessions? }
1020
+ def api_backup_config(req, res)
1021
+ body = parse_json_body(req) || {}
1022
+ cfg = BackupManager.update_config(
1023
+ enabled: body.key?("enabled") ? body["enabled"] : nil,
1024
+ cron: body["cron"],
1025
+ dest_dir: body.key?("dest_dir") ? body["dest_dir"] : nil,
1026
+ keep: body["keep"],
1027
+ include_sessions: body.key?("include_sessions") ? body["include_sessions"] : nil
1028
+ )
1029
+ json_response(res, 200, { ok: true, config: cfg, status: BackupManager.status })
1030
+ rescue StandardError => e
1031
+ json_response(res, 500, { ok: false, error: e.message })
1032
+ end
1033
+
863
1034
  # POST /api/telemetry
864
1035
  # Body: { "event": "share_open" | "share_download", ... }
865
1036
  # Fire-and-forget telemetry from the WebUI frontend.
@@ -903,6 +1074,65 @@ module Clacky
903
1074
  json_response(res, 500, { error: e.message })
904
1075
  end
905
1076
 
1077
+ def api_media_video(req, res)
1078
+ body = parse_json_body(req)
1079
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
1080
+
1081
+ prompt = body["prompt"].to_s
1082
+ if prompt.strip.empty?
1083
+ return json_response(res, 422, { error: "prompt is required" })
1084
+ end
1085
+
1086
+ aspect_ratio = body["aspect_ratio"].to_s
1087
+ aspect_ratio = "landscape" if aspect_ratio.empty?
1088
+ duration = body["duration_seconds"]
1089
+ 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?
1092
+
1093
+ result = Clacky::Media::Generator.new(@agent_config).generate_video(
1094
+ prompt: prompt,
1095
+ aspect_ratio: aspect_ratio,
1096
+ duration_seconds: duration,
1097
+ output_dir: output_dir,
1098
+ image: image
1099
+ )
1100
+ if result["success"]
1101
+ log_media_usage(result, prompt: prompt)
1102
+ end
1103
+ status = result["success"] ? 200 : 422
1104
+ json_response(res, status, result)
1105
+ rescue StandardError => e
1106
+ json_response(res, 500, { error: e.message })
1107
+ end
1108
+
1109
+ def api_media_audio_speech(req, res)
1110
+ body = parse_json_body(req)
1111
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
1112
+
1113
+ input = body["input"].to_s
1114
+ if input.strip.empty?
1115
+ return json_response(res, 422, { error: "input is required" })
1116
+ end
1117
+
1118
+ 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?
1121
+
1122
+ result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1123
+ input: input,
1124
+ voice: voice,
1125
+ output_dir: output_dir
1126
+ )
1127
+ if result["success"]
1128
+ log_media_usage(result, prompt: input)
1129
+ end
1130
+ status = result["success"] ? 200 : 422
1131
+ json_response(res, status, result)
1132
+ rescue StandardError => e
1133
+ json_response(res, 500, { error: e.message })
1134
+ end
1135
+
906
1136
  private def log_media_usage(result, prompt:)
907
1137
  usage = result["usage"]
908
1138
  cost = result["cost_usd"]
@@ -919,7 +1149,11 @@ module Clacky
919
1149
  end
920
1150
  parts << format("cost_usd=%.6f", cost.to_f) if cost
921
1151
  parts << "prompt=#{prompt[0, 60].inspect}"
922
- Clacky::Logger.info("[Media] image generated #{parts.join(" ")}")
1152
+ kind = if result.key?("video") then "video"
1153
+ elsif result.key?("audio") then "audio"
1154
+ else "image"
1155
+ end
1156
+ Clacky::Logger.info("[Media] #{kind} generated #{parts.join(" ")}")
923
1157
  end
924
1158
 
925
1159
  # GET /api/media/types
@@ -2703,19 +2937,38 @@ module Clacky
2703
2937
  # Convert it to the Linux-side path so File.exist? works.
2704
2938
  path = Utils::EnvironmentDetector.win_to_linux_path(path)
2705
2939
 
2706
- # Security: only serve image files
2940
+ # Security: only serve media files (images + videos)
2707
2941
  ext = File.extname(path).downcase
2708
- unless Utils::FileProcessor::LOCAL_IMAGE_EXTENSIONS.include?(ext)
2709
- return json_response(res, 403, { error: "not an image file" })
2942
+ unless Utils::FileProcessor::LOCAL_MEDIA_EXTENSIONS.include?(ext)
2943
+ return json_response(res, 403, { error: "not a supported media file" })
2710
2944
  end
2711
2945
 
2712
2946
  return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
2713
2947
 
2948
+ file_size = File.size(path)
2714
2949
  mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
2715
- res.status = 200
2716
- res["Content-Type"] = mime
2717
- res["Cache-Control"] = "private, max-age=3600"
2718
- res.body = File.binread(path)
2950
+
2951
+ # Support HTTP Range requests for video seeking
2952
+ range_header = req["Range"]
2953
+ if range_header && range_header =~ /\Abytes=(\d*)-(\d*)\z/
2954
+ start_byte = ($1.empty? ? 0 : $1.to_i)
2955
+ end_byte = ($2.empty? ? file_size - 1 : $2.to_i)
2956
+ end_byte = [end_byte, file_size - 1].min
2957
+
2958
+ res.status = 206
2959
+ res["Content-Type"] = mime
2960
+ res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
2961
+ res["Accept-Ranges"] = "bytes"
2962
+ res["Cache-Control"] = "private, max-age=3600"
2963
+ res["Content-Length"] = (end_byte - start_byte + 1).to_s
2964
+ IO.binread(path, end_byte - start_byte + 1, start_byte).then { |data| res.body = data }
2965
+ else
2966
+ res.status = 200
2967
+ res["Content-Type"] = mime
2968
+ res["Accept-Ranges"] = "bytes"
2969
+ res["Cache-Control"] = "private, max-age=3600"
2970
+ res.body = File.binread(path)
2971
+ end
2719
2972
  rescue => e
2720
2973
  json_response(res, 500, { error: e.message })
2721
2974
  end
@@ -3112,6 +3365,7 @@ module Clacky
3112
3365
  query = URI.decode_www_form(req.query_string.to_s).to_h
3113
3366
  rel = query["path"].to_s
3114
3367
  absolute_mode = query["absolute"] == "true"
3368
+ show_hidden = query["show_hidden"] == "true"
3115
3369
 
3116
3370
  # Absolute mode: allow browsing outside working directory (e.g., root "/")
3117
3371
  if absolute_mode
@@ -3131,7 +3385,9 @@ module Clacky
3131
3385
  end
3132
3386
 
3133
3387
  return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
3134
- entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
3388
+ entries = Dir.children(target).reject do |name|
3389
+ IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
3390
+ end
3135
3391
 
3136
3392
  items = entries.filter_map do |name|
3137
3393
  full = File.join(target, name)
@@ -3155,6 +3411,45 @@ module Clacky
3155
3411
  rescue StandardError => e
3156
3412
  json_response(res, 500, { error: e.message })
3157
3413
  end
3414
+
3415
+ # GET /api/dirs?path=<absolute-or-~-path>
3416
+ # Session-independent directory browser used by the New Session modal,
3417
+ # where no session (and thus no working_dir) exists yet. Always operates
3418
+ # in absolute mode and lists directories only.
3419
+ def api_browse_dirs(req, res)
3420
+ query = URI.decode_www_form(req.query_string.to_s).to_h
3421
+ rel = query["path"].to_s.strip
3422
+ show_hidden = query["show_hidden"] == "true"
3423
+ rel = Dir.home if rel.empty?
3424
+ target = File.expand_path(rel.start_with?("~") ? rel.sub(/\A~/, Dir.home) : rel)
3425
+
3426
+ # The requested directory may not exist yet (e.g. the default
3427
+ # ~/clacky_workspace before any session created it). Instead of 404,
3428
+ # walk up to the nearest existing ancestor so the picker stays usable.
3429
+ until Dir.exist?(target)
3430
+ parent = File.dirname(target)
3431
+ break if parent == target
3432
+ target = parent
3433
+ end
3434
+ return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
3435
+
3436
+ entries = Dir.children(target).reject do |name|
3437
+ IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
3438
+ end
3439
+ items = entries.filter_map do |name|
3440
+ full = File.join(target, name)
3441
+ next unless File.directory?(full) && File.exist?(full)
3442
+ { name: name, path: full, type: "dir" }
3443
+ rescue StandardError
3444
+ nil
3445
+ end
3446
+ items.sort_by! { |it| it[:name].downcase }
3447
+
3448
+ json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, entries: items })
3449
+ rescue StandardError => e
3450
+ json_response(res, 500, { error: e.message })
3451
+ end
3452
+
3158
3453
  # Body: { enabled: true/false }
3159
3454
  def api_toggle_skill(name, req, res)
3160
3455
  body = parse_json_body(req)
@@ -3171,6 +3466,14 @@ module Clacky
3171
3466
  json_response(res, 422, { error: e.message })
3172
3467
  end
3173
3468
 
3469
+ private def api_delete_skill(name, res)
3470
+ skill = @skill_loader[name]
3471
+ return json_response(res, 404, { error: "Skill not found: #{name}" }) unless skill
3472
+
3473
+ FileUtils.rm_rf(skill.directory)
3474
+ json_response(res, 200, { ok: true })
3475
+ end
3476
+
3174
3477
  # POST /api/my-skills/:name/publish
3175
3478
  # GET /api/creator/skills
3176
3479
  # Returns two separate groups:
@@ -3908,7 +4211,7 @@ module Clacky
3908
4211
  # ── Config API ────────────────────────────────────────────────────────────
3909
4212
 
3910
4213
  # GET /api/config — return current model configurations
3911
- def api_get_config(res)
4214
+ def api_get_config(req, res)
3912
4215
  models = @agent_config.models.map.with_index do |m, i|
3913
4216
  {
3914
4217
  id: m["id"], # Stable runtime id — use this for switching
@@ -3921,19 +4224,58 @@ module Clacky
3921
4224
  }
3922
4225
  end
3923
4226
  # Filter out auto-injected models (lite, derived media) AND media
3924
- # entries (image/video/audio) — those are managed via the dedicated
4227
+ # entries (image/video/audio/ocr) — those are managed via the dedicated
3925
4228
  # media-config UI, not the chat-model card list.
3926
4229
  models.reject! do |m|
3927
4230
  raw = @agent_config.models[m[:index]]
3928
- raw["auto_injected"] || Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s)
4231
+ raw["auto_injected"] ||
4232
+ Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s) ||
4233
+ raw["type"].to_s == "ocr"
3929
4234
  end
4235
+ # Capabilities follow the model the *session* is actually running on
4236
+ # (it may differ from the global default after a per-session switch).
4237
+ query = URI.decode_www_form(req.query_string.to_s).to_h
4238
+ cfg = config_for_session(query["session_id"]) || @agent_config
3930
4239
  json_response(res, 200, {
3931
4240
  models: models,
3932
4241
  current_index: @agent_config.current_model_index,
3933
- current_id: @agent_config.current_model&.dig("id")
4242
+ current_id: @agent_config.current_model&.dig("id"),
4243
+ media_capabilities: media_capabilities_payload(cfg)
3934
4244
  })
3935
4245
  end
3936
4246
 
4247
+ # Resolve the AgentConfig for a given session, falling back to nil when
4248
+ # the session isn't live so callers can use the global config instead.
4249
+ def config_for_session(session_id)
4250
+ return nil if session_id.to_s.strip.empty?
4251
+ return nil unless @registry.ensure(session_id)
4252
+
4253
+ agent = nil
4254
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
4255
+ agent&.config
4256
+ end
4257
+
4258
+ # Capability summary for the model dropdown's footer.
4259
+ # vision — true when the current default model handles images itself
4260
+ # OR a vision sidecar is configured (ocr_state covers both).
4261
+ # image/video/audio — true only when a dedicated sidecar is configured;
4262
+ # the chat model can never generate these on its own.
4263
+ def media_capabilities_payload(cfg = @agent_config)
4264
+ ocr = cfg.ocr_state
4265
+ out = {
4266
+ vision: {
4267
+ configured: !!ocr["configured"],
4268
+ primary: !!ocr["primary"],
4269
+ model: ocr["model"]
4270
+ }
4271
+ }
4272
+ Clacky::Providers::MEDIA_KINDS.each do |t|
4273
+ state = cfg.media_state(t)
4274
+ out[t] = { configured: !!state["configured"], model: state["model"] }
4275
+ end
4276
+ out
4277
+ end
4278
+
3937
4279
  # GET /api/config/settings — return advanced settings
3938
4280
  def api_get_settings(res)
3939
4281
  json_response(res, 200, {
@@ -210,6 +210,8 @@ module Clacky
210
210
 
211
211
  # Check all enabled schedules against the given time and fire matching ones.
212
212
  private def tick(now)
213
+ maybe_run_backup(now)
214
+
213
215
  load_schedules.each do |schedule|
214
216
  next unless schedule["enabled"] != false
215
217
  next unless cron_matches?(schedule["cron"].to_s, now)
@@ -248,6 +250,23 @@ module Clacky
248
250
  Clacky::Logger.error("scheduler_fire_error", task: schedule["task"], error: e)
249
251
  end
250
252
 
253
+ # Built-in automatic backup hook. Runs as a plain system operation —
254
+ # no AI session — when the backup cron matches and auto-backup is enabled.
255
+ private def maybe_run_backup(now)
256
+ cfg = BackupManager.config
257
+ return unless cfg["enabled"]
258
+ return unless cron_matches?(cfg["cron"].to_s, now)
259
+
260
+ minute_key = now.strftime("%Y%m%d%H%M")
261
+ return if @last_backup_minute == minute_key
262
+
263
+ @last_backup_minute = minute_key
264
+ result = BackupManager.run!
265
+ Clacky::Logger.info("scheduler_backup_done", archive: File.basename(result[:archive]), size: result[:size])
266
+ rescue => e
267
+ Clacky::Logger.error("scheduler_backup_error", error: e)
268
+ end
269
+
251
270
  # ── Cron parsing ─────────────────────────────────────────────────────────
252
271
 
253
272
  # Returns true if the 5-field cron expression matches the given Time.
@@ -44,7 +44,7 @@ module Clacky
44
44
  error: nil,
45
45
  error_code: nil,
46
46
  top_up_url: nil,
47
- updated_at: Time.now,
47
+ updated_at: nil,
48
48
  agent: nil,
49
49
  ui: nil,
50
50
  thread: nil,
@@ -170,6 +170,7 @@ module Clacky
170
170
  live_name = nil if live_name&.empty?
171
171
  live_cost_source = s[:agent]&.cost_source
172
172
  { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
173
+ updated_at: s[:updated_at]&.iso8601,
173
174
  model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
174
175
  total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
175
176
  cost_source: live_cost_source,
@@ -227,7 +228,9 @@ module Clacky
227
228
  pinned, non_pinned = all.partition { |s| s[:pinned] }
228
229
 
229
230
  # `before` cursor ONLY applies to non-pinned (paginated) sessions.
230
- non_pinned = non_pinned.select { |s| (s[:created_at] || "") < before } if before
231
+ # Cursor field must match the sort key in all_sessions (updated_at,
232
+ # falling back to created_at for legacy rows).
233
+ non_pinned = non_pinned.select { |s| (s[:updated_at] || s[:created_at] || "") < before } if before
231
234
  non_pinned = non_pinned.first(limit) if limit
232
235
 
233
236
  # Pinned section: only included on the first page (before == nil) so
@@ -267,6 +270,7 @@ module Clacky
267
270
  live_name = s[:agent]&.name
268
271
  live_name = nil if live_name&.empty?
269
272
  { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
273
+ updated_at: s[:updated_at]&.iso8601,
270
274
  model: model_info&.dig(:model), model_id: model_info&.dig(:id),
271
275
  name: live_name, total_tasks: s[:agent]&.total_tasks,
272
276
  total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
@@ -301,7 +305,7 @@ module Clacky
301
305
  agent_profile: (s[:agent_profile] || "general").to_s,
302
306
  working_dir: s[:working_dir],
303
307
  created_at: s[:created_at],
304
- updated_at: s[:updated_at],
308
+ updated_at: ls&.dig(:updated_at) || s[:updated_at],
305
309
  total_tasks: ls&.dig(:total_tasks) || s.dig(:stats, :total_tasks) || 0,
306
310
  total_cost: ls&.dig(:total_cost) || s.dig(:stats, :total_cost_usd) || 0.0,
307
311
  cost_source: (ls&.dig(:cost_source) || s.dig(:stats, :cost_source) || "estimated").to_s,
@@ -466,7 +470,7 @@ module Clacky
466
470
  working_dir: agent.working_dir,
467
471
  status: session[:status],
468
472
  created_at: agent.created_at,
469
- updated_at: session[:updated_at].iso8601,
473
+ updated_at: session[:updated_at]&.iso8601 || agent.created_at,
470
474
  total_tasks: agent.total_tasks || 0,
471
475
  total_cost: agent.total_cost || 0.0,
472
476
  cost_source: agent.cost_source.to_s,
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "securerandom"
6
6
  require "open3"
7
+ require "set"
7
8
 
8
9
  module Clacky
9
10
  class SessionManager
@@ -162,14 +163,15 @@ module Clacky
162
163
  chunk_path
163
164
  end
164
165
 
165
- # All sessions from disk, newest-first (sorted by created_at).
166
+ # All sessions from disk, newest-first (sorted by last activity / updated_at,
167
+ # falling back to created_at for legacy sessions without updated_at).
166
168
  # Optional filters:
167
169
  # current_dir: (String) if given, sessions matching working_dir come first
168
170
  # limit: (Integer) max number of sessions to return
169
171
  def all_sessions(current_dir: nil, limit: nil)
170
172
  sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
171
173
  load_session_file(filepath)
172
- end.sort_by { |s| s[:created_at] || "" }.reverse
174
+ end.sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
173
175
 
174
176
  if current_dir
175
177
  current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
@@ -412,5 +414,41 @@ module Clacky
412
414
  rescue JSON::ParserError, Errno::ENOENT
413
415
  nil
414
416
  end
417
+
418
+ # Remove Time Machine snapshots that no longer belong to any known session.
419
+ # Snapshots are keyed by full session_id; session files are named by the
420
+ # 8-char id prefix, so a snapshot dir is an orphan when its prefix matches
421
+ # no active or trashed session file. Returns the count of removed dirs.
422
+ def self.cleanup_orphan_snapshots(sessions_dir: SESSIONS_DIR, snapshots_root: nil)
423
+ snapshots_root ||= File.join(Dir.home, ".clacky", "snapshots")
424
+ return 0 unless Dir.exist?(snapshots_root)
425
+
426
+ require_relative "utils/trash_directory"
427
+ known = _session_id_prefixes(File.join(sessions_dir, "*.json"))
428
+ trash_dir = Clacky::TrashDirectory.sessions_trash_dir
429
+ known += _session_id_prefixes(File.join(trash_dir, "*.json")) if Dir.exist?(trash_dir)
430
+ known = known.to_set
431
+
432
+ removed = 0
433
+ Dir.children(snapshots_root).each do |name|
434
+ dir = File.join(snapshots_root, name)
435
+ next unless File.directory?(dir)
436
+ next if known.include?(name[0, 8])
437
+
438
+ FileUtils.rm_rf(dir)
439
+ removed += 1
440
+ end
441
+ removed
442
+ end
443
+
444
+ # Session filenames look like "<datetime>-<8hexid>.json"; pull out the
445
+ # trailing 8-char id prefix, which matches a snapshot dir's name prefix.
446
+ def self._session_id_prefixes(glob)
447
+ Dir.glob(glob).filter_map do |p|
448
+ m = File.basename(p, ".json").match(/-([0-9a-f]{8})\z/)
449
+ m && m[1]
450
+ end
451
+ end
452
+ private_class_method :_session_id_prefixes
415
453
  end
416
454
  end