openclacky 1.2.18 → 1.3.0

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 +21 -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 +28 -0
  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 +345 -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 +2038 -1147
  31. data/lib/clacky/web/app.js +22 -1
  32. data/lib/clacky/web/backup.js +119 -0
  33. data/lib/clacky/web/billing.js +94 -7
  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 +159 -31
  39. data/lib/clacky/web/index.html +175 -55
  40. data/lib/clacky/web/logo_nav_dark.png +0 -0
  41. data/lib/clacky/web/onboard.js +114 -28
  42. data/lib/clacky/web/sessions.js +436 -192
  43. data/lib/clacky/web/settings.js +21 -1
  44. data/lib/clacky/web/skills.js +1 -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 +7 -3
  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)
@@ -822,6 +854,96 @@ module Clacky
822
854
  end
823
855
  end
824
856
 
857
+ # POST /api/onboard/device/start
858
+ # Kicks off a device-authorization flow against the platform. Returns the
859
+ # device_code (held by the client for polling) plus the user-facing
860
+ # verification URL the browser should open.
861
+ def api_onboard_device_start(req, res)
862
+ client = Clacky::PlatformHttpClient.new
863
+ result = client.post("/api/v1/device/authorize", {
864
+ device_id: onboard_device_id,
865
+ device_info: { os: RUBY_PLATFORM, hostname: Socket.gethostname, app_version: Clacky::VERSION }
866
+ })
867
+
868
+ if result[:success]
869
+ data = result[:data]
870
+ json_response(res, 200, {
871
+ ok: true,
872
+ device_code: data["device_code"],
873
+ user_code: data["user_code"],
874
+ verification_uri: data["verification_uri"],
875
+ verification_uri_complete: data["verification_uri_complete"],
876
+ interval: data["interval"] || 5
877
+ })
878
+ else
879
+ json_response(res, 502, { ok: false, error: result[:error] })
880
+ end
881
+ end
882
+
883
+ # POST /api/onboard/device/poll { device_code }
884
+ # Polls the platform once. While pending, returns { status: "pending" }.
885
+ # On approval, persists the issued key into agent_config and returns
886
+ # { status: "approved" } so the frontend can proceed to the onboard session.
887
+ def api_onboard_device_poll(req, res)
888
+ body = parse_json_body(req) || {}
889
+ device_code = body["device_code"].to_s
890
+ if device_code.empty?
891
+ return json_response(res, 422, { ok: false, error: "device_code is required" })
892
+ end
893
+
894
+ client = Clacky::PlatformHttpClient.new
895
+ result = client.post("/api/v1/device/token", { device_code: device_code })
896
+ data = result[:data] || {}
897
+ status = data["status"]
898
+
899
+ if result[:success] && status == "approved"
900
+ persist_onboard_model(
901
+ api_key: data["api_key"],
902
+ base_url: data["base_url"],
903
+ model: data["default_model"]
904
+ )
905
+ json_response(res, 200, {
906
+ ok: true,
907
+ status: "approved",
908
+ default_model: data["default_model"]
909
+ })
910
+ elsif status == "pending"
911
+ json_response(res, 200, { ok: true, status: "pending" })
912
+ else
913
+ # denied / expired / consumed / network error — surface to the client.
914
+ json_response(res, 200, {
915
+ ok: false,
916
+ status: status || "error",
917
+ error: result[:error]
918
+ })
919
+ end
920
+ end
921
+
922
+ # Stable per-machine id for the onboarding device flow. Independent of the
923
+ # brand/license device_id — onboarding can happen before any license.
924
+ private def onboard_device_id
925
+ components = [Socket.gethostname, ENV["USER"] || ENV["USERNAME"] || "", RUBY_PLATFORM]
926
+ Digest::SHA256.hexdigest(components.join(":"))
927
+ end
928
+
929
+ # Persist a device-flow-issued model as the default and re-anchor current_*.
930
+ private def persist_onboard_model(api_key:, base_url:, model:)
931
+ @agent_config.models.each { |m| m.delete("type") if m["type"] == "default" }
932
+ entry = {
933
+ "id" => SecureRandom.uuid,
934
+ "model" => model,
935
+ "base_url" => base_url,
936
+ "api_key" => api_key,
937
+ "anthropic_format" => false,
938
+ "type" => "default"
939
+ }
940
+ @agent_config.models << entry
941
+ @agent_config.current_model_id = entry["id"]
942
+ @agent_config.current_model_index = @agent_config.models.length - 1
943
+ @agent_config.save
944
+ end
945
+
946
+
825
947
  # GET /api/browser/status
826
948
  # Returns real daemon liveness from BrowserManager (not just yml read).
