openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Clacky
6
+ # Reassembles an OpenAI Responses API event stream into the non-streaming
7
+ # response shape that MessageFormat::OpenAIResponses.parse_response consumes,
8
+ # while invoking on_chunk(input_tokens:, output_tokens:) for progress updates.
9
+ #
10
+ # The Responses API uses typed SSE events rather than the Chat Completions
11
+ # delta-based format. Key event types:
12
+ #
13
+ # response.created -> response object created
14
+ # response.output_item.added -> new output item (message or function_call)
15
+ # response.output_text.delta -> text content delta
16
+ # response.output_text.done -> text content complete
17
+ # response.reasoning_text.delta -> reasoning content delta (OpenAI official / DeepSeek)
18
+ # response.function_call_arguments.delta -> tool call argument delta
19
+ # response.output_item.done -> output item complete
20
+ # response.completed -> final event with full response + usage
21
+ # response.done -> terminal event (OpenAI official API)
22
+ # response.incomplete -> response ended prematurely
23
+ #
24
+ # Unlike Chat Completions, there is no "[DONE]" sentinel; the stream ends
25
+ # with a "response.completed" / "response.done" (or "response.incomplete") event.
26
+ class OpenAIResponsesStreamAggregator
27
+ def initialize(on_chunk: nil)
28
+ @on_chunk = on_chunk
29
+ @text = +""
30
+ @reasoning_text = +""
31
+ @function_calls = {} # call_id -> { name:, arguments: }
32
+ @call_id_by_item_id = {} # item_id -> call_id (for delta routing)
33
+ @usage = nil
34
+ @status = nil
35
+ @incomplete_details = nil
36
+ @last_input_tokens = 0
37
+ @last_output_tokens = 0
38
+ @parse_failures = 0
39
+ @frames_seen = 0
40
+ @bytes_seen = 0
41
+ end
42
+
43
+ attr_reader :parse_failures, :frames_seen, :bytes_seen
44
+
45
+ def saw_done?
46
+ !@status.nil?
47
+ end
48
+
49
+ def handle(data_str)
50
+ @bytes_seen += data_str.bytesize
51
+ @frames_seen += 1
52
+ data = parse_or_nil(data_str)
53
+ return unless data
54
+
55
+ event_type = data["type"]
56
+
57
+ case event_type
58
+ when "response.output_text.delta"
59
+ @text << data["delta"].to_s
60
+ emit_estimate_progress
61
+ # OpenAI's official API emits response.reasoning_text.delta; keep
62
+ # response.reasoning.delta as a tolerant alias for providers that
63
+ # rename it.
64
+ when "response.reasoning_text.delta", "response.reasoning.delta"
65
+ @reasoning_text << data["delta"].to_s
66
+ when "response.output_item.added"
67
+ handle_item_added(data)
68
+ when "response.function_call_arguments.delta"
69
+ handle_function_call_delta(data)
70
+ when "response.output_item.done"
71
+ handle_item_done(data)
72
+ when "response.completed", "response.done"
73
+ # OpenAI's official API sends response.completed followed by
74
+ # response.done as the terminal event; DeepSeek sends only
75
+ # response.completed. Either one marks a successful end.
76
+ @status = "completed"
77
+ response = data["response"]
78
+ @usage ||= response["usage"] if response && response["usage"]
79
+ # Also sync output items from the final response if our incremental
80
+ # tracking missed anything (e.g. provider sent complete items only).
81
+ sync_from_final_output(response["output"]) if response && response["output"]
82
+ emit_usage_progress(@usage) if @usage
83
+ when "response.incomplete"
84
+ @status = "incomplete"
85
+ # Preserve incomplete_details (e.g. reason: "max_output_tokens" vs
86
+ # "content_filter") so finish_reason mapping can distinguish a token
87
+ # limit from a safety-filtered response.
88
+ response = data["response"]
89
+ @incomplete_details = response["incomplete_details"] if response.is_a?(Hash) && response["incomplete_details"]
90
+ when "response.created", "response.in_progress"
91
+ # Informational events, no action needed
92
+ end
93
+ end
94
+
95
+ # Render into the non-streaming Responses API response shape
96
+ # so MessageFormat::OpenAIResponses.parse_response works unchanged.
97
+ def to_h
98
+ output = []
99
+
100
+ # Message item with text content
101
+ unless @text.empty?
102
+ output << {
103
+ "type" => "message",
104
+ "role" => "assistant",
105
+ "content" => [{ "type" => "output_text", "text" => @text.to_s }]
106
+ }
107
+ end
108
+
109
+ # Reasoning item. Both providers stream reasoning via
110
+ # response.reasoning(_text).delta; emit it as a top-level "reasoning"
111
+ # item (DeepSeek shape) so MessageFormat::OpenAIResponses#extract_reasoning
112
+ # can consume it. Without this the streaming path silently drops
113
+ # reasoning content while the non-streaming path keeps it.
114
+ unless @reasoning_text.empty?
115
+ output << {
116
+ "type" => "reasoning",
117
+ "content" => [{ "type" => "reasoning_text", "text" => @reasoning_text.to_s }]
118
+ }
119
+ end
120
+
121
+ # Function call items
122
+ @function_calls.each_value do |fc|
123
+ output << {
124
+ "type" => "function_call",
125
+ "call_id" => fc[:call_id],
126
+ "name" => fc[:name],
127
+ "arguments" => fc[:arguments].to_s
128
+ }
129
+ end
130
+
131
+ result = {
132
+ "output" => output,
133
+ "status" => @status || "completed",
134
+ "usage" => @usage || {}
135
+ }
136
+ result["incomplete_details"] = @incomplete_details if @incomplete_details
137
+ result
138
+ end
139
+
140
+ private def handle_item_added(data)
141
+ item = data["item"]
142
+ return unless item
143
+
144
+ if item["type"] == "function_call"
145
+ call_id = item["call_id"]
146
+ item_id = item["id"]
147
+ @call_id_by_item_id[item_id] = call_id if item_id
148
+ @function_calls[call_id] = {
149
+ call_id: call_id,
150
+ name: item["name"],
151
+ arguments: +""
152
+ }
153
+ end
154
+ end
155
+
156
+ private def handle_function_call_delta(data)
157
+ # The delta event carries item_id to identify which function call
158
+ # the arguments belong to. Map it to the call_id we registered in
159
+ # handle_item_added. Fall back to the most recent call if mapping fails.
160
+ item_id = data["item_id"]
161
+ call_id = @call_id_by_item_id[item_id] || @function_calls.keys.last
162
+
163
+ return unless call_id && @function_calls[call_id]
164
+
165
+ @function_calls[call_id][:arguments] << data["delta"].to_s
166
+ end
167
+
168
+ private def handle_item_done(data)
169
+ item = data["item"]
170
+ return unless item
171
+
172
+ if item["type"] == "function_call"
173
+ call_id = item["call_id"]
174
+ fc = @function_calls[call_id]
175
+ # Use the complete arguments from the done event if available -
176
+ # more reliable than accumulated deltas.
177
+ if fc && item["arguments"]
178
+ fc[:arguments] = item["arguments"]
179
+ end
180
+ end
181
+ end
182
+
183
+ private def sync_from_final_output(output)
184
+ return unless output.is_a?(Array)
185
+
186
+ # Sync per-item: only backfill what incremental tracking missed.
187
+ # A provider may send complete message items but only deltas for
188
+ # function calls (or vice versa), so don't require both to be empty.
189
+ output.each do |item|
190
+ case item["type"]
191
+ when "message"
192
+ content = item["content"]
193
+ next unless content.is_a?(Array)
194
+
195
+ content.each do |block|
196
+ case block["type"]
197
+ when "output_text"
198
+ @text << block["text"].to_s if @text.empty?
199
+ when "reasoning"
200
+ backfill_reasoning(block["content"], block["summary"])
201
+ end
202
+ end
203
+ when "reasoning"
204
+ backfill_reasoning(item["content"], item["summary"])
205
+ when "function_call"
206
+ next if @function_calls.key?(item["call_id"])
207
+ @function_calls[item["call_id"]] = {
208
+ call_id: item["call_id"],
209
+ name: item["name"],
210
+ arguments: item["arguments"].to_s
211
+ }
212
+ end
213
+ end
214
+ end
215
+
216
+ # Backfill @reasoning_text from the final response snapshot when no
217
+ # reasoning delta was seen. Handles both item shapes: OpenAI's official
218
+ # reasoning block inside the message item, and DeepSeek's top-level
219
+ # reasoning item. Prefers full reasoning text; falls back to the summary
220
+ # (mirrors MessageFormat::OpenAIResponses#extract_reasoning).
221
+ private def backfill_reasoning(content_blocks, summary_blocks = nil)
222
+ return unless @reasoning_text.empty?
223
+
224
+ return if append_typed_blocks(content_blocks, "reasoning_text")
225
+
226
+ append_typed_blocks(content_blocks, "summary_text") || append_typed_blocks(summary_blocks, "summary_text")
227
+ end
228
+
229
+ # Append every block of the given type to @reasoning_text.
230
+ # Returns true when at least one block matched.
231
+ private def append_typed_blocks(blocks, type)
232
+ return false unless blocks.is_a?(Array)
233
+
234
+ found = false
235
+ blocks.each do |block|
236
+ next unless block.is_a?(Hash) && block["type"] == type
237
+
238
+ @reasoning_text << block["text"].to_s if block["text"]
239
+ found = true
240
+ end
241
+ found
242
+ end
243
+
244
+ private def parse_or_nil(s)
245
+ # Some providers / proxies append a "[DONE]" sentinel (inherited from
246
+ # Chat Completions SSE) even for the Responses API. Silently ignore it
247
+ # instead of logging a parse failure.
248
+ return nil if s.to_s.strip == "[DONE]"
249
+
250
+ JSON.parse(s)
251
+ rescue JSON::ParserError => e
252
+ @parse_failures += 1
253
+ if @parse_failures == 1
254
+ Clacky::Logger.warn("stream.parse_failure",
255
+ provider: "openai-responses",
256
+ error: "#{e.class}: #{e.message}",
257
+ frame_head: s.to_s[0, 200],
258
+ frame_bytes: s.to_s.bytesize
259
+ )
260
+ end
261
+ nil
262
+ end
263
+
264
+ private def emit_estimate_progress
265
+ return unless @on_chunk
266
+ output = approximate_output_tokens
267
+ return if output == @last_output_tokens
268
+ @last_output_tokens = output
269
+ @on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
270
+ rescue => e
271
+ Clacky::Logger.warn("[OpenAIResponsesStreamAggregator] on_chunk: #{e.class}: #{e.message}")
272
+ end
273
+
274
+ # Rough char/4 estimate; replaced by the real count when the upstream
275
+ # emits the response.completed event with usage data.
276
+ private def approximate_output_tokens
277
+ total_chars = @text.bytesize + @reasoning_text.bytesize +
278
+ @function_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
279
+ (total_chars / 4.0).ceil
280
+ end
281
+
282
+ private def emit_usage_progress(u)
283
+ return unless @on_chunk
284
+ total_prompt = u["input_tokens"].to_i
285
+ output = u["output_tokens"].to_i
286
+ return if total_prompt == @last_input_tokens && output == @last_output_tokens
287
+ @last_input_tokens = total_prompt
288
+ @last_output_tokens = output
289
+ @on_chunk.call(input_tokens: total_prompt, output_tokens: output)
290
+ rescue => e
291
+ Clacky::Logger.warn("[OpenAIResponsesStreamAggregator] on_chunk: #{e.class}: #{e.message}")
292
+ end
293
+ end
294
+ end
@@ -16,7 +16,7 @@ module Clacky
16
16
 
