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
data/lib/clacky/agent.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "agent/skill_evolution"
24
24
  require_relative "agent/skill_reflector"
25
25
  require_relative "agent/skill_auto_creator"
26
26
  require_relative "agent/fake_tool_call_detector"
27
+ require_relative "agent/feedback"
27
28
  require_relative "agent/goal_state"
28
29
  require_relative "agent/goal_manager"
29
30
 
@@ -31,6 +32,7 @@ module Clacky
31
32
  class Agent
32
33
  # Include all functionality modules
33
34
  include MessageCompressorHelper
35
+ include Feedback
34
36
  include ToolExecutor
35
37
  include CostTracker
36
38
  include SessionSerializer
@@ -52,8 +54,18 @@ module Clacky
52
54
  attr_accessor :pinned
53
55
  attr_accessor :channel_info
54
56
  attr_accessor :project_id
57
+ # ISO8601 stamp set when the user archives the session; nil = active.
58
+ # Archived sessions stay on disk but drop out of every normal list.
59
+ attr_accessor :archived_at
55
60
 
56
61
  REASONING_EFFORTS = %w[low medium high xhigh max].freeze
62
+ # Sessionbar/config values meaning "no explicit level". They stay a string
63
+ # instead of collapsing to nil so format adapters can tell an explicit
64
+ # "default" apart from "user never chose": for models whose provider
65
+ # default is the deepest level (MiniMax M3.1-Flash, GLM-5.3) the adapters
66
+ # map this value to their lightest effort.
67
+ REASONING_DEFAULT = "off"
68
+ REASONING_DEFAULT_ALIASES = %w[off none disabled nothink].freeze
57
69
  MAX_VIDEO_UNDERSTANDING_BYTES = Utils::FileProcessor::MAX_FILE_BYTES
58
70
  MAX_VIDEO_BASE64_BYTES = 45 * 1024 * 1024
59
71
  MAX_VIDEO_DESCRIPTION_CHARS = 500
@@ -68,6 +80,16 @@ module Clacky
68
80
  @config&.permission_mode&.to_s || ""
69
81
  end
70
82
 
83
+ # Persist an explicit session choice separately from temporary goal mode.
84
+ def permission_mode=(value)
85
+ mode = value.to_s
86
+ raise ArgumentError, "Invalid permission mode" unless %w[confirm_all auto_approve].include?(mode)
87
+ @permission_mode_override = mode
88
+ @config.permission_mode = mode.to_sym
89
+ @pre_goal_permission_mode = nil
90
+ notify_permission_mode_change(mode.to_sym)
91
+ end
92
+
71
93
  def reasoning_effort=(value)
72
94
  @reasoning_effort = normalize_reasoning_effort(value)
73
95
  end
@@ -75,7 +97,8 @@ module Clacky
75
97
  private def normalize_reasoning_effort(value)
76
98
  return nil if value.nil?
77
99
  str = value.to_s.strip.downcase
78
- return nil if str.empty? || str == "off" || str == "none"
100
+ return nil if str.empty?
101
+ return REASONING_DEFAULT if REASONING_DEFAULT_ALIASES.include?(str)
79
102
  return str if REASONING_EFFORTS.include?(str)
80
103
  nil
81
104
  end
@@ -85,6 +108,7 @@ module Clacky
85
108
  def initialize(client, config, working_dir:, ui:, profile:, session_id:, source:)
86
109
  @client = client # Client for current model
87
110
  @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
111
+ @client_options = @config.client_options
88
112
  @agent_profile = AgentProfile.load(profile)
89
113
  @source = source.to_sym # :manual | :cron | :channel
90
114
  @channel_info = nil # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
@@ -93,6 +117,7 @@ module Clacky
93
117
  @session_id = session_id
94
118
  @name = ""
95
119
  @pinned = false
120
+ @archived_at = nil
96
121
  @history = MessageHistory.new
97
122
  @todos = [] # Store todos in memory
98
123
  @iterations = 0
@@ -113,7 +138,7 @@ module Clacky
113
138
  @task_cost_source = :estimated # Track cost source for current task
114
139
  @previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
115
140
  @latest_latency = nil # Most recent LLM call's latency metrics (see Client#send_messages_with_tools)
