openclacky 0.9.23 → 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 +19 -1
- data/lib/clacky/agent/message_compressor_helper.rb +19 -3
- data/lib/clacky/agent/session_serializer.rb +170 -4
- data/lib/clacky/agent.rb +20 -7
- data/lib/clacky/message_format/anthropic.rb +14 -2
- data/lib/clacky/message_format/bedrock.rb +14 -2
- data/lib/clacky/providers.rb +9 -0
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
- data/lib/clacky/server/http_server.rb +27 -24
- data/lib/clacky/server/session_registry.rb +26 -10
- 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,24 @@ 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
|
+
|
|
10
28
|
## [0.9.23] - 2026-04-01
|
|
11
29
|
|
|
12
30
|
### Improved
|
|
@@ -17,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
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
|
|
18
36
|
|
|
19
37
|
### More
|
|
20
|
-
-
|
|
38
|
+
- ClackyAI provider updated to use the latest model name format (abs- prefix)
|
|
21
39
|
|
|
22
40
|
## [0.9.22] - 2026-03-31
|
|
23
41
|
|
|
@@ -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
|
@@ -218,7 +218,9 @@ module Clacky
|
|
|
218
218
|
all_disk_files = disk_files + downgraded
|
|
219
219
|
|
|
220
220
|
# Format user message — text + inline vision images
|
|
221
|
-
|
|
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] } })
|
|
222
224
|
|
|
223
225
|
# Parse disk files — agent's responsibility, not the upload layer.
|
|
224
226
|
# process_path runs the parser script and returns a FileRef with preview_path or parse_error.
|
|
@@ -1111,15 +1113,25 @@ module Clacky
|
|
|
1111
1113
|
|
|
1112
1114
|
# Build user message content for LLM.
|
|
1113
1115
|
# Returns plain String when no vision images; Array of content parts otherwise.
|
|
1114
|
-
|
|
1115
|
-
|
|
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 ||= []
|
|
1116
1122
|
|
|
1117
|
-
return text if
|
|
1123
|
+
return text if vision_images.empty?
|
|
1118
1124
|
|
|
1119
1125
|
content = []
|
|
1120
1126
|
content << { type: "text", text: text } unless text.nil? || text.empty?
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
|
1123
1135
|
end
|
|
1124
1136
|
content
|
|
1125
1137
|
end
|
|
@@ -1156,7 +1168,8 @@ module Clacky
|
|
|
1156
1168
|
"Today is #{Time.now.strftime('%Y-%m-%d, %A')}",
|
|
1157
1169
|
"Current model: #{current_model}",
|
|
1158
1170
|
os != :unknown ? "OS: #{Clacky::Utils::EnvironmentDetector.os_label}" : nil,
|
|
1159
|
-
desktop ? "Desktop: #{desktop}" : nil
|
|
1171
|
+
desktop ? "Desktop: #{desktop}" : nil,
|
|
1172
|
+
"Working directory: #{@working_dir}"
|
|
1160
1173
|
].compact.join(". ")
|
|
1161
1174
|
|
|
1162
1175
|
content = "[Session context: #{parts}]"
|
|
@@ -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"
|
|
@@ -180,6 +180,8 @@ module Clacky
|
|
|
180
180
|
|
|
181
181
|
# regular user/assistant message
|
|
182
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?
|
|
183
185
|
{ role: role, content: blocks }
|
|
184
186
|
end
|
|
185
187
|
|
|
@@ -187,11 +189,17 @@ module Clacky
|
|
|
187
189
|
private_class_method def self.content_to_blocks(content)
|
|
188
190
|
case content
|
|
189
191
|
when String
|
|
192
|
+
# Bedrock rejects blank text blocks — skip empty strings
|
|
193
|
+
return [] if content.empty?
|
|
194
|
+
|
|
190
195
|
[{ text: content }]
|
|
191
196
|
when Array
|
|
192
197
|
content.map { |b| normalize_block(b) }.compact
|
|
193
198
|
else
|
|
194
|
-
|
|
199
|
+
str = content.to_s
|
|
200
|
+
return [] if str.empty?
|
|
201
|
+
|
|
202
|
+
[{ text: str }]
|
|
195
203
|
end
|
|
196
204
|
end
|
|
197
205
|
|
|
@@ -201,7 +209,11 @@ module Clacky
|
|
|
201
209
|
|
|
202
210
|
case block[:type]
|
|
203
211
|
when "text"
|
|
204
|
-
|
|
212
|
+
# Bedrock rejects blank text blocks — drop them
|
|
213
|
+
text = block[:text].to_s
|
|
214
|
+
return nil if text.empty?
|
|
215
|
+
|
|
216
|
+
{ text: text }
|
|
205
217
|
when "image_url"
|
|
206
218
|
# Bedrock image format — base64 only
|
|
207
219
|
url = block.dig(:image_url, :url) || block[:url]
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -59,6 +59,15 @@ module Clacky
|
|
|
59
59
|
"abs-claude-haiku-4-5"
|
|
60
60
|
],
|
|
61
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/"
|
|
62
71
|
}.freeze
|
|
63
72
|
|
|
64
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
|
|
@@ -34,7 +34,18 @@ module Clacky
|
|
|
34
34
|
rendered = Array(files).filter_map do |f|
|
|
35
35
|
url = f[:data_url] || f["data_url"]
|
|
36
36
|
name = f[:name] || f["name"]
|
|
37
|
-
|
|
37
|
+
path = f[:path] || f["path"]
|
|
38
|
+
|
|
39
|
+
if url
|
|
40
|
+
url
|
|
41
|
+
elsif path && File.exist?(path.to_s)
|
|
42
|
+
# Reconstruct data_url from the tmp file (still present on disk)
|
|
43
|
+
Utils::FileProcessor.image_path_to_data_url(path) rescue "expired:#{name}"
|
|
44
|
+
elsif name
|
|
45
|
+
# File badge for non-image disk files, or image whose tmp file is gone
|
|
46
|
+
type = f[:type] || f["type"] || ""
|
|
47
|
+
type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
|
|
48
|
+
end
|
|
38
49
|
end
|
|
39
50
|
ev[:images] = rendered unless rendered.empty?
|
|
40
51
|
@events << ev
|
|
@@ -393,12 +404,16 @@ module Clacky
|
|
|
393
404
|
|
|
394
405
|
def api_list_sessions(req, res)
|
|
395
406
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
396
|
-
limit = [query["limit"].to_i.then { |n| n > 0 ? n :
|
|
397
|
-
before = query["before"].to_s.strip.then
|
|
398
|
-
|
|
399
|
-
|
|
407
|
+
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
|
|
408
|
+
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
409
|
+
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
410
|
+
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
411
|
+
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
412
|
+
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
413
|
+
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
414
|
+
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
400
415
|
# Fetch one extra to detect has_more without a separate count query
|
|
401
|
-
sessions = @registry.list(limit: limit + 1, before: before,
|
|
416
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
|
|
402
417
|
has_more = sessions.size > limit
|
|
403
418
|
sessions = sessions.first(limit)
|
|
404
419
|
json_response(res, 200, { sessions: sessions, has_more: has_more })
|
|
@@ -1774,24 +1789,12 @@ module Clacky
|
|
|
1774
1789
|
interrupt_session(session_id)
|
|
1775
1790
|
|
|
1776
1791
|
when "list_sessions"
|
|
1777
|
-
# Initial load:
|
|
1778
|
-
#
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
"cron" => { source: "cron", profile: "general" },
|
|
1784
|
-
"channel" => { source: "channel", profile: "general" },
|
|
1785
|
-
"setup" => { source: "setup", profile: "general" },
|
|
1786
|
-
"coding" => { profile: "coding" },
|
|
1787
|
-
}
|
|
1788
|
-
by_bucket = buckets.each_with_object({}) do |(key, params), h|
|
|
1789
|
-
page = @registry.list(limit: 6, **params) # +1 to detect has_more
|
|
1790
|
-
h[key] = { sessions: page.first(5), has_more: page.size > 5 }
|
|
1791
|
-
end
|
|
1792
|
-
all_sessions = by_bucket.values.flat_map { |v| v[:sessions] }.uniq { |s| s[:id] }
|
|
1793
|
-
has_more_map = by_bucket.transform_values { |v| v[:has_more] }
|
|
1794
|
-
conn.send_json(type: "session_list", sessions: all_sessions, has_more_by_source: has_more_map)
|
|
1792
|
+
# Initial load: newest 20 sessions regardless of source/profile.
|
|
1793
|
+
# Single unified query — frontend shows all in one time-sorted list.
|
|
1794
|
+
page = @registry.list(limit: 21)
|
|
1795
|
+
has_more = page.size > 20
|
|
1796
|
+
all_sessions = page.first(20)
|
|
1797
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more)
|
|
1795
1798
|
|
|
1796
1799
|
when "run_task"
|
|
1797
1800
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -145,24 +145,40 @@ module Clacky
|
|
|
145
145
|
# before: ISO8601 cursor — only sessions with created_at < before
|
|
146
146
|
#
|
|
147
147
|
# source and profile are orthogonal — either can be nil independently.
|
|
148
|
-
def list(limit: nil, before: nil,
|
|
148
|
+
def list(limit: nil, before: nil, q: nil, date: nil, type: nil)
|
|
149
149
|
return [] unless @session_manager
|
|
150
150
|
|
|
151
151
|
live = @mutex.synchronize do
|
|
152
152
|
@sessions.transform_values do |s|
|
|
153
153
|
model_info = s[:agent]&.current_model_info
|
|
154
|
-
|
|
154
|
+
live_name = s[:agent]&.name
|
|
155
|
+
live_name = nil if live_name&.empty?
|
|
156
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
|
|
157
|
+
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost }
|
|
155
158
|
end
|
|
156
159
|
end
|
|
157
160
|
|
|
158
161
|
all = @session_manager.all_sessions # already sorted newest-first
|
|
159
162
|
|
|
160
|
-
# ──
|
|
161
|
-
|
|
162
|
-
#
|
|
163
|
+
# ── type filter (replaces old source/profile split) ──────────────────
|
|
164
|
+
# type=coding → agent_profile == "coding"
|
|
165
|
+
# type=manual/cron/channel/setup → source match (profile=general implied)
|
|
166
|
+
if type
|
|
167
|
+
if type == "coding"
|
|
168
|
+
all = all.select { |s| (s[:agent_profile] || "general").to_s == "coding" }
|
|
169
|
+
else
|
|
170
|
+
all = all.select { |s| s_source(s) == type && (s[:agent_profile] || "general").to_s != "coding" }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
163
173
|
|
|
164
|
-
# ──
|
|
165
|
-
all = all.select { |s|
|
|
174
|
+
# ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
|
|
175
|
+
all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
|
|
176
|
+
|
|
177
|
+
# ── name search ──────────────────────────────────────────────────────
|
|
178
|
+
if q && !q.empty?
|
|
179
|
+
q_down = q.downcase
|
|
180
|
+
all = all.select { |s| (s[:name] || "").downcase.include?(q_down) }
|
|
181
|
+
end
|
|
166
182
|
|
|
167
183
|
all = all.select { |s| (s[:created_at] || "") < before } if before
|
|
168
184
|
all = all.first(limit) if limit
|
|
@@ -172,7 +188,7 @@ module Clacky
|
|
|
172
188
|
ls = live[id]
|
|
173
189
|
{
|
|
174
190
|
id: id,
|
|
175
|
-
name: s[:name] || "",
|
|
191
|
+
name: ls&.dig(:name) || s[:name] || "",
|
|
176
192
|
status: ls ? ls[:status].to_s : "idle",
|
|
177
193
|
error: ls ? ls[:error] : nil,
|
|
178
194
|
model: ls&.dig(:model),
|
|
@@ -181,8 +197,8 @@ module Clacky
|
|
|
181
197
|
working_dir: s[:working_dir],
|
|
182
198
|
created_at: s[:created_at],
|
|
183
199
|
updated_at: s[:updated_at],
|
|
184
|
-
total_tasks: s.dig(:stats, :total_tasks) || 0,
|
|
185
|
-
total_cost: s.dig(:stats, :total_cost_usd) || 0.0,
|
|
200
|
+
total_tasks: ls&.dig(:total_tasks) || s.dig(:stats, :total_tasks) || 0,
|
|
201
|
+
total_cost: ls&.dig(:total_cost) || s.dig(:stats, :total_cost_usd) || 0.0,
|
|
186
202
|
}
|
|
187
203
|
end
|
|
188
204
|
end
|
|
@@ -61,6 +61,15 @@ module Clacky
|
|
|
61
61
|
# 4. Call parent class execution method
|
|
62
62
|
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines, output_buffer: output_buffer, working_dir: working_dir)
|
|
63
63
|
|
|
64
|
+
# 4a. If macOS xcode-select shim detected, replace stderr with actionable message
|
|
65
|
+
if xcode_tools_missing?(result[:stderr])
|
|
66
|
+
result = result.merge(
|
|
67
|
+
stderr: "Xcode Command Line Tools are not installed.\nRun: bash ~/.clacky/scripts/install_system_deps.sh\nThen retry the original command.",
|
|
68
|
+
exit_code: 1,
|
|
69
|
+
success: false
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
64
73
|
# 5. Enhance result information
|
|
65
74
|
enhance_result(result, command, safe_command, safety_replacer)
|
|
66
75
|
|
|
@@ -294,6 +303,14 @@ module Clacky
|
|
|
294
303
|
return text if text.length <= max_length
|
|
295
304
|
"#{text[0...max_length-3]}..."
|
|
296
305
|
end
|
|
306
|
+
|
|
307
|
+
# Returns true if stderr contains the macOS xcode-select shim message,
|
|
308
|
+
# which appears when Xcode Command Line Tools are not installed and the
|
|
309
|
+
# user (or LLM) tries to run python3, git, make, gcc, etc.
|
|
310
|
+
def xcode_tools_missing?(stderr)
|
|
311
|
+
return false if stderr.nil? || stderr.empty?
|
|
312
|
+
stderr.include?("xcode-select") && stderr.include?("No developer tools were found")
|
|
313
|
+
end
|
|
297
314
|
end
|
|
298
315
|
|
|
299
316
|
class CommandSafetyReplacer
|