17
17
  # === Output display ===
18
18
 
19
- def show_assistant_message(content, files:, interim: false)
19
+ def show_assistant_message(content, files:, interim: false, created_at: nil)
20
20
  puts_line(content) unless content.nil? || content.strip.empty?
21
21
  files.each { |f| puts_line("📄 File: #{f[:path]}") }
22
22
  end
@@ -87,7 +87,7 @@ module Clacky
87
87
  puts_line("[shell] #{command}")
88
88
  end
89
89
 
90
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
90
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
91
91
  parts = ["[done] iterations=#{iterations}", "cost=$#{cost.round(4)}"]
92
92
  parts << "duration=#{duration.round(1)}s" if duration
93
93
  puts_line(parts.join(" "))
@@ -121,11 +121,21 @@ module Clacky
121
121
  def emit(type, **data)
122
122
  return unless type == "ext.advisor.recommendations"
123
123
 
124
- content = (data[:content] || data["content"]).to_s.strip
125
- return if content.empty?
124
+ options = data[:options] || data["options"]
125
+ return unless options.is_a?(Array)
126
+
127
+ lines = options.map do |opt|
128
+ action = (opt[:action] || opt["action"]).to_s.strip
129
+ next if action.empty?
130
+
131
+ reason = (opt[:reason] || opt["reason"]).to_s.strip
132
+ reason.empty? ? "- #{action}" : "- #{action} (#{reason})"
133
+ end.compact
134
+ return if lines.empty?
126
135
 
