openclacky 1.5.20 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +18 -0
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +5 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +23 -14
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +194 -47
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +61 -38
- data/lib/clacky/session_manager.rb +7 -1
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +132 -13
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +366 -105
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +3 -1
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/i18n.js +106 -30
- data/lib/clacky/web/index.html +90 -60
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +244 -131
- data/lib/clacky/web/settings.js +143 -8
- data/lib/clacky/web/ws-dispatcher.js +9 -13
- data/lib/clacky.rb +1 -0
- metadata +4 -1
|
@@ -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,8 +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 ["
|
|
736
|
-
when ["
|
|
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)
|
|
737
747
|
when ["GET", "/api/profile"] then api_profile_get(res)
|
|
738
748
|
when ["PUT", "/api/profile"] then api_profile_put(req, res)
|
|
739
749
|
when ["GET", "/api/memories"] then api_memories_list(res)
|
|
@@ -870,6 +880,9 @@ module Clacky
|
|
|
870
880
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
|
|
871
881
|
session_id = path.sub("/api/sessions/", "").sub("/project", "")
|
|
872
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)
|
|
873
886
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
|
|
874
887
|
session_id = path.sub("/api/sessions/", "").sub("/model", "")
|
|
875
888
|
api_switch_session_model(session_id, req, res)
|
|
@@ -950,6 +963,9 @@ module Clacky
|
|
|
950
963
|
elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
|
|
951
964
|
project_id = path.sub("/api/projects/", "")
|
|
952
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)
|
|
953
969
|
elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
|
|
954
970
|
project_id = path.sub("/api/projects/", "").sub("/sessions", "")
|
|
955
971
|
api_delete_project_sessions(project_id, res)
|
|
@@ -968,6 +984,7 @@ module Clacky
|
|
|
968
984
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
969
985
|
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
|
|
970
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 }
|
|
971
988
|
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
972
989
|
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
973
990
|
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
@@ -987,14 +1004,15 @@ module Clacky
|
|
|
987
1004
|
# The archive view is the exception: it groups archived sessions by
|
|
988
1005
|
# project by hand, so project membership must survive both filters.
|
|
989
1006
|
exclude_project = archived ? false : (before ? true : !!type)
|
|
990
|
-
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, archived: archived)
|
|
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)
|
|
991
1008
|
|
|
992
1009
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
993
1010
|
has_more = non_pinned_part.size > limit
|
|
994
1011
|
non_pinned_part = non_pinned_part.first(limit)
|
|
995
1012
|
sessions = pinned_part + non_pinned_part
|
|
996
1013
|
|
|
997
|
-
payload = { sessions: sessions, has_more: has_more,
|
|
1014
|
+
payload = { sessions: sessions, has_more: has_more, next_cursor: non_pinned_part.last&.dig(:cursor),
|
|
1015
|
+
groups: @registry.group_stats_all }
|
|
998
1016
|
# The archive page offers "delete all", so it needs the size of the whole
|
|
999
1017
|
# archive — the paged `sessions` array only ever shows one page of it.
|
|
1000
1018
|
payload[:archived_count] = @registry.archived_count if archived
|
|
@@ -1682,6 +1700,22 @@ module Clacky
|
|
|
1682
1700
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1683
1701
|
end
|
|
1684
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
|
+
|
|
1685
1719
|
# GET /api/backup/status
|
|
1686
1720
|
def api_backup_status(res)
|
|
1687
1721
|
json_response(res, 200, BackupManager.status)
|
|
@@ -2293,38 +2327,66 @@ module Clacky
|
|
|
2293
2327
|
end
|
|
2294
2328
|
|
|
2295
2329
|
# PATCH /api/config/search
|
|
2296
|
-
# Body: { provider
|
|
2330
|
+
# Body: { provider?: "tavily"|"deepseek_web"|"", key?: "..." }
|
|
2331
|
+
# Omitted fields keep their current value.
|
|
2297
2332
|
def api_update_search_config(req, res)
|
|
2298
2333
|
body = parse_json_body(req)
|
|
2299
2334
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2300
2335
|
|
|
2301
2336
|
provider = body["provider"].to_s.strip
|
|
2302
|
-
|
|
2337
|
+
if body.key?("provider") && !search_provider_known?(provider)
|
|
2303
2338
|
return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
|
|
2304
2339
|
end
|
|
2305
2340
|
|
|
2306
2341
|
key = body["key"].to_s
|
|
2307
2342
|
key = Clacky::SearchConfig.key if key.include?("****")
|
|
2308
2343
|
|
|
2309
|
-
Clacky::SearchConfig.
|
|
2310
|
-
|
|
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"] })
|
|
2311
2365
|
rescue => e
|
|
2312
2366
|
json_response(res, 422, { error: e.message })
|
|
2313
2367
|
end
|
|
2314
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
|
+
|
|
2315
2376
|
# POST /api/config/search/test
|
|
2316
|
-
# Runs the
|
|
2317
|
-
# 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.
|
|
2318
2379
|
def api_test_search_config(req, res)
|
|
2319
2380
|
body = parse_json_body(req) || {}
|
|
2320
2381
|
provider = body["provider"].to_s.strip
|
|
2321
2382
|
return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
|
|
2322
2383
|
|
|
2323
|
-
script = Clacky::Utils::SearcherManager.path_for(provider)
|
|
2324
|
-
return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
|
|
2325
|
-
|
|
2326
2384
|
key = body["key"].to_s
|
|
2327
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
|
|
2328
2390
|
|
|
2329
2391
|
stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
|
|
2330
2392
|
{ "CLACKY_SEARCH_KEY" => key },
|
|
@@ -2353,6 +2415,23 @@ module Clacky
|
|
|
2353
2415
|
json_response(res, 200, { ok: false, message: e.message })
|
|
2354
2416
|
end
|
|
2355
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
|
+
|
|
2356
2435
|
# POST /api/internal/ocr-image
|
|
2357
2436
|
# Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
|
|
2358
2437
|
# transcribe a single image via the configured OCR sidecar. Localhost-
|
|
@@ -6034,20 +6113,23 @@ module Clacky
|
|
|
6034
6113
|
json_response(res, 200, { ok: true, session_id: session_id })
|
|
6035
6114
|
end
|
|
6036
6115
|
|
|
6037
|
-
#
|
|
6038
|
-
#
|
|
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".
|
|
6039
6121
|
private def api_trash_sessions_delete(req, res)
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
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?) : []
|
|
6043
6124
|
|
|
6044
|
-
|
|
6125
|
+
if ids.empty?
|
|
6126
|
+
json_response(res, 400, { ok: false, error: "session_ids is required" })
|
|
6127
|
+
return
|
|
6128
|
+
end
|
|
6045
6129
|
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
days_old: days_i
|
|
6050
|
-
})
|
|
6130
|
+
deleted = @session_manager.permanent_delete_trash_sessions(ids)
|
|
6131
|
+
|
|
6132
|
+
json_response(res, 200, { ok: true, deleted_count: deleted })
|
|
6051
6133
|
end
|
|
6052
6134
|
|
|
6053
6135
|
# ── Trash helpers (private) ─────────────────────────────────────
|
|
@@ -7250,6 +7332,45 @@ module Clacky
|
|
|
7250
7332
|
json_response(res, 500, { error: e.message })
|
|
7251
7333
|
end
|
|
7252
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
|
+
|
|
7253
7374
|
private def _delete_project_sessions(project_id)
|
|
7254
7375
|
deleted = 0
|
|
7255
7376
|
@session_manager.all_sessions.each do |session_data|
|
|
@@ -7346,6 +7467,24 @@ module Clacky
|
|
|
7346
7467
|
json_response(res, 500, { error: e.message })
|
|
7347
7468
|
end
|
|
7348
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
|
+
|
|
7349
7488
|
def api_switch_session_model(session_id, req, res)
|
|
7350
7489
|
body = parse_json_body(req)
|
|
7351
7490
|
model_id = body["model_id"].to_s.strip
|
|
@@ -7589,15 +7728,21 @@ module Clacky
|
|
|
7589
7728
|
json_response(res, 201, { session: @registry.snapshot(fork_id) })
|
|
7590
7729
|
end
|
|
7591
7730
|
|
|
7592
|
-
#
|
|
7593
|
-
#
|
|
7594
|
-
# the
|
|
7595
|
-
#
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
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
|
|
7601
7746
|
|
|
7602
7747
|
# `list` just read these ids off disk, so skip the per-id existence
|
|
7603
7748
|
# probe — `load` re-parses every session file for each call.
|
|
@@ -7841,14 +7986,16 @@ module Clacky
|
|
|
7841
7986
|
raw_images = (msg["images"] || []).map do |data_url|
|
|
7842
7987
|
{ "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
|
|
7843
7988
|
end
|
|
7844
|
-
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || []
|
|
7989
|
+
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
|
|
7845
7990
|
|
|
7846
|
-
when "
|
|
7991
|
+
when "feedback_intervene"
|
|
7847
7992
|
session_id = msg["session_id"] || conn.session_id
|
|
7848
|
-
@registry.get(session_id)
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
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
|
|
7852
7999
|
|
|
7853
8000
|
when "confirmation"
|
|
7854
8001
|
session_id = msg["session_id"] || conn.session_id
|
|
@@ -7868,7 +8015,9 @@ module Clacky
|
|
|
7868
8015
|
pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
|
|
7869
8016
|
non_pinned_limit = [10 - pinned_part.size, 0].max
|
|
7870
8017
|
has_more = non_pinned_part.size > non_pinned_limit
|
|
7871
|
-
|
|
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
|
|
7872
8021
|
projects = @project_manager.all
|
|
7873
8022
|
if projects.any?
|
|
7874
8023
|
project_ids = projects.map { |p| p[:id] }
|
|
@@ -7880,7 +8029,7 @@ module Clacky
|
|
|
7880
8029
|
paged_ids = all_sessions.map { |s| s[:id] }.to_set
|
|
7881
8030
|
all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
|
|
7882
8031
|
end
|
|
7883
|
-
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,
|
|
7884
8033
|
groups: groups, projects: projects)
|
|
7885
8034
|
|
|
7886
8035
|
when "run_task"
|
|
@@ -7948,12 +8097,10 @@ module Clacky
|
|
|
7948
8097
|
end
|
|
7949
8098
|
end
|
|
7950
8099
|
|
|
7951
|
-
def handle_user_message(session_id, content, files = [], references: []
|
|
8100
|
+
def handle_user_message(session_id, content, files = [], references: [])
|
|
7952
8101
|
return unless @registry.exist?(session_id)
|
|
7953
8102
|
|
|
7954
8103
|
session = @registry.get(session_id)
|
|
7955
|
-
feedback_options = feedback ? { feedback: feedback } : {}
|
|
7956
|
-
|
|
7957
8104
|
mode = @agent_config.input_behavior
|
|
7958
8105
|
queued = false
|
|
7959
8106
|
queued_created_at = nil
|
|
@@ -7961,7 +8108,7 @@ module Clacky
|
|
|
7961
8108
|
if s[:status] == :running && %w[queue steer].include?(mode)
|
|
7962
8109
|
queued_created_at = Time.now.to_f
|
|
7963
8110
|
s[:agent].enqueue_input(content, delivery: mode.to_sym, files: files, references_display: references,
|
|
7964
|
-
reference_contexts: build_reference_contexts(references), created_at: queued_created_at
|
|
8111
|
+
reference_contexts: build_reference_contexts(references), created_at: queued_created_at)
|
|
7965
8112
|
queued = true
|
|
7966
8113
|
end
|
|
7967
8114
|
end
|
|
@@ -7984,7 +8131,7 @@ module Clacky
|
|
|
7984
8131
|
if old_thread && !old_thread.join(2)
|
|
7985
8132
|
retained_created_at = Time.now.to_f
|
|
7986
8133
|
session[:agent].enqueue_input(content, files: files, references_display: references,
|
|
7987
|
-
reference_contexts: build_reference_contexts(references), created_at: retained_created_at
|
|
8134
|
+
reference_contexts: build_reference_contexts(references), created_at: retained_created_at)
|
|
7988
8135
|
broadcast(session_id, { type: "input_enqueued", session_id: session_id, created_at: retained_created_at })
|
|
7989
8136
|
broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
|
|
7990
8137
|
return
|
|
@@ -8042,7 +8189,7 @@ module Clacky
|
|
|
8042
8189
|
# "Thinking..." forever. A web-originated turn bound to an IM channel gets a
|
|
8043
8190
|
# card too, so tools/narration render like a native IM turn (#603).
|
|
8044
8191
|
@channel_manager.channel_ui_for_session(session_id)&.start_task(reply_to: nil)
|
|
8045
|
-
agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references
|
|
8192
|
+
agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references)
|
|
8046
8193
|
end
|
|
8047
8194
|
end
|
|
8048
8195
|
|
|
@@ -6,7 +6,8 @@ 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
|
|
9
|
+
# Prices are what the platform actually bills: post-promotion rates plus
|
|
10
|
+
# any lane markup. `discount` is present when a promotion applied,
|
|
10
11
|
# carrying both the rate and its reason ("channel" or "promo") so the UI
|
|
11
12
|
# can pick the right explanation.
|
|
12
13
|
module ModelPrices
|
|
@@ -25,12 +26,13 @@ module Clacky
|
|
|
25
26
|
next unless pricing
|
|
26
27
|
info = Clacky::ModelPricing.discount_info(name)
|
|
27
28
|
rate = info ? info[:rate] : 1.0
|
|
29
|
+
markup = Clacky::ModelPricing.markup_rate(name)
|
|
28
30
|
# Rates are not powers of two, so the discounted product carries float
|
|
29
31
|
# noise (3.0 * 0.95 == 2.8499999999999996) that would surface verbatim
|
|
30
32
|
# in the picker tooltips. Four decimals is already finer than any
|
|
31
33
|
# listed price.
|
|
32
|
-
pin = (pricing[:input][:default] * rate).round(4)
|
|
33
|
-
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)
|
|
34
36
|
entry = { in: pin, out: pout, ratio: (pin + pout) / base_total }
|
|
35
37
|
entry[:discount] = { rate: rate, reason: info[:reason] } if rate < 1.0
|
|
36
38
|
prices[name] = entry
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "../agent_config"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module Server
|
|
8
|
+
# Shared directory policy for master lifecycle and CLI discovery.
|
|
9
|
+
module PidFiles
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def directory
|
|
13
|
+
override = ENV["CLACKY_PID_DIR"]
|
|
14
|
+
configured = if override && !override.strip.empty?
|
|
15
|
+
override
|
|
16
|
+
else
|
|
17
|
+
AgentConfig.load.pid_dir
|
|
18
|
+
end
|
|
19
|
+
return Dir.tmpdir if configured.nil? || configured.strip.empty?
|
|
20
|
+
|
|
21
|
+
File.expand_path(configured)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
|
-
|
|
4
|
+
require_relative "pid_files"
|
|
5
5
|
require_relative "../banner"
|
|
6
6
|
require_relative "../version"
|
|
7
7
|
require_relative "../agent_config"
|
|
@@ -248,13 +248,19 @@ module Clacky
|
|
|
248
248
|
end
|
|
249
249
|
|
|
250
250
|
def pid_file_path
|
|
251
|
-
File.join(
|
|
251
|
+
File.join(pid_directory, "clacky-master-#{@port}.pid")
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
def write_pid_file
|
|
255
|
+
FileUtils.mkdir_p(pid_directory)
|
|
255
256
|
File.write(pid_file_path, Process.pid.to_s)
|
|
256
257
|
end
|
|
257
258
|
|
|
259
|
+
# Keep cleanup tied to the directory used at startup, even if config changes.
|
|
260
|
+
private def pid_directory
|
|
261
|
+
@pid_directory ||= PidFiles.directory
|
|
262
|
+
end
|
|
263
|
+
|
|
258
264
|
def remove_pid_file
|
|
259
265
|
# A replacement may already own the port after this master was stopped.
|
|
260
266
|
if File.exist?(pid_file_path) && File.read(pid_file_path).strip == Process.pid.to_s
|
|
@@ -318,7 +324,7 @@ module Clacky
|
|
|
318
324
|
end
|
|
319
325
|
|
|
320
326
|
private def kill_master_on_port(port)
|
|
321
|
-
path = File.join(
|
|
327
|
+
path = File.join(pid_directory, "clacky-master-#{port}.pid")
|
|
322
328
|
return unless File.exist?(path)
|
|
323
329
|
|
|
324
330
|
pid = File.read(path).strip.to_i
|
|
@@ -194,7 +194,8 @@ module Clacky
|
|
|
194
194
|
# profile: "general"|"coding"|nil
|
|
195
195
|
# nil = no agent_profile filter
|
|
196
196
|
# limit: max sessions to return (applies to NON-PINNED only; see below)
|
|
197
|
-
# before:
|
|
197
|
+
# before, before_id: cursor — only sessions sorting after (before, before_id)
|
|
198
|
+
# in the newest-first order; take it from a row's `cursor`
|
|
198
199
|
# (also applies to NON-PINNED only; pinned items are a separate
|
|
199
200
|
# logical section, they should never be paginated away)
|
|
200
201
|
# include_pinned: when true (default), all matching pinned sessions are
|
|
@@ -207,13 +208,13 @@ module Clacky
|
|
|
207
208
|
# archived: false (default) → archived sessions are excluded from the
|
|
208
209
|
# result; true → ONLY archived sessions are returned. The two
|
|
209
210
|
# sets are disjoint, so every other caller keeps seeing exactly
|
|
210
|
-
# the sessions it saw before archiving existed.
|
|
211
|
+
# the sessions it saw before archiving existed. nil → no filter.
|
|
211
212
|
#
|
|
212
213
|
# Ordering of the returned array:
|
|
213
214
|
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
214
215
|
#
|
|
215
216
|
# source and profile are orthogonal — either can be nil independently.
|
|
216
|
-
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, archived: false)
|
|
217
|
+
def list(limit: nil, before: nil, before_id: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, archived: false)
|
|
217
218
|
return [] unless @session_manager
|
|
218
219
|
|
|
219
220
|
live = @mutex.synchronize do
|
|
@@ -227,6 +228,7 @@ module Clacky
|
|
|
227
228
|
model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
|
|
228
229
|
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
|
|
229
230
|
cost_source: live_cost_source,
|
|
231
|
+
permission_mode: s[:agent]&.permission_mode,
|
|
230
232
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
231
233
|
latest_latency: s[:agent]&.latest_latency,
|
|
232
234
|
card_model: model_info&.dig(:card_model),
|
|
@@ -238,7 +240,7 @@ module Clacky
|
|
|
238
240
|
|
|
239
241
|
all = @session_manager.all_sessions # already sorted newest-first
|
|
240
242
|
|
|
241
|
-
all = all.select { |s| archived ? s[:archived_at].to_s != "" : s[:archived_at].to_s == "" }
|
|
243
|
+
all = all.select { |s| archived ? s[:archived_at].to_s != "" : s[:archived_at].to_s == "" } unless archived.nil?
|
|
242
244
|
|
|
243
245
|
# ── type filter (replaces old source/profile split) ──────────────────
|
|
244
246
|
# type=coding → agent_profile == "coding"
|
|
@@ -297,9 +299,11 @@ module Clacky
|
|
|
297
299
|
pinned, non_pinned = all.partition { |s| s[:pinned] }
|
|
298
300
|
|
|
299
301
|
# `before` cursor ONLY applies to non-pinned (paginated) sessions.
|
|
300
|
-
# Cursor
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
# Cursor must match the sort key in all_sessions; the id breaks same-second ties.
|
|
303
|
+
if before
|
|
304
|
+
boundary = [before, before_id.to_s]
|
|
305
|
+
non_pinned = non_pinned.select { |s| (sort_key(s) <=> boundary) < 0 }
|
|
306
|
+
end
|
|
303
307
|
non_pinned = non_pinned.first(limit) if limit
|
|
304
308
|
|
|
305
309
|
# Pinned section: only included on the first page (before == nil) so
|
|
@@ -343,6 +347,7 @@ module Clacky
|
|
|
343
347
|
model: model_info&.dig(:model), model_id: model_info&.dig(:id),
|
|
344
348
|
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
345
349
|
total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
|
|
350
|
+
permission_mode: s[:agent]&.permission_mode,
|
|
346
351
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
347
352
|
latest_latency: s[:agent]&.latest_latency,
|
|
348
353
|
card_model: model_info&.dig(:card_model),
|
|
@@ -361,6 +366,10 @@ module Clacky
|
|
|
361
366
|
# Merge a single disk-side session hash with the corresponding live
|
|
362
367
|
# in-memory agent fields (may be nil) into the row shape the frontend
|
|
363
368
|
# consumes.
|
|
369
|
+
private def sort_key(s)
|
|
370
|
+
[s[:updated_at] || s[:created_at] || "", s[:session_id].to_s]
|
|
371
|
+
end
|
|
372
|
+
|
|
364
373
|
private def build_enriched_row(s, ls)
|
|
365
374
|
id = s[:session_id]
|
|
366
375
|
{
|
|
@@ -373,6 +382,7 @@ module Clacky
|
|
|
373
382
|
raw_message: ls&.dig(:raw_message),
|
|
374
383
|
model: ls&.dig(:model),
|
|
375
384
|
model_id: ls&.dig(:model_id),
|
|
385
|
+
permission_mode: ls&.dig(:permission_mode) || s.dig(:config, :permission_mode_override) || "confirm_all",
|
|
376
386
|
card_model: ls&.dig(:card_model),
|
|
377
387
|
sub_model: ls&.dig(:sub_model),
|
|
378
388
|
sub_model_options: ls&.dig(:sub_model_options) || [],
|
|
@@ -395,6 +405,8 @@ module Clacky
|
|
|
395
405
|
channel_info: s[:channel_info],
|
|
396
406
|
project_id: s[:project_id],
|
|
397
407
|
todos: ls&.dig(:todos) || s[:todos] || [],
|
|
408
|
+
# Rows report live activity in updated_at; paging needs the on-disk sort key.
|
|
409
|
+
cursor: sort_key(s).then { |at, sid| { before: at, before_id: sid } },
|
|
398
410
|
}
|
|
399
411
|
end
|
|
400
412
|
|