116
- @reasoning_effort = nil # Per-session reasoning effort override; nil = provider default
141
+ @reasoning_effort = REASONING_DEFAULT # Sessionbar shows "Default" for a fresh session; adapters resolve it per model
117
142
  @ui = ui # UIController for direct UI interaction
118
143
  @debug_logs = [] # Debug logs for troubleshooting
119
144
  @input_mutex = Mutex.new
@@ -235,23 +260,25 @@ module Clacky
235
260
  # Rebuild the underlying Client (and dependent components) to pick up
236
261
  # credentials/model name from the currently-selected model in @config.
237
262
  private def rebuild_client_for_current_model!
238
- entry = @config.current_model
239
- @client = Clacky::Client.new(
240
- @config.api_key,
241
- base_url: @config.base_url,
242
- model: @config.model_name,
243
- anthropic_format: @config.anthropic_format?,
244
- api_format: @config.api_format,
245
- provider_id: @config.provider_id_for(entry),
246
- capabilities: entry && entry["capabilities"]
247
- )
248
- # Update message compressor with new client and model
249
- @message_compressor = MessageCompressor.new(@client, model: current_model)
263
+ build_client!
250
264
 
251
265
  # Inject a new session context to notify the AI of the model switch
252
266
  inject_session_context
253
267
  end
254
268
 
269
+ private def build_client!
270
+ @client_options = @config.client_options
271
+ opts = @client_options.dup
272
+ @client = Clacky::Client.new(opts.delete(:api_key), **opts)
273
+ # Update message compressor with new client and model
274
+ @message_compressor = MessageCompressor.new(@client, model: current_model)
275
+ end
276
+
277
+ # The shared model card can be edited (Settings, config reload) under a live session.
278
+ private def refresh_client_if_card_changed!
279
+ build_client! unless @config.client_options == @client_options
280
+ end
281
+
255
282
  # Change the working directory for this session
256
283
  # Injects a new session context to notify the AI of the directory change
257
284
  def change_working_dir(new_dir)
@@ -329,6 +356,14 @@ module Clacky
329
356
  self
330
357
  end
331
358
 
359
+ # Built-in run failures share the persisted event trail, without exposing
360
+ # built-in event types through the extension API.
361
+ def record_error_event(message, code: nil, top_up_url: nil, raw_message: nil)
362
+ data = { message: message, code: code, top_up_url: top_up_url, raw_message: raw_message }.compact
363
+ @history.append_ext_event({ type: "error", data: data })
364
+ self
365
+ end
366
+
332
367
  # Resolve the model name the goal judge should use. Prefer the provider's
333
368
  # lite model (cheap/fast for a one-line verdict); fall back to the primary
334
369
  # model when no lite is resolvable.
@@ -757,7 +792,7 @@ module Clacky
757
792
  @ui&.show_token_usage(response[:token_usage]) if response[:token_usage]
758
793
 
759
794
  # Act: Execute tool calls
760
- action_result = act(response[:tool_calls])
795
+ action_result = act(response[:tool_calls], assistant_message: response.delete(:history_message))
761
796
 
762
797
  # Check if ask_user was called
763
798
  if action_result[:awaiting_feedback]
@@ -928,6 +963,33 @@ module Clacky
928
963
  private def append_user_input(user_input, files: nil, reference_contexts: nil,
929
964
  display_text: nil, created_at: nil, references_display: nil,
930
965
  task_id: @current_task_id)
966
+ # Resolve lightweight metadata first, then commit the accepted user turn
967
+ # before any attachment parser/OCR/sidecar work can block or fail. Session
968
+ # switching and graceful shutdown both read from @history, so the turn
969
+ # must already be authoritative while those slow operations are running.
970
+ skill_command = parse_skill_command(user_input)
971
+ skill_command_display = if skill_command[:found] && skill_command[:skill]
972
+ skill_command[:skill].display_name(Thread.current[:lang])
973
+ end
974
+ created_at ||= Time.now.to_f
975
+ message_id = SecureRandom.uuid
976
+ staged = Array(files).any?
977
+ staged_display_files = display_files_for(files)
978
+ message_metadata = {
979
+ role: "user", message_id: message_id, task_id: task_id, created_at: created_at,
980
+ display_text: display_text,
981
+ skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
982
+ skill_command_display: skill_command_display,
983
+ display_references: Array(references_display).empty? ? nil : references_display
984
+ }
985
+ if staged
986
+ @history.append(message_metadata.merge(
987
+ content: user_input,
988
+ display_files: staged_display_files.empty? ? nil : staged_display_files
989
+ ))
990
+ staged_message_id = message_id
991
+ end
992
+
931
993
  # Split files into vision images and disk files; downgrade oversized images to disk