127
136
  puts_line("")
128
- puts_line("💡 #{content}")
137
+ puts_line("💡")
138
+ lines.each { |line| puts_line(line) }
129
139
  end
130
140
 
131
141
  def log(message, level: :info)
@@ -31,6 +31,7 @@ module Clacky
31
31
  "api" => "bedrock",
32
32
  "default_model" => "abs-claude-sonnet-5",
33
33
  "models" => [
34
+ "abs-claude-fable-5-1",
34
35
  "abs-claude-fable-5",
35
36
  "abs-claude-opus-5",
36
37
  "abs-claude-opus-4-8",
@@ -44,6 +45,8 @@ module Clacky
44
45
  "dsk-deepseek-v4-flash",
45
46
  "dsk-deepseek-v4-flash-vision-exp",
46
47
  "or-gemini-3-1-pro",
48
+ "or-gemini-3-8-flash",
49
+ "or-gemini-3-7-flash",
47
50
  "or-gemini-3-6-flash",
48
51
  "or-gemini-3-5-flash"
49
52
  ],
@@ -96,34 +99,42 @@ module Clacky
96
99
  # routes them to Vertex AI Gemini (generateContent with inline
97
100
  # audio parts). The gateway returns transcription text.
98
101
  "stt_models" => [
102
+ "or-stt-gemini-3-8-flash",
103
+ "or-stt-gemini-3-7-flash",
99
104
  "or-stt-gemini-3-6-flash",
100
105
  "or-stt-gemini-3-5-flash",
101
106
  "or-stt-gemini-1-5-pro"
102
107
  ],
