openclacky 1.5.19 → 1.5.21

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -1
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tmpdir"
3
+ require_relative "pid_files"
4
4
  require "socket"
5
5
 
6
6
  module Clacky
7
7
  module Server
8
8
  # Discover locally-running Clacky server(s) by scanning PID files
9
- # written by Master at /tmp/clacky-master-<port>.pid.
9
+ # written by Master in the configured PID directory.
10
10
  #
11
11
  # Used by the CLI (bare `clacky agent` mode) to auto-detect a sibling
12
12
  # server process, so skills that call back into the server (channels,
@@ -17,7 +17,6 @@ module Clacky
17
17
  # Does NOT probe the TCP port (avoids false positives from stale files
18
18
  # but also avoids noisy connection attempts).
19
19
  module Discover
20
- PID_FILE_GLOB = File.join(Dir.tmpdir, "clacky-master-*.pid").freeze
21
20
  PID_FILE_REGEX = /clacky-master-(\d+)\.pid\z/.freeze
22
21
 
23
22
  module_function
@@ -41,7 +40,9 @@ module Clacky
41
40
  #
42
41
  # @return [Array<Hash>] sorted by port ascending
43
42
  def find_all_local
44
- Dir.glob(PID_FILE_GLOB).filter_map do |path|
43
+ directory = PidFiles.directory
44
+ Dir.glob("clacky-master-*.pid", base: directory).filter_map do |filename|
45
+ path = File.join(directory, filename)
45
46
  m = path.match(PID_FILE_REGEX)
46
47
  next nil unless m
47
48
 
@@ -110,6 +110,15 @@ module Clacky
110
110
 
111
111
  def show_tool_call(name, args)
112
112
  args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
113
+ if Clacky::Tools::AskUser.feedback_tool?(name)
114
+ questions = Clacky::Tools::AskUser.normalize_questions(args_data)
115
+ first = questions.first || {}
116
+ @events << { type: "request_feedback", session_id: @session_id,
117
+ request_id: Clacky::Tools::AskUser.fetch_key(args_data, :request_id),
118
+ context: Clacky::Tools::AskUser.fetch_key(args_data, :context).to_s,
119
+ question: first[:question].to_s, options: first[:options] || [], questions: questions }
120
+ return
121
+ end
113
122
  summary = tool_call_summary(name, args_data)
114
123
  @events << stamp({ type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary })
115
124
  end
@@ -700,6 +709,7 @@ module Clacky
700
709
  when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
701
710
  when ["POST", "/api/browser/reload"] then api_browser_reload(res)
702
711
  when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
712
+ when ["POST", "/api/browser/settings"] then api_browser_settings(req, res)
703
713
  when ["GET", "/api/backup/status"] then api_backup_status(res)
704
714
  when ["POST", "/api/backup/run"] then api_backup_run(res)
705
715
  when ["GET", "/api/backup/download"] then api_backup_download(res)
@@ -732,7 +742,8 @@ module Clacky
732
742
  when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
733
743
  when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
734
744
  when ["POST", "/api/trash/sessions/restore"] then api_trash_session_restore(req, res)
735
- when ["DELETE", "/api/trash/sessions"] then api_trash_sessions_delete(req, res)
745
+ when ["POST", "/api/trash/sessions/delete"] then api_trash_sessions_delete(req, res)
746
+ when ["POST", "/api/archive/sessions/delete"] then api_archive_sessions_delete(req, res)
736
747
  when ["GET", "/api/profile"] then api_profile_get(res)
737
748
  when ["PUT", "/api/profile"] then api_profile_put(req, res)
738
749
  when ["GET", "/api/memories"] then api_memories_list(res)
@@ -869,6 +880,9 @@ module Clacky
869
880
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
870
881
  session_id = path.sub("/api/sessions/", "").sub("/project", "")
871
882
  api_update_session_project(session_id, req, res)
883
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/permission_mode$})
884
+ session_id = path.split("/")[3]
885
+ api_switch_session_permission_mode(session_id, req, res)
872
886
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
873
887
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
874
888
  api_switch_session_model(session_id, req, res)
