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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +5 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/extension/loader.rb +5 -3
  21. data/lib/clacky/locales/en.rb +5 -5
  22. data/lib/clacky/locales/zh.rb +5 -5
  23. data/lib/clacky/message_format/anthropic.rb +20 -0
  24. data/lib/clacky/message_format/open_ai.rb +31 -14
  25. data/lib/clacky/message_history.rb +21 -3
  26. data/lib/clacky/platform_http_client.rb +6 -2
  27. data/lib/clacky/providers.rb +23 -14
  28. data/lib/clacky/search_config.rb +47 -13
  29. data/lib/clacky/server/browser_manager.rb +35 -5
  30. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  31. data/lib/clacky/server/discover.rb +5 -4
  32. data/lib/clacky/server/http_server.rb +194 -47
  33. data/lib/clacky/server/model_prices.rb +5 -3
  34. data/lib/clacky/server/pid_files.rb +25 -0
  35. data/lib/clacky/server/server_master.rb +9 -3
  36. data/lib/clacky/server/session_registry.rb +19 -7
  37. data/lib/clacky/server/web_ui_controller.rb +61 -38
  38. data/lib/clacky/session_manager.rb +7 -1
  39. data/lib/clacky/skill.rb +1 -1
  40. data/lib/clacky/tools/ask_user.rb +4 -1
  41. data/lib/clacky/tools/base.rb +3 -2
  42. data/lib/clacky/tools/browser.rb +18 -2
  43. data/lib/clacky/tools/file_reader.rb +1 -1
  44. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  45. data/lib/clacky/tools/terminal.rb +65 -23
  46. data/lib/clacky/tools/trash_manager.rb +5 -3
  47. data/lib/clacky/tools/web_fetch.rb +81 -22
  48. data/lib/clacky/tools/web_search.rb +45 -4
  49. data/lib/clacky/ui2/ui_controller.rb +1 -1
  50. data/lib/clacky/ui_interface.rb +4 -3
  51. data/lib/clacky/utils/file_processor.rb +51 -0
  52. data/lib/clacky/utils/model_pricing.rb +132 -13
  53. data/lib/clacky/version.rb +1 -1
  54. data/lib/clacky/web/app.css +366 -105
  55. data/lib/clacky/web/components/chat-navigator.js +8 -1
  56. data/lib/clacky/web/components/mentions.js +2 -13
  57. data/lib/clacky/web/components/model-picker.js +3 -1
  58. data/lib/clacky/web/components/onboard.js +13 -10
  59. data/lib/clacky/web/features/archive/store.js +30 -16
  60. data/lib/clacky/web/features/archive/view.js +20 -10
  61. data/lib/clacky/web/features/backup/view.js +5 -5
  62. data/lib/clacky/web/features/new-session/store.js +3 -3
  63. data/lib/clacky/web/features/trash/store.js +10 -7
  64. data/lib/clacky/web/features/trash/view.js +9 -4
  65. data/lib/clacky/web/i18n.js +106 -30
  66. data/lib/clacky/web/index.html +90 -60
  67. data/lib/clacky/web/projects.js +56 -1
  68. data/lib/clacky/web/sessions.js +244 -131
  69. data/lib/clacky/web/settings.js +143 -8
  70. data/lib/clacky/web/ws-dispatcher.js +9 -13
  71. data/lib/clacky.rb +1 -0
  72. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a87f3bff7a9f13d77ae6ae11ccc67a67a6429d596d922309e3f3847469b240f4
4
- data.tar.gz: 6f7043a63e27dfb922bac4e586ec19c6d0659efb75ba9ef7e1b522b1a16b9410
3
+ metadata.gz: a120f8d1189892876508f1c49309b7e4a21652c9293499211444b6e42770f649
4
+ data.tar.gz: 2c02a199d26ae835426fa5ce8fb65665ac3c342aa7b717337c3e72470fd868f7
5
5
  SHA512:
6
- metadata.gz: 23cc0c1d6902579a09f2cb3c9b9f692c61dcb485daca62ff226ea6fa1c165afa141fbee8ee51de24e97ec4d7060bc872988c6f8b785575180f821a52e68acc26
7
- data.tar.gz: d198a4ed2d6887094fe8f90fc1d13a429cd06008f47859acb7b334804d5d8dfc727f31dba6486ae02d11613bfd0dbe2f0004c83090a8e7f457d31891b4e29fae
6
+ metadata.gz: aed2dcb3fb47de8a9fc9b20cccbae1f45e70338b37a44e5edbea6ec54c8c3065a27976a2ea5cb2b34ac209cbc3d53c8b6866598deb1a78ff5194ecbef6c0d66e
7
+ data.tar.gz: 948e67f2dc8f003e55daea5f8e4d790e5368ef58eec63489d317e8be4336e2e04a9db498bdb1e362005a66b502f930a1dd5c88e9da6607863d9dfc4b06986feb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.21] - 2026-10-22
8
+
9
+ ### Added
10
+ - Switch the current session between Manual and Auto approval from the composer, even while a task is running
11
+ - Wait 20 seconds on `ask_user` in auto mode before the agent decides, with the delay configurable through `ask_user_timeout_seconds`
12
+ - Add DeepSeek hosted web search and page fetch
13
+ - Archive every session in a project at once
14
+ - Add MiniMax M3.1 Flash
15
+ - Support multiline terminal commands (#641 - @YeEmrick)
16
+ - Fall back to a dependency-free PNG256 encoder when no image tool is available (#668 - @YeEmrick)
17
+ - Configure where the server writes its PID file (#663)
18
+ - Explain an unreachable license server where the license is entered
19
+ - Point extension publishers at the contributor page
20
+ - Prepare and persist shared feedback requests (#649)
21
+
22
+ ### Improved
23
+ - Downscale oversized browser screenshots so they cost fewer tokens
24
+ - Raise the file reader per-line limit to 8000 characters (#645 - @YeEmrick)
25
+ - Spell out the billing basis in price tooltips
26
+ - Tidy the session search palette and its content-match snippets
27
+
28
+ ### Fixed
29
+ - Refresh data management lists after archiving or deleting from the sidebar (#673)
30
+ - Make bulk delete in the recycle bin and archive respect the active filters (#672)
31
+ - Keep routing channel messages to a session after it is archived (#671)
32
+ - Stop long terminal commands from echoing a duplicated wrapped tail (#670)
33
+ - Page session lists by a server-issued cursor so long lists no longer skip sessions (#669)
34
+ - Refresh the header title when the active session is renamed (#666)
35
+ - Match chat navigator rounds by task id so compression no longer misaligns them (#664)
36
+ - Stop dropping replayed chunks that continue an earlier turn (#662)
37
+ - Apply model card edits to a running session immediately (#661)
38
+ - Keep the attachment strip inside the new-session composer card (#659)
39
+ - Keep the /#new draft name out of sessions created by a quick action (#658)
40
+ - Keep the Default reasoning choice intact after reopening settings
41
+ - Suggest switching models when retries are exhausted
42
+ - Correct MiniMax-M3 rates with its 512K tier
43
+ - Complete MiniMax Flash effort handling and retire the legacy Ollama choices (#655 - @octo-patch)
44
+ - Drop tool results with a blank call id before sending
45
+ - Preserve response headers when handling Anthropic stream errors (#642)
46
+ - Stop compressing oversized recent messages over and over (#643)
47
+ - Localize device-login errors and stop showing the user code
48
+ - Localize the QR lightbox caption and stop clipping its close button
49
+ - Keep web search on the free engine chain unless DeepSeek is chosen
50
+ - Stop extension reloads from serving a stale cached bundle when the manifest path changes
51
+
52
+ ### More
53
+ - Retire the slow GPT-6 and Grok 4.7 aliases from the model picker; their lanes stay routable for existing sessions
54
+ - Bill the Bedrock GPT lane at a 20% markup
55
+
7
56
  ## [1.5.20] - 2026-10-15
8
57
 
9
58
  ### Added
data/README.md CHANGED
@@ -207,6 +207,24 @@ Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provide
207
207
 
208
208
  Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
209
209
 
210
+ In Web, use the permission selector above the message box to switch the current
211
+ session between **Manual** and **Auto**, including while running. The choice
212
+ is saved for that session. Running tools finish normally; subsequent actions use
213
+ the new mode. Switching to Manual stops an active question countdown. Auto
214
+ approve runs tools without approval.
215
+
216
+ In auto-approve mode, CLI and Web wait 20 seconds after `ask_user` before the
217
+ agent decides how to continue. Typing or selecting an answer cancels the timer
218
+ and waits for submission. Configure the duration in `~/.clacky/config.yml`:
219
+
220
+ ```yaml
221
+ settings:
222
+ ask_user_timeout_seconds: 20
223
+ ```
224
+
225
+ Use a non-negative integer (seconds); `0` continues immediately. Missing or
226
+ invalid values use 20 seconds. Other permission modes wait for an answer.
227
+
210
228
  ## Coding use case
211
229
 
212
230
  OpenClacky works as a general AI coding assistant — scaffold full-stack apps, add features, or explore unfamiliar codebases:
@@ -40,6 +40,7 @@ module Clacky
40
40
  rounds = []
41
41
  nested = []
42
42
  section_count = 0
43
+ continuation_start = nil
43
44
  user_start = nil
44
45
  user_task_id = nil
45
46
  archived_at = nil
@@ -60,6 +61,7 @@ module Clacky
60
61
  header = chunk_section_header(text, directory)
61
62
  if header
62
63
  section_count += 1
64
+ continuation_start ||= start
63
65
  nested << header[:nested_chunk] if header[:nested_chunk]
64
66
  user_start = header[:role] == "user" ? start : nil
65
67
  user_task_id = header[:role] == "user" ? header[:task_id] : nil
@@ -78,7 +80,12 @@ module Clacky
78
80
  end
79
81
  rounds.last[:length] = position - rounds.last[:start] if rounds.any?
80
82
  end
81
- { rounds: rounds, nested: nested, section_count: section_count,
83
+ # Compression can cut mid-turn: sections before the first user turn continue the previous turn.
84
+ continuation_end = rounds.any? ? rounds.first[:start] : position
85
+ if continuation_start && continuation_end > continuation_start
86
+ continuation_range = { start: continuation_start, length: continuation_end - continuation_start }
87
+ end
88
+ { rounds: rounds, nested: nested, section_count: section_count, continuation_range: continuation_range,
82
89
  base_time: (archived_at || File.mtime(path)).to_f }
83
90
  end
84
91
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ module Feedback
6
+ # Extensions prepare a single immutable snapshot before the UI or its
7
+ # countdown sees it. The same snapshot becomes the model's tool result.
8
+ private def prepare_feedback_request(call, result, assistant_message:)
9
+ args = call[:arguments].is_a?(String) ? JSON.parse(call[:arguments]) : call[:arguments]
10
+ request = {
11
+ request_id: call[:id].to_s,
12
+ questions: Tools::AskUser.normalize_questions(args),
13
+ context: Tools::AskUser.fetch_key(args, :context).to_s
14
+ }
15
+ @hooks.trigger(:prepare_feedback, request)
16
+ check_stale!
17
+ request[:request_id] = call[:id].to_s
18
+ request[:questions] = Tools::AskUser.normalize_questions(request)
19
+ raise ArgumentError, "Feedback preparation removed all questions" if request[:questions].empty?
20
+
21
+ request[:context] = request[:context].to_s
22
+ request[:answer_context] = request[:answer_context].to_s
23
+ @history.attach_feedback_request(message: assistant_message, tool_call_id: call[:id], request: request)
24
+ result[:message] = [Tools::AskUser.render_text(request[:questions], request[:context]),
25
+ request[:answer_context]].reject(&:empty?).join("\n\n")
26
+ request.reject { |key, _| key == :answer_context }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -11,7 +11,7 @@ module Clacky
11
11
  sources = navigation_sources
12
12
  manifest = sources.map do |source|
13
13
  entry = { key: source[:key], version: source[:version], count: navigation_source_count(source),
14
- volatile: !!source[:rounds] || source[:continuation].to_a.any? }
14
+ volatile: !!source[:rounds] || source[:continuation].to_a.any? || source[:continuation_ranges].to_a.any? }
15
15
  if source[:rounds]
16
16
  entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) }
17
17
  else
@@ -144,6 +144,13 @@ module Clacky
144
144
  end
145
145
  chunks.uniq.each { |path| append_source.call(path) }
146
146
  @chunk_index_mutex&.synchronize { @chunk_indexes&.delete_if { |path, _| !visited.include?(path) } }
147
+ owner = nil
148
+ sources.each do |source|
149
+ if owner && source[:index][:continuation_range]
150
+ (owner[:continuation_ranges] ||= []) << { path: source[:path], index: source[:index] }
151
+ end
152
+ owner = source if navigation_source_count(source).positive?
153
+ end
147
154
  last_archive = sources.reverse_each.find { |source| navigation_source_count(source).positive? }
148
155
  last_archive[:continuation] = leading if last_archive
149
156
  version = rounds.map { |round| round[:user_msg].values_at(:created_at, :task_id) }.hash.to_s
@@ -201,8 +208,15 @@ module Clacky
201
208
  end
202
209
  result
203
210
  end
204
- if rounds.any? && offset + length == count && source[:continuation]
205
- rounds.last[:events].concat(source[:continuation])
211
+ if rounds.any? && offset + length == count
212
+ Array(source[:continuation_ranges]).each do |entry|
213
+ events = parse_chunk_md_events(entry[:path], excluded_paths: siblings,
214
+ byte_range: entry[:index][:continuation_range], index: entry[:index])
215
+ raise ArgumentError, "History changed while reading" unless chunk_file_version(entry[:path]) == entry[:index][:version]
216
+
217
+ rounds.last[:events].concat(events)
218
+ end
219
+ rounds.last[:events].concat(source[:continuation]) if source[:continuation]
206
220
  end
207
221
  rounds.each { |round| round[:events].reject! { |event| event[:compressed_summary] } }
208
222
  rounds.each_with_index do |round, position|
@@ -5,6 +5,7 @@ module Clacky
5
5
  HOOK_EVENTS = [
6
6
  :before_tool_use,
7
7
  :after_tool_use,
8
+ :prepare_feedback,
8
9
  :on_tool_error,
9
10
  :on_start,
10
11
  :on_complete,
@@ -34,6 +35,10 @@ module Clacky
34
35
  # call `agent.emit_event(...)`. Blocks are procs — those declaring fewer
35
36
  # params (`|call|`, `|call, result|`) silently ignore extra arguments.
36
37
  # Start/complete append context after the agent. Completion is observation-only.
38
+ # prepare_feedback receives (request, agent) after tool tracking, before UI
39
+ # display/countdown. It may extend questions, display context and model-only
40
+ # answer_context in place; answer_context is never sent to UI subscribers.
41
+ # return values are ignored. Keep work bounded; no separate UI events.
37
42
  def trigger(event, *args)
38
43
  validate_event!(event)
39
44
  # Keep payload hashes positional: Ruby 2.6 otherwise promotes their keys
@@ -46,7 +51,7 @@ module Clacky
46
51
  callback_args = [*args, @agent]
47
52
  callback_args << context.dup if context
48
53
  hook_result = hook.call(*callback_args)
49
- next if event == :on_complete
54
+ next if [:on_complete, :prepare_feedback].include?(event)
50
55
  next unless hook_result.is_a?(Hash)
51
56
  # First deny wins and stops the chain: a weaker later verdict must
52
57
  # never clobber a stronger earlier one, and the first deny's reason
@@ -48,6 +48,7 @@ module Clacky
48
48
  private def call_llm(agent_upstream_fails: nil, agent_upgrade_fails: nil)
49
49
  # Transition :fallback_active → :probing if cooling-off has expired.
50
50
  @config.maybe_start_probing
51
+ refresh_client_if_card_changed!
51
52
 
52
53
  tools_to_send = @tool_registry.all_definitions
53
54
 
@@ -246,6 +246,25 @@ module Clacky
246
246
  # Note: we need to remove the compression instruction message we just added
247
247
  original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
248
248
 
249
+ # Keep the usual recent messages unless they would immediately trigger
250
+ # compression again. The summary covers all original_messages, so any
251
+ # dropped recent messages can safely join the archive below.
252
+ [nil, 10, 0].each do |count|
253
+ if count
254
+ compression_context[:recent_messages] = get_recent_messages_with_tool_pairs(compression_context[:recent_messages], count)
255
+ end
256
+ candidate = @message_compressor.rebuild_with_compression(
257
+ compressed_content,
258
+ original_messages: original_messages,
259
+ recent_messages: compression_context[:recent_messages],
260
+ pulled_back_messages: compression_context[:pulled_back_messages] || []
261
+ )
262
+ break if MessageHistory.new(candidate).estimate_tokens < @config.compression_threshold &&
263
+ candidate.size < @config.message_count_threshold
264
+
265
+ raise "History still exceeds compression thresholds after summarization" if count == 0
266
+ end
267
+
249
268
  # Archive compressed messages to a chunk MD file before discarding them.
250
269
  #
251
270
  # IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
@@ -309,7 +328,7 @@ module Clacky
309
328
  { basename: c[:basename], path: c[:path], topics: c[:topics] }
310
329
  end
311
330
 
312
- @history.replace_all(@message_compressor.rebuild_with_compression(
331
+ rebuilt_messages = @message_compressor.rebuild_with_compression(
313
332
  compressed_content,
314
333
  original_messages: original_messages,
315
334
  recent_messages: compression_context[:recent_messages],
@@ -317,7 +336,14 @@ module Clacky
317
336
  topics: topics,
318
337
  previous_chunks: previous_chunks,
319
338
  pulled_back_messages: compression_context[:pulled_back_messages] || []
320
- ))
339
+ )
340
+ # Archive references also consume context; never re-enter compression
341
+ # indefinitely if even the final rebuilt history cannot fit.
342
+ if MessageHistory.new(rebuilt_messages).estimate_tokens >= @config.compression_threshold ||
343
+ rebuilt_messages.size >= @config.message_count_threshold
344
+ raise "History still exceeds compression thresholds after summarization"
345
+ end
346
+ @history.replace_all(rebuilt_messages)
321
347
 
322
348
  # Reset to the estimated size of the rebuilt (small) history.
323
349
  # The compression call_llm reported the OLD large token count, so
@@ -43,6 +43,9 @@ module Clacky
43
43
  # Restore project association (nil = no project)
44
44
  @project_id = session_data[:project_id]
45
45
 
46
+ mode_override = session_data.dig(:config, :permission_mode_override)
47
+ self.permission_mode = mode_override if %w[confirm_all auto_approve].include?(mode_override)
48
+
46
49
  # Restore standing goal state (nil = no goal). The judge client/model
47
50
  # are rebound lazily via #goal_manager when first accessed after
48
51
  # restore — but the persisted state must be reattached here so a
@@ -283,6 +286,7 @@ module Clacky
283
286
  # model_name is saved so the session can restore its original model on restart
284
287
  # (falling back to the current default if the model no longer exists).
285
288
  permission_mode: @config.permission_mode.to_s,
289
+ permission_mode_override: @permission_mode_override,
286
290
  enable_compression: @config.enable_compression,
287
291
  enable_idle_compression: @config.enable_idle_compression,
288
292
  enable_prompt_caching: @config.enable_prompt_caching,
@@ -430,7 +434,7 @@ module Clacky
430
434
  # @param chunk_path [String] Path to the chunk md file
431
435
  # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
432
436
  private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new, excluded_paths: Set.new,
433
- byte_range: nil, index: nil, round_offset: 0)
437
+ byte_range: nil, index: nil, round_offset: 0, continue_round: nil)
434
438
  return [] unless chunk_path
435
439
 
436
440
  # 1. Try the stored absolute path first (same machine, normal case).
@@ -506,7 +510,7 @@ module Clacky
506
510
 
507
511
  # Group into rounds: each user section starts a new round
508
512
  rounds = []
509
- current_round = nil
513
+ current_round = continue_round
510
514
  round_index = 0
511
515
 
512
516
  sections.each do |sec|
@@ -606,6 +610,14 @@ module Clacky
606
610
  []
607
611
  end
608
612
 
613
+ # Parse a chunk byte range holding only the tail of a turn that began in an earlier chunk.
614
+ private def parse_chunk_md_events(chunk_path, byte_range:, index:, excluded_paths: Set.new)
615
+ round = { events: [] }
616
+ parse_chunk_md_to_rounds(chunk_path, excluded_paths: excluded_paths, byte_range: byte_range,
617
+ index: index, continue_round: round)
618
+ round[:events]
619
+ end
620
+
609
621
 
610
622
  # Pull lightweight attachment badge metadata out of a chunk section.
611
623
  # The marker is internal archive data and must not appear in replayed text.
@@ -779,6 +791,12 @@ module Clacky
779
791
  # Special handling: the ask_user question is shown as an assistant
780
792
  # message (matching real-time behavior), not as a tool call.
781
793
  if Clacky::Tools::AskUser.feedback_tool?(name)
794
+ prepared = msg[:feedback_requests] || {}
795
+ request = prepared[tc[:id].to_s] || prepared[tc[:id].to_s.to_sym]
796
+ if request
797
+ ui.show_tool_call(name, request.reject { |key, _| key.to_s == "answer_context" })
798
+ next
799
+ end
782
800
  questions = Clacky::Tools::AskUser.normalize_questions(args)
783
801
  context = Clacky::Tools::AskUser.fetch_key(args, :context).to_s
784
802
  first = questions.first
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
@@ -57,6 +59,13 @@ module Clacky
57
59
  attr_accessor :archived_at
58
60
 
59
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
60
69
  MAX_VIDEO_UNDERSTANDING_BYTES = Utils::FileProcessor::MAX_FILE_BYTES
61
70
  MAX_VIDEO_BASE64_BYTES = 45 * 1024 * 1024
62
71
  MAX_VIDEO_DESCRIPTION_CHARS = 500
@@ -71,6 +80,16 @@ module Clacky
71
80
  @config&.permission_mode&.to_s || ""
72
81
  end
73
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
+
74
93
  def reasoning_effort=(value)
75
94
  @reasoning_effort = normalize_reasoning_effort(value)
76
95
  end
@@ -78,7 +97,8 @@ module Clacky
78
97
  private def normalize_reasoning_effort(value)
79
98
  return nil if value.nil?
80
99
  str = value.to_s.strip.downcase
81
- 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)
82
102
  return str if REASONING_EFFORTS.include?(str)
83
103
  nil
84
104
  end
@@ -88,6 +108,7 @@ module Clacky
88
108
  def initialize(client, config, working_dir:, ui:, profile:, session_id:, source:)
89
109
  @client = client # Client for current model
90
110
  @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
111
+ @client_options = @config.client_options
91
112
  @agent_profile = AgentProfile.load(profile)
92
113
  @source = source.to_sym # :manual | :cron | :channel
93
114
  @channel_info = nil # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
@@ -117,7 +138,7 @@ module Clacky
117
138
  @task_cost_source = :estimated # Track cost source for current task
118
139
  @previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
119
140
  @latest_latency = nil # Most recent LLM call's latency metrics (see Client#send_messages_with_tools)
120
- @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
121
142
  @ui = ui # UIController for direct UI interaction
122
143
  @debug_logs = [] # Debug logs for troubleshooting
123
144
  @input_mutex = Mutex.new
@@ -239,23 +260,25 @@ module Clacky
239
260
  # Rebuild the underlying Client (and dependent components) to pick up
240
261
  # credentials/model name from the currently-selected model in @config.
241
262
  private def rebuild_client_for_current_model!
242
- entry = @config.current_model
243
- @client = Clacky::Client.new(
244
- @config.api_key,
245
- base_url: @config.base_url,
246
- model: @config.model_name,
247
- anthropic_format: @config.anthropic_format?,
248
- api_format: @config.api_format,
249
- provider_id: @config.provider_id_for(entry),
250
- capabilities: entry && entry["capabilities"]
251
- )
252
- # Update message compressor with new client and model
253
- @message_compressor = MessageCompressor.new(@client, model: current_model)
263
+ build_client!
254
264
 
255
265
  # Inject a new session context to notify the AI of the model switch
256
266
  inject_session_context
257
267
  end
258
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
+
259
282
  # Change the working directory for this session
260
283
  # Injects a new session context to notify the AI of the directory change
261
284
  def change_working_dir(new_dir)
@@ -497,7 +520,7 @@ module Clacky
497
520
  @name = new_name.to_s.strip
498
521
  end
499
522
 
500
- def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil, feedback: nil)
523
+ def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
501
524
  # Initialized here (not mid-body) because run's rescue/ensure are
502
525
  # method-level and must be able to reference them on any exit path.
503
526
  result = nil
@@ -534,7 +557,6 @@ module Clacky
534
557
 
535
558
  # Start new task for Time Machine
536
559
  task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
537
- @feedback = feedback
538
560
  run_context = { session_id: @session_id, run_id: task_id.to_s,
539
561
  source: @source.to_s, input_created_at: created_at,
540
562
  observed_at: Time.now.utc.iso8601(6), status: "running" }
@@ -770,7 +792,7 @@ module Clacky
770
792
  @ui&.show_token_usage(response[:token_usage]) if response[:token_usage]
771
793
 
772
794
  # Act: Execute tool calls
773
- action_result = act(response[:tool_calls])
795
+ action_result = act(response[:tool_calls], assistant_message: response.delete(:history_message))
774
796
 
775
797
  # Check if ask_user was called
776
798
  if action_result[:awaiting_feedback]
@@ -1330,7 +1352,6 @@ module Clacky
1330
1352
  end
1331
1353
  entries.each do |entry|
1332
1354
  options = entry[:options].dup
1333
- options.delete(:feedback)
1334
1355
  source = options.delete(:source) || :web
1335
1356
  history_size = @history.size
1336
1357
  begin
@@ -1537,7 +1558,7 @@ module Clacky
1537
1558
  # "reasoning_content must be passed back" contract.
1538
1559
  msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
1539
1560
  check_stale!
1540
- @history.append(msg)
1561
+ @history.append(msg) { |stored_message| response[:history_message] = stored_message }
1541
1562
 
1542
1563
  # Close the thinking spinner before returning. The caller (run loop)
1543
1564
  # is about to render the assistant message and/or tool invocations,
@@ -1561,7 +1582,7 @@ module Clacky
1561
1582
  raise Clacky::AgentInterrupted, "Task superseded by a newer task on another thread"
1562
1583
  end
1563
1584
 
1564
- private def act(tool_calls)
1585
+ private def act(tool_calls, assistant_message: nil)
1565
1586
  return { denied: false, feedback: nil, tool_results: [], awaiting_feedback: false } unless tool_calls
1566
1587
 
1567
1588
  denied = false
@@ -1742,31 +1763,21 @@ module Clacky
1742
1763
  # result path so the model sees the error and can retry.
1743
1764
  if Tools::AskUser.feedback_tool?(call[:name]) &&
1744
1765
  result.is_a?(Hash) && result[:awaiting_feedback]
1745
- # Pass the raw call arguments to show_tool_call so the WebUI controller
1746
- # can extract the questions and emit a "request_feedback" event
1747
- # (renders as a clickable card in the browser).
1748
- # Fallback UIs (terminal, IM channels) receive the formatted text message.
1749
- if @feedback && @ui.respond_to?(:wait_for_feedback)
1750
- questions = Tools::AskUser.normalize_questions(args)
1751
- question = questions.first
1752
- label = if questions.one? && !question[:multi] && question[:recommended]
1753
- question[:options][question[:recommended]]
1754
- end
1755
- answer, feedback_options = @ui.wait_for_feedback(timeout_seconds: @feedback[:timeout_seconds], question: question[:question], recommended_label: label) do
1756
- @ui.show_tool_call(call[:name], call[:arguments])
1757
- end
1758
- check_stale!
1759
- result[:awaiting_feedback] = awaiting_feedback = !answer
1760
- enqueue_input("Answer: #{answer}", delivery: :steer, created_at: Time.now.to_f, **(feedback_options || {})) if answer
1761
- elsif @config.permission_mode == :auto_approve
1762
- @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)
1770
+
1771
+ if @config.permission_mode == :auto_approve
1763
1772
  # auto_approve means the agent runs unattended by default, but a
1764
1773
  # human MAY be watching the terminal. Show a short interactive
1765
1774
  # countdown: if the user steps in, hand control over and wait for
1766
1775
  # their answer; otherwise auto-decide and keep going.
1767
- countdown = @ui&.request_feedback_with_countdown(seconds: 10)
1776
+ countdown = @ui&.request_feedback_with_countdown(seconds: @config.ask_user_timeout_seconds)
1768
1777
 
1769
- if @ui.nil? || countdown == :timeout
1778
+ if countdown == :timeout && @config.permission_mode != :auto_approve
1779
+ awaiting_feedback = true
1780
+ elsif @ui.nil? || countdown == :timeout
1770
1781
  result = result.merge(
1771
1782
  auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1772
1783
  )
@@ -1782,7 +1793,6 @@ module Clacky
1782
1793
  end
1783
1794
  else
1784
1795
  # confirm_all / confirm_safes — a human is present, truly wait for user input.
1785
- @ui&.show_tool_call(call[:name], call[:arguments])
1786
1796
  awaiting_feedback = true
1787
1797
  end
1788
1798
  else
@@ -2105,8 +2115,11 @@ module Clacky
2105
2115
  @tool_registry.register(Tools::Edit.new)
2106
2116
  @tool_registry.register(Tools::Glob.new)
2107
2117
  @tool_registry.register(Tools::Grep.new)
2108
- @tool_registry.register(Tools::WebSearch.new)
2109
- @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
2110
2123
  @tool_registry.register(Tools::TodoManager.new)
2111
2124
  @tool_registry.register(Tools::AskUser.new)
2112
2125
  @tool_registry.register(Tools::InvokeSkill.new)