103
108
  "stt_model_aliases" => {
109
+ "or-stt-gemini-3-8-flash" => "Gemini 3.8 Flash STT",
110
+ "or-stt-gemini-3-7-flash" => "Gemini 3.7 Flash STT",
104
111
  "or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
105
112
  "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
106
113
  "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
107
114
  },
108
- "default_stt_model" => "or-stt-gemini-3-6-flash",
115
+ "default_stt_model" => "or-stt-gemini-3-8-flash",
109
116
  # Video understanding models served by the openclacky gateway, which
110
117
  # routes video frames to Gemini (generateContent with inline image
111
118
  # parts). The gateway returns analysis text.
112
119
  "video_understanding_models" => [
120
+ "or-gemini-3-8-flash",
121
+ "or-gemini-3-7-flash",
113
122
  "or-gemini-3-6-flash",
114
123
  "or-gemini-3-5-flash",
115
124
  "or-gemini-3-1-pro"
116
125
  ],
117
126
  "video_understanding_model_aliases" => {
127
+ "or-gemini-3-8-flash" => "Gemini 3.8 Flash",
128
+ "or-gemini-3-7-flash" => "Gemini 3.7 Flash",
118
129
  "or-gemini-3-6-flash" => "Gemini 3.6 Flash",
119
130
  "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
120
131
  "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
121
132
  },
122
- "default_video_understanding_model" => "or-gemini-3-6-flash",
133
+ "default_video_understanding_model" => "or-gemini-3-8-flash",
123
134
  # Default OCR sidecar — used when the primary model is text-only.
124
135
  # Candidates are derived from the provider's vision-capable models;
125
136
  # this just picks the cheap+fast default to surface in "auto" mode.
126
- "default_ocr_model" => "or-gemini-3-6-flash",
137
+ "default_ocr_model" => "dsk-deepseek-v4-flash-vision-exp",
127
138
  # Provider-level default: the Claude family served here is vision-capable.