932
994
  image_files, disk_files = partition_files(Array(files))
933
995
  vision_images, downgraded = resolve_vision_images(image_files)
@@ -999,37 +1061,21 @@ module Clacky
999
1061
  # stored here — they recover from image_url blocks in user_content. Downgraded
1000
1062
  # images (provider has no vision / too large / OCR'd) DO need path here so the
1001
1063
  # UI can re-render them from the on-disk copy across session switches.
1002
- display_files = all_disk_files.filter_map do |f|
1003
- # @mention file/directory references are replayed from display_references
1004
- # (with mention badges), so skip them here to avoid double-rendering as
1005
- # plain attachment badges.
1006
- next if f[:reference] || f["reference"]
1007
- name = f[:name] || f["name"]
1008
- next unless name
1009
- { name: name, type: f[:type] || f["type"] || "file",
1010
- path: f[:path] || f["path"],
1011
- preview_path: f[:preview_path] || f["preview_path"] }
1064
+ display_files = display_files_for(all_disk_files)
1065
+ resolved_message = message_metadata.merge(
1066
+ content: user_content,
1067
+ display_files: display_files.empty? ? nil : display_files
1068
+ )
1069
+ if staged
1070
+ replaced = @history.replace_user_message(
1071
+ message_id: message_id,
1072
+ message: resolved_message
1073
+ )
1074
+ raise AgentError, "Accepted user message disappeared during attachment processing" unless replaced
1075
+ else
1076
+ @history.append(resolved_message)
1012
1077
  end
1013
1078
 
1014
- # Resolved once here (not after append) so the user message can carry the
1015
- # confirmed skill name: only a skill that actually dispatches gets marked,
1016
- # so the UI never highlights a typo'd or unavailable command. The display
1017
- # name is resolved against the client's language (Thread.current[:lang],
1018
- # seeded from the WS message / X-Lang header) so the Web UI and third-party
1019
- # clients can render a localized label without re-resolving the skill.
1020
- skill_command = parse_skill_command(user_input)
1021
- skill_command_display = if skill_command[:found] && skill_command[:skill]
1022
- skill_command[:skill].display_name(Thread.current[:lang])
1023
- end
1024
-
1025
- created_at ||= Time.now.to_f
1026
- @history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
1027
- display_text: display_text,
1028
- skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
1029
- skill_command_display: skill_command_display,
1030
- display_files: display_files.empty? ? nil : display_files,
1031
- display_references: Array(references_display).empty? ? nil : references_display })
1032
-
1033
1079
  # Inject disk file references as a system_injected message so:
1034
1080
  # - LLM sees the file info (system_injected is NOT stripped from to_api)
1035
1081
  # - replay_history skips it (next if ev[:system_injected]), keeping the user bubble clean
@@ -1144,6 +1190,36 @@ module Clacky
1144
1190
  # skill-declared model not found) still stops the progress spinner.
1145
1191
  inject_skill_command_as_assistant_message(skill_command, task_id)
1146
1192
 
1193
+ rescue Clacky::AgentInterrupted, StandardError
1194
+ # The staged turn exists only to make an accepted upload replayable while
1195
+ # attachment work is in progress. If that work crashes, remove this turn
1196
+ # and anything injected after it so the next request cannot inherit a
1197
+ # half-resolved attachment context. Parser failures returned as FileRef
1198
+ # metadata do not raise and therefore remain visible/actionable.
1199
+ @history.truncate_from_message(staged_message_id) if staged_message_id
1200
+ raise
1201
+ end
1202
+
1203
+ # Build replay-only attachment metadata without parsing file contents.
1204
+ # @mention references render through display_references and must not also
1205
+ # appear as plain attachment badges.
1206
+ private def display_files_for(files)
1207
+ Array(files).filter_map do |file|
1208
+ next if file[:reference] || file["reference"]
1209
+
1210
+ name = file[:name] || file["name"]
1211
+ next unless name
1212
+
1213
+ mime_type = file[:mime_type] || file["mime_type"]
1214
+ type = file[:type] || file["type"]
1215
+ type ||= "image" if mime_type.to_s.start_with?("image/")
1216
+ {
1217
+ name: name,
1218
+ type: type || "file",
1219
+ path: file[:path] || file["path"],
1220
+ preview_path: file[:preview_path] || file["preview_path"]
1221
+ }
1222
+ end
1147
1223
  end
