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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdaf1f7be658187d9515e3fcd1ca37a1ec8bdf04267b434a4d5722c3cbee3970
4
- data.tar.gz: a7a62edc84b707407fd06b186627b1a705a8e9977b60b88453e23759dc43f4b4
3
+ metadata.gz: a120f8d1189892876508f1c49309b7e4a21652c9293499211444b6e42770f649
4
+ data.tar.gz: 2c02a199d26ae835426fa5ce8fb65665ac3c342aa7b717337c3e72470fd868f7
5
5
  SHA512:
6
- metadata.gz: 574f85b7317b21b2d231f009405275a6aa77df9758bd2bb9d861a948d1bf35f32d876c1c154fb87f8248786490734aab84a6568ba522e37e840913216510f504
7
- data.tar.gz: 6509de425a59612b5fad807c172affad49bee4da8b83d59ba9a204c3355814c1ef8f1b8ee233bee43755c624c3bbf4b9c7278378bf3b68e7faebcfc7096a6530
6
+ metadata.gz: aed2dcb3fb47de8a9fc9b20cccbae1f45e70338b37a44e5edbea6ec54c8c3065a27976a2ea5cb2b34ac209cbc3d53c8b6866598deb1a78ff5194ecbef6c0d66e
7
+ data.tar.gz: 948e67f2dc8f003e55daea5f8e4d790e5368ef58eec63489d317e8be4336e2e04a9db498bdb1e362005a66b502f930a1dd5c88e9da6607863d9dfc4b06986feb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,101 @@ 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
+
56
+ ## [1.5.20] - 2026-10-15
57
+
58
+ ### Added
59
+ - Add a data management page with session archiving and a recycle bin
60
+ - Add a tab context menu and reopen tabs after a reload
61
+ - Preview audio and video files in the workspace (#634)
62
+ - Preview pending image attachments before sending (#629)
63
+ - Zoom and pan in the viewer's image preview
64
+ - Move the todo list into a session bar popover (#619)
65
+ - Add opt-in per-task feedback countdowns (#633 - @no7dw)
66
+ - Name a session automatically from its first message
67
+ - Show the active session title in the top header
68
+ - Show the QQ bind QR inline with expiry refresh and a sticky success state (#621)
69
+ - Add the oc-grok-4.7, abs-claude-haiku-5-5 and or-gemini-4-argon aliases
70
+
71
+ ### Improved
72
+ - Lead model picker rows with their provider
73
+ - Retune the model series discounts and say where they come from
74
+ - Point the openclacky lite tier at claude-haiku-5-5
75
+ - Soften the segmented control hover
76
+ - Tidy the top-up label and the model row spacing
77
+ - Put the model remark under the API key and align it left
78
+
79
+ ### Fixed
80
+ - Pair replayed tool results with their own calls so a refreshed session no longer strands them in the wrong tool item (#640)
81
+ - Keep the assistant timestamp when a session is archived and replayed from chunk history (#639 - @BoyYangzai)
82
+ - Stop dropping attachment messages while a task is being processed (#628)
83
+ - Handle a missing workspace path in the directory picker (#623)
84
+ - Restore the settings tab after switching sessions (#626)
85
+ - Send model picker actions to the right settings tab (#624)
86
+ - Stop session action buttons from squeezing the session title (#625)
87
+ - Improve the mobile billing chart usability (#627)
88
+ - Stop truncating the navigator preview too early (#632)
89
+ - Refresh the Files tree after restoring a task from the timeline (#638)
90
+ - Checkpoint the latest task after a session reload (#637)
91
+ - Localize screenshot upload errors and retry after binding a device (#636)
92
+ - Persist run errors in the session history
93
+ - Stop pinning an auto-routed task to a dead lane when a failure carries no tier
94
+ - Reword network failures and link them to the key console
95
+ - Align the sidebar toggle buttons and unify their icons at 16px (#622)
96
+ - Fade the collapsed aside opener icon
97
+
98
+ ### More
99
+ - Retire gemini-3.7-flash and drop the oc-minimax-m2.7 alias from the model picker
100
+ - Remove the OrcaRouter and Requesty provider presets
101
+
7
102
  ## [1.5.19] - 2026-10-10
8
103
 
9
104
  ### Added
data/README.md CHANGED
@@ -205,7 +205,25 @@ $ openclacky
205
205
 
206
206
  Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provider).
207
207
 
208
- Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty** — or any custom endpoint.
208
+ Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
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.
209
227
 
210
228
  ## Coding use case
211
229
 
data/README_CN.md CHANGED
@@ -189,7 +189,7 @@ $ openclacky
189
189
 
190
190
  设置你的 **API Key**、**模型**和 **Base URL**(任意 OpenAI 兼容提供商)。
191
191
 
192
- 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty**,或任意自定义端点。
192
+ 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter**,或任意自定义端点。
193
193
 
194
194
  ## 代码开发场景
195
195
 
data/README_JA.md CHANGED
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  **API Key**、**Model**、**Base URL**(任意の OpenAI 互換プロバイダー)を設定します。
196
196
 
197
- 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty** — または任意のカスタムエンドポイント。
197
+ 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — または任意のカスタムエンドポイント。
198
198
 
199
199
  ## コーディングのユースケース
200
200
 
@@ -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
 
@@ -56,8 +57,15 @@ module Clacky
56
57
  retries = 0
57
58
  # Per-call tier attribution for auto routing: each retryable failure is
58
59
  # charged to the tier the gateway actually routed that call to (echoed
59
- # in X-Clacky-Routed-Tier). Failures without a tier (network layer,
60
- # timeouts — the gateway never answered) are not attributed to either.
60
+ # in X-Clacky-Routed-Tier). A tier-less failure is charged to the floor
61
+ # when the call went out as "auto": the gateway only stamps the tier
62
+ # header after it has routed the call, so a missing tier means the
63
+ # failure happened before routing (HTML error page, truncated body,
64
+ # rejection at the auth layer) — and the gateway defaults to the floor
65
+ # lane in exactly that case. Leaving it uncharged would keep
66
+ # agent_upstream_fails at 0 and pin the task to a dead floor lane until
67
+ # its retry budget ran out. Non-auto calls and unknown tier values are
68
+ # attributed to neither lane.
61
69
  floor_fails = 0
62
70
  upgrade_fails = 0
63
71
 
@@ -247,7 +255,7 @@ module Clacky
247
255
  else
248
256
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
249
257
  # Progress cleanup is the caller's responsibility (via its own ensure block).
250
- raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
258
+ raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message, action_url: @client.account_url)
251
259
  end
252
260
 
253
261
  rescue RetryableError => e
@@ -257,6 +265,12 @@ module Clacky
257
265
  case e.routed_tier
258
266
  when "floor" then floor_fails += 1
259
267
  when "upgrade" then upgrade_fails += 1
268
+ when nil then floor_fails += 1 if api_call_model == "auto"
269
+ else
270
+ if defined?(Clacky::Logger)
271
+ Clacky::Logger.warn("llm_caller.unknown_routed_tier",
272
+ tier: e.routed_tier, model: api_call_model)
273
+ end
260
274
  end
261
275
 
262
276
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -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
@@ -641,6 +667,7 @@ module Clacky
641
667
  lines << "## Assistant"
642
668
  end
643
669
  lines << ""
670
+ append_created_at_line(lines, msg)
644
671
  # Include tool calls summary if present
645
672
  # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
646
673
  if msg[:tool_calls]&.any?
@@ -733,6 +760,13 @@ module Clacky
733
760
  lines << ""
734
761
  end
735
762
 
763
+ def append_created_at_line(lines, msg)
764
+ return unless msg[:created_at].is_a?(Numeric)
765
+
766
+ lines << "_Created at: #{msg[:created_at]}_"
767
+ lines << ""
768
+ end
769
+
736
770
  # Serialize persisted extension events into the chunk MD so they survive
737
771
  # compression and can be replayed in place. Mirrors the "_Tool calls:"
738
772
  # convention; parse_chunk_md_to_rounds reads it back.
@@ -17,6 +17,7 @@ module Clacky
17
17
  @session_id = session_data[:session_id]
18
18
  @name = session_data[:name] || ""
19
19
  @pinned = session_data[:pinned] || false
20
+ @archived_at = session_data[:archived_at]
20
21
  @history = MessageHistory.new(session_data[:messages] || [])
21
22
  @input_queue = (session_data[:pending_inputs] || []).map do |entry|
22
23
  entry.reject { |key, _| key == :steer_target }.merge(delivery: "queue")
@@ -42,6 +43,9 @@ module Clacky
42
43
  # Restore project association (nil = no project)
43
44
  @project_id = session_data[:project_id]
44
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
+
45
49
  # Restore standing goal state (nil = no goal). The judge client/model
46
50
  # are rebound lazily via #goal_manager when first accessed after
47
51
  # restore — but the persisted state must be reattached here so a
@@ -75,6 +79,9 @@ module Clacky
75
79
  @task_parents = raw_parents.each_with_object({}) { |(k, v), h| h[k.to_s.to_i] = v.to_i }
76
80
  @current_task_id = (session_data.dig(:time_machine, :current_task_id) || 0).to_i
77
81
  @active_task_id = (session_data.dig(:time_machine, :active_task_id) || 0).to_i
82
+ # Not persisted: assume the latest task's AFTER may be stale so the next
83
+ # task start re-checkpoints it instead of skipping it.
84
+ @latest_after_dirty = true
78
85
 
79
86
  raw_meta = session_data.dig(:time_machine, :task_meta) || {}
80
87
  @task_meta = raw_meta.each_with_object({}) do |(k, v), h|
@@ -99,7 +106,20 @@ module Clacky
99
106
  last_error = session_data.dig(:stats, :last_error)
100
107
 
101
108
  if last_status == "error" && last_error
102
- @pending_error_rollback = true
109
+ real_users = @history.real_user_messages
110
+ task_tagged_history = real_users.any? { |message| !message[:task_id].nil? }
111
+ failed_turn_persisted = real_users.any? do |message|
112
+ message[:task_id].to_s == @current_task_id.to_s
113
+ end
114
+
115
+ # Modern sessions identify each user turn by task_id. Only schedule
116
+ # the legacy deferred rollback when the failed task's user message
117
+ # actually survived in history. Attachment setup can now roll its
118
+ # staged turn back before the error snapshot is saved; blindly
119
+ # removing the last user message after restore would then delete the
120
+ # preceding successful turn. Sessions without task ids keep the old
121
+ # fallback for backward compatibility.
122
+ @pending_error_rollback = true if !task_tagged_history || failed_turn_persisted
103
123
  end
104
124
 
105
125
  saved_reasoning = session_data.dig(:config, :reasoning_effort)
@@ -244,6 +264,9 @@ module Clacky
244
264
  session_id: @session_id,
245
265
  name: @name,
246
266
  pinned: @pinned,
267
+ # Kept next to the other scalars so SessionManager's header-only
268
+ # reader can pick it up without parsing the message payload.
269
+ archived_at: @archived_at,
247
270
  created_at: @created_at,
248
271
  updated_at: stamp,
249
272
  working_dir: @working_dir,
@@ -263,6 +286,7 @@ module Clacky
263
286
  # model_name is saved so the session can restore its original model on restart
264
287
  # (falling back to the current default if the model no longer exists).
265
288
  permission_mode: @config.permission_mode.to_s,
289
+ permission_mode_override: @permission_mode_override,
266
290
  enable_compression: @config.enable_compression,
267
291
  enable_idle_compression: @config.enable_idle_compression,
268
292
  enable_prompt_caching: @config.enable_prompt_caching,
@@ -410,7 +434,7 @@ module Clacky
410
434
  # @param chunk_path [String] Path to the chunk md file
411
435
  # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
412
436
  private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new, excluded_paths: Set.new,
413
- byte_range: nil, index: nil, round_offset: 0)
437
+ byte_range: nil, index: nil, round_offset: 0, continue_round: nil)
414
438
  return [] unless chunk_path
415
439
 
416
440
  # 1. Try the stored absolute path first (same machine, normal case).
@@ -486,7 +510,7 @@ module Clacky
486
510
 
487
511
  # Group into rounds: each user section starts a new round
488
512
  rounds = []
489
- current_round = nil
513
+ current_round = continue_round
490
514
  round_index = 0
491
515
 
492
516
  sections.each do |sec|
@@ -543,12 +567,15 @@ module Clacky
543
567
  # Old: "_Tool calls: name1, name2_" (backward compat)
544
568
  remaining_lines = []
545
569
  pending_tool_entries = [] # [{name:, args:}]
570
+ created_at = nil
546
571
 
547
572
  text.each_line do |line|
548
573
  stripped = line.strip
549
574
  if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
550
575
  raw = m[1]
551
576
  pending_tool_entries.concat(parse_tool_calls_line(raw))
577
+ elsif (m = stripped.match(/\A_Created at: (\d+(?:\.\d+)?)_\z/))
578
+ created_at = m[1].to_f
552
579
  else
553
580
  remaining_lines << line
554
581
  end
@@ -557,7 +584,7 @@ module Clacky
557
584
  # Flush any plain text
558
585
  plain_text = remaining_lines.join.strip
559
586
  unless plain_text.empty?
560
- current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty? }
587
+ current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty?, created_at: created_at }
561
588
  end
562
589
 
563
590
  # Emit one synthetic tool_calls message per detected tool
@@ -583,6 +610,14 @@ module Clacky
583
610
  []
584
611
  end
585
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
+
586
621
 
587
622
  # Pull lightweight attachment badge metadata out of a chunk section.
588
623
  # The marker is internal archive data and must not appear in replayed text.
@@ -756,6 +791,12 @@ module Clacky
756
791
  # Special handling: the ask_user question is shown as an assistant
757
792
  # message (matching real-time behavior), not as a tool call.
758
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
759
800
  questions = Clacky::Tools::AskUser.normalize_questions(args)
760
801
  context = Clacky::Tools::AskUser.fetch_key(args, :context).to_s
761
802
  first = questions.first