128
139
  "capabilities" => { "vision" => true }.freeze,
129
140
  # Model-level overrides: DeepSeek models routed through this provider
@@ -143,6 +154,7 @@ module Clacky
143
154
  # themselves, so they're intentionally not listed here as keys —
144
155
  # no injection happens when the default model is already lite-class.
145
156
  "lite_models" => {
157
+ "abs-claude-fable-5-1" => "abs-claude-haiku-4-5",
146
158
  "abs-claude-fable-5" => "abs-claude-haiku-4-5",
147
159
  "abs-claude-opus-5" => "abs-claude-haiku-4-5",
148
160
  "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
@@ -157,6 +169,7 @@ module Clacky
157
169
  # Fallback chain: if a model is unavailable, try the next one in order.
158
170
  # Keys are primary model names; values are the fallback model to use instead.
159
171
  "fallback_models" => {
172
+ "abs-claude-fable-5-1" => "abs-claude-fable-5",
160
173
  "abs-claude-fable-5" => "abs-claude-opus-5",
161
174
  "abs-claude-opus-5" => "abs-claude-opus-4-8",
162
175
  "abs-claude-sonnet-5" => "abs-claude-sonnet-4-6",
@@ -271,7 +284,7 @@ module Clacky
271
284
  "base_url" => "https://open.bigmodel.cn/api/paas/v4",
272
285
  "api" => "openai-completions",
273
286
  "default_model" => "glm-5.3",
274
- "models" => ["glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
287
+ "models" => ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
275
288
  # Zhipu / Z.ai expose four functionally-equivalent endpoints:
276
289
  # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
277
290
  # each with a general-billing and a Coding-Plan subpath. They share the
@@ -287,10 +300,12 @@ module Clacky
287
300
  { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
288
301
  { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
289
302
  ].freeze,
290
- # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
303
+ # GLM models are text-only except the vision-capable SKUs: glm-5v-turbo
304
+ # ("v" = visual) and glm-5.3-flash (GLM-5's first natively-multimodal model).
291
305
  "capabilities" => { "vision" => false }.freeze,
292
306
  "model_capabilities" => {
293
- "glm-5v-turbo" => { "vision" => true }.freeze
307
+ "glm-5v-turbo" => { "vision" => true }.freeze,
308
+ "glm-5.3-flash" => { "vision" => true }.freeze
294
309
  }.freeze,
295
310
  "default_ocr_model" => "glm-5v-turbo",
296
311
  "website_url" => "https://open.bigmodel.cn/console/overview"
@@ -441,8 +456,9 @@ module Clacky
441
456
  "name" => "Qwen (Alibaba)",
442
457
  "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
443
458
  "api" => "openai-completions",
444
- "default_model" => "qwen3.7-max",
459
+ "default_model" => "qwen3.8-max",
445
460
  "models" => [
461
+ "qwen3.8-max",
446
462
  "qwen3.7-max",
447
463
  "qwen3.6-plus",
448
464
  "qwen3.6-max",
@@ -461,6 +477,7 @@ module Clacky
461
477
  }.freeze,
462
478
  "default_ocr_model" => "qwen3.6-flash",
463
479
  "lite_models" => {
480
+ "qwen3.8-max" => "qwen3.6-flash",
464
481
  "qwen3.7-max" => "qwen3.6-flash",
465
482
  "qwen3.6-plus" => "qwen3.6-flash",
466
483
  "qwen3.6-max" => "qwen3.6-flash",
@@ -201,7 +201,7 @@ module Clacky
201
201
  end
202
202
  end
203
203
 
204
- def show_assistant_message(content, files:, interim: false)
204
+ def show_assistant_message(content, files:, interim: false, created_at: nil)
205
205
  thinking_text, clean_text = extract_thinking_and_content(content)
206
206
  unless thinking_text.to_s.strip.empty?
207
207
  # Show live thinking with spinner + timer in fixed area
@@ -312,7 +312,7 @@ module Clacky
312
312
  @shell.sidebar.update_context(token_data) if @shell.sidebar
313
313
  end
314
314
 
315
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
315
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
316
316
  set_idle_status
317
317
  return if awaiting_user_feedback || iterations <= 5
318
318
 
@@ -26,16 +26,20 @@ module Clacky
26
26
  # @param session_builder [Proc] (name:, working_dir:) => session_id — from HttpServer
27
27
  # @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
28
28
  # @param interrupt_session [Proc] (session_id) — from HttpServer
29
+ # @param persist_session [Proc] (agent) — from HttpServer; flushes the agent to
30
+ # its session file. Needed because clearing a stale channel_info must survive a
31
+ # restart, and ChannelManager has no direct access to the session store.
29
32
  # @param channel_config [Clacky::ChannelConfig]
30
33
  # @param binding_mode [:chat | :chat_user | :user] how to map IM identities to sessions.
31
34
  # :chat (default) — one session per chat (all users in a chat share it).
32
35
  # :chat_user — one session per (chat, user) pair.
33
36
  # :user — one session per user (merges all chats).
34
- def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat)
37
+ def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, persist_session: nil, binding_mode: :chat)
35
38
  @registry = session_registry
36
39
  @session_builder = session_builder
37
40
  @run_agent_task = run_agent_task
38
41
  @interrupt_session = interrupt_session
42
+ @persist_session = persist_session
39
43
  @channel_config = channel_config
40
44
  @binding_mode = binding_mode
41
45
  @adapters = []
@@ -391,32 +395,49 @@ module Clacky
391
395
  end
392
396
 
393
397
  # Detach channel_ui from the old session's web_ui, reattach to the new one.
394
- # Also clear the old session's persisted agent.channel_info if it still
395
- # matches this key — keeping channel_keys and channel_info strictly in sync
396
- # so resolve_session never sees two sessions claim the same key via different
397
- # sources (see comment in resolve_session).
398
+ # Stale channel_info for this key is then cleared from every other session
399
+ # (including ones evicted from memory) so resolve_session and
400
+ # restore_channel_bindings never see two sessions claim the same key.
398
401
  old_session_id = resolve_session(event)
399
402
  channel_ui = old_session_id ? channel_ui_for_session(old_session_id) : nil
403
+ cleared_agent = nil
400
404
 
401
405
  if channel_ui
402
406
  @registry.with_session(old_session_id) do |s|
403
407
  s[:ui]&.unsubscribe_channel(channel_ui)
404
408
  s.delete(:channel_ui)
405
- if s[:agent]&.respond_to?(:channel_info=) && s[:agent].respond_to?(:channel_info) &&
406
- s[:agent].channel_info && channel_key_from_info(s[:agent].channel_info) == key
407
- s[:agent].channel_info = nil
409
+ agent = s[:agent]
410
+ if agent&.channel_info && channel_key_from_info(agent.channel_info) == key
411
+ agent.channel_info = nil
412
+ cleared_agent = agent
408
413
  end
409
414
  end
410
415
  else
411
416
  channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) }, -> { @channel_config.status_messages_enabled? }, -> { @channel_config.process_messages_enabled? })
