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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +15 -20
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
- data/lib/clacky/media/base.rb +93 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +57 -0
- data/lib/clacky/media/openai_compat.rb +160 -0
- data/lib/clacky/message_history.rb +12 -7
- data/lib/clacky/providers.rb +28 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1
- data/lib/clacky/server/backup_manager.rb +200 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
- data/lib/clacky/server/channel/channel_manager.rb +65 -50
- data/lib/clacky/server/http_server.rb +345 -14
- data/lib/clacky/server/scheduler.rb +19 -0
- data/lib/clacky/server/session_registry.rb +8 -4
- data/lib/clacky/session_manager.rb +40 -2
- data/lib/clacky/tools/trash_manager.rb +14 -0
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +34 -7
- data/lib/clacky/ui2/ui_controller.rb +150 -19
- data/lib/clacky/utils/file_processor.rb +75 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2038 -1147
- data/lib/clacky/web/app.js +22 -1
- data/lib/clacky/web/backup.js +119 -0
- data/lib/clacky/web/billing.js +94 -7
- data/lib/clacky/web/channels.js +81 -11
- data/lib/clacky/web/design-sample.css +247 -0
- data/lib/clacky/web/design-sample.html +127 -0
- data/lib/clacky/web/favicon.svg +16 -0
- data/lib/clacky/web/i18n.js +159 -31
- data/lib/clacky/web/index.html +175 -55
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/onboard.js +114 -28
- data/lib/clacky/web/sessions.js +436 -192
- data/lib/clacky/web/settings.js +21 -1
- data/lib/clacky/web/skills.js +1 -1
- data/lib/clacky/web/tasks.js +129 -61
- data/lib/clacky/web/utils.js +72 -0
- data/lib/clacky/web/ws-dispatcher.js +6 -0
- data/lib/clacky.rb +1 -0
- metadata +7 -3
- 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
|
-
|
|
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
|
|
2937
|
+
# Security: only serve media files (images + videos)
|
|
2707
2938
|
ext = File.extname(path).downcase
|
|
2708
|
-
unless Utils::FileProcessor::
|
|
2709
|
-
return json_response(res, 403, { error: "not
|
|
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
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
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
|
|
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"] ||
|
|
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:
|
|
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
|
-
|
|
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].
|
|
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
|
|
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
|