@@ -949,6 +963,9 @@ module Clacky
949
963
  elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
950
964
  project_id = path.sub("/api/projects/", "")
951
965
  api_update_project(project_id, req, res)
966
+ elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/sessions/archive$})
967
+ project_id = path.sub("/api/projects/", "").sub("/sessions/archive", "")
968
+ api_archive_project_sessions(project_id, res)
952
969
  elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
953
970
  project_id = path.sub("/api/projects/", "").sub("/sessions", "")
954
971
  api_delete_project_sessions(project_id, res)
@@ -967,11 +984,13 @@ module Clacky
967
984
  query = URI.decode_www_form(req.query_string.to_s).to_h
968
985
  limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
969
986
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
987
+ before_id = query["before_id"].to_s.strip.then { |v| v.empty? ? nil : v }
970
988
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
971
989
  q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
972
990
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
973
991
  type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
974
992
  exclude_type = query["exclude_type"].to_s.split(",").map(&:strip).reject(&:empty?).then { |v| v.empty? ? nil : v }
993
+ archived = %w[1 true yes].include?(query["archived"].to_s.strip.downcase)
975
994
  # Backward-compat: ?source=<x> and ?profile=coding → type
976
995
  type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
977
996
  type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
@@ -981,16 +1000,24 @@ module Clacky
981
1000
  # in the project section, so they must not eat into this page's quota.
982
1001
  # Search (no before) keeps the original semantics: without a type filter
983
1002
  # it may still match project sessions.
984
- exclude_project = before ? true : !!type
985
- sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project)
1003
+ #
1004
+ # The archive view is the exception: it groups archived sessions by
1005
+ # project by hand, so project membership must survive both filters.
1006
+ exclude_project = archived ? false : (before ? true : !!type)
1007
+ sessions = @registry.list(limit: limit + 1, before: before, before_id: before_id, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project, archived: archived)
986
1008
 
987
1009
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
988
1010
  has_more = non_pinned_part.size > limit
989
1011
  non_pinned_part = non_pinned_part.first(limit)
990
1012
  sessions = pinned_part + non_pinned_part
991
1013
 
992
- json_response(res, 200, { sessions: sessions, has_more: has_more,
993
- groups: @registry.group_stats_all })
1014
+ payload = { sessions: sessions, has_more: has_more, next_cursor: non_pinned_part.last&.dig(:cursor),
1015
+ groups: @registry.group_stats_all }
1016
+ # The archive page offers "delete all", so it needs the size of the whole
1017
+ # archive — the paged `sessions` array only ever shows one page of it.
1018
+ payload[:archived_count] = @registry.archived_count if archived
1019
+
1020
+ json_response(res, 200, payload)
994
1021
  end
995
1022
 
996
1023
  # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
@@ -1673,6 +1700,22 @@ module Clacky
1673
1700
  json_response(res, 500, { ok: false, error: e.message })
1674
1701
  end
1675
1702
 
1703
+ # POST /api/browser/settings
1704
+ # Body: { screenshot_max_base64_bytes: 300000 }
1705
+ # The field is optional — omitting it keeps the current value.
1706
+ def api_browser_settings(req, res)
1707
+ body = JSON.parse(req.body.to_s) rescue {}
1708
+
1709
+ settings = @browser_manager.update_screenshot_settings(
1710
+ max_base64_bytes: body["screenshot_max_base64_bytes"]
1711
+ )
1712
+ json_response(res, 200, { ok: true }.merge(settings))
1713
+ rescue ArgumentError => e
1714
+ json_response(res, 422, { ok: false, error: e.message })
1715
+ rescue StandardError => e
1716
+ json_response(res, 500, { ok: false, error: e.message })
1717
+ end
1718
+
1676
1719
  # GET /api/backup/status
1677
1720
  def api_backup_status(res)
1678
1721
  json_response(res, 200, BackupManager.status)
@@ -2284,38 +2327,66 @@ module Clacky
2284
2327
  end
2285
2328
 
2286
2329
  # PATCH /api/config/search
2287
- # Body: { provider: "tavily"|"", key?: "..." }
2330
+ # Body: { provider?: "tavily"|"deepseek_web"|"", key?: "..." }
2331
+ # Omitted fields keep their current value.
2288
2332
  def api_update_search_config(req, res)
2289
2333
  body = parse_json_body(req)
2290
2334
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
2291
2335
 
2292
2336
  provider = body["provider"].to_s.strip
2293
- unless provider.empty? || Clacky::Utils::SearcherManager.path_for(provider)
2337
+ if body.key?("provider") && !search_provider_known?(provider)
2294
2338
  return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
2295
2339
  end
2296
2340
 
2297
2341
  key = body["key"].to_s
2298
2342
  key = Clacky::SearchConfig.key if key.include?("****")
2299
2343
 
2300
- Clacky::SearchConfig.save(provider: provider, key: key)
2301
- json_response(res, 200, { ok: true, provider: provider })
2344
+ current = Clacky::SearchConfig.load
2345
+ final_provider = body.key?("provider") ? provider : current["provider"]
2346
+ final_key = body.key?("key") ? key : current["key"]
2347
+
2348
+ # deepseek_web falls back to the configured model key, so it may be
2349
+ # saved without one. Every other searcher is a script that reads the
2350
+ # key from CLACKY_SEARCH_KEY and cannot run without it.
2351
+ if !final_provider.empty? &&
2352
+ final_provider != Clacky::SearchConfig::DEEPSEEK_WEB &&
2353
+ final_key.to_s.strip.empty?
2354
+ return json_response(res, 422, {
2355
+ error: "API key is required for search provider '#{final_provider}'"
2356
+ })
2357
+ end
2358
+
2359
+ Clacky::SearchConfig.save(
2360
+ provider: body.key?("provider") ? provider : nil,
2361
+ key: body.key?("key") ? key : nil
2362
+ )
2363
+ config = Clacky::SearchConfig.load
2364
+ json_response(res, 200, { ok: true, provider: config["provider"] })
2302
2365
  rescue => e
2303
2366
  json_response(res, 422, { error: e.message })
2304
2367
  end
2305
2368
 
2369
+ private def search_provider_known?(provider)
2370
+ return true if provider.empty?
2371
+ return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2372
+
2373
+ !Clacky::Utils::SearcherManager.path_for(provider).nil?
2374
+ end
2375
+
2306
2376
  # POST /api/config/search/test
2307
- # Runs the searcher with a fixed query so the user gets a definitive
2308
- # answer before saving, rather than discovering a bad key mid-task.
2377
+ # Runs the configured engine with a fixed query so the user gets a
2378
+ # definitive answer before saving, rather than discovering a bad key mid-task.
2309
2379
  def api_test_search_config(req, res)
2310
2380
  body = parse_json_body(req) || {}
2311
2381
  provider = body["provider"].to_s.strip
2312
2382
  return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
2313
2383
 
2314
- script = Clacky::Utils::SearcherManager.path_for(provider)
2315
- return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
2316
-
2317
2384
  key = body["key"].to_s
2318
2385
  key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
2386
+ return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2387
+
2388
+ script = Clacky::Utils::SearcherManager.path_for(provider)
2389
+ return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
2319
2390
 
2320
2391
  stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
2321
2392
  { "CLACKY_SEARCH_KEY" => key },
@@ -2344,6 +2415,23 @@ module Clacky
2344
2415
  json_response(res, 200, { ok: false, message: e.message })
2345
2416
  end
2346
2417
 
2418
+ private def test_deepseek_web_search(res, key)
2419
+ key = @agent_config.deepseek_web_key.to_s if key.empty?
2420
+ if key.empty?
2421
+ return json_response(res, 200, {
2422
+ ok: false, message: "No DeepSeek key: fill one in or configure a DeepSeek / OpenClacky model"
2423
+ })
2424
+ end
2425
+
2426
+ results = Clacky::DeepSeekWebTools.search(
2427
+ query: "openclacky", count: 1, api_key: key,
2428
+ base_url: @agent_config.deepseek_web_base_url(key)
2429
+ )
2430
+ json_response(res, 200, { ok: true, message: "Returned #{results.size} result(s)" })
2431
+ rescue => e
2432
+ json_response(res, 200, { ok: false, message: e.message })
2433
+ end
2434
+
2347
2435
  # POST /api/internal/ocr-image
2348
2436
  # Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
2349
2437
  # transcribe a single image via the configured OCR sidecar. Localhost-
@@ -4679,19 +4767,39 @@ module Clacky
4679
4767
  return
4680
4768
  end
4681
4769
 
4682
- # Support HTTP Range requests for video seeking
4770
+ # Support a single HTTP byte range for media seeking. Browsers normally
4771
+ # send an explicit or open-ended start, while other clients may use the
4772
+ # standard suffix form ("bytes=-N").
4683
4773
  range_header = req["Range"]
4684
4774
  if range_header && range_header =~ /\Abytes=(\d*)-(\d*)\z/
4685
- start_byte = ($1.empty? ? 0 : $1.to_i)
4686
- end_byte = ($2.empty? ? file_size - 1 : $2.to_i)
4687
- end_byte = [end_byte, file_size - 1].min
4775
+ first, last = $1, $2
4776
+ unsatisfiable = file_size.zero?
4777
+
4778
+ if first.empty?
4779
+ suffix_length = last.to_i
4780
+ unsatisfiable ||= suffix_length.zero?
4781
+ start_byte = [file_size - suffix_length, 0].max
4782
+ end_byte = file_size - 1
4783
+ else
4784
+ start_byte = first.to_i
4785
+ end_byte = last.empty? ? file_size - 1 : [last.to_i, file_size - 1].min
4786
+ unsatisfiable ||= start_byte >= file_size || end_byte < start_byte
4787
+ end
4688
4788
 
4689
- res.status = 206
4690
- res["Content-Type"] = mime
4691
- res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
4789
+ res["Content-Type"] = mime
4692
4790
  res["Accept-Ranges"] = "bytes"
4693
- res["Content-Length"] = (end_byte - start_byte + 1).to_s
4694
- IO.binread(path, end_byte - start_byte + 1, start_byte).then { |data| res.body = data }
4791
+ if unsatisfiable
4792
+ res.status = 416
4793
+ res["Content-Range"] = "bytes */#{file_size}"
4794
+ res["Content-Length"] = "0"
4795
+ res.body = ""
4796
+ else
4797
+ length = end_byte - start_byte + 1
4798
+ res.status = 206
4799
+ res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
4800
+ res["Content-Length"] = length.to_s
4801
+ res.body = IO.binread(path, length, start_byte)
4802
+ end
4695
4803
  else
4696
4804
  res.status = 200
4697
4805
  res["Content-Type"] = mime
@@ -6005,20 +6113,23 @@ module Clacky
6005
6113
  json_response(res, 200, { ok: true, session_id: session_id })
6006
6114
  end
6007
6115
 
6008
- # DELETE /api/trash/sessions?days_old=N
6009
- # Bulk: permanently delete sessions older than N days (default: 7).
6116
+ # POST /api/trash/sessions/delete
6117
+ # Body: { session_ids: ["...", ...] }
6118
+ # Bulk: permanently delete exactly these sessions. The caller decides the
6119
+ # scope (the page's filters live in the browser), so an empty list is
6120
+ # rejected rather than read as "everything".
6010
6121
  private def api_trash_sessions_delete(req, res)
6011
- query = URI.decode_www_form(req.query_string.to_s).to_h
6012
- days_old = query["days_old"].to_s.strip
6013
- days_i = days_old.empty? ? 7 : days_old.to_i
6122
+ raw = parse_json_body(req)["session_ids"]
6123
+ ids = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
6014
6124
 
6015
- deleted = @session_manager.cleanup_trash(days: days_i)
6125
+ if ids.empty?
6126
+ json_response(res, 400, { ok: false, error: "session_ids is required" })
6127
+ return
6128
+ end
6016
6129
 