412
417
  end
413
418
 
419
+ @persist_session&.call(cleared_agent) if cleared_agent
420
+ clear_channel_info_for_key(key, keep_session_id: session_id)
421
+
414
422
  bind_key_to_session(key, session_id)
423
+ bound_agent = nil
415
424
  @registry.with_session(session_id) do |s|
416
425
  s[:ui]&.subscribe_channel(channel_ui)
417
426
  s[:channel_ui] = channel_ui
427
+
428
+ # Stamp channel_info now instead of waiting for the next inbound message
429
+ # (see route_message): a restart in between would otherwise find no session
430
+ # carrying this key and drop the binding.
431
+ agent = s[:agent]
432
+ if agent
433
+ agent.channel_info = extract_channel_info(event)
434
+ bound_agent = agent
435
+ end
418
436
  end
419
437
 
438
+ # Flush outside with_session: the registry mutex must not be held across file IO.
439
+ @persist_session&.call(bound_agent) if bound_agent
440
+
420
441
  Clacky::Logger.info("[ChannelManager] Bound #{key} -> session #{session_id[0, 8]}")
421
442
  adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}`.")
422
443
 
@@ -431,21 +452,43 @@ module Clacky
431
452
 
432
453
  when "/unbind"
433
454
  unbound = false
455
+ cleared_agents = []
434
456
  @registry.list.each do |summary|
435
457
  @registry.with_session(summary[:id]) do |s|
436
- if s[:channel_keys]&.delete(key)
437
- unbound = true
438
- # Keep agent.channel_info in sync with channel_keys (see resolve_session).
439
- # Without this, after process restart + eviction, the fallback path would
440
- # silently re-bind this key back to the unbinded session via stale
441
- # channel_info, defeating /unbind.
442
- if s[:agent]&.respond_to?(:channel_info=) && s[:agent].respond_to?(:channel_info) &&
443
- s[:agent].channel_info && channel_key_from_info(s[:agent].channel_info) == key
444
- s[:agent].channel_info = nil
445
- end
458
+ # Set#delete always returns self — use delete? so an unknown key
459
+ # reports "No binding found." instead of a false "Unbound.".
460
+ next unless s[:channel_keys]&.delete?(key)
461
+
462
+ unbound = true
463
+
464
+ agent = s[:agent]
465
+ if agent&.channel_info && channel_key_from_info(agent.channel_info) == key
466
+ # Clear in memory here rather than relying on clear_channel_info_for_key:
467
+ # that scan reads the on-disk summary, which misses a channel_info that
468
+ # was stamped on this live agent but not persisted yet.
469
+ agent.channel_info = nil
470
+ cleared_agents << agent
471
+ end
472
+
473
+ # Detach channel_ui once the last key is gone, otherwise outbound
474
+ # broadcasts keep reaching the IM chat through web_ui's subscriber
475
+ # list even though inbound messages no longer resolve this session.
476
+ # Keys are a Set: a session may still be reachable via another key.
477
+ if s[:channel_keys].empty?
478
+ s[:ui]&.unsubscribe_channel(s[:channel_ui]) if s[:channel_ui]
479
+ s.delete(:channel_ui)
446
480
  end
447
481
  end
448
482
  end
483
+
484
+ if unbound
485
+ cleared_agents.each { |agent| @persist_session&.call(agent) }
486
+ # Drop channel_info everywhere else too: restore_channel_bindings would
487
+ # otherwise hand the key to an older session that still carries a stale
488
+ # copy on disk, silently re-binding it after a restart.
489
+ clear_channel_info_for_key(key)
490
+ end
491
+
449
492
  adapter.send_text(chat_id, unbound ? "Unbound." : "No binding found.")
450
493
 
451
494
  when "/status"
@@ -750,6 +793,38 @@ module Clacky
750
793
  result
751
794
  end
752
795
 
796
+ # Clear a stale agent.channel_info for `key` across every session except
797
+ # `keep_session_id`. Every inbound message stamps channel_info onto the
798
+ # bound agent (see route_message), so switching bindings leaves the field
799
+ # behind on older sessions. Those leftovers are what restore_channel_bindings
800
+ # reads at startup, letting an abandoned session silently reclaim the key.
801
+ # The on-disk summary is authoritative here, so sessions evicted from
802
+ # memory are cleaned too — which is exactly the case /bind used to miss.
803
+ def clear_channel_info_for_key(key, keep_session_id: nil)
804
+ @registry.list(limit: nil).each do |summary|
805
+ next if summary[:id] == keep_session_id
806
+
807
+ info = summary[:channel_info]
808
+ next unless info.is_a?(Hash) && info[:platform] && info[:user_id] && info[:chat_id]
809
+ next unless channel_key_from_info(info) == key
810
+ next unless @registry.ensure(summary[:id])
811
+
812
+ cleared = nil
813
+ @registry.with_session(summary[:id]) do |s|
814
+ agent = s[:agent]
815
+ next unless agent&.channel_info && channel_key_from_info(agent.channel_info) == key
816
+
817
+ agent.channel_info = nil
818
+ cleared = agent
819
+ end
820
+ next unless cleared
821
+
822
+ # Flush outside with_session: the registry mutex must not be held across file IO.
823
+ @persist_session&.call(cleared)
824
+ Clacky::Logger.info("[ChannelManager] Cleared stale channel_info #{key} from session #{summary[:id][0, 8]}")
825
+ end
826
+ end
827
+
753
828
  def bind_key_to_session(key, session_id)
754
829
  @registry.list.each do |summary|
755
830
  @registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }