openclacky 1.2.5 → 1.2.7

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +24 -10
  6. data/lib/clacky/agent/llm_caller.rb +25 -3
  7. data/lib/clacky/agent/message_compressor.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  9. data/lib/clacky/agent/session_serializer.rb +23 -4
  10. data/lib/clacky/agent/tool_executor.rb +14 -0
  11. data/lib/clacky/agent/tool_registry.rb +0 -7
  12. data/lib/clacky/agent.rb +43 -10
  13. data/lib/clacky/agent_config.rb +54 -6
  14. data/lib/clacky/billing/billing_store.rb +62 -4
  15. data/lib/clacky/brand_config.rb +5 -0
  16. data/lib/clacky/cli.rb +76 -24
  17. data/lib/clacky/client.rb +59 -4
  18. data/lib/clacky/default_parsers/wps_parser.rb +82 -0
  19. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  20. data/lib/clacky/json_ui_controller.rb +5 -2
  21. data/lib/clacky/message_format/anthropic.rb +13 -3
  22. data/lib/clacky/message_format/bedrock.rb +2 -2
  23. data/lib/clacky/plain_ui_controller.rb +1 -1
  24. data/lib/clacky/platform_http_client.rb +28 -1
  25. data/lib/clacky/providers.rb +11 -29
  26. data/lib/clacky/server/channel/channel_manager.rb +148 -12
  27. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  28. data/lib/clacky/server/http_server.rb +133 -13
  29. data/lib/clacky/server/session_registry.rb +30 -4
  30. data/lib/clacky/server/web_ui_controller.rb +6 -3
  31. data/lib/clacky/tools/browser.rb +4 -13
  32. data/lib/clacky/tools/terminal.rb +23 -27
  33. data/lib/clacky/ui2/ui_controller.rb +1 -1
  34. data/lib/clacky/ui_interface.rb +1 -1
  35. data/lib/clacky/utils/file_processor.rb +3 -0
  36. data/lib/clacky/utils/parser_manager.rb +3 -0
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +659 -75
  39. data/lib/clacky/web/app.js +0 -1
  40. data/lib/clacky/web/billing.js +371 -99
  41. data/lib/clacky/web/i18n.js +48 -2
  42. data/lib/clacky/web/index.html +34 -1
  43. data/lib/clacky/web/sessions.js +213 -82
  44. data/lib/clacky/web/settings.js +59 -17
  45. data/lib/clacky/web/workspace.js +204 -0
  46. data/lib/clacky/web/ws-dispatcher.js +19 -3
  47. data/lib/clacky.rb +9 -3
  48. metadata +4 -5
  49. data/lib/clacky/tools/list_tasks.rb +0 -54
  50. data/lib/clacky/tools/redo_task.rb +0 -41
  51. data/lib/clacky/tools/undo_task.rb +0 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 234f676c7efe1f2803eeb4f262b20014e2a4b967442a03af70dc3e04e81b4f6b
4
- data.tar.gz: 6e9161983ebec82c44467a28e92ff25636718942bec953ebdc7ef6593b515c7e
3
+ metadata.gz: 8057fdabcb077c8ca378fcbbc0efa442abc6b9664464d2d8d1b737e0206d2ed9
4
+ data.tar.gz: 657f282a20664ef793d7ff663e963739f5fbdb478b8e174df9c5e459ec09231b
5
5
  SHA512:
6
- metadata.gz: afd3471fa15b77e3921aa640fbfd7cf937030478d834c06d0ad2d52faa6ada82561929ce7aa4ac2b075a71b1cf97bd5295ec80af4d2903dbf55c983bb4263e1f
7
- data.tar.gz: 7713e3b3a0aa9775a4a993b651a536552963320787f072ea6f79dbde0bf345a1d680b3cc0a07eec1d5667da69899f770344ba376b52d44d12360ac3ec60402a4
6
+ metadata.gz: fad3e045271032a1150745f1d8531aeed24ea376efdcd99346aeaeec4eb5f1edec187e9dbe38ee8d19e5a9cf599bfb7e5431ad55e5993f1dd243bb4ebf5faa2d
7
+ data.tar.gz: 95b1a7ec783b459f5c70b99d3535f5a0054d4a8c72d855d9c98b9771cde9d59cb33dcf609be844fade932e1487c82c525cf4354438c5ad4b271494a4166dc729
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.7] - 2026-06-01
9
+
10
+ ### Added
11
+ - Session workspace file explorer — browse and download files in Web UI sidebar
12
+ - Channel manager: new, clear, and skill commands
13
+ - Top-up link in Web UI under billing section
14
+ - Sub-model switching within active sessions
15
+ - Claude 4.8 model support
16
+ - Connection test improvements for custom Claude models
17
+
18
+ ### Fixed
19
+ - Nil error in compress top match parsing
20
+ - Empty 0-token responses causing agent stalls (close #218)
21
+ - Brand config built-in skills loaded without MANIFEST.enc.json (C-5627)
22
+ - Terminal .exe stdin isolation at command-wrap on WSL (close #221)
23
+ - Overly verbose builtin skills installed message during onboarding
24
+
25
+ ### More
26
+ - Add Docker installation section to README
27
+
28
+ ## [1.2.6] - 2026-05-29
29
+
30
+ ### Added
31
+ - WPS format support for document processing
32
+ - `--json` flag support with `-m` option
33
+
34
+ ### Improved
35
+ - Billing UI and model filtering experience
36
+ - Brand skill download now retries on failure
37
+ - DeepSeek compatibility handling within OpenRouter provider
38
+
39
+ ### Fixed
40
+ - Claude `tool_use.id` error when switching between models
41
+ - Browser page navigation pageid bug
42
+ - Browser IIFE execution issue
43
+ - WSL UTF-8 command encoding bug
44
+ - File preview path directory resolution
45
+
46
+ ### More
47
+ - Remove list/undo/redo task tool
48
+ - Clean up legacy provider code
49
+ - Improve platform error messages for easier diagnostics
50
+
8
51
  ## [1.2.5] - 2026-05-28
9
52
 
10
53
  ### Fixed
data/README.md CHANGED
@@ -108,6 +108,40 @@ gem install openclacky
108
108
 
109
109
  see more: https://www.openclacky.com/docs/installation
110
110
 
111
+ ### Docker
112
+
113
+ Build:
114
+
115
+ ```bash
116
+ git clone https://github.com/clacky-ai/openclacky.git
117
+ cd openclacky
118
+ docker build -t openclacky .
119
+ ```
120
+
121
+ **Linux:**
122
+
123
+ ```bash
124
+ docker run -d --network=host -e CLACKY_ACCESS_KEY="" openclacky
125
+ ```
126
+
127
+ `--network=host` is required so the agent inside the container can reach Chrome's remote debugging port running on the host.
128
+
129
+ **macOS / Windows:**
130
+
131
+ ```bash
132
+ docker run -d -p 7070:7070 -e CLACKY_ACCESS_KEY="" openclacky
133
+ ```
134
+
135
+ > **Note:** On macOS/Windows, `--network=host` is not supported — browser automation may be limited.
136
+
137
+ Open **http://localhost:7070** after starting.
138
+
139
+ Environment variables:
140
+
141
+ | Variable | Description |
142
+ |---|---|
143
+ | `CLACKY_ACCESS_KEY` | Protect the Web UI with an access key (empty = public mode) |
144
+
111
145
 
112
146
  ## Quick Start
113
147
 
data/README_CN.md CHANGED
@@ -104,6 +104,40 @@ gem install openclacky
104
104
 
105
105
  详见:https://www.openclacky.com/docs/installation
106
106
 
107
+ ### Docker
108
+
109
+ 构建:
110
+
111
+ ```bash
112
+ git clone https://github.com/clacky-ai/openclacky.git
113
+ cd openclacky
114
+ docker build -t openclacky .
115
+ ```
116
+
117
+ **Linux:**
118
+
119
+ ```bash
120
+ docker run -d --network=host -e CLACKY_ACCESS_KEY="" openclacky
121
+ ```
122
+
123
+ `--network=host` 使容器与宿主机共享网络栈,Agent 可直接访问宿主机上运行的 Chrome 远程调试端口。
124
+
125
+ **macOS / Windows:**
126
+
127
+ ```bash
128
+ docker run -d -p 7070:7070 -e CLACKY_ACCESS_KEY="" openclacky
129
+ ```
130
+
131
+ > **注意:** macOS/Windows 不支持 `--network=host`,浏览器自动化功能可能受限。
132
+
133
+ 启动后访问 **http://localhost:7070**。
134
+
135
+ 环境变量:
136
+
137
+ | 变量 | 说明 |
138
+ |---|---|
139
+ | `CLACKY_ACCESS_KEY` | 设置访问密钥保护 Web UI(留空 = 公开模式) |
140
+
107
141
  ## 快速开始
108
142
 
109
143
  ### 终端(CLI)
@@ -17,7 +17,8 @@ module Clacky
17
17
  # Updates total cost and displays iteration statistics
18
18
  # @param usage [Hash] Usage data from API response
19
19
  # @param raw_api_usage [Hash, nil] Raw API usage data for debugging
20
- def track_cost(usage, raw_api_usage: nil)
20
+ # @param model [String, nil] Model name to use for billing (defaults to current_model)
21
+ def track_cost(usage, raw_api_usage: nil, model: nil)
21
22
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
22
23
  iteration_cost = nil
23
24
  if usage[:api_cost]
@@ -28,7 +29,10 @@ module Clacky
28
29
  @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
29
30
  else
30
31
  # Priority 2: Calculate from tokens using ModelPricing
31
- result = ModelPricing.calculate_cost(model: current_model, usage: usage)
32
+ # Use provided model name (from API call time) to ensure accurate billing
33
+ # even if the user switches models during the API call
34
+ billing_model = model || current_model
35
+ result = ModelPricing.calculate_cost(model: billing_model, usage: usage)
32
36
  cost = result[:cost]
33
37
  pricing_source = result[:source]
34
38
 
@@ -101,7 +105,7 @@ module Clacky
101
105
 
102
106
  # Persist billing record (skip for subagents to avoid double-counting)
103
107
  unless @is_subagent
104
- persist_billing_record(usage, iteration_cost)
108
+ persist_billing_record(usage, iteration_cost, model: model)
105
109
  end
106
110
 
107
111
  # Return token_data so the caller can display it at the right moment
@@ -111,25 +115,29 @@ module Clacky
111
115
  # Persist a billing record to the billing store
112
116
  # @param usage [Hash] Usage data from API
113
117
  # @param cost [Float, nil] Calculated cost for this iteration
114
- def persist_billing_record(usage, cost)
115
- return if cost.nil? # Skip if cost is unknown
118
+ # @param model [String, nil] Model name to use for billing (defaults to current_model)
119
+ def persist_billing_record(usage, cost, model: nil)
120
+ # Always save billing records for usage tracking, even if cost is unknown (nil).
121
+ # This ensures all API calls are recorded for statistics purposes.
122
+ billing_model = model || current_model
123
+ effective_cost = cost || 0.0 # Use 0 if pricing is unknown
116
124
 
117
125
  record = Billing::BillingRecord.new(
118
126
  session_id: @session_id,
119
127
  timestamp: Time.now,
120
- model: current_model,
128
+ model: billing_model,
121
129
  prompt_tokens: usage[:prompt_tokens] || 0,
122
130
  completion_tokens: usage[:completion_tokens] || 0,
123
131
  cache_read_tokens: usage[:cache_read_input_tokens] || 0,
124
132
  cache_write_tokens: usage[:cache_creation_input_tokens] || 0,
125
- cost_usd: cost,
126
- cost_source: @cost_source
133
+ cost_usd: effective_cost,
134
+ cost_source: cost.nil? ? :unknown : @cost_source
127
135
  )
128
136
 
129
137
  billing_store.append(record)
130
138
  rescue => e
131
139
  # Billing persistence is non-critical; log and continue
132
- @ui&.log("Failed to persist billing record: #{e.message}", level: :debug) if @config&.verbose
140
+ Clacky::Logger.warn("billing.persist_error", error: e.message, model: billing_model)
133
141
  end
134
142
 
135
143
  # Estimate token count for a message content
@@ -169,7 +177,13 @@ module Clacky
169
177
  else
170
178
  total_tokens - @previous_total_tokens
171
179
  end
172
- @previous_total_tokens = total_tokens # Update for next iteration
180
+
181
+ # Guard: do NOT overwrite @previous_total_tokens with 0 when the upstream
182
+ # returns missing/zero usage (observed when history overflows the model
183
+ # context: response comes back as content="" + finish_reason="stop" +
184
+ # zero usage). Resetting to 0 would disable the compression trigger on
185
+ # subsequent turns and poison the session permanently.
186
+ @previous_total_tokens = total_tokens if total_tokens > 0
173
187
 
174
188
  {
175
189
  delta_tokens: delta_tokens,
@@ -88,6 +88,11 @@ module Clacky
88
88
  # the error propagate.
89
89
  context_overflow_retry_attempted = false
90
90
 
91
+ # Capture model name at API call time for accurate billing tracking.
92
+ # If the user switches models while the API call is in progress, we still
93
+ # want to bill under the model that actually handled the request.
94
+ api_call_model = current_model
95
+
91
96
  begin
92
97
  begin
93
98
  # Use active_messages (Time Machine) when undone, otherwise send full history.
@@ -100,7 +105,7 @@ module Clacky
100
105
 
101
106
  response = @client.send_messages_with_tools(
102
107
  messages_to_send,
103
- model: current_model,
108
+ model: api_call_model,
104
109
  tools: tools_to_send,
105
110
  max_tokens: @config.max_tokens,
106
111
  enable_caching: @config.enable_prompt_caching,
@@ -124,8 +129,22 @@ module Clacky
124
129
  # block below handles retry + fallback identically to 5xx/429.
125
130
  detect_upstream_truncation!(response)
126
131
 
132
+ # Empty response detector: model returned nothing (no content, no
133
+ # tool_calls, finish_reason != "stop"). DeepSeek via OpenRouter
134
+ # occasionally does this. Treat as transient failure and retry.
135
+ if response[:content].to_s.strip.empty? &&
136
+ (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
137
+ response[:finish_reason].to_s != "stop" &&
138
+ response[:finish_reason].to_s != "length"
139
+ Clacky::Logger.warn("llm.empty_response_detected",
140
+ model: api_call_model,
141
+ finish_reason: response[:finish_reason].to_s,
142
+ completion_tokens: response.dig(:token_usage, :completion_tokens)
143
+ )
144
+ raise RetryableError, "[LLM] Model returned empty response (no content, no tool_calls), retrying..."
145
+ end
146
+
127
147
  rescue Faraday::TimeoutError => e
128
- # ── Read-timeout path (distinct from connection-level failures) ──
129
148
  # Faraday::TimeoutError on our non-streaming POST almost always means
130
149
  # the *response* took longer than the 300s read-timeout to come back —
131
150
  # i.e. the model is trying to produce a huge output in one shot
@@ -210,6 +229,7 @@ module Clacky
210
229
  if retries <= current_max
211
230
  if retries == RETRIES_BEFORE_FALLBACK && !@config.fallback_active?
212
231
  if try_activate_fallback(current_model)
232
+ api_call_model = current_model
213
233
  retries = 0
214
234
  retry
215
235
  end
@@ -299,7 +319,9 @@ module Clacky
299
319
  end
300
320
 
301
321
  # Track cost and collect token usage data.
302
- token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
322
+ # Pass the model name captured at API call time to ensure accurate billing
323
+ # even if the user switched models during the (potentially long) API call.
324
+ token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage], model: api_call_model)
303
325
  response[:token_usage] = token_data
304
326
 
305
327
  # [DIAG] Log raw client response shape. Only emit when we see the
@@ -137,7 +137,8 @@ module Clacky
137
137
  # Returns the topics string if found, nil otherwise.
138
138
  # e.g. "<topics>Rails setup, database config</topics>" → "Rails setup, database config"
139
139
  def parse_topics(content)
140
- m = content.match(/<topics>(.*?)<\/topics>/m)
140
+ return nil if content.nil? || content.to_s.empty?
141
+ m = content.to_s.match(/<topics>(.*?)<\/topics>/m)
141
142
  m ? m[1].strip : nil
142
143
  end
143
144
 
@@ -124,8 +124,12 @@ module Clacky
124
124
  # Check if compression is enabled
125
125
  return nil unless @config.enable_compression
126
126
 
127
- # Use actual API-reported tokens from last request
128
- total_tokens = @previous_total_tokens
127
+ # Use the larger of: API-reported tokens from last response, or current
128
+ # estimated history size. The estimate guards the case where a single
129
+ # huge tool result was just appended after the last API call — without
130
+ # this, the next request can ship the bloated history before any
131
+ # compression triggers (issue #218).
132
+ total_tokens = [@previous_total_tokens, @history.estimate_tokens].max
129
133
  message_count = @history.size
130
134
 
131
135
  # Force compression (for idle compression) - use lower threshold
@@ -80,12 +80,28 @@ module Clacky
80
80
  end
81
81
  end
82
82
 
83
+ # Re-apply the per-session sub-model pin (if any). Done AFTER
84
+ # switch_model_by_id so the overlay isn't cleared by the card switch
85
+ # invariant. Validation happens at write-time (the WebUI/API enforces
86
+ # the name belongs to the card's provider) — at restore-time we trust
87
+ # what we previously wrote.
88
+ saved_sub_model = session_data.dig(:config, :sub_model)
89
+ if saved_sub_model && !saved_sub_model.to_s.empty?
90
+ set_session_sub_model(saved_sub_model)
91
+ end
92
+
83
93
  # Rebuild and refresh the system prompt so any newly installed skills
84
94
  # (or other configuration changes since the session was saved) are
85
95
  # reflected immediately — without requiring the user to create a new session.
86
96
  refresh_system_prompt
87
97
  end
88
98
 
99
+ private def persisted_card_field(key)
100
+ card_id = @config.current_model_id
101
+ return nil unless card_id
102
+ @config.models.find { |m| m["id"] == card_id }&.dig(key)
103
+ end
104
+
89
105
  # Generate session data for saving
90
106
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
91
107
  # @param error_message [String] Error message if status is :error
@@ -134,10 +150,13 @@ module Clacky
134
150
  reasoning_effort: @reasoning_effort,
135
151
  # Persist the current model identity so the session can restore its
136
152
  # original model on restart. model_name + model_base_url form a
137
- # composite key to avoid matching a different provider's model of
138
- # the same name. Falls back to default if the model no longer exists.
139
- model_name: @config.current_model&.dig("model"),
140
- model_base_url: @config.current_model&.dig("base_url")
153
+ # composite key that points at the underlying card (NOT the
154
+ # sub-model overlay) overlays are layered on top via :sub_model
155
+ # below so card lookup stays stable when the user toggles
156
+ # sub-models.
157
+ model_name: persisted_card_field("model"),
158
+ model_base_url: persisted_card_field("base_url"),
159
+ sub_model: @config.session_model_overlay_name
141
160
  },
142
161
  stats: stats_data,
143
162
  messages: @history.to_a
@@ -108,6 +108,15 @@ module Clacky
108
108
  preview_error
109
109
  rescue JSON::ParserError
110
110
  nil
111
+ rescue StandardError => e
112
+ @debug_logs << {
113
+ timestamp: Time.now.iso8601,
114
+ event: "tool_preview_error",
115
+ tool_name: call[:name],
116
+ error_class: e.class.name,
117
+ error_message: e.message
118
+ }
119
+ nil
111
120
  end
112
121
  end
113
122
 
@@ -397,6 +406,11 @@ module Clacky
397
406
  return { error: "File not found: #{path}", path: path }
398
407
  end
399
408
 
409
+ if File.directory?(expanded_path)
410
+ @ui&.show_file_error("Path is a directory, not a file: #{path}")
411
+ return { error: "Path is a directory, not a file: #{path}", path: path }
412
+ end
413
+
400
414
  if old_string.empty?
401
415
  @ui&.show_file_error("No old_string provided (nothing to replace)")
402
416
  return { error: "No old_string provided (nothing to replace)" }
@@ -59,13 +59,6 @@ module Clacky
59
59
  "ask_user" => "request_user_feedback",
60
60
  "user_feedback" => "request_user_feedback",
61
61
  "ask" => "request_user_feedback",
62
- # undo_task aliases
63
- "undo" => "undo_task",
64
- # redo_task aliases
65
- "redo" => "redo_task",
66
- # list_tasks aliases
67
- "tasks" => "list_tasks",
68
- "task_history" => "list_tasks",
69
62
  # trash_manager aliases
70
63
  "trash" => "trash_manager",
71
64
  "delete" => "trash_manager",
data/lib/clacky/agent.rb CHANGED
@@ -172,6 +172,18 @@ module Clacky
172
172
  true
173
173
  end
174
174
 
175
+ # Pin this session to a sub-model name without changing its underlying
176
+ # card (credentials / base_url stay put). Pass nil or "" to clear and
177
+ # fall back to the card's default model. Validation that the name is
178
+ # listed under the current provider is the caller's job.
179
+ # @param model_name [String, nil]
180
+ # @return [Boolean]
181
+ def set_session_sub_model(model_name)
182
+ @config.session_model_overlay = model_name
183
+ rebuild_client_for_current_model!
184
+ true
185
+ end
186
+
175
187
  # Rebuild the underlying Client (and dependent components) to pick up
176
188
  # credentials/model name from the currently-selected model in @config.
177
189
  private def rebuild_client_for_current_model!
@@ -206,10 +218,16 @@ module Clacky
206
218
  model = @config.current_model
207
219
  return nil unless model
208
220
 
221
+ card_id = @config.current_model_id
222
+ base_entry = card_id ? @config.models.find { |m| m["id"] == card_id } : nil
223
+ sub_model = @config.session_model_overlay_name
224
+
209
225
  {
210
226
  id: model["id"],
211
227
  model: model["model"],
212
- base_url: model["base_url"]
228
+ base_url: model["base_url"],
229
+ card_model: base_entry&.dig("model"),
230
+ sub_model: sub_model
213
231
  }
214
232
  end
215
233
 
@@ -902,11 +920,6 @@ module Clacky
902
920
  args[:skill_loader] = @skill_loader
903
921
  end
904
922
 
905
- # Special handling for Time Machine tools: inject agent
906
- if ["undo_task", "redo_task", "list_tasks"].include?(call[:name])
907
- args[:agent] = self
908
- end
909
-
910
923
  # Inject working_dir so tools don't rely on Dir.chdir global state
911
924
  args[:working_dir] = @working_dir if @working_dir
912
925
 
@@ -1024,7 +1037,10 @@ module Clacky
1024
1037
  check_stale!
1025
1038
 
1026
1039
  formatted_messages = @client.format_tool_results(response, tool_results, model: current_model)
1027
- formatted_messages.each { |msg| @history.append(msg.merge(task_id: @current_task_id)) }
1040
+ formatted_messages.each do |msg|
1041
+ truncated = truncate_oversized_tool_content(msg)
1042
+ @history.append(truncated.merge(task_id: @current_task_id))
1043
+ end
1028
1044
 
1029
1045
  # Append a follow-up `role:"user"` message for any image payloads that
1030
1046
  # could not be delivered inside the tool message.
@@ -1060,6 +1076,26 @@ module Clacky
1060
1076
  end
1061
1077
  end
1062
1078
 
1079
+ # Cap oversized tool result content to keep a single tool message from
1080
+ # blowing up the prompt budget (issue #218: a 7350-path glob produced a
1081
+ # ~890k-char result that pushed history past the model context window
1082
+ # and poisoned the session). Only string content is truncated — Array
1083
+ # content (multipart/image blocks) is left alone since image payloads
1084
+ # are handled by the image_inject path above.
1085
+ MAX_TOOL_RESULT_CHARS = 80_000
1086
+
1087
+ private def truncate_oversized_tool_content(msg)
1088
+ content = msg[:content]
1089
+ return msg unless content.is_a?(String) && content.length > MAX_TOOL_RESULT_CHARS
1090
+
1091
+ original_len = content.length
1092
+ head = content[0, MAX_TOOL_RESULT_CHARS]
1093
+ truncated = head + "\n\n[Tool result truncated: #{original_len} chars total, " \
1094
+ "showing first #{MAX_TOOL_RESULT_CHARS}. Use a more specific query/limit, " \
1095
+ "or read the raw output via file_reader/grep on the underlying source.]"
1096
+ msg.merge(content: truncated)
1097
+ end
1098
+
1063
1099
  # Enqueue an inline skill injection to be flushed after observe().
1064
1100
  # Called by InvokeSkill#execute to avoid injecting during tool execution,
1065
1101
  # which would break Bedrock's toolUse/toolResult pairing requirement.
@@ -1183,9 +1219,6 @@ module Clacky
1183
1219
  @tool_registry.register(Tools::TodoManager.new)
1184
1220
  @tool_registry.register(Tools::RequestUserFeedback.new)
1185
1221
  @tool_registry.register(Tools::InvokeSkill.new)
1186
- @tool_registry.register(Tools::UndoTask.new)
1187
- @tool_registry.register(Tools::RedoTask.new)
1188
- @tool_registry.register(Tools::ListTasks.new)
1189
1222
  @tool_registry.register(Tools::Browser.new)
1190
1223
  end
1191
1224
 
@@ -211,6 +211,14 @@ module Clacky
211
211
  # Keys honored: "api_key", "base_url", "model", "anthropic_format".
212
212
  # @return [Hash, nil]
213
213
  @virtual_model_overlay = options[:virtual_model_overlay]
214
+
215
+ # Per-session sub-model override. Persists across restarts via the
216
+ # session file. Independent of @virtual_model_overlay (which is for
217
+ # short-lived subagent forks). Used by the WebUI sub-model switcher
218
+ # to pin a session to e.g. "dsk-deepseek-v4-pro" while the underlying
219
+ # card still says "abs-claude-sonnet-4-6". Only the "model" key is
220
+ # honored — sub-model switching never changes credentials.
221
+ @session_model_overlay = options[:session_model_overlay]
214
222
  end
215
223
 
216
224
  # Load configuration from file
@@ -354,6 +362,9 @@ module Clacky
354
362
  if @virtual_model_overlay
355
363
  copy.instance_variable_set(:@virtual_model_overlay, @virtual_model_overlay.dup)
356
364
  end
365
+ if @session_model_overlay
366
+ copy.instance_variable_set(:@session_model_overlay, @session_model_overlay.dup)
367
+ end
357
368
  copy
358
369
  end
359
370
 
@@ -431,9 +442,12 @@ module Clacky
431
442
  index = @models.find_index { |m| m["id"] == id }
432
443
  return false if index.nil?
433
444
 
445
+ previous_id = @current_model_id
434
446
  @current_model_id = id
435
447
  @current_model_index = index
436
448
 
449
+ @session_model_overlay = nil if previous_id != id
450
+
437
451
  true
438
452
  end
439
453
 
@@ -760,14 +774,18 @@ module Clacky
760
774
  resolved = resolve_current_model_entry
761
775
  return nil unless resolved
762
776
 
763
- # If a virtual overlay is active (e.g. subagent running on lite-model
764
- # credentials), return a *merged copy* so callers see the overlay fields
765
- # but the shared @models hash is never mutated.
777
+ # Merge order (low high): base entry, session-level sub-model override,
778
+ # then short-lived subagent overlay. Both layers are kept separate so
779
+ # a subagent fork can stack its own credentials on top of an active
780
+ # sub-model pin without erasing it.
781
+ merged = resolved
782
+ if @session_model_overlay && !@session_model_overlay.empty?
783
+ merged = merged.merge(@session_model_overlay)
784
+ end
766
785
  if @virtual_model_overlay && !@virtual_model_overlay.empty?
767
- resolved.merge(@virtual_model_overlay)
768
- else
769
- resolved
786
+ merged = merged.merge(@virtual_model_overlay)
770
787
  end
788
+ merged.equal?(resolved) ? resolved : merged
771
789
  end
772
790
 
773
791
  # Internal: resolve the current model entry from @models (no overlay).
@@ -822,6 +840,36 @@ module Clacky
822
840
  @virtual_model_overlay
823
841
  end
824
842
 
843
+ # Apply a session-level sub-model override. Lives on this AgentConfig
844
+ # only (each session deep_copy's its own scalar ivar) and survives a
845
+ # restart through the session file. Pass nil or "" to clear.
846
+ #
847
+ # The override only rewrites the resolved current_model's "model" field —
848
+ # api_key / base_url / anthropic_format come from the underlying card,
849
+ # so the user's credentials and provider identity are untouched.
850
+ #
851
+ # @param model_name [String, nil] sub-model name, e.g. "dsk-deepseek-v4-pro"
852
+ def session_model_overlay=(model_name)
853
+ if model_name.nil? || model_name.to_s.strip.empty?
854
+ @session_model_overlay = nil
855
+ else
856
+ @session_model_overlay = { "model" => model_name.to_s.strip }
857
+ end
858
+ end
859
+
860
+ # @return [Hash, nil] the active session sub-model overlay
861
+ def session_model_overlay
862
+ @session_model_overlay
863
+ end
864
+
865
+ # Convenience accessor: the sub-model name currently pinned on this
866
+ # session, or nil when no override is active. Used by serializers and
867
+ # the WebUI to surface "card · sub-model" two-line displays.
868
+ # @return [String, nil]
869
+ def session_model_overlay_name
870
+ @session_model_overlay && @session_model_overlay["model"]
871
+ end
872
+
825
873
  # Query whether the *current* model supports a given capability.
826
874
  #
827
875
  # This is the single entry-point callers (Agent, downgrade pipeline, UI)