6017
- json_response(res, 200, {
6018
- ok: true,
6019
- deleted_count: deleted,
6020
- days_old: days_i
6021
- })
6130
+ deleted = @session_manager.permanent_delete_trash_sessions(ids)
6131
+
6132
+ json_response(res, 200, { ok: true, deleted_count: deleted })
6022
6133
  end
6023
6134
 
6024
6135
  # ── Trash helpers (private) ─────────────────────────────────────
@@ -6468,9 +6579,17 @@ module Clacky
6468
6579
  provider_id: m["provider_id"],
6469
6580
  provider_name: provider && provider["name"],
6470
6581
  provider_name_key: provider && provider["name_key"],
6582
+ # Where the row's provider manages keys / billing — the picker
6583
+ # links to it from the per-row top-up shortcut.
6584
+ website_url: provider && provider["website_url"],
6471
6585
  capabilities: m["capabilities"],
6472
6586
  remark: m["remark"],
6473
- type: m["type"]
6587
+ type: m["type"],
6588
+ # Sub-model list for this row: the models of the provider its
6589
+ # endpoint belongs to (same rule the session status bar uses), so
6590
+ # the picker can offer the switcher on every row and not only on
6591
+ # the session's current one.
6592
+ sub_models: provider_id ? Clacky::Providers.models(provider_id) : []
6474
6593
  }
6475
6594
  end
6476
6595
  # Sidecar capability configs (media/ocr) and auto-injected entries are
@@ -7213,6 +7332,45 @@ module Clacky
7213
7332
  json_response(res, 500, { error: e.message })
7214
7333
  end
7215
7334
 
7335
+ # POST /api/projects/:id/sessions/archive - archive every live session in a project
7336
+ def api_archive_project_sessions(project_id, res)
7337
+ return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
7338
+
7339
+ archived = _archive_project_sessions(project_id)
7340
+ json_response(res, 200, { ok: true, archived: archived })
7341
+ rescue => e
7342
+ json_response(res, 500, { error: e.message })
7343
+ end
7344
+
7345
+ # Sessions already in the archive keep their original archived_at, so the
7346
+ # archive page stays ordered by when the user actually put them away.
7347
+ private def _archive_project_sessions(project_id)
7348
+ archived = 0
7349
+ now = Time.now.iso8601
7350
+
7351
+ @session_manager.all_sessions.each do |session_data|
7352
+ next unless session_data[:project_id].to_s == project_id
7353
+ next if session_data[:archived_at]
7354
+
7355
+ sid = session_data[:session_id]
7356
+ live = nil
7357
+ @registry.with_session(sid) { |s| live = s[:agent] }
7358
+
7359
+ if live
7360
+ live.archived_at = now
7361
+ @session_manager.save(live.to_session_data(updated_at: Time.now))
7362
+ else
7363
+ # Untouched on disk: patch the loaded row rather than restoring an
7364
+ # agent for every session in the project just to stamp one field.
7365
+ @session_manager.save(session_data.merge(archived_at: now, updated_at: now))
7366
+ end
7367
+
7368
+ broadcast(sid, { type: "session_updated", session_id: sid, archived_at: now })
7369
+ archived += 1
7370
+ end
7371
+ archived
7372
+ end
7373
+
7216
7374
  private def _delete_project_sessions(project_id)
7217
7375
  deleted = 0
7218
7376
  @session_manager.all_sessions.each do |session_data|
@@ -7264,6 +7422,11 @@ module Clacky
7264
7422
  body = parse_json_body(req)
7265
7423
  new_name = body["name"]&.to_s&.strip
7266
7424
  pinned = body["pinned"]
7425
+ archived = body["archived"]
7426
+
7427
+ if !archived.nil? && archived != true && archived != false
7428
+ return json_response(res, 400, { error: "archived must be true or false" })
7429
+ end
7267
7430
 
7268
7431
  return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
7269
7432
 
@@ -7279,6 +7442,11 @@ module Clacky
7279
7442
  if !pinned.nil?
7280
7443
  agent.pinned = pinned
7281
7444
  end
7445
+
7446
+ # Update archived status if provided
7447
+ if !archived.nil?
7448
+ agent.archived_at = archived ? Time.now.iso8601 : nil
7449
+ end
7282
7450
 