1148
1224
 
1149
1225
  def enqueue_input(content, delivery: :queue, **options)
@@ -1482,7 +1558,7 @@ module Clacky
1482
1558
  # "reasoning_content must be passed back" contract.
1483
1559
  msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
1484
1560
  check_stale!
1485
- @history.append(msg)
1561
+ @history.append(msg) { |stored_message| response[:history_message] = stored_message }
1486
1562
 
1487
1563
  # Close the thinking spinner before returning. The caller (run loop)
1488
1564
  # is about to render the assistant message and/or tool invocations,
@@ -1506,7 +1582,7 @@ module Clacky
1506
1582
  raise Clacky::AgentInterrupted, "Task superseded by a newer task on another thread"
1507
1583
  end
1508
1584
 
1509
- private def act(tool_calls)
1585
+ private def act(tool_calls, assistant_message: nil)
1510
1586
  return { denied: false, feedback: nil, tool_results: [], awaiting_feedback: false } unless tool_calls
1511
1587
 
1512
1588
  denied = false
@@ -1687,20 +1763,21 @@ module Clacky
1687
1763
  # result path so the model sees the error and can retry.
1688
1764
  if Tools::AskUser.feedback_tool?(call[:name]) &&
1689
1765
  result.is_a?(Hash) && result[:awaiting_feedback]
1690
- # Pass the raw call arguments to show_tool_call so the WebUI controller
1691
- # can extract the questions and emit a "request_feedback" event
1692
- # (renders as a clickable card in the browser).
1693
- # Fallback UIs (terminal, IM channels) receive the formatted text message.
1694
- @ui&.show_tool_call(call[:name], call[:arguments])
1766
+ # Prepare and persist the complete request before displaying it or
1767
+ # starting the wait. Extensions and native questions share one result.
1768
+ request = prepare_feedback_request(call, result, assistant_message: assistant_message)
1769
+ @ui&.show_tool_call(call[:name], request)
1695
1770
 
1696
1771
  if @config.permission_mode == :auto_approve
1697
1772
  # auto_approve means the agent runs unattended by default, but a
1698
1773
  # human MAY be watching the terminal. Show a short interactive
1699
1774
  # countdown: if the user steps in, hand control over and wait for
1700
1775
  # their answer; otherwise auto-decide and keep going.
1701
- countdown = @ui&.request_feedback_with_countdown(seconds: 10)
1776
+ countdown = @ui&.request_feedback_with_countdown(seconds: @config.ask_user_timeout_seconds)
1702
1777
 
1703
- if @ui.nil? || countdown == :timeout
1778
+ if countdown == :timeout && @config.permission_mode != :auto_approve
1779
+ awaiting_feedback = true
1780
+ elsif @ui.nil? || countdown == :timeout
1704
1781
  result = result.merge(
1705
1782
  auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1706
1783
  )
@@ -2038,8 +2115,11 @@ module Clacky
2038
2115
  @tool_registry.register(Tools::Edit.new)
2039
2116
  @tool_registry.register(Tools::Glob.new)
2040
2117
  @tool_registry.register(Tools::Grep.new)
2041
- @tool_registry.register(Tools::WebSearch.new)
2042
- @tool_registry.register(Tools::WebFetch.new)
2118
+ [Tools::WebSearch, Tools::WebFetch].each do |klass|
2119
+ tool = klass.new
2120
+ tool.agent = self
2121
+ @tool_registry.register(tool)
2122
+ end
2043
2123
  @tool_registry.register(Tools::TodoManager.new)
2044
2124
  @tool_registry.register(Tools::AskUser.new)