827
949
  def api_browser_status(res)
@@ -860,6 +982,52 @@ module Clacky
860
982
  json_response(res, 500, { ok: false, error: e.message })
861
983
  end
862
984
 
985
+ # GET /api/backup/status
986
+ def api_backup_status(res)
987
+ json_response(res, 200, BackupManager.status)
988
+ rescue StandardError => e
989
+ json_response(res, 500, { ok: false, error: e.message })
990
+ end
991
+
992
+ # POST /api/backup/run — run a backup immediately.
993
+ def api_backup_run(res)
994
+ result = BackupManager.run!
995
+ json_response(res, 200, { ok: true, archive: File.basename(result[:archive]),
996
+ size: result[:size], dest_dir: result[:dest_dir],
997
+ status: BackupManager.status })
998
+ rescue StandardError => e
999
+ json_response(res, 500, { ok: false, error: e.message })
1000
+ end
1001
+
1002
+ # GET /api/backup/download — build a one-off archive and stream it
1003
+ # directly to the browser. Not written to dest_dir nor recorded.
1004
+ def api_backup_download(res)
1005
+ result = BackupManager.build_download!
1006
+ res.status = 200
1007
+ res["Content-Type"] = "application/gzip"
1008
+ res["Content-Disposition"] = %(attachment; filename="#{result[:filename]}")
1009
+ res["Cache-Control"] = "no-store"
1010
+ res.body = File.binread(result[:path])
1011
+ rescue StandardError => e
1012
+ json_response(res, 500, { ok: false, error: e.message })
1013
+ ensure
1014
+ FileUtils.rm_f(result[:path]) if result && result[:path]
1015
+ end
1016
+ # Body: { enabled?, cron?, dest_dir?, keep?, include_sessions? }
1017
+ def api_backup_config(req, res)
1018
+ body = parse_json_body(req) || {}
1019
+ cfg = BackupManager.update_config(
1020
+ enabled: body.key?("enabled") ? body["enabled"] : nil,
1021
+ cron: body["cron"],
1022
+ dest_dir: body.key?("dest_dir") ? body["dest_dir"] : nil,
1023
+ keep: body["keep"],
1024
+ include_sessions: body.key?("include_sessions") ? body["include_sessions"] : nil
1025
+ )
1026
+ json_response(res, 200, { ok: true, config: cfg, status: BackupManager.status })
1027
+ rescue StandardError => e
1028
+ json_response(res, 500, { ok: false, error: e.message })
1029
+ end
1030
+
863
1031
  # POST /api/telemetry
864
1032
  # Body: { "event": "share_open" | "share_download", ... }
865
1033
  # Fire-and-forget telemetry from the WebUI frontend.
@@ -903,6 +1071,65 @@ module Clacky
903
1071
  json_response(res, 500, { error: e.message })
904
1072
  end
905
1073
 
1074
+ def api_media_video(req, res)
1075
+ body = parse_json_body(req)
1076
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
1077
+
1078
+ prompt = body["prompt"].to_s
1079
+ if prompt.strip.empty?
1080
+ return json_response(res, 422, { error: "prompt is required" })
1081
+ end
1082
+
1083
+ aspect_ratio = body["aspect_ratio"].to_s
1084
+ aspect_ratio = "landscape" if aspect_ratio.empty?
1085
+ duration = body["duration_seconds"]
1086
+ image = body["image"]
1087
+ output_dir = body["output_dir"].to_s
1088
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1089
+
1090
+ result = Clacky::Media::Generator.new(@agent_config).generate_video(
1091
+ prompt: prompt,
1092
+ aspect_ratio: aspect_ratio,
1093
+ duration_seconds: duration,
1094
+ output_dir: output_dir,
1095
+ image: image
1096
+ )
1097
+ if result["success"]
1098
+ log_media_usage(result, prompt: prompt)
1099
+ end
1100
+ status = result["success"] ? 200 : 422
1101
+ json_response(res, status, result)
1102
+ rescue StandardError => e
1103
+ json_response(res, 500, { error: e.message })
1104
+ end
1105
+
1106
+ def api_media_audio_speech(req, res)
1107
+ body = parse_json_body(req)
1108
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
1109
+
1110
+ input = body["input"].to_s
1111
+ if input.strip.empty?
1112
+ return json_response(res, 422, { error: "input is required" })
1113
+ end
1114
+
1115
+ voice = body["voice"]
1116
+ output_dir = body["output_dir"].to_s
1117
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1118
+
1119
+ result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1120
+ input: input,
1121
+ voice: voice,
1122
+ output_dir: output_dir
1123
+ )
1124
+ if result["success"]
1125
+ log_media_usage(result, prompt: input)
1126
+ end
1127
+ status = result["success"] ? 200 : 422
1128
+ json_response(res, status, result)
1129
+ rescue StandardError => e
1130
+ json_response(res, 500, { error: e.message })
1131
+ end
1132
+
906
1133
  private def log_media_usage(result, prompt:)