7283
7451
  # Save session data
7284
7452
  @session_manager.save(agent.to_session_data(updated_at: Time.now))
@@ -7287,16 +7455,36 @@ module Clacky
7287
7455
  update_data = { type: "session_updated", session_id: session_id }
7288
7456
  update_data[:name] = new_name if new_name && !new_name.empty?
7289
7457
  update_data[:pinned] = pinned unless pinned.nil?
7458
+ update_data[:archived_at] = agent.archived_at unless archived.nil?
7290
7459
  broadcast(session_id, update_data)
7291
7460
 
7292
7461
  response_data = { ok: true }
7293
7462
  response_data[:name] = new_name if new_name && !new_name.empty?
7294
7463
  response_data[:pinned] = pinned unless pinned.nil?
7464
+ response_data[:archived_at] = agent.archived_at unless archived.nil?
7295
7465
  json_response(res, 200, response_data)
7296
7466
  rescue => e
7297
7467
  json_response(res, 500, { error: e.message })
7298
7468
  end
7299
7469
 
7470
+ # PATCH /api/sessions/:id/permission_mode — session-only, never global.
7471
+ def api_switch_session_permission_mode(session_id, req, res)
7472
+ mode = parse_json_body(req)["permission_mode"].to_s
7473
+ unless %w[confirm_all auto_approve].include?(mode)
7474
+ return json_response(res, 400, { error: "Invalid permission mode" })
7475
+ end
7476
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
7477
+
7478
+ @registry.with_session(session_id) do |session|
7479
+ agent = session[:agent]
7480
+ return json_response(res, 404, { error: "Session not found" }) unless agent
7481
+ agent.permission_mode = mode
7482
+ @session_manager.save(agent.to_session_data(updated_at: Time.now))
7483
+ end
7484
+ broadcast_session_update(session_id)
7485
+ json_response(res, 200, { ok: true, permission_mode: mode })
7486
+ end
7487
+
7300
7488
  def api_switch_session_model(session_id, req, res)
7301
7489
  body = parse_json_body(req)
7302
7490
  model_id = body["model_id"].to_s.strip
@@ -7540,6 +7728,29 @@ module Clacky
7540
7728
  json_response(res, 201, { session: @registry.snapshot(fork_id) })
7541
7729
  end
7542
7730
 
7731
+ # POST /api/archive/sessions/delete — soft-deletes into the recycle bin.
7732
+ # With `session_ids`, only those still-archived chats go; without a body,
7733
+ # the whole archive (the page is paged, so only the server can list it).
7734
+ # An explicit empty list is rejected so "matches nothing" never means "all".
7735
+ def api_archive_sessions_delete(req, res)
7736
+ body = parse_json_body(req)
7737
+ ids = @registry.list(archived: true).map { |s| s[:id] }
7738
+
7739
+ if !body.is_a?(Hash) || body.key?("session_ids")
7740
+ raw = body.is_a?(Hash) ? body["session_ids"] : nil
7741
+ requested = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
7742
+ return json_response(res, 400, { ok: false, error: "session_ids must not be empty" }) if requested.empty?
7743
+
7744
+ ids &= requested
7745
+ end
7746
+
7747
+ # `list` just read these ids off disk, so skip the per-id existence
7748
+ # probe — `load` re-parses every session file for each call.
7749
+ ids.each { |session_id| soft_delete_session!(session_id, on_disk: true) }
7750
+
7751
+ json_response(res, 200, { ok: true, deleted_count: ids.size })
7752
+ end
7753
+
7543
7754
  def api_delete_session(session_id, res)
7544
7755
  # A session exists if it's either in the runtime registry OR on disk.
7545
7756
  # Old sessions that were never restored into memory this server run
@@ -7547,28 +7758,36 @@ module Clacky
7547
7758
  # must still be able to delete them. Previously this endpoint only
7548
7759
  # consulted @registry and returned 404 for disk-only sessions,
7549
7760
  # causing the "can't delete old sessions" bug.
7550
- in_registry = @registry.exist?(session_id)
7551
- on_disk = !@session_manager.load(session_id).nil?
7761
+ return json_response(res, 404, { error: "Session not found" }) unless soft_delete_session!(session_id)
7552
7762
 
7553
- unless in_registry || on_disk
7554
- return json_response(res, 404, { error: "Session not found" })
7555
- end
7763
+ json_response(res, 200, { ok: true })
7764
+ end
7765
+
7766
+ # Move one session to the recycle bin and tell everyone who can see it.
7767
+ # Returns false when the session is unknown — neither live nor on disk.
7768
+ #
7769
+ # `on_disk` skips the existence probe for callers that already scanned the
7770
+ # disk themselves: `SessionManager#load` re-parses every session file until
7771
+ # it finds a match, which is far too expensive to run per id in a bulk path.
7772
+ private def soft_delete_session!(session_id, on_disk: nil)
7773
+ in_registry = @registry.exist?(session_id)
7774
+ on_disk = !@session_manager.load(session_id).nil? if on_disk.nil?
7775
+ return false unless in_registry || on_disk
7556
7776
 
7557
- # Registry delete is best-effort — only meaningful when the session
7558
- # is actually live (cancels idle timer, interrupts the agent thread).
7559
- # For disk-only sessions this is a no-op and returns false, which is
7560
- # fine and no longer blocks the disk cleanup below.
7777
+ # Registry delete is best-effort — only meaningful when the session is
7778
+ # actually live (cancels idle timer, interrupts the agent thread). For
7779
+ # disk-only sessions this is a no-op and returns false, which is fine and
7780
+ # no longer blocks the disk cleanup below.
7561
7781
  @registry.delete(session_id) if in_registry
7562
7782
 
7563
7783
  # Soft-delete: move session to trash instead of permanently destroying it.
7564
7784
  @session_manager.soft_delete(session_id) if on_disk
7565
7785
 
7566
- # Notify any still-connected clients (mainly matters when the
7567
- # session was live, but harmless otherwise).
7786
+ # Notify any still-connected clients (mainly matters when the session was
7787
+ # live, but harmless otherwise) so other tabs drop the row too.
7568
7788
  broadcast(session_id, { type: "session_deleted", session_id: session_id })
7569
7789
  unsubscribe_all(session_id)
7570
-
7571
- json_response(res, 200, { ok: true })
7790
+ true
7572
7791
  end
7573
7792
 
7574
7793
  # Export a session bundle as a .zip download containing:
@@ -7769,6 +7988,15 @@ module Clacky
7769
7988
  end
7770
7989
  handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
7771
7990
 
7991
+ when "feedback_intervene"
7992
+ session_id = msg["session_id"] || conn.session_id
7993
+ session = @registry.get(session_id)
7994
+ if session&.dig(:ui)&.intervene_feedback(msg["request_id"])
7995
+ # Finish the handoff before processing the next message on this connection.
7996
+ # An immediate submission then uses the ordinary awaiting-feedback path.
7997
+ session[:thread]&.join
7998
+ end
7999
+
7772
8000
  when "confirmation"
7773
8001
  session_id = msg["session_id"] || conn.session_id
7774
8002
  deliver_confirmation(session_id, msg["id"], msg["result"])
@@ -7787,7 +8015,9 @@ module Clacky
7787
8015
  pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
7788
8016
  non_pinned_limit = [10 - pinned_part.size, 0].max
7789
8017
  has_more = non_pinned_part.size > non_pinned_limit
7790
- all_sessions = pinned_part + non_pinned_part.first(non_pinned_limit)
8018
+ non_pinned_part = non_pinned_part.first(non_pinned_limit)
8019
+ next_cursor = non_pinned_part.last&.dig(:cursor)
8020
+ all_sessions = pinned_part + non_pinned_part
7791
8021
  projects = @project_manager.all
7792
8022
  if projects.any?
7793
8023
  project_ids = projects.map { |p| p[:id] }
@@ -7799,7 +8029,7 @@ module Clacky
7799
8029
  paged_ids = all_sessions.map { |s| s[:id] }.to_set
