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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/lib/clacky/agent/goal_manager.rb +305 -0
  4. data/lib/clacky/agent/goal_state.rb +88 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +4 -1
  6. data/lib/clacky/agent/session_serializer.rb +27 -3
  7. data/lib/clacky/agent.rb +202 -3
  8. data/lib/clacky/agent_config.rb +4 -2
  9. data/lib/clacky/agent_profile.rb +25 -3
  10. data/lib/clacky/cli.rb +15 -15
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  12. data/lib/clacky/default_extensions/ext-studio/ext.yml +4 -4
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +130 -59
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +12 -4
  15. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  16. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  17. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  18. data/lib/clacky/default_searchers/tavily.rb +98 -0
  19. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  20. data/lib/clacky/extension/api_extension.rb +29 -1
  21. data/lib/clacky/extension/loader.rb +1 -0
  22. data/lib/clacky/extension/packager.rb +114 -20
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/idle_compression_timer.rb +1 -3
  25. data/lib/clacky/message_format/anthropic.rb +1 -1
  26. data/lib/clacky/message_format/bedrock.rb +1 -1
  27. data/lib/clacky/message_format/open_ai.rb +8 -0
  28. data/lib/clacky/providers.rb +65 -8
  29. data/lib/clacky/search_config.rb +57 -0
  30. data/lib/clacky/server/http_server.rb +249 -31
  31. data/lib/clacky/server/session_registry.rb +10 -3
  32. data/lib/clacky/server/web_ui_controller.rb +12 -2
  33. data/lib/clacky/session_manager.rb +1 -0
  34. data/lib/clacky/tools/terminal.rb +30 -3
  35. data/lib/clacky/tools/web_search.rb +45 -6
  36. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  37. data/lib/clacky/ui2/components/input_area.rb +26 -2
  38. data/lib/clacky/ui2/ui_controller.rb +17 -0
  39. data/lib/clacky/ui_interface.rb +9 -0
  40. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  41. data/lib/clacky/utils/model_pricing.rb +0 -10
  42. data/lib/clacky/utils/searcher_manager.rb +87 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +530 -197
  45. data/lib/clacky/web/app.js +28 -3
  46. data/lib/clacky/web/components/chat-navigator.js +260 -0
  47. data/lib/clacky/web/features/billing/view.js +2 -2
  48. data/lib/clacky/web/features/brand/view.js +5 -1
  49. data/lib/clacky/web/features/extensions/store.js +214 -9
  50. data/lib/clacky/web/features/extensions/view.js +158 -13
  51. data/lib/clacky/web/features/new-session/store.js +25 -3
  52. data/lib/clacky/web/features/new-session/view.js +337 -140
  53. data/lib/clacky/web/features/skills/store.js +4 -2
  54. data/lib/clacky/web/features/skills/view.js +1 -1
  55. data/lib/clacky/web/i18n.js +72 -18
  56. data/lib/clacky/web/index.html +71 -64
  57. data/lib/clacky/web/projects.js +36 -19
  58. data/lib/clacky/web/sessions.js +173 -8
  59. data/lib/clacky/web/settings.js +85 -0
  60. data/lib/clacky/web/skills.js +24 -4
  61. data/lib/clacky/web/utils.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +63 -3
  63. data/lib/clacky.rb +2 -0
  64. 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
- product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
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
- 300
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
- # DELETE /api/store/extension body: { id: <slug> }
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
- Clacky::ExtensionPackager.install(download_url, force: true)
2747
- Clacky::ExtensionLoader.invalidate_cache!
2748
- Clacky::Telemetry.extension_install!(name) unless name.empty?
2749
- json_response(res, 200, { ok: true, name: name })
2750
- rescue Clacky::ExtensionPackager::Error => e
2751
- json_response(res, 422, { ok: false, error: e.message })
2752
- rescue StandardError => e
2753
- json_response(res, 500, { ok: false, error: e.message })
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
- agents = Clacky::AgentProfile.all
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: 16, exclude_type: "cron", exclude_project: true)
6670
- has_more = page.size > 15
6671
- all_sessions = page.first(15)
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
- web_ui&.show_user_message(content, created_at: msg_created_at, source: :web)
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, preserve_updated_at: true)) if agent
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 (PDF, doc, etc., no data_url) → "pdf:name" sentinel (renders a badge)
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
- url || (name ? "pdf:#{name}" : nil)
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.byteslice(0, OVERFLOW_PREVIEW_CHARS)
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.