openclacky 1.5.4 → 1.5.6
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 +63 -0
- data/lib/clacky/agent/goal_manager.rb +305 -0
- data/lib/clacky/agent/goal_state.rb +88 -0
- data/lib/clacky/agent/message_compressor_helper.rb +4 -1
- data/lib/clacky/agent/session_serializer.rb +27 -3
- data/lib/clacky/agent.rb +202 -3
- data/lib/clacky/agent_config.rb +4 -2
- data/lib/clacky/agent_profile.rb +25 -3
- data/lib/clacky/cli.rb +15 -15
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +4 -4
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +130 -59
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +12 -4
- data/lib/clacky/default_extensions/git/ext.yml +3 -0
- data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
- data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
- data/lib/clacky/default_searchers/tavily.rb +98 -0
- data/lib/clacky/default_skills/new/SKILL.md +17 -12
- data/lib/clacky/extension/api_extension.rb +29 -1
- data/lib/clacky/extension/loader.rb +1 -0
- data/lib/clacky/extension/packager.rb +114 -20
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/idle_compression_timer.rb +1 -3
- data/lib/clacky/message_format/anthropic.rb +1 -1
- data/lib/clacky/message_format/bedrock.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +8 -0
- data/lib/clacky/providers.rb +65 -8
- data/lib/clacky/search_config.rb +57 -0
- data/lib/clacky/server/http_server.rb +249 -31
- data/lib/clacky/server/session_registry.rb +10 -3
- data/lib/clacky/server/web_ui_controller.rb +12 -2
- data/lib/clacky/session_manager.rb +1 -0
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/tools/web_search.rb +45 -6
- data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
- data/lib/clacky/ui2/components/input_area.rb +26 -2
- data/lib/clacky/ui2/ui_controller.rb +17 -0
- data/lib/clacky/ui_interface.rb +9 -0
- data/lib/clacky/utils/gitignore_parser.rb +7 -1
- data/lib/clacky/utils/model_pricing.rb +0 -10
- data/lib/clacky/utils/searcher_manager.rb +87 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +530 -197
- data/lib/clacky/web/app.js +28 -3
- data/lib/clacky/web/components/chat-navigator.js +260 -0
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/brand/view.js +5 -1
- data/lib/clacky/web/features/extensions/store.js +214 -9
- data/lib/clacky/web/features/extensions/view.js +158 -13
- data/lib/clacky/web/features/new-session/store.js +25 -3
- data/lib/clacky/web/features/new-session/view.js +337 -140
- data/lib/clacky/web/features/skills/store.js +4 -2
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/i18n.js +72 -18
- data/lib/clacky/web/index.html +71 -64
- data/lib/clacky/web/projects.js +36 -19
- data/lib/clacky/web/sessions.js +173 -8
- data/lib/clacky/web/settings.js +85 -0
- data/lib/clacky/web/skills.js +24 -4
- data/lib/clacky/web/utils.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +63 -3
- data/lib/clacky.rb +2 -0
- metadata +7 -1
|
@@ -217,6 +217,9 @@ module Clacky
|
|
|
217
217
|
# Lazy: process-wide MCP registry. Created on first /api/mcp/:name access
|
|
218
218
|
# so test setups that override Dir.home in before-hooks still work.
|
|
219
219
|
@mcp_registry_mutex = Mutex.new
|
|
220
|
+
# Install jobs: job_id => { stage:, progress:, error:, done: }
|
|
221
|
+
@install_jobs = {}
|
|
222
|
+
@install_jobs_mutex = Mutex.new
|
|
220
223
|
# Access key authentication:
|
|
221
224
|
# - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
|
|
222
225
|
# - Any other bind address requires CLACKY_ACCESS_KEY env var.
|
|
@@ -370,10 +373,15 @@ module Clacky
|
|
|
370
373
|
|
|
371
374
|
server.mount_proc("/") do |req, res|
|
|
372
375
|
if req.path == "/" || req.path == "/index.html"
|
|
373
|
-
|
|
376
|
+
brand = Clacky::BrandConfig.load
|
|
377
|
+
product_name = brand.product_name || "OpenClacky"
|
|
378
|
+
logo_url = brand.logo_url || "/logo_nav_dark.png"
|
|
379
|
+
logo_hidden = logo_url.start_with?("http://", "https://") ? 'style="display:none"' : ""
|
|
374
380
|
pure = req.query["pure"] == "true"
|
|
375
381
|
html = File.read(index_html_path)
|
|
376
382
|
.gsub("{{BRAND_NAME}}", product_name)
|
|
383
|
+
.gsub("{{BRAND_LOGO_URL}}", logo_url)
|
|
384
|
+
.gsub("{{BRAND_LOGO_HIDDEN}}", logo_hidden)
|
|
377
385
|
.gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
|
|
378
386
|
res.status = 200
|
|
379
387
|
res["Content-Type"] = "text/html; charset=utf-8"
|
|
@@ -493,7 +501,9 @@ module Clacky
|
|
|
493
501
|
# Building/extracting a tar.gz of ~/.clacky can take a while.
|
|
494
502
|
120
|
|
495
503
|
elsif path == "/api/store/extension/install"
|
|
496
|
-
|
|
504
|
+
10 # now async — just spawns a thread and returns job_id immediately
|
|
505
|
+
elsif path == "/api/store/extension/install/status"
|
|
506
|
+
10
|
|
497
507
|
else
|
|
498
508
|
30
|
|
499
509
|
end
|
|
@@ -539,6 +549,9 @@ module Clacky
|
|
|
539
549
|
when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
|
|
540
550
|
when ["PATCH", "/api/config/ocr"] then api_update_ocr_config(req, res)
|
|
541
551
|
when ["POST", "/api/config/ocr/test"] then api_test_ocr_config(req, res)
|
|
552
|
+
when ["GET", "/api/config/search"] then api_get_search_config(res)
|
|
553
|
+
when ["PATCH", "/api/config/search"] then api_update_search_config(req, res)
|
|
554
|
+
when ["POST", "/api/config/search/test"] then api_test_search_config(req, res)
|
|
542
555
|
when ["POST", "/api/internal/ocr-image"] then api_internal_ocr_image(req, res)
|
|
543
556
|
when ["GET", "/api/providers"] then api_list_providers(res)
|
|
544
557
|
when ["GET", "/api/onboard/status"] then api_onboard_status(res)
|
|
@@ -560,9 +573,11 @@ module Clacky
|
|
|
560
573
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
561
574
|
when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
|
|
562
575
|
when ["GET", "/api/store/extensions/brand"] then api_store_extensions_brand(res)
|
|
576
|
+
when ["GET", "/api/store/extensions/system"] then api_store_extensions_system(res)
|
|
563
577
|
when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
|
|
564
578
|
when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
|
|
565
579
|
when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
|
|
580
|
+
when ["GET", "/api/store/extension/install/status"] then api_store_extension_install_status(req, res)
|
|
566
581
|
when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
|
|
567
582
|
when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
|
|
568
583
|
when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
|
|
@@ -880,7 +895,7 @@ module Clacky
|
|
|
880
895
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
881
896
|
if agent
|
|
882
897
|
agent.project_id = project_id_override
|
|
883
|
-
@session_manager.save(agent.to_session_data)
|
|
898
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
884
899
|
end
|
|
885
900
|
end
|
|
886
901
|
|
|
@@ -2088,6 +2103,80 @@ module Clacky
|
|
|
2088
2103
|
json_response(res, 200, { ok: false, message: e.message })
|
|
2089
2104
|
end
|
|
2090
2105
|
|
|
2106
|
+
# GET /api/config/search
|
|
2107
|
+
# Lists installed searchers so the UI can offer whatever the user dropped
|
|
2108
|
+
# into ~/.clacky/searchers/, not just the bundled ones.
|
|
2109
|
+
def api_get_search_config(res)
|
|
2110
|
+
config = Clacky::SearchConfig.load
|
|
2111
|
+
json_response(res, 200, {
|
|
2112
|
+
ok: true,
|
|
2113
|
+
provider: config["provider"],
|
|
2114
|
+
key_masked: config["key"].empty? ? nil : mask_api_key(config["key"]),
|
|
2115
|
+
available: Clacky::Utils::SearcherManager.available
|
|
2116
|
+
})
|
|
2117
|
+
end
|
|
2118
|
+
|
|
2119
|
+
# PATCH /api/config/search
|
|
2120
|
+
# Body: { provider: "tavily"|"", key?: "..." }
|
|
2121
|
+
def api_update_search_config(req, res)
|
|
2122
|
+
body = parse_json_body(req)
|
|
2123
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2124
|
+
|
|
2125
|
+
provider = body["provider"].to_s.strip
|
|
2126
|
+
unless provider.empty? || Clacky::Utils::SearcherManager.path_for(provider)
|
|
2127
|
+
return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
|
|
2128
|
+
end
|
|
2129
|
+
|
|
2130
|
+
key = body["key"].to_s
|
|
2131
|
+
key = Clacky::SearchConfig.key if key.include?("****")
|
|
2132
|
+
|
|
2133
|
+
Clacky::SearchConfig.save(provider: provider, key: key)
|
|
2134
|
+
json_response(res, 200, { ok: true, provider: provider })
|
|
2135
|
+
rescue => e
|
|
2136
|
+
json_response(res, 422, { error: e.message })
|
|
2137
|
+
end
|
|
2138
|
+
|
|
2139
|
+
# POST /api/config/search/test
|
|
2140
|
+
# Runs the searcher with a fixed query so the user gets a definitive
|
|
2141
|
+
# answer before saving, rather than discovering a bad key mid-task.
|
|
2142
|
+
def api_test_search_config(req, res)
|
|
2143
|
+
body = parse_json_body(req) || {}
|
|
2144
|
+
provider = body["provider"].to_s.strip
|
|
2145
|
+
return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
|
|
2146
|
+
|
|
2147
|
+
script = Clacky::Utils::SearcherManager.path_for(provider)
|
|
2148
|
+
return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
|
|
2149
|
+
|
|
2150
|
+
key = body["key"].to_s
|
|
2151
|
+
key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
|
|
2152
|
+
|
|
2153
|
+
stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
|
|
2154
|
+
{ "CLACKY_SEARCH_KEY" => key },
|
|
2155
|
+
Clacky::Utils::SearcherManager.interpreter_for(script),
|
|
2156
|
+
script, "openclacky", "1",
|
|
2157
|
+
timeout: 30
|
|
2158
|
+
)
|
|
2159
|
+
|
|
2160
|
+
if status == :timeout
|
|
2161
|
+
return json_response(res, 200, { ok: false, message: "searcher timed out" })
|
|
2162
|
+
end
|
|
2163
|
+
|
|
2164
|
+
unless status.success?
|
|
2165
|
+
message = stderr.to_s.strip
|
|
2166
|
+
return json_response(res, 200, { ok: false, message: message.empty? ? "searcher failed" : message })
|
|
2167
|
+
end
|
|
2168
|
+
|
|
2169
|
+
count = begin
|
|
2170
|
+
JSON.parse(stdout.to_s).size
|
|
2171
|
+
rescue StandardError
|
|
2172
|
+
return json_response(res, 200, { ok: false, message: "searcher did not output a JSON array" })
|
|
2173
|
+
end
|
|
2174
|
+
|
|
2175
|
+
json_response(res, 200, { ok: true, message: "Returned #{count} result(s)" })
|
|
2176
|
+
rescue => e
|
|
2177
|
+
json_response(res, 200, { ok: false, message: e.message })
|
|
2178
|
+
end
|
|
2179
|
+
|
|
2091
2180
|
# POST /api/internal/ocr-image
|
|
2092
2181
|
# Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
|
|
2093
2182
|
# transcribe a single image via the configured OCR sidecar. Localhost-
|
|
@@ -2527,6 +2616,47 @@ module Clacky
|
|
|
2527
2616
|
end
|
|
2528
2617
|
end
|
|
2529
2618
|
|
|
2619
|
+
# GET /api/store/extensions/system
|
|
2620
|
+
#
|
|
2621
|
+
# Returns only the builtin (system) extensions shipped with the gem.
|
|
2622
|
+
def api_store_extensions_system(res)
|
|
2623
|
+
result = Clacky::ExtensionLoader.load_all
|
|
2624
|
+
disabled = Clacky::ExtensionLoader.disabled_ids
|
|
2625
|
+
|
|
2626
|
+
hidden = %w[coding general ext-studio].to_set
|
|
2627
|
+
|
|
2628
|
+
extensions = Array(result&.containers).filter_map do |ext_id, container|
|
|
2629
|
+
next unless container[:layer] == :builtin
|
|
2630
|
+
next if hidden.include?(ext_id)
|
|
2631
|
+
|
|
2632
|
+
local_name = container[:name].to_s.then { |n| n.empty? ? ext_id : n }
|
|
2633
|
+
local_desc = container.dig(:raw, "description").to_s
|
|
2634
|
+
local_desc_zh = container.dig(:raw, "description_zh").to_s
|
|
2635
|
+
local_author = container[:author].to_s
|
|
2636
|
+
local_units = units_from_container(container)
|
|
2637
|
+
{
|
|
2638
|
+
"id" => ext_id,
|
|
2639
|
+
"name" => local_name,
|
|
2640
|
+
"display_name" => container.dig(:raw, "display_name") || local_name,
|
|
2641
|
+
"display_name_zh" => container.dig(:raw, "display_name_zh"),
|
|
2642
|
+
"slug" => ext_id,
|
|
2643
|
+
"version" => container[:version],
|
|
2644
|
+
"description" => local_desc,
|
|
2645
|
+
"description_zh" => local_desc_zh,
|
|
2646
|
+
"author" => local_author,
|
|
2647
|
+
"units" => local_units,
|
|
2648
|
+
"layer" => "builtin",
|
|
2649
|
+
"installed" => true,
|
|
2650
|
+
"removable" => false,
|
|
2651
|
+
"disabled" => disabled.include?(ext_id),
|
|
2652
|
+
}
|
|
2653
|
+
end
|
|
2654
|
+
|
|
2655
|
+
json_response(res, 200, { ok: true, extensions: extensions })
|
|
2656
|
+
rescue StandardError => e
|
|
2657
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2658
|
+
end
|
|
2659
|
+
|
|
2530
2660
|
# GET /api/store/extensions/installed
|
|
2531
2661
|
#
|
|
2532
2662
|
# Returns all locally installed extensions (all layers: builtin, installed,
|
|
@@ -2656,6 +2786,7 @@ module Clacky
|
|
|
2656
2786
|
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2657
2787
|
container = extension_container(slug)
|
|
2658
2788
|
ext = ext.merge(
|
|
2789
|
+
"slug" => slug,
|
|
2659
2790
|
"installed" => !container.nil?,
|
|
2660
2791
|
"installed_version" => container&.dig(:version),
|
|
2661
2792
|
"removable" => container && container[:layer] == :installed,
|
|
@@ -2732,7 +2863,9 @@ module Clacky
|
|
|
2732
2863
|
json_response(res, 500, { ok: false, error: e.message })
|
|
2733
2864
|
end
|
|
2734
2865
|
|
|
2735
|
-
#
|
|
2866
|
+
# POST /api/store/extension/install body: { download_url:, name: }
|
|
2867
|
+
# Starts an async install job and returns immediately with a job_id.
|
|
2868
|
+
# Poll GET /api/store/extension/install/status?job_id=xxx for progress.
|
|
2736
2869
|
def api_store_extension_install(req, res)
|
|
2737
2870
|
body = parse_json_body(req)
|
|
2738
2871
|
download_url = body["download_url"].to_s.strip
|
|
@@ -2743,14 +2876,71 @@ module Clacky
|
|
|
2743
2876
|
return
|
|
2744
2877
|
end
|
|
2745
2878
|
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2879
|
+
job_id = SecureRandom.hex(8)
|
|
2880
|
+
@install_jobs_mutex.synchronize do
|
|
2881
|
+
sweep_stale_install_jobs!
|
|
2882
|
+
@install_jobs[job_id] = { stage: "downloading", progress: 0, error: nil, done: false, updated_at: Time.now }
|
|
2883
|
+
end
|
|
2884
|
+
|
|
2885
|
+
Thread.new do
|
|
2886
|
+
begin
|
|
2887
|
+
on_progress = lambda do |info|
|
|
2888
|
+
@install_jobs_mutex.synchronize do
|
|
2889
|
+
@install_jobs[job_id] = info.merge(error: nil, done: false, updated_at: Time.now)
|
|
2890
|
+
end
|
|
2891
|
+
end
|
|
2892
|
+
Clacky::ExtensionPackager.install(download_url, force: true, on_progress: on_progress)
|
|
2893
|
+
Clacky::ExtensionLoader.invalidate_cache!
|
|
2894
|
+
Clacky::Telemetry.extension_install!(name) unless name.empty?
|
|
2895
|
+
@install_jobs_mutex.synchronize do
|
|
2896
|
+
@install_jobs[job_id] = { stage: "done", progress: 100, error: nil, done: true, updated_at: Time.now }
|
|
2897
|
+
end
|
|
2898
|
+
rescue StandardError => e
|
|
2899
|
+
@install_jobs_mutex.synchronize do
|
|
2900
|
+
@install_jobs[job_id] = { stage: "error", progress: 0, error: e.message, done: true, updated_at: Time.now }
|
|
2901
|
+
end
|
|
2902
|
+
end
|
|
2903
|
+
end
|
|
2904
|
+
|
|
2905
|
+
json_response(res, 200, { ok: true, job_id: job_id })
|
|
2906
|
+
end
|
|
2907
|
+
|
|
2908
|
+
# GET /api/store/extension/install/status?job_id=xxx
|
|
2909
|
+
def api_store_extension_install_status(req, res)
|
|
2910
|
+
job_id = req.query["job_id"].to_s.strip
|
|
2911
|
+
if job_id.empty?
|
|
2912
|
+
json_response(res, 400, { ok: false, error: "Missing job_id." })
|
|
2913
|
+
return
|
|
2914
|
+
end
|
|
2915
|
+
job = @install_jobs_mutex.synchronize do
|
|
2916
|
+
sweep_stale_install_jobs!
|
|
2917
|
+
@install_jobs[job_id]
|
|
2918
|
+
end
|
|
2919
|
+
if job.nil?
|
|
2920
|
+
json_response(res, 404, { ok: false, error: "Job not found." })
|
|
2921
|
+
return
|
|
2922
|
+
end
|
|
2923
|
+
# Clean up finished jobs after they are read as done
|
|
2924
|
+
if job[:done]
|
|
2925
|
+
@install_jobs_mutex.synchronize { @install_jobs.delete(job_id) }
|
|
2926
|
+
end
|
|
2927
|
+
json_response(res, 200, { ok: true }.merge(job))
|
|
2928
|
+
end
|
|
2929
|
+
|
|
2930
|
+
# Install jobs can go stale in two ways:
|
|
2931
|
+
# - the client abandons the poll (tab closed, network drop) so a
|
|
2932
|
+
# finished job never gets picked up and deleted by the status endpoint
|
|
2933
|
+
# - the worker thread dies without ever updating the job hash (should
|
|
2934
|
+
# not happen given the rescue in api_store_extension_install, but a
|
|
2935
|
+
# hung Thread with no timeout upstream would otherwise pin memory
|
|
2936
|
+
# forever)
|
|
2937
|
+
# Sweep anything past its TTL. Must be called from inside
|
|
2938
|
+
# @install_jobs_mutex.synchronize.
|
|
2939
|
+
INSTALL_JOB_TTL = 30 * 60 # seconds
|
|
2940
|
+
|
|
2941
|
+
private def sweep_stale_install_jobs!
|
|
2942
|
+
cutoff = Time.now - INSTALL_JOB_TTL
|
|
2943
|
+
@install_jobs.delete_if { |_id, job| (job[:updated_at] || Time.at(0)) < cutoff }
|
|
2754
2944
|
end
|
|
2755
2945
|
|
|
2756
2946
|
def api_store_extension_uninstall(req, res)
|
|
@@ -4354,12 +4544,30 @@ module Clacky
|
|
|
4354
4544
|
# Each entry carries { id, title, title_zh, description, description_zh,
|
|
4355
4545
|
# source, order, layer, author }. Extensions can supply title_zh /
|
|
4356
4546
|
# description_zh / author in their ext.yml so the New Session cards read
|
|
4357
|
-
# naturally and credit their author.
|
|
4547
|
+
# naturally and credit their author. Third-party extension agents are
|
|
4548
|
+
# ordered by most-recently-used (derived from session history) so the
|
|
4549
|
+
# ones the user actually uses float to the front of that group.
|
|
4358
4550
|
def api_list_agents(res)
|
|
4359
|
-
|
|
4551
|
+
recency = agent_last_used_recency
|
|
4552
|
+
agents = Clacky::AgentProfile.all(recency: recency)
|
|
4360
4553
|
json_response(res, 200, { agents: agents })
|
|
4361
4554
|
end
|
|
4362
4555
|
|
|
4556
|
+
# Map agent_profile id => most recent session's updated_at (epoch seconds),
|
|
4557
|
+
# scanning every session on disk. Used to rank extension agent cards by
|
|
4558
|
+
# recent usage instead of a static declared `order`.
|
|
4559
|
+
private def agent_last_used_recency
|
|
4560
|
+
recency = {}
|
|
4561
|
+
@session_manager.all_sessions.each do |s|
|
|
4562
|
+
id = (s[:agent_profile] || "general").to_s
|
|
4563
|
+
ts = s[:updated_at] || s[:created_at]
|
|
4564
|
+
next unless ts
|
|
4565
|
+
epoch = Time.parse(ts.to_s).to_i rescue next
|
|
4566
|
+
recency[id] = epoch if epoch > (recency[id] || 0)
|
|
4567
|
+
end
|
|
4568
|
+
recency
|
|
4569
|
+
end
|
|
4570
|
+
|
|
4363
4571
|
# GET /api/sessions/:id/skills — list user-invocable skills for a session,
|
|
4364
4572
|
# filtered by the session's agent profile. Used by the frontend slash-command
|
|
4365
4573
|
# autocomplete so only skills valid for the current profile are suggested.
|
|
@@ -4499,7 +4707,7 @@ module Clacky
|
|
|
4499
4707
|
task_id = body["task_id"].to_i
|
|
4500
4708
|
result = agent.switch_to_task(task_id)
|
|
4501
4709
|
if result[:success]
|
|
4502
|
-
@session_manager.save(agent.to_session_data(status: :success))
|
|
4710
|
+
@session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
|
|
4503
4711
|
broadcast_session_update(session_id)
|
|
4504
4712
|
json_response(res, 200, { ok: true, message: result[:message], task_id: result[:task_id] })
|
|
4505
4713
|
else
|
|
@@ -5667,6 +5875,7 @@ module Clacky
|
|
|
5667
5875
|
json_response(res, 200, {
|
|
5668
5876
|
ok: true,
|
|
5669
5877
|
enable_compression: @agent_config.enable_compression,
|
|
5878
|
+
enable_idle_compression: @agent_config.enable_idle_compression,
|
|
5670
5879
|
enable_prompt_caching: @agent_config.enable_prompt_caching,
|
|
5671
5880
|
memory_update_enabled: @agent_config.memory_update_enabled,
|
|
5672
5881
|
proxy_url: @agent_config.proxy_url.to_s,
|
|
@@ -5692,6 +5901,9 @@ module Clacky
|
|
|
5692
5901
|
if body.key?("enable_compression")
|
|
5693
5902
|
@agent_config.enable_compression = !!body["enable_compression"]
|
|
5694
5903
|
end
|
|
5904
|
+
if body.key?("enable_idle_compression")
|
|
5905
|
+
@agent_config.enable_idle_compression = !!body["enable_idle_compression"]
|
|
5906
|
+
end
|
|
5695
5907
|
if body.key?("enable_prompt_caching")
|
|
5696
5908
|
@agent_config.enable_prompt_caching = !!body["enable_prompt_caching"]
|
|
5697
5909
|
end
|
|
@@ -6180,7 +6392,7 @@ module Clacky
|
|
|
6180
6392
|
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
6181
6393
|
|
|
6182
6394
|
agent.project_id = new_pid ? new_pid.to_s : nil
|
|
6183
|
-
@session_manager.save(agent.to_session_data)
|
|
6395
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
6184
6396
|
broadcast_session_update(session_id)
|
|
6185
6397
|
|
|
6186
6398
|
json_response(res, 200, { ok: true, project_id: agent.project_id })
|
|
@@ -6209,7 +6421,7 @@ module Clacky
|
|
|
6209
6421
|
end
|
|
6210
6422
|
|
|
6211
6423
|
# Save session data
|
|
6212
|
-
@session_manager.save(agent.to_session_data)
|
|
6424
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
6213
6425
|
|
|
6214
6426
|
# Broadcast update event
|
|
6215
6427
|
update_data = { type: "session_updated", session_id: session_id }
|
|
@@ -6253,7 +6465,7 @@ module Clacky
|
|
|
6253
6465
|
end
|
|
6254
6466
|
|
|
6255
6467
|
# Persist the change (saves to session file, NOT global config.yml)
|
|
6256
|
-
@session_manager.save(agent.to_session_data)
|
|
6468
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
6257
6469
|
|
|
6258
6470
|
# Broadcast update to all clients
|
|
6259
6471
|
broadcast_session_update(session_id)
|
|
@@ -6264,7 +6476,7 @@ module Clacky
|
|
|
6264
6476
|
end
|
|
6265
6477
|
|
|
6266
6478
|
# PATCH /api/sessions/:id/reasoning_effort
|
|
6267
|
-
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" }
|
|
6479
|
+
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" | "max" }
|
|
6268
6480
|
def api_switch_session_reasoning_effort(session_id, req, res)
|
|
6269
6481
|
body = parse_json_body(req)
|
|
6270
6482
|
raw = body["reasoning_effort"]
|
|
@@ -6275,7 +6487,7 @@ module Clacky
|
|
|
6275
6487
|
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
6276
6488
|
|
|
6277
6489
|
agent.reasoning_effort = raw
|
|
6278
|
-
@session_manager.save(agent.to_session_data)
|
|
6490
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
6279
6491
|
broadcast_session_update(session_id)
|
|
6280
6492
|
|
|
6281
6493
|
json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
|
|
@@ -6318,7 +6530,7 @@ module Clacky
|
|
|
6318
6530
|
end
|
|
6319
6531
|
|
|
6320
6532
|
agent.set_session_sub_model(model_name)
|
|
6321
|
-
@session_manager.save(agent.to_session_data)
|
|
6533
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
6322
6534
|
broadcast_session_update(session_id)
|
|
6323
6535
|
|
|
6324
6536
|
json_response(res, 200, { ok: true, sub_model: agent.current_model_info[:sub_model] })
|
|
@@ -6442,7 +6654,7 @@ module Clacky
|
|
|
6442
6654
|
agent.change_working_dir(expanded_dir)
|
|
6443
6655
|
|
|
6444
6656
|
# Persist the change
|
|
6445
|
-
@session_manager.save(agent.to_session_data)
|
|
6657
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
6446
6658
|
|
|
6447
6659
|
# Broadcast update to all clients
|
|
6448
6660
|
broadcast_session_update(session_id)
|
|
@@ -6666,9 +6878,9 @@ module Clacky
|
|
|
6666
6878
|
|
|
6667
6879
|
when "list_sessions"
|
|
6668
6880
|
stats = @registry.cron_stats
|
|
6669
|
-
page = @registry.list(limit:
|
|
6670
|
-
has_more = page.size >
|
|
6671
|
-
all_sessions = page.first(
|
|
6881
|
+
page = @registry.list(limit: 11, exclude_type: "cron", exclude_project: true)
|
|
6882
|
+
has_more = page.size > 10
|
|
6883
|
+
all_sessions = page.first(10)
|
|
6672
6884
|
projects = @project_manager.all
|
|
6673
6885
|
# Include ALL sessions that belong to any project, regardless of the
|
|
6674
6886
|
# 15-item pagination limit. We merge them into the same `sessions`
|
|
@@ -6766,7 +6978,13 @@ module Clacky
|
|
|
6766
6978
|
msg_created_at = Time.now.to_f
|
|
6767
6979
|
web_ui = nil
|
|
6768
6980
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
6769
|
-
|
|
6981
|
+
# Pass the uploaded files through to the realtime broadcast so images and
|
|
6982
|
+
# attachments render in the bubble immediately. agent.run later appends the
|
|
6983
|
+
# authoritative display_files to history — but only after vision/OCR
|
|
6984
|
+
# processing finishes, which can take seconds. Without the preview here, a
|
|
6985
|
+
# page refresh inside that window shows the message without its image (the
|
|
6986
|
+
# "need to refresh several times before the image appears" bug).
|
|
6987
|
+
web_ui&.show_user_message(content, created_at: msg_created_at, source: :web, files: Array(files))
|
|
6770
6988
|
|
|
6771
6989
|
# File references are now handled inside agent.run — injected as a system_injected
|
|
6772
6990
|
# message after the user message, so replay_history skips them automatically.
|
|
@@ -6877,7 +7095,7 @@ module Clacky
|
|
|
6877
7095
|
Clacky::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
|
|
6878
7096
|
end
|
|
6879
7097
|
thread.join(2)
|
|
6880
|
-
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
7098
|
+
@session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
|
|
6881
7099
|
end
|
|
6882
7100
|
end
|
|
6883
7101
|
|
|
@@ -6934,7 +7152,7 @@ module Clacky
|
|
|
6934
7152
|
# with the tab/window in the background — can still chime. Not part of
|
|
6935
7153
|
# session history: a chime is a live cue, never replayed on refresh.
|
|
6936
7154
|
broadcast_all(type: "task_finished", session_id: session_id)
|
|
6937
|
-
@session_manager.save(agent.to_session_data(status: :success))
|
|
7155
|
+
@session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
|
|
6938
7156
|
# Start idle compression timer now that the agent is idle
|
|
6939
7157
|
idle_timer&.start
|
|
6940
7158
|
rescue Clacky::AgentInterrupted
|
|
@@ -6943,7 +7161,7 @@ module Clacky
|
|
|
6943
7161
|
next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
|
|
6944
7162
|
broadcast_session_update(session_id)
|
|
6945
7163
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
6946
|
-
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
7164
|
+
@session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
|
|
6947
7165
|
rescue => e
|
|
6948
7166
|
# Route error through web_ui so channel subscribers (飞书/企微) receive it too.
|
|
6949
7167
|
web_ui = nil
|
|
@@ -6959,7 +7177,7 @@ module Clacky
|
|
|
6959
7177
|
next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6960
7178
|
broadcast_session_update(session_id)
|
|
6961
7179
|
web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6962
|
-
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
|
|
7180
|
+
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message, updated_at: Time.now))
|
|
6963
7181
|
end
|
|
6964
7182
|
# Register the thread only if we still own the epoch; a faster
|
|
6965
7183
|
# superseding task may have already replaced it.
|
|
@@ -7111,7 +7329,7 @@ module Clacky
|
|
|
7111
7329
|
|
|
7112
7330
|
# Persist an initial snapshot so the session is immediately visible in registry.list
|
|
7113
7331
|
# (which reads from disk). Without this, new sessions only appear after their first task.
|
|
7114
|
-
@session_manager.save(agent.to_session_data)
|
|
7332
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
7115
7333
|
|
|
7116
7334
|
session_id
|
|
7117
7335
|
end
|
|
@@ -221,7 +221,8 @@ module Clacky
|
|
|
221
221
|
latest_latency: s[:agent]&.latest_latency,
|
|
222
222
|
card_model: model_info&.dig(:card_model),
|
|
223
223
|
sub_model: model_info&.dig(:sub_model),
|
|
224
|
-
sub_model_options: sub_model_options_for(model_info)
|
|
224
|
+
sub_model_options: sub_model_options_for(model_info),
|
|
225
|
+
todos: s[:agent]&.todos }
|
|
225
226
|
end
|
|
226
227
|
end
|
|
227
228
|
|
|
@@ -338,7 +339,12 @@ module Clacky
|
|
|
338
339
|
latest_latency: s[:agent]&.latest_latency,
|
|
339
340
|
card_model: model_info&.dig(:card_model),
|
|
340
341
|
sub_model: model_info&.dig(:sub_model),
|
|
341
|
-
sub_model_options: sub_model_options_for(model_info)
|
|
342
|
+
sub_model_options: sub_model_options_for(model_info),
|
|
343
|
+
# Live agent todos take precedence over the stale on-disk copy.
|
|
344
|
+
# On-disk todos are only persisted when agent.run finishes, so a
|
|
345
|
+
# snapshot taken mid-run (e.g. right after completing the last
|
|
346
|
+
# task) would otherwise still return the old list.
|
|
347
|
+
todos: s[:agent]&.todos }
|
|
342
348
|
end
|
|
343
349
|
|
|
344
350
|
build_enriched_row(disk, live)
|
|
@@ -379,6 +385,7 @@ module Clacky
|
|
|
379
385
|
pinned: s[:pinned] || false,
|
|
380
386
|
channel_info: s[:channel_info],
|
|
381
387
|
project_id: s[:project_id],
|
|
388
|
+
todos: ls&.dig(:todos) || s[:todos] || [],
|
|
382
389
|
}
|
|
383
390
|
end
|
|
384
391
|
|
|
@@ -521,7 +528,7 @@ module Clacky
|
|
|
521
528
|
|
|
522
529
|
private def persist_and_release(id, session)
|
|
523
530
|
agent = session[:agent]
|
|
524
|
-
@session_manager&.save(agent.to_session_data(status: :success
|
|
531
|
+
@session_manager&.save(agent.to_session_data(status: :success)) if agent
|
|
525
532
|
|
|
526
533
|
@mutex.synchronize do
|
|
527
534
|
s = @sessions[id]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
3
4
|
require "json"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require_relative "../ui_interface"
|
|
@@ -70,11 +71,20 @@ module Clacky
|
|
|
70
71
|
data[:created_at] = created_at if created_at
|
|
71
72
|
# Build ev.images for the frontend renderer (history_user_message):
|
|
72
73
|
# - Images with data_url → pass the data_url directly (<img> thumbnail)
|
|
73
|
-
# - Disk files (
|
|
74
|
+
# - Disk image files (type=="image", has path) → /api/local-image proxy URL
|
|
75
|
+
# - Disk files (PDF, doc, etc., no data_url, no image path) → "pdf:name" sentinel
|
|
74
76
|
rendered = Array(files).filter_map do |f|
|
|
75
77
|
url = f[:data_url] || f["data_url"]
|
|
76
78
|
name = f[:name] || f["name"]
|
|
77
|
-
|
|
79
|
+
type = f[:type] || f["type"]
|
|
80
|
+
path = f[:path] || f["path"]
|
|
81
|
+
if url
|
|
82
|
+
url
|
|
83
|
+
elsif type.to_s == "image" && path && File.exist?(path.to_s)
|
|
84
|
+
"/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
85
|
+
elsif name
|
|
86
|
+
"pdf:#{name}"
|
|
87
|
+
end
|
|
78
88
|
end
|
|
79
89
|
data[:images] = rendered unless rendered.empty?
|
|
80
90
|
emit("history_user_message", **data)
|
|
@@ -36,6 +36,7 @@ module Clacky
|
|
|
36
36
|
# Keep only the most recent 200 sessions (best-effort, never block save)
|
|
37
37
|
begin
|
|
38
38
|
cleanup_by_count(keep: 200, keep_cron: 200)
|
|
39
|
+
cleanup_trash(days: 8)
|
|
39
40
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
40
41
|
# Cleanup is non-critical; swallow all errors (including AgentInterrupted)
|
|
41
42
|
end
|
|
@@ -480,9 +480,7 @@ module Clacky
|
|
|
480
480
|
# leaves the result as invalid UTF-8. Re-scrub after truncation so
|
|
481
481
|
# everything downstream (JSON.generate, format_result, UI) gets a
|
|
482
482
|
# guaranteed-valid UTF-8 string.
|
|
483
|
-
preview = cleaned
|
|
484
|
-
preview.force_encoding(Encoding::UTF_8)
|
|
485
|
-
preview = preview.scrub("?") unless preview.valid_encoding?
|
|
483
|
+
preview = smart_truncate(cleaned, OVERFLOW_PREVIEW_CHARS)
|
|
486
484
|
|
|
487
485
|
notice = if overflow_file
|
|
488
486
|
"\n\n...[Output truncated for LLM: showing first #{OVERFLOW_PREVIEW_CHARS} " \
|
|
@@ -1429,6 +1427,35 @@ module Clacky
|
|
|
1429
1427
|
nil
|
|
1430
1428
|
end
|
|
1431
1429
|
|
|
1430
|
+
# Smart truncation: keep head + tail within char budget.
|
|
1431
|
+
# Ensures error messages at end of output remain visible.
|
|
1432
|
+
private def smart_truncate(text, max_chars)
|
|
1433
|
+
total_chars = text.bytesize
|
|
1434
|
+
return text if total_chars <= max_chars
|
|
1435
|
+
|
|
1436
|
+
total_lines = text.lines.count
|
|
1437
|
+
|
|
1438
|
+
# 60% head, 40% tail
|
|
1439
|
+
head_budget = (max_chars * 0.6).to_i
|
|
1440
|
+
tail_budget = max_chars - head_budget
|
|
1441
|
+
|
|
1442
|
+
head = text.byteslice(0, head_budget)
|
|
1443
|
+
head.force_encoding(Encoding::UTF_8)
|
|
1444
|
+
head = head.scrub("?") unless head.valid_encoding?
|
|
1445
|
+
|
|
1446
|
+
tail = text.byteslice(-tail_budget, tail_budget)
|
|
1447
|
+
tail.force_encoding(Encoding::UTF_8)
|
|
1448
|
+
tail = tail.scrub("?") unless tail.valid_encoding?
|
|
1449
|
+
|
|
1450
|
+
# Align tail to line boundary if cut mid-line
|
|
1451
|
+
if tail.include?("\n") && !tail.start_with?("\n")
|
|
1452
|
+
first_nl = tail.index("\n")
|
|
1453
|
+
tail = tail[(first_nl + 1)..-1] || ""
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
"#{head}\n\n...[#{total_lines} lines, #{total_chars} chars total, truncated]...\n\n#{tail}"
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1432
1459
|
|
|
1433
1460
|
|
|
1434
1461
|
# Max visible length of a command inside the tool-call summary line.
|