openclacky 0.9.22 → 0.9.24
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 +30 -0
- data/lib/clacky/agent/message_compressor_helper.rb +19 -3
- data/lib/clacky/agent/session_serializer.rb +170 -4
- data/lib/clacky/agent.rb +22 -7
- data/lib/clacky/agent_config.rb +4 -2
- data/lib/clacky/cli.rb +4 -1
- data/lib/clacky/client.rb +3 -3
- data/lib/clacky/message_format/anthropic.rb +14 -2
- data/lib/clacky/message_format/bedrock.rb +20 -5
- data/lib/clacky/providers.rb +14 -4
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
- data/lib/clacky/server/http_server.rb +29 -25
- data/lib/clacky/server/session_registry.rb +26 -10
- data/lib/clacky/session_manager.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +17 -0
- data/lib/clacky/tools/web_search.rb +21 -67
- data/lib/clacky/utils/file_processor.rb +1 -1
- data/lib/clacky/utils/scripts_manager.rb +1 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +281 -34
- data/lib/clacky/web/app.js +130 -22
- data/lib/clacky/web/channels.js +0 -1
- data/lib/clacky/web/i18n.js +26 -8
- data/lib/clacky/web/index.html +46 -22
- data/lib/clacky/web/onboard.js +0 -1
- data/lib/clacky/web/sessions.js +215 -104
- data/lib/clacky/web/settings.js +0 -1
- data/lib/clacky/web/skills.js +3 -6
- data/lib/clacky/web/tasks.js +2 -4
- data/scripts/install_system_deps.sh +254 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5338bcdeaf4b2aafed416365f56467a99a6107911b24df2bfe9d94d757d03528
|
|
4
|
+
data.tar.gz: 8e06f6ca6b0d5a0a9c70ab758fc13b2f84d755f7d61e30a9708fdadd4db82309
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7862cd557ea5ba9e88184b314fd88fc820d1f4e0c5a9bbebbfcdee75f33301876aff7df7f13cf07eaa21c7fb47d0efa65e7f051c1641ced6c20d1142e9e52819
|
|
7
|
+
data.tar.gz: b33e3cc3331e70328d366a38b67e472c760eb6bbe4b5621532ee4ae60585303d9042c4953f29f31e4256f357765d8aafee2b3e69fa87f31c386f9310be790c46
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.24] - 2026-04-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **New session list & search in Web UI**: sidebar now shows full session history with real-time search — find any past conversation instantly
|
|
14
|
+
- **Session type indicators**: sessions are labeled by type (chat / agent) so you can see at a glance what kind of interaction it was
|
|
15
|
+
- **Image lightbox**: click any image in the chat to expand it full-screen with a clean overlay viewer
|
|
16
|
+
- **Session history replay for streaming messages**: chunk-based (streaming) messages are now fully replayed when revisiting a past session
|
|
17
|
+
- **Xiaomi AI provider**: added Xiaomi as a supported AI provider
|
|
18
|
+
- **Chinese Bing web search**: web search now uses cn.bing.com for users in China, improving search relevance and reliability
|
|
19
|
+
- **Auto-install system dependencies script**: agent can now automatically install missing system packages (Node, Python, etc.) via a bundled `install_system_deps.sh` script
|
|
20
|
+
- **User message timestamps**: each user message now displays the time it was sent
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Bedrock file attachments & partial cost tracking**: fixed file handling and cost accumulation for AWS Bedrock sessions
|
|
24
|
+
- **Session name timestamp**: fixed incorrect timestamp display on session names
|
|
25
|
+
- **New session scroll**: new sessions now correctly scroll to the latest message
|
|
26
|
+
- **Feishu WebSocket client crash**: fixed a nil-reference error that caused the Feishu WS client to crash on reconnect
|
|
27
|
+
|
|
28
|
+
## [0.9.23] - 2026-04-01
|
|
29
|
+
|
|
30
|
+
### Improved
|
|
31
|
+
- **API client model parameter propagation**: the Client class now accepts and uses an explicit model parameter, enabling better model detection and API routing across all client instantiation points (CLI, agent, subagent)
|
|
32
|
+
- **Bedrock API detection**: improved detection of Bedrock Converse API usage by checking both API key prefix (ABSK) and model prefix (abs-), providing more robust handling of Bedrock models
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- **CLI -c option model initialization**: fixed a bug where the CLI command with -c option was not passing the model name to the client, causing routing failures for certain providers
|
|
36
|
+
|
|
37
|
+
### More
|
|
38
|
+
- ClackyAI provider updated to use the latest model name format (abs- prefix)
|
|
39
|
+
|
|
10
40
|
## [0.9.22] - 2026-03-31
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -305,9 +305,11 @@ module Clacky
|
|
|
305
305
|
return nil unless @session_id && @created_at
|
|
306
306
|
|
|
307
307
|
# Messages being compressed = original minus system message minus recent messages
|
|
308
|
+
# Also exclude system-injected scaffolding (session context, memory prompts, etc.)
|
|
309
|
+
# — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
|
|
308
310
|
recent_set = recent_messages.to_a
|
|
309
311
|
messages_to_archive = original_messages.reject do |m|
|
|
310
|
-
m[:role] == "system" || recent_set.include?(m)
|
|
312
|
+
m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
|
|
311
313
|
end
|
|
312
314
|
|
|
313
315
|
return nil if messages_to_archive.empty?
|
|
@@ -374,9 +376,23 @@ module Clacky
|
|
|
374
376
|
end
|
|
375
377
|
lines << ""
|
|
376
378
|
# Include tool calls summary if present
|
|
379
|
+
# Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
|
|
377
380
|
if msg[:tool_calls]&.any?
|
|
378
|
-
|
|
379
|
-
|
|
381
|
+
tc_parts = msg[:tool_calls].map do |tc|
|
|
382
|
+
name = tc.dig(:function, :name) || tc[:name] || ""
|
|
383
|
+
next nil if name.empty?
|
|
384
|
+
|
|
385
|
+
args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
|
|
386
|
+
args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
|
|
387
|
+
if args.is_a?(Hash) && !args.empty?
|
|
388
|
+
# Truncate large string values to keep chunk MD readable
|
|
389
|
+
compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
|
|
390
|
+
"#{name} | #{compact.to_json}"
|
|
391
|
+
else
|
|
392
|
+
name
|
|
393
|
+
end
|
|
394
|
+
end.compact
|
|
395
|
+
lines << "_Tool calls: #{tc_parts.join("; ")}_"
|
|
380
396
|
lines << ""
|
|
381
397
|
end
|
|
382
398
|
lines << format_message_content(content) if content
|
|
@@ -114,6 +114,7 @@ module Clacky
|
|
|
114
114
|
# Replay conversation history by calling ui.show_* methods for each message.
|
|
115
115
|
# Supports cursor-based pagination using created_at timestamps on user messages.
|
|
116
116
|
# Each "round" starts at a user message and includes all subsequent assistant/tool messages.
|
|
117
|
+
# Compressed chunks (chunk_path on assistant messages) are transparently expanded.
|
|
117
118
|
#
|
|
118
119
|
# @param ui [Object] UI interface that responds to show_user_message, show_assistant_message, etc.
|
|
119
120
|
# @param limit [Integer] Maximum number of rounds (user turns) to replay
|
|
@@ -147,9 +148,19 @@ module Clacky
|
|
|
147
148
|
rounds << current_round
|
|
148
149
|
elsif current_round
|
|
149
150
|
current_round[:events] << msg
|
|
151
|
+
elsif msg[:compressed_summary] && msg[:chunk_path]
|
|
152
|
+
# Compressed summary sitting before any user rounds — expand it from chunk md
|
|
153
|
+
chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
|
|
154
|
+
rounds.concat(chunk_rounds)
|
|
150
155
|
end
|
|
151
156
|
end
|
|
152
157
|
|
|
158
|
+
# Expand any compressed_summary assistant messages sitting inside a round's events.
|
|
159
|
+
# These occur when compression happened mid-round (rare) — expand them in-place.
|
|
160
|
+
rounds.each do |round|
|
|
161
|
+
round[:events].select! { |ev| !ev[:compressed_summary] }
|
|
162
|
+
end
|
|
163
|
+
|
|
153
164
|
# Apply before-cursor filter: only rounds whose user message created_at < before
|
|
154
165
|
if before
|
|
155
166
|
rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
|
|
@@ -193,6 +204,159 @@ module Clacky
|
|
|
193
204
|
{ has_more: has_more }
|
|
194
205
|
end
|
|
195
206
|
|
|
207
|
+
# Parse a chunk MD file into an array of rounds compatible with replay_history.
|
|
208
|
+
# Each round is { user_msg: Hash, events: Array<Hash> }.
|
|
209
|
+
# Timestamps are synthesised from the chunk's archived_at, spread backwards.
|
|
210
|
+
# Recursively expands nested chunk references (compressed summary inside a chunk).
|
|
211
|
+
#
|
|
212
|
+
# @param chunk_path [String] Path to the chunk md file
|
|
213
|
+
# @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
|
|
214
|
+
private def parse_chunk_md_to_rounds(chunk_path)
|
|
215
|
+
return [] unless chunk_path && File.exist?(chunk_path.to_s)
|
|
216
|
+
|
|
217
|
+
raw = File.read(chunk_path.to_s, encoding: "utf-8")
|
|
218
|
+
|
|
219
|
+
# Parse YAML front matter to get archived_at for synthetic timestamps
|
|
220
|
+
archived_at = nil
|
|
221
|
+
if raw.start_with?("---")
|
|
222
|
+
fm_end = raw.index("\n---\n", 4)
|
|
223
|
+
if fm_end
|
|
224
|
+
fm_text = raw[4...fm_end]
|
|
225
|
+
fm_text.each_line do |line|
|
|
226
|
+
if line.start_with?("archived_at:")
|
|
227
|
+
archived_at = Time.parse(line.split(":", 2).last.strip) rescue nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
base_time = (archived_at || Time.now).to_f
|
|
233
|
+
chunk_dir = File.dirname(chunk_path.to_s)
|
|
234
|
+
|
|
235
|
+
# Split into sections by ## headings
|
|
236
|
+
sections = []
|
|
237
|
+
current_role = nil
|
|
238
|
+
current_lines = []
|
|
239
|
+
current_nested_chunk = nil # chunk reference from a Compressed Summary heading
|
|
240
|
+
|
|
241
|
+
raw.each_line do |line|
|
|
242
|
+
stripped = line.chomp
|
|
243
|
+
if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
|
|
244
|
+
# Nested chunk reference — record it, treat as assistant section
|
|
245
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
246
|
+
current_role = "assistant"
|
|
247
|
+
current_lines = []
|
|
248
|
+
current_nested_chunk = File.join(chunk_dir, m[1])
|
|
249
|
+
elsif stripped.match?(/\A## (User|Assistant)/)
|
|
250
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
251
|
+
current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
|
|
252
|
+
current_lines = []
|
|
253
|
+
current_nested_chunk = nil
|
|
254
|
+
elsif stripped.match?(/\A### Tool Result:/)
|
|
255
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
256
|
+
current_role = "tool"
|
|
257
|
+
current_lines = []
|
|
258
|
+
current_nested_chunk = nil
|
|
259
|
+
else
|
|
260
|
+
current_lines << line
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
264
|
+
|
|
265
|
+
# Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
|
|
266
|
+
sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
|
|
267
|
+
|
|
268
|
+
# Group into rounds: each user section starts a new round
|
|
269
|
+
rounds = []
|
|
270
|
+
current_round = nil
|
|
271
|
+
round_index = 0
|
|
272
|
+
|
|
273
|
+
sections.each do |sec|
|
|
274
|
+
text = sec[:lines].join.strip
|
|
275
|
+
|
|
276
|
+
# Nested chunk: expand it recursively, prepend before current rounds
|
|
277
|
+
if sec[:nested_chunk]
|
|
278
|
+
nested = parse_chunk_md_to_rounds(sec[:nested_chunk])
|
|
279
|
+
rounds = nested + rounds unless nested.empty?
|
|
280
|
+
# Also render its summary text as an assistant event in current round if any
|
|
281
|
+
if current_round && !text.empty?
|
|
282
|
+
current_round[:events] << { role: "assistant", content: text }
|
|
283
|
+
end
|
|
284
|
+
next
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
next if text.empty?
|
|
288
|
+
|
|
289
|
+
if sec[:role] == "user"
|
|
290
|
+
round_index += 1
|
|
291
|
+
# Synthetic timestamp: spread rounds backwards from archived_at
|
|
292
|
+
synthetic_ts = base_time - (sections.size - round_index) * 1.0
|
|
293
|
+
current_round = {
|
|
294
|
+
user_msg: {
|
|
295
|
+
role: "user",
|
|
296
|
+
content: text,
|
|
297
|
+
created_at: synthetic_ts,
|
|
298
|
+
_from_chunk: true
|
|
299
|
+
},
|
|
300
|
+
events: []
|
|
301
|
+
}
|
|
302
|
+
rounds << current_round
|
|
303
|
+
elsif current_round
|
|
304
|
+
if sec[:role] == "assistant"
|
|
305
|
+
# Detect "_Tool calls: ..._" lines — convert to tool_calls events
|
|
306
|
+
# so _replay_single_message renders them as tool group UI (same as live).
|
|
307
|
+
#
|
|
308
|
+
# Formats supported:
|
|
309
|
+
# New: "_Tool calls: name | {"arg":"val"}; name2 | {"k":"v"}_"
|
|
310
|
+
# Old: "_Tool calls: name1, name2_" (backward compat)
|
|
311
|
+
remaining_lines = []
|
|
312
|
+
pending_tool_entries = [] # [{name:, args:}]
|
|
313
|
+
|
|
314
|
+
text.each_line do |line|
|
|
315
|
+
stripped = line.strip
|
|
316
|
+
if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
|
|
317
|
+
raw = m[1]
|
|
318
|
+
# New format uses ";" as separator between tools (each entry: "name | {json}")
|
|
319
|
+
# Old format uses "," with no JSON part.
|
|
320
|
+
entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
|
|
321
|
+
entries.each do |entry|
|
|
322
|
+
entry = entry.strip
|
|
323
|
+
if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
|
|
324
|
+
tool_name = parts[1].strip
|
|
325
|
+
args = JSON.parse(parts[2]) rescue {}
|
|
326
|
+
pending_tool_entries << { name: tool_name, args: args }
|
|
327
|
+
else
|
|
328
|
+
pending_tool_entries << { name: entry, args: {} }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
else
|
|
332
|
+
remaining_lines << line
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Flush any plain text
|
|
337
|
+
plain_text = remaining_lines.join.strip
|
|
338
|
+
current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
|
|
339
|
+
|
|
340
|
+
# Emit one synthetic tool_calls message per detected tool
|
|
341
|
+
pending_tool_entries.each do |entry|
|
|
342
|
+
current_round[:events] << {
|
|
343
|
+
role: "assistant",
|
|
344
|
+
content: "",
|
|
345
|
+
tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
|
|
346
|
+
}
|
|
347
|
+
end
|
|
348
|
+
else
|
|
349
|
+
current_round[:events] << { role: "tool", content: text }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
rounds
|
|
355
|
+
rescue => e
|
|
356
|
+
Clacky::Logger.warn("parse_chunk_md_to_rounds failed for #{chunk_path}: #{e.message}")
|
|
357
|
+
[]
|
|
358
|
+
end
|
|
359
|
+
|
|
196
360
|
|
|
197
361
|
# Render a single non-user message into the UI.
|
|
198
362
|
# Used by both the normal round-based replay and the compressed-session fallback.
|
|
@@ -328,12 +492,14 @@ module Clacky
|
|
|
328
492
|
next unless block[:type] == "image_url"
|
|
329
493
|
|
|
330
494
|
url = block.dig(:image_url, :url)
|
|
331
|
-
|
|
495
|
+
# image_path is stored at send-time so replay can reconstruct the image from tmp
|
|
496
|
+
path = block[:image_path]
|
|
497
|
+
|
|
498
|
+
next unless url&.start_with?("data:") || path
|
|
332
499
|
|
|
333
|
-
|
|
334
|
-
mime_type = url[/\Adata:([^;]+);/, 1] || "image/jpeg"
|
|
500
|
+
mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
|
|
335
501
|
ext = mime_type.split("/").last
|
|
336
|
-
{ name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url }
|
|
502
|
+
{ name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
|
|
337
503
|
end
|
|
338
504
|
end
|
|
339
505
|
end
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -131,6 +131,7 @@ module Clacky
|
|
|
131
131
|
@client = Clacky::Client.new(
|
|
132
132
|
@config.api_key,
|
|
133
133
|
base_url: @config.base_url,
|
|
134
|
+
model: @config.model_name,
|
|
134
135
|
anthropic_format: @config.anthropic_format?
|
|
135
136
|
)
|
|
136
137
|
# Update message compressor with new client and model
|
|
@@ -217,7 +218,9 @@ module Clacky
|
|
|
217
218
|
all_disk_files = disk_files + downgraded
|
|
218
219
|
|
|
219
220
|
# Format user message — text + inline vision images
|
|
220
|
-
|
|
221
|
+
# Store the tmp path alongside the data_url so the history replay can
|
|
222
|
+
# reconstruct the image if the base64 was stripped (e.g. after compression).
|
|
223
|
+
user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } })
|
|
221
224
|
|
|
222
225
|
# Parse disk files — agent's responsibility, not the upload layer.
|
|
223
226
|
# process_path runs the parser script and returns a FileRef with preview_path or parse_error.
|
|
@@ -923,6 +926,7 @@ module Clacky
|
|
|
923
926
|
subagent_client = Clacky::Client.new(
|
|
924
927
|
subagent_config.api_key,
|
|
925
928
|
base_url: subagent_config.base_url,
|
|
929
|
+
model: subagent_config.model_name,
|
|
926
930
|
anthropic_format: subagent_config.anthropic_format?
|
|
927
931
|
)
|
|
928
932
|
|
|
@@ -1109,15 +1113,25 @@ module Clacky
|
|
|
1109
1113
|
|
|
1110
1114
|
# Build user message content for LLM.
|
|
1111
1115
|
# Returns plain String when no vision images; Array of content parts otherwise.
|
|
1112
|
-
|
|
1113
|
-
|
|
1116
|
+
# Build user message content for LLM.
|
|
1117
|
+
# vision_images: Array of String (plain url) OR Hash { url:, path: }
|
|
1118
|
+
# path is stored in the block so history replay can reconstruct the image
|
|
1119
|
+
# from the tmp file when the base64 data_url is no longer available.
|
|
1120
|
+
private def format_user_content(text, vision_images)
|
|
1121
|
+
vision_images ||= []
|
|
1114
1122
|
|
|
1115
|
-
return text if
|
|
1123
|
+
return text if vision_images.empty?
|
|
1116
1124
|
|
|
1117
1125
|
content = []
|
|
1118
1126
|
content << { type: "text", text: text } unless text.nil? || text.empty?
|
|
1119
|
-
|
|
1120
|
-
|
|
1127
|
+
vision_images.each do |img|
|
|
1128
|
+
if img.is_a?(Hash)
|
|
1129
|
+
block = { type: "image_url", image_url: { url: img[:url] } }
|
|
1130
|
+
block[:image_path] = img[:path] if img[:path]
|
|
1131
|
+
content << block
|
|
1132
|
+
else
|
|
1133
|
+
content << { type: "image_url", image_url: { url: img } }
|
|
1134
|
+
end
|
|
1121
1135
|
end
|
|
1122
1136
|
content
|
|
1123
1137
|
end
|
|
@@ -1154,7 +1168,8 @@ module Clacky
|
|
|
1154
1168
|
"Today is #{Time.now.strftime('%Y-%m-%d, %A')}",
|
|
1155
1169
|
"Current model: #{current_model}",
|
|
1156
1170
|
os != :unknown ? "OS: #{Clacky::Utils::EnvironmentDetector.os_label}" : nil,
|
|
1157
|
-
desktop ? "Desktop: #{desktop}" : nil
|
|
1171
|
+
desktop ? "Desktop: #{desktop}" : nil,
|
|
1172
|
+
"Working directory: #{@working_dir}"
|
|
1158
1173
|
].compact.join(". ")
|
|
1159
1174
|
|
|
1160
1175
|
content = "[Session context: #{parts}]"
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -325,9 +325,9 @@ module Clacky
|
|
|
325
325
|
current_model&.dig("anthropic_format") || false
|
|
326
326
|
end
|
|
327
327
|
|
|
328
|
-
# Check if current model uses
|
|
328
|
+
# Check if current model uses Bedrock Converse API (ABSK key prefix or abs- model prefix)
|
|
329
329
|
def bedrock?
|
|
330
|
-
Clacky::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s)
|
|
330
|
+
Clacky::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s, model_name.to_s)
|
|
331
331
|
end
|
|
332
332
|
|
|
333
333
|
# Add a new model configuration
|
|
@@ -434,6 +434,8 @@ module Clacky
|
|
|
434
434
|
if data.is_a?(Array)
|
|
435
435
|
# New format: top-level array of model configurations
|
|
436
436
|
models = data.map do |m|
|
|
437
|
+
# Deep copy to avoid shared references between models
|
|
438
|
+
m = m.dup.transform_values { |v| v.is_a?(String) ? v.dup : v }
|
|
437
439
|
# Convert old name-based format to new model-based format if needed
|
|
438
440
|
if m["name"] && !m["model"]
|
|
439
441
|
m["model"] = m["name"]
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -84,7 +84,7 @@ module Clacky
|
|
|
84
84
|
agent_config.verbose = options[:verbose] if options[:verbose]
|
|
85
85
|
|
|
86
86
|
# Create client for current model
|
|
87
|
-
client = Clacky::Client.new(agent_config.api_key, base_url: agent_config.base_url, anthropic_format: agent_config.anthropic_format?)
|
|
87
|
+
client = Clacky::Client.new(agent_config.api_key, base_url: agent_config.base_url, model: agent_config.model_name, anthropic_format: agent_config.anthropic_format?)
|
|
88
88
|
|
|
89
89
|
# Resolve agent profile name from --agent option
|
|
90
90
|
agent_profile = options[:agent] || "coding"
|
|
@@ -137,6 +137,7 @@ module Clacky
|
|
|
137
137
|
test_client = Clacky::Client.new(
|
|
138
138
|
test_config.api_key,
|
|
139
139
|
base_url: test_config.base_url,
|
|
140
|
+
model: test_config.model_name,
|
|
140
141
|
anthropic_format: test_config.anthropic_format?
|
|
141
142
|
)
|
|
142
143
|
|
|
@@ -162,6 +163,7 @@ module Clacky
|
|
|
162
163
|
agent.instance_variable_set(:@client, Clacky::Client.new(
|
|
163
164
|
config.api_key,
|
|
164
165
|
base_url: config.base_url,
|
|
166
|
+
model: config.model_name,
|
|
165
167
|
anthropic_format: config.anthropic_format?
|
|
166
168
|
))
|
|
167
169
|
|
|
@@ -829,6 +831,7 @@ module Clacky
|
|
|
829
831
|
Clacky::Client.new(
|
|
830
832
|
agent_config.api_key,
|
|
831
833
|
base_url: agent_config.base_url,
|
|
834
|
+
model: agent_config.model_name,
|
|
832
835
|
anthropic_format: agent_config.anthropic_format?
|
|
833
836
|
)
|
|
834
837
|
end
|
data/lib/clacky/client.rb
CHANGED
|
@@ -8,13 +8,13 @@ module Clacky
|
|
|
8
8
|
MAX_RETRIES = 10
|
|
9
9
|
RETRY_DELAY = 5 # seconds
|
|
10
10
|
|
|
11
|
-
def initialize(api_key, base_url:, model
|
|
11
|
+
def initialize(api_key, base_url:, model:, anthropic_format: false)
|
|
12
12
|
@api_key = api_key
|
|
13
13
|
@base_url = base_url
|
|
14
14
|
@model = model
|
|
15
15
|
@use_anthropic_format = anthropic_format
|
|
16
|
-
# Detect Bedrock
|
|
17
|
-
@use_bedrock = MessageFormat::Bedrock.bedrock_api_key?(api_key)
|
|
16
|
+
# Detect Bedrock: ABSK key prefix (native AWS) or abs- model prefix (Clacky AI proxy)
|
|
17
|
+
@use_bedrock = MessageFormat::Bedrock.bedrock_api_key?(api_key, model)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# Returns true when the client is using the AWS Bedrock Converse API.
|
|
@@ -168,6 +168,8 @@ module Clacky
|
|
|
168
168
|
# user message as a cache breakpoint, which invalidates the intended cache boundary
|
|
169
169
|
# and results in cache misses (cache_read=0) every turn.
|
|
170
170
|
blocks = content_to_blocks(content)
|
|
171
|
+
# Anthropic rejects messages with an empty content array — use a placeholder text block.
|
|
172
|
+
blocks = [{ type: "text", text: "..." }] if blocks.empty?
|
|
171
173
|
{ role: role, content: blocks }
|
|
172
174
|
end
|
|
173
175
|
|
|
@@ -176,11 +178,17 @@ module Clacky
|
|
|
176
178
|
private_class_method def self.content_to_blocks(content)
|
|
177
179
|
case content
|
|
178
180
|
when String
|
|
181
|
+
# Anthropic rejects blank text blocks — skip empty strings
|
|
182
|
+
return [] if content.empty?
|
|
183
|
+
|
|
179
184
|
[{ type: "text", text: content }]
|
|
180
185
|
when Array
|
|
181
186
|
content.map { |b| normalize_block(b) }.compact
|
|
182
187
|
else
|
|
183
|
-
|
|
188
|
+
str = content.to_s
|
|
189
|
+
return [] if str.empty?
|
|
190
|
+
|
|
191
|
+
[{ type: "text", text: str }]
|
|
184
192
|
end
|
|
185
193
|
end
|
|
186
194
|
|
|
@@ -190,8 +198,12 @@ module Clacky
|
|
|
190
198
|
|
|
191
199
|
case block[:type]
|
|
192
200
|
when "text"
|
|
201
|
+
# Anthropic rejects blank text blocks — drop them instead of sending { type:"text", text:"" }
|
|
202
|
+
text = block[:text]
|
|
203
|
+
return nil if text.nil? || text.empty?
|
|
204
|
+
|
|
193
205
|
# Preserve cache_control if present (placed by Client#apply_message_caching)
|
|
194
|
-
result = { type: "text", text:
|
|
206
|
+
result = { type: "text", text: text }
|
|
195
207
|
result[:cache_control] = block[:cache_control] if block[:cache_control]
|
|
196
208
|
result
|
|
197
209
|
when "image_url"
|
|
@@ -16,9 +16,12 @@ module Clacky
|
|
|
16
16
|
#
|
|
17
17
|
# This module converts canonical format ↔ Bedrock Converse API format.
|
|
18
18
|
module Bedrock
|
|
19
|
-
# Detect if the
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
# Detect if the request should use the Bedrock Converse API.
|
|
20
|
+
# Matches either:
|
|
21
|
+
# - API key with "ABSK" prefix (native AWS Bedrock)
|
|
22
|
+
# - Model ID with "abs-" prefix (Clacky AI proxy that speaks Bedrock Converse)
|
|
23
|
+
def self.bedrock_api_key?(api_key, model)
|
|
24
|
+
api_key.to_s.start_with?("ABSK") || model.to_s.start_with?("abs-")
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
module_function
|
|
@@ -177,6 +180,8 @@ module Clacky
|
|
|
177
180
|
|
|
178
181
|
# regular user/assistant message
|
|
179
182
|
blocks = content_to_blocks(content)
|
|
183
|
+
# Bedrock rejects messages with an empty content array — use a placeholder text block.
|
|
184
|
+
blocks = [{ text: "..." }] if blocks.empty?
|
|
180
185
|
{ role: role, content: blocks }
|
|
181
186
|
end
|
|
182
187
|
|
|
@@ -184,11 +189,17 @@ module Clacky
|
|
|
184
189
|
private_class_method def self.content_to_blocks(content)
|
|
185
190
|
case content
|
|
186
191
|
when String
|
|
192
|
+
# Bedrock rejects blank text blocks — skip empty strings
|
|
193
|
+
return [] if content.empty?
|
|
194
|
+
|
|
187
195
|
[{ text: content }]
|
|
188
196
|
when Array
|
|
189
197
|
content.map { |b| normalize_block(b) }.compact
|
|
190
198
|
else
|
|
191
|
-
|
|
199
|
+
str = content.to_s
|
|
200
|
+
return [] if str.empty?
|
|
201
|
+
|
|
202
|
+
[{ text: str }]
|
|
192
203
|
end
|
|
193
204
|
end
|
|
194
205
|
|
|
@@ -198,7 +209,11 @@ module Clacky
|
|
|
198
209
|
|
|
199
210
|
case block[:type]
|
|
200
211
|
when "text"
|
|
201
|
-
|
|
212
|
+
# Bedrock rejects blank text blocks — drop them
|
|
213
|
+
text = block[:text].to_s
|
|
214
|
+
return nil if text.empty?
|
|
215
|
+
|
|
216
|
+
{ text: text }
|
|
202
217
|
when "image_url"
|
|
203
218
|
# Bedrock image format — base64 only
|
|
204
219
|
url = block.dig(:image_url, :url) || block[:url]
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -49,15 +49,25 @@ module Clacky
|
|
|
49
49
|
}.freeze,
|
|
50
50
|
|
|
51
51
|
"clackyai" => {
|
|
52
|
-
"name" => "
|
|
52
|
+
"name" => "ClackyAI",
|
|
53
53
|
"base_url" => "https://api.clacky.ai",
|
|
54
54
|
"api" => "bedrock",
|
|
55
|
-
"default_model" => "
|
|
55
|
+
"default_model" => "abs-claude-sonnet-4-6",
|
|
56
56
|
"models" => [
|
|
57
|
-
"
|
|
58
|
-
"
|
|
57
|
+
"abs-claude-opus-4-6",
|
|
58
|
+
"abs-claude-sonnet-4-6",
|
|
59
|
+
"abs-claude-haiku-4-5"
|
|
59
60
|
],
|
|
60
61
|
"website_url" => "https://clacky.ai"
|
|
62
|
+
}.freeze,
|
|
63
|
+
|
|
64
|
+
"mimo" => {
|
|
65
|
+
"name" => "MiMo (Xiaomi)",
|
|
66
|
+
"base_url" => "https://api.xiaomimimo.com/v1",
|
|
67
|
+
"api" => "openai-completions",
|
|
68
|
+
"default_model" => "mimo-v2-pro",
|
|
69
|
+
"models" => ["mimo-v2-pro", "mimo-v2-omni"],
|
|
70
|
+
"website_url" => "https://platform.xiaomimimo.com/"
|
|
61
71
|
}.freeze
|
|
62
72
|
|
|
63
73
|
}.freeze
|
|
@@ -160,6 +160,12 @@ module Clacky
|
|
|
160
160
|
@ping_interval = (client_config["PingInterval"] || 90).to_i
|
|
161
161
|
|
|
162
162
|
url = data.dig("data", "URL")
|
|
163
|
+
if url.nil? || url.strip.empty?
|
|
164
|
+
Clacky::Logger.error("[feishu-ws] WebSocket endpoint URL is missing from response. " \
|
|
165
|
+
"Please verify your Feishu App ID and App Secret are correct.")
|
|
166
|
+
raise "Failed to get WebSocket endpoint: URL is missing (check your Feishu App ID / App Secret)"
|
|
167
|
+
end
|
|
168
|
+
|
|
163
169
|
if url =~ /service_id=(\d+)/
|
|
164
170
|
@service_id = $1.to_i
|
|
165
171
|
end
|