907
1134
  usage = result["usage"]
908
1135
  cost = result["cost_usd"]
@@ -919,7 +1146,11 @@ module Clacky
919
1146
  end
920
1147
  parts << format("cost_usd=%.6f", cost.to_f) if cost
921
1148
  parts << "prompt=#{prompt[0, 60].inspect}"
922
- Clacky::Logger.info("[Media] image generated #{parts.join(" ")}")
1149
+ kind = if result.key?("video") then "video"
1150
+ elsif result.key?("audio") then "audio"
1151
+ else "image"
1152
+ end
1153
+ Clacky::Logger.info("[Media] #{kind} generated #{parts.join(" ")}")
923
1154
  end
924
1155
 
925
1156
  # GET /api/media/types
@@ -2703,19 +2934,38 @@ module Clacky
2703
2934
  # Convert it to the Linux-side path so File.exist? works.
2704
2935
  path = Utils::EnvironmentDetector.win_to_linux_path(path)
2705
2936
 
2706
- # Security: only serve image files
2937
+ # Security: only serve media files (images + videos)
2707
2938
  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" })
2939
+ unless Utils::FileProcessor::LOCAL_MEDIA_EXTENSIONS.include?(ext)
2940
+ return json_response(res, 403, { error: "not a supported media file" })
2710
2941
  end
2711
2942
 
2712
2943
  return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
2713
2944
 
2945
+ file_size = File.size(path)
2714
2946
  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)
2947
+
2948
+ # Support HTTP Range requests for video seeking
2949
+ range_header = req["Range"]
2950
+ if range_header && range_header =~ /\Abytes=(\d*)-(\d*)\z/
2951
+ start_byte = ($1.empty? ? 0 : $1.to_i)
2952
+ end_byte = ($2.empty? ? file_size - 1 : $2.to_i)
2953
+ end_byte = [end_byte, file_size - 1].min
2954
+
2955
+ res.status = 206
2956
+ res["Content-Type"] = mime
2957
+ res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
2958
+ res["Accept-Ranges"] = "bytes"
2959
+ res["Cache-Control"] = "private, max-age=3600"
2960
+ res["Content-Length"] = (end_byte - start_byte + 1).to_s
2961
+ IO.binread(path, end_byte - start_byte + 1, start_byte).then { |data| res.body = data }
2962
+ else
2963
+ res.status = 200
2964
+ res["Content-Type"] = mime
2965
+ res["Accept-Ranges"] = "bytes"
2966
+ res["Cache-Control"] = "private, max-age=3600"
2967
+ res.body = File.binread(path)
2968
+ end
2719
2969
  rescue => e
2720
2970
  json_response(res, 500, { error: e.message })
2721
2971
  end
@@ -3112,6 +3362,7 @@ module Clacky
3112
3362
  query = URI.decode_www_form(req.query_string.to_s).to_h
3113
3363
  rel = query["path"].to_s
3114
3364
  absolute_mode = query["absolute"] == "true"
3365
+ show_hidden = query["show_hidden"] == "true"
3115
3366
 
3116
3367
  # Absolute mode: allow browsing outside working directory (e.g., root "/")
3117
3368
  if absolute_mode
@@ -3131,7 +3382,9 @@ module Clacky
3131
3382
  end
3132
3383
 
3133
3384
  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) }
3385
+ entries = Dir.children(target).reject do |name|
3386
+ IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
3387
+ end
3135
3388
 
3136
3389
  items = entries.filter_map do |name|
3137
3390
  full = File.join(target, name)
@@ -3155,6 +3408,45 @@ module Clacky
3155
3408
  rescue StandardError => e
3156
3409
  json_response(res, 500, { error: e.message })
3157
3410
  end