7800
8030
  all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
7801
8031
  end
7802
- conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
8032
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, next_cursor: next_cursor,
7803
8033
  groups: groups, projects: projects)
7804
8034
 
7805
8035
  when "run_task"
@@ -7871,7 +8101,6 @@ module Clacky
7871
8101
  return unless @registry.exist?(session_id)
7872
8102
 
7873
8103
  session = @registry.get(session_id)
7874
-
7875
8104
  mode = @agent_config.input_behavior
7876
8105
  queued = false
7877
8106
  queued_created_at = nil
@@ -7916,8 +8145,8 @@ module Clacky
7916
8145
  # Auto-name the session from the first user message (before agent starts running).
7917
8146
  # Skip if the name looks like it was set by the user (not a system-generated "Session N").
7918
8147
  if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
7919
- auto_name = content.gsub(/\s+/, " ").strip[0, 30]
7920
- auto_name += "…" if content.strip.length > 30
8148
+ auto_name = content.gsub(/\s+/, " ").strip[0, 80]
8149
+ auto_name += "…" if content.strip.length > 80
7921
8150
  agent.rename(auto_name)
7922
8151
  broadcast(session_id, { type: "session_renamed", session_id: session_id, name: auto_name })
7923
8152
  end
@@ -8227,11 +8456,14 @@ module Clacky
8227
8456
  if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
8228
8457
  preset = Clacky::Providers::PRESETS[e.provider_id]
8229
8458
  top_up_url = preset && preset["website_url"]
8459
+ elsif e.respond_to?(:action_url) && e.action_url
8460
+ top_up_url = e.action_url
8230
8461
  end
8231
8462
  user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
8232
8463
  raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
8233
8464
  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)
8234
8465
  broadcast_session_update(session_id)
8466
+ agent.record_error_event(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
8235
8467
  web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
8236
8468
  @session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message, updated_at: Time.now))
8237
8469
  end
@@ -6,7 +6,10 @@ module Clacky
6
6
  # Resolves each model's input/output price (USD per 1M tokens) from
7
7
  # Clacky::ModelPricing plus a cost ratio vs the baseline model, so the
8
8
  # web UI never hardcodes prices - updating PRICING_TABLE is enough.
9
- # Prices are post-promotion; `discount` is present when a rule applied.
9
+ # Prices are what the platform actually bills: post-promotion rates plus
10
+ # any lane markup. `discount` is present when a promotion applied,
11
+ # carrying both the rate and its reason ("channel" or "promo") so the UI
12
+ # can pick the right explanation.
10
13
  module ModelPrices
11
14
  BASELINE_MODEL = "claude-sonnet-5"
12
15
 
@@ -21,15 +24,17 @@ module Clacky
21
24
  pricing = Clacky::ModelPricing.get_pricing(name)
22
25
  pricing = Clacky::ModelPricing.resolve_deepseek_tier(pricing, now) if pricing && pricing[:deepseek]
23
26
  next unless pricing
24
- rate = Clacky::ModelPricing.discount_rate(name)
27
+ info = Clacky::ModelPricing.discount_info(name)
28
+ rate = info ? info[:rate] : 1.0
29
+ markup = Clacky::ModelPricing.markup_rate(name)
25
30
  # Rates are not powers of two, so the discounted product carries float
26
31
  # noise (3.0 * 0.95 == 2.8499999999999996) that would surface verbatim
27
32
  # in the picker tooltips. Four decimals is already finer than any
28
33
  # listed price.
29
- pin = (pricing[:input][:default] * rate).round(4)
30
- pout = (pricing[:output][:default] * rate).round(4)
34
+ pin = (pricing[:input][:default] * rate * markup).round(4)
35
+ pout = (pricing[:output][:default] * rate * markup).round(4)
31
36
  entry = { in: pin, out: pout, ratio: (pin + pout) / base_total }
32
- entry[:discount] = { rate: rate } if rate < 1.0
37
+ entry[:discount] = { rate: rate, reason: info[:reason] } if rate < 1.0
33
38
  prices[name] = entry
34
39
  end
35
40