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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- 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
|
-
|
|
125
|
-
return
|
|
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("💡
|
|
137
|
+
puts_line("💡")
|
|
138
|
+
lines.each { |line| puts_line(line) }
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
def log(message, level: :info)
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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-
|
|
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-
|
|
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" => "
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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) }
|