2045
2125
  @tool_registry.register(Tools::InvokeSkill.new)
@@ -158,19 +158,21 @@ module Clacky
158
158
  DEFAULT_COMPRESSION_THRESHOLD = 150_000
159
159
  DEFAULT_MESSAGE_COUNT_THRESHOLD = 200
160
160
 
161
- attr_accessor :input_behavior, :permission_mode, :max_tokens, :verbose,
161
+ attr_accessor :ask_user_timeout_seconds, :input_behavior, :permission_mode, :max_tokens, :verbose,
162
162
  :enable_compression, :enable_idle_compression, :enable_prompt_caching,
163
163
  :compression_threshold, :message_count_threshold,
164
164
  :models, :current_model_index, :current_model_id,
165
165
  :memory_update_enabled, :skill_evolution,
166
166
  :max_running_agents, :max_idle_agents,
167
- :default_working_dir,
167
+ :default_working_dir, :pid_dir,
168
168
  :proxy_url,
169
169
  :clacky_license_server
170
170
 
171
171
  def initialize(options = {})
172
172
  @permission_mode = validate_permission_mode(options[:permission_mode])
173
173
  @input_behavior = %w[queue steer interrupt].include?(options[:input_behavior].to_s) ? options[:input_behavior].to_s : "queue"
174
+ timeout = options.fetch(:ask_user_timeout_seconds, 20)
175
+ @ask_user_timeout_seconds = timeout.is_a?(Integer) && timeout >= 0 ? timeout : 20
174
176
  @max_tokens = options[:max_tokens] || 16384
175
177
  @verbose = options[:verbose] || false
176
178
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
@@ -220,6 +222,7 @@ module Clacky
220
222
  @max_idle_agents = options[:max_idle_agents] || 10
221
223
 
222
224
  @default_working_dir = options[:default_working_dir] || ENV["CLACKY_WORKSPACE_DIR"]
225
+ @pid_dir = options[:pid_dir]
223
226
 
224
227
  # HTTP proxy policy. The user's shell ENV (HTTP_PROXY etc.) is always
225
228
  # ignored — set proxy_url here to route Clacky's outbound HTTP through
@@ -455,9 +458,9 @@ module Clacky
455
458
  CONFIG_SETTINGS_KEYS = %w[
456
459
  enable_compression enable_idle_compression enable_prompt_caching
457
460
  compression_threshold message_count_threshold
458
- memory_update_enabled input_behavior
461
+ memory_update_enabled input_behavior ask_user_timeout_seconds
459
462
  skill_evolution max_running_agents max_idle_agents
460
- default_working_dir
463
+ default_working_dir pid_dir
461
464
  proxy_url clacky_license_server
462
465
  ].freeze
463
466
 
@@ -470,6 +473,7 @@ module Clacky
470
473
  end
471
474
  settings = {
472
475
  "input_behavior" => @input_behavior,
476
+ "ask_user_timeout_seconds" => @ask_user_timeout_seconds,
473
477
  "enable_compression" => @enable_compression,
474
478
  "enable_idle_compression" => @enable_idle_compression,
475
479
  "enable_prompt_caching" => @enable_prompt_caching,
@@ -480,6 +484,7 @@ module Clacky
480
484
  "max_running_agents" => @max_running_agents,
481
485
  "max_idle_agents" => @max_idle_agents,
482
486
  "default_working_dir" => @default_working_dir,
487
+ "pid_dir" => @pid_dir,
483
488
  "proxy_url" => @proxy_url,
484
489
  "clacky_license_server" => @clacky_license_server
485
490
  }
@@ -715,6 +720,20 @@ module Clacky
715
720
  @models.find { |m| m["type"] == type }
716
721
  end
717
722
 
723
+ # Everything Client.new needs from the current card; also the staleness key for a live Client.
724
+ def client_options
725
+ entry = current_model
726
+ {
727
+ api_key: api_key,
728
+ base_url: base_url,
729
+ model: model_name,
730
+ anthropic_format: anthropic_format?,
731
+ api_format: api_format,
732
+ provider_id: provider_id_for(entry),
733
+ capabilities: entry && entry["capabilities"]
734
+ }
735
+ end
736
+
718
737
  # Resolve the provider id for a model entry. An explicit `provider_id`