3411
+
3412
+ # GET /api/dirs?path=<absolute-or-~-path>
3413
+ # Session-independent directory browser used by the New Session modal,
3414
+ # where no session (and thus no working_dir) exists yet. Always operates
3415
+ # in absolute mode and lists directories only.
3416
+ def api_browse_dirs(req, res)
3417
+ query = URI.decode_www_form(req.query_string.to_s).to_h
3418
+ rel = query["path"].to_s.strip
3419
+ show_hidden = query["show_hidden"] == "true"
3420
+ rel = Dir.home if rel.empty?
3421
+ target = File.expand_path(rel.start_with?("~") ? rel.sub(/\A~/, Dir.home) : rel)
3422
+
3423
+ # The requested directory may not exist yet (e.g. the default
3424
+ # ~/clacky_workspace before any session created it). Instead of 404,
3425
+ # walk up to the nearest existing ancestor so the picker stays usable.
3426
+ until Dir.exist?(target)
3427
+ parent = File.dirname(target)
3428
+ break if parent == target
3429
+ target = parent
3430
+ end
3431
+ return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
3432
+
3433
+ entries = Dir.children(target).reject do |name|
3434
+ IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
3435
+ end
3436
+ items = entries.filter_map do |name|
3437
+ full = File.join(target, name)
3438
+ next unless File.directory?(full) && File.exist?(full)
3439
+ { name: name, path: full, type: "dir" }
3440
+ rescue StandardError
3441
+ nil
3442
+ end
3443
+ items.sort_by! { |it| it[:name].downcase }
3444
+
3445
+ json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, entries: items })
3446
+ rescue StandardError => e
3447
+ json_response(res, 500, { error: e.message })
3448
+ end
3449
+
3158
3450
  # Body: { enabled: true/false }
3159
3451
  def api_toggle_skill(name, req, res)
3160
3452
  body = parse_json_body(req)
@@ -3908,7 +4200,7 @@ module Clacky
3908
4200
  # ── Config API ────────────────────────────────────────────────────────────
3909
4201
 
3910
4202
  # GET /api/config — return current model configurations
3911
- def api_get_config(res)
4203
+ def api_get_config(req, res)
3912
4204
  models = @agent_config.models.map.with_index do |m, i|
3913
4205
  {
3914
4206
  id: m["id"], # Stable runtime id — use this for switching
@@ -3921,19 +4213,58 @@ module Clacky
3921
4213
  }
3922
4214
  end
3923
4215
  # Filter out auto-injected models (lite, derived media) AND media
3924
- # entries (image/video/audio) — those are managed via the dedicated
4216
+ # entries (image/video/audio/ocr) — those are managed via the dedicated
3925
4217
  # media-config UI, not the chat-model card list.
3926
4218
  models.reject! do |m|
3927
4219
  raw = @agent_config.models[m[:index]]
3928
- raw["auto_injected"] || Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s)
4220
+ raw["auto_injected"] ||
4221
+ Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s) ||
4222
+ raw["type"].to_s == "ocr"
3929
4223
  end
4224
+ # Capabilities follow the model the *session* is actually running on
4225
+ # (it may differ from the global default after a per-session switch).
4226
+ query = URI.decode_www_form(req.query_string.to_s).to_h
4227
+ cfg = config_for_session(query["session_id"]) || @agent_config
3930
4228
  json_response(res, 200, {
3931
4229
  models: models,
3932
4230
  current_index: @agent_config.current_model_index,
3933
- current_id: @agent_config.current_model&.dig("id")
4231
+ current_id: @agent_config.current_model&.dig("id"),
4232
+ media_capabilities: media_capabilities_payload(cfg)
3934
4233
  })
3935
4234
  end
3936
4235
 
4236
+ # Resolve the AgentConfig for a given session, falling back to nil when
4237
+ # the session isn't live so callers can use the global config instead.
4238
+ def config_for_session(session_id)
4239
+ return nil if session_id.to_s.strip.empty?
4240
+ return nil unless @registry.ensure(session_id)
4241
+
4242
+ agent = nil
4243
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
4244
+ agent&.config
4245
+ end
4246
+
4247
+ # Capability summary for the model dropdown's footer.
4248
+ # vision — true when the current default model handles images itself
4249
+ # OR a vision sidecar is configured (ocr_state covers both).
4250
+ # image/video/audio — true only when a dedicated sidecar is configured;
4251
+ # the chat model can never generate these on its own.
4252
+ def media_capabilities_payload(cfg = @agent_config)
4253
+ ocr = cfg.ocr_state
4254
+ out = {
4255
+ vision: {
4256
+ configured: !!ocr["configured"],
4257
+ primary: !!ocr["primary"],
4258
+ model: ocr["model"]
4259
+ }
4260
+ }
4261
+ Clacky::Providers::MEDIA_KINDS.each do |t|
4262
+ state = cfg.media_state(t)
4263
+ out[t] = { configured: !!state["configured"], model: state["model"] }
4264
+ end
4265
+ out
4266
+ end
4267
+
3937
4268
  # GET /api/config/settings — return advanced settings
3938
4269
  def api_get_settings(res)
3939
4270
  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