719
738
  # naming a known preset wins over base_url/api_key heuristics — that lets
720
739
  # a custom base_url inherit a preset's lineup (media sidecars, OCR models,
@@ -731,6 +750,65 @@ module Clacky
731
750
  )
732
751
  end
733
752
 
753
+ # Find the API key to use for DeepSeek web search / page fetch.
754
+ # Resolution order:
755
+ # 1. Explicit key stored in ~/.clacky/search.yml, when DeepSeek is selected
756
+ # 2. Key from the model in use, when its provider declares deepseek_web_tools
757
+ # 3. Key from any other configured model declaring the same capability
758
+ # 4. nil if none is configured
759
+ def deepseek_web_key
760
+ if Clacky::SearchConfig.deepseek_web?
761
+ explicit = Clacky::SearchConfig.key.to_s.strip
762
+ return explicit unless explicit.empty?
763
+ end
764
+
765
+ entry = web_tool_candidates.find do |m|
766
+ next false unless m.is_a?(Hash)
767
+ next false if m["api_key"].to_s.strip.empty?
768
+ Clacky::Providers.deepseek_web_tools?(provider_id_for(m))
769
+ end
770
+ entry ? entry["api_key"].to_s.strip : nil
771
+ end
772
+
773
+ # True when the paid DeepSeek web tools are usable: they are the selected
774
+ # search provider and a key could be resolved.
775
+ def deepseek_web_ready?
776
+ return false unless Clacky::SearchConfig.deepseek_web?
777
+
778
+ !deepseek_web_key.to_s.strip.empty?
779
+ rescue StandardError
780
+ false
781
+ end
782
+
783
+ # Endpoint base URL for DeepSeek web tools given the resolved key.
784
+ # If the key belongs to an OpenClacky model entry (or starts with clacky-),
785
+ # route to that model's base_url so the platform gateway can proxy and bill
786
+ # through the unified balance. Otherwise direct to DeepSeek official.
787
+ def deepseek_web_base_url(resolved_key = nil)
788
+ key = resolved_key || deepseek_web_key
789
+ default_url = Clacky::DeepSeekWebTools::DEFAULT_BASE_URL
790
+ return default_url if key.nil? || key.empty?
791
+
792
+ matching = web_tool_candidates.find { |m| m.is_a?(Hash) && m["api_key"].to_s.strip == key }
793
+ if matching
794
+ pid = provider_id_for(matching)
795
+ if pid == Clacky::Providers::OPENCLACKY_ID && matching["base_url"].to_s.strip != ""
796
+ return matching["base_url"].to_s.chomp("/")
797
+ end
798
+ elsif key.start_with?("clacky-")
799
+ return Clacky::Providers::PRESETS.fetch(Clacky::Providers::OPENCLACKY_ID)["base_url"].chomp("/")
800
+ end
801
+
802
+ default_url
803
+ end
804
+
805
+ # Current model first (so a session-level switch or overlay wins), then the
806
+ # rest of the configured models as fallback.
807
+ private def web_tool_candidates
808
+ current = current_model
809
+ current ? [current] + @models : @models
810
+ end
811
+
734
812
  private def derive_media_model(kind, model_override: nil)
735
813
  anchor = current_model || find_model_by_type("default")
736
814
  return nil unless anchor
@@ -42,15 +42,20 @@ module Clacky
42
42
  "abs-claude-sonnet-5" => "global.anthropic.claude-sonnet-5",
43
43
  "abs-claude-sonnet-4-6" => "global.anthropic.claude-sonnet-4-6",
44
44
  "abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
45
+ "abs-claude-haiku-5-5" => "global.anthropic.claude-haiku-5-5",
45
46
  "abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
46
- # GLM / Kimi / MiniMax via TokHub (oc-). The gateway books these under
47
- # the alias itself, so the mapping is an identity — it exists so the
48
- # billing UI can resolve the alias instead of dropping the record as
49
- # unknown.
47
+ # GLM / Kimi / Grok / GPT via TokHub (oc-). The gateway books these under the
48
+ # alias itself, so the mapping is an identity — it exists so the billing UI
49
+ # can resolve the alias instead of dropping the record as unknown.
50
50
  "oc-glm-5.3" => "oc-glm-5.3",
51
51
  "oc-kimi-k3" => "oc-kimi-k3",
52
- "oc-minimax-m2.7" => "oc-minimax-m2.7",
52
+ "oc-gpt-6.1-sol" => "oc-gpt-6.1-sol",
53
+ "oc-gpt-6-astra" => "oc-gpt-6-astra",
54
+ "oc-gpt-6-sol" => "oc-gpt-6-sol",
55
+ "oc-gpt-6-luna" => "oc-gpt-6-luna",
56
+ "oc-grok-4.7" => "oc-grok-4.7",
53
57
  # gemini chat (or-)
58
+ "or-gemini-4-argon" => "gemini-4-argon",
54
59
  "or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
55
60
  "or-gemini-3-8-flash" => "gemini-3.8-flash",
56
61
  "or-gemini-3-7-flash" => "gemini-3.7-flash",
@@ -683,7 +683,7 @@ module Clacky
683
683
  already_exists = body["code"].to_s.include?("taken") ||
684
684
  body["code"].to_s.include?("already") ||
685
685
  result[:error].to_s.include?("HTTP 409")
686
- { success: false, error: msg, already_exists: already_exists }
686
+ { success: false, error: msg, already_exists: already_exists, error_code: code }
687
687
  end
688
688
  rescue StandardError => e
689
689
  { success: false, error: "Network error: #{e.message}" }
data/lib/clacky/cli.rb CHANGED
@@ -346,8 +346,9 @@ module Clacky
346
346
  end
347
347
 
348
348
  # Handle the `/think` slash command — pick the reasoning effort level
349
- # for the current session. "off" normalizes to nil (provider default),
350
- # matching the Web UI's reasoning_effort switcher semantics.
349
+ # for the current session. "off" is kept as an explicit choice so model
350
+ # adapters can resolve it per family (MiniMax Flash and GLM-5.3 turn it
351
+ # into their lightest effort instead of the provider's deepest default).
351
352
  private def handle_think_command(ui_controller, agent, session_manager = nil)
352
353
  choice = ui_controller.show_reasoning_effort_menu(agent.reasoning_effort)
353
354
  return if choice.nil?
@@ -481,8 +482,8 @@ module Clacky
481
482
  private def auto_name_session(agent, input)
482
483
  return unless agent.history.empty?
483
484
 
484
- auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 30]
485
- auto_name += "…" if input.to_s.strip.length > 30
485
+ auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 80]
486
+ auto_name += "…" if input.to_s.strip.length > 80
486
487
  agent.rename(auto_name)
487
488
  end
488
489
 
data/lib/clacky/client.rb CHANGED
@@ -56,6 +56,13 @@ module Clacky
56
56
  @read_timeout = read_timeout
57
57
  end
58
58
 
59
+ # Console URL where the user can inspect credit / API key state. nil when
60
+ # the provider has no preset (custom base_url).
61
+ def account_url
62
+ preset = Providers.get(@provider_id)
63
+ preset && preset["website_url"]
64
+ end
65
+
59
66
  # Returns true when the client is using the AWS Bedrock Converse API.
60
67
  def bedrock?
61
68
  @use_bedrock
@@ -122,7 +129,7 @@ module Clacky
122
129
  response = bedrock_connection.post(bedrock_endpoint(api_model)) { |r| r.body = body.to_json }
123
130
  parse_simple_bedrock_response(response)
124
131
  elsif anthropic_format?
125
- body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
132
+ body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false, reasoning_effort: reasoning_effort)
126
133
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
127
134
  parse_simple_anthropic_response(response)
128
135
  elsif responses_format?
@@ -349,10 +356,10 @@ module Clacky
349
356
  end
350
357
 
351
358
  unless response.status == 200
352
- recovered_body = response.body.to_s
353
- recovered_body = sse_buf.to_s if recovered_body.empty?
354
- recovered = Struct.new(:status, :body).new(response.status, recovered_body)
355
- raise_error(recovered)
359
+ # Recover the streamed error body on the original response so headers
360
+ # remain available to error classification and routed-tier attribution.
361
+ response.env.body = sse_buf if response.body.to_s.empty?
362
+ raise_error(response)
356
363
  end
357
364
 
358
365
  result = aggregator.to_h