openclacky 0.9.23 → 0.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/lib/clacky/agent/message_compressor_helper.rb +24 -4
  4. data/lib/clacky/agent/session_serializer.rb +197 -4
  5. data/lib/clacky/agent.rb +20 -7
  6. data/lib/clacky/default_skills/channel-setup/SKILL.md +24 -8
  7. data/lib/clacky/default_skills/product-help/SKILL.md +14 -1
  8. data/lib/clacky/message_format/anthropic.rb +14 -2
  9. data/lib/clacky/message_format/bedrock.rb +14 -2
  10. data/lib/clacky/providers.rb +9 -0
  11. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
  12. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +47 -14
  13. data/lib/clacky/server/http_server.rb +99 -40
  14. data/lib/clacky/server/server_master.rb +1 -0
  15. data/lib/clacky/server/session_registry.rb +35 -14
  16. data/lib/clacky/tools/safe_shell.rb +21 -0
  17. data/lib/clacky/tools/shell.rb +66 -28
  18. data/lib/clacky/tools/web_search.rb +21 -67
  19. data/lib/clacky/utils/file_processor.rb +35 -3
  20. data/lib/clacky/utils/scripts_manager.rb +2 -1
  21. data/lib/clacky/version.rb +1 -1
  22. data/lib/clacky/web/app.css +281 -34
  23. data/lib/clacky/web/app.js +134 -22
  24. data/lib/clacky/web/channels.js +0 -1
  25. data/lib/clacky/web/i18n.js +26 -8
  26. data/lib/clacky/web/index.html +47 -23
  27. data/lib/clacky/web/onboard.js +0 -1
  28. data/lib/clacky/web/sessions.js +214 -104
  29. data/lib/clacky/web/settings.js +0 -1
  30. data/lib/clacky/web/skills.js +3 -6
  31. data/lib/clacky/web/tasks.js +2 -4
  32. data/lib/clacky/web/weixin-qr.html +108 -3
  33. data/scripts/install.ps1 +1 -1
  34. data/scripts/install_browser.sh +48 -0
  35. data/scripts/install_system_deps.sh +254 -0
  36. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1add81a8b60017ed8ed70e090af57bd68d0dce5670124fab1c13b878e57d21d
4
- data.tar.gz: 1607062100acf56326e62d75a2fa0599d4eee61686785fceed1e16578c025c6c
3
+ metadata.gz: 931a504ff578fcee85cf5d361feeca8b772a0aafe3438c4f27cc7da52573453a
4
+ data.tar.gz: f1cef39e28c531fef2b2b0fb3cf435ffb05729fdeaae12dadb4e338df073ec1e
5
5
  SHA512:
6
- metadata.gz: 3d499b6484f327b0cdcdaaba21019c9a5b314ba73910c03fde7d64be880e365dea1d1a5bd82cdf993647b11e5b749cd667567265897b99d4e5f98678651ea464
7
- data.tar.gz: f02f3b6317c4971152d055a4b6861d53bf719e8278bc65dd3031a80f718937f93c92add6b589b3c3396589e85d1dca4cf1100a440c65691352b0ab4e273c7f43
6
+ metadata.gz: 8b8bc2a7dd702beb4570e40264b63b75f170c26dea56a354b20c9afdd1d97fb0ef1e2a8010457170d23823ed6bc82353bbfd390c9ef8f6137ad9bbf73e15ee17
7
+ data.tar.gz: dc13201eda667f31435309a3664503fd53f1973983e0494f2b29b2ff5c0fca336b9801c265aa18830e1878e0a970237de16ab41833992119d8ea24f2eea66b62
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.25] - 2026-04-02
11
+
12
+ ### Added
13
+ - **CSV file upload support**: you can now upload `.csv` files in the Web UI — agent can read and analyse tabular data directly
14
+ - **Browser install tips**: when a browser-dependent command fails, the agent now shows a clear install tip with instructions to set up Chrome/Edge, rather than a cryptic error
15
+ - **Auto-focus on file upload dialog**: the file input field is now auto-focused when the upload dialog opens, improving keyboard UX
16
+ - **Session ID search in Web UI**: you can now search sessions by session ID in addition to session name
17
+
18
+ ### Fixed
19
+ - **WeChat (Weixin) file upload**: fixed a bug where file attachments sent via WeChat were not correctly forwarded to the agent
20
+ - **WeChat without browser**: WeChat channel now works even when no browser tool is configured — falls back gracefully
21
+ - **API message timeout**: fixed a race condition in message compression / session serialisation that could cause API requests to time out mid-conversation
22
+ - **Session chunk replay**: fixed a bug where streaming (chunk-based) messages were incorrectly replayed when restoring a session
23
+
24
+ ### Improved
25
+ - **Shell tool robustness**: `pkill` commands are now scope-limited to prevent accidental process kills; server process cleans up properly when the terminal is closed
26
+ - **Broken pipe handling**: improved error handling in the HTTP server and shell tool to avoid noisy broken-pipe errors on abrupt connection close
27
+
28
+ ### More
29
+ - Updated product-help skill with new session search and CSV upload documentation
30
+ - Updated channel-setup skill with improved WeChat non-browser setup guide
31
+
32
+ ## [0.9.24] - 2026-04-02
33
+
34
+ ### Added
35
+ - **New session list & search in Web UI**: sidebar now shows full session history with real-time search — find any past conversation instantly
36
+ - **Session type indicators**: sessions are labeled by type (chat / agent) so you can see at a glance what kind of interaction it was
37
+ - **Image lightbox**: click any image in the chat to expand it full-screen with a clean overlay viewer
38
+ - **Session history replay for streaming messages**: chunk-based (streaming) messages are now fully replayed when revisiting a past session
39
+ - **Xiaomi AI provider**: added Xiaomi as a supported AI provider
40
+ - **Chinese Bing web search**: web search now uses cn.bing.com for users in China, improving search relevance and reliability
41
+ - **Auto-install system dependencies script**: agent can now automatically install missing system packages (Node, Python, etc.) via a bundled `install_system_deps.sh` script
42
+ - **User message timestamps**: each user message now displays the time it was sent
43
+
44
+ ### Fixed
45
+ - **Bedrock file attachments & partial cost tracking**: fixed file handling and cost accumulation for AWS Bedrock sessions
46
+ - **Session name timestamp**: fixed incorrect timestamp display on session names
47
+ - **New session scroll**: new sessions now correctly scroll to the latest message
48
+ - **Feishu WebSocket client crash**: fixed a nil-reference error that caused the Feishu WS client to crash on reconnect
49
+
10
50
  ## [0.9.23] - 2026-04-01
11
51
 
12
52
  ### Improved
@@ -17,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
57
  - **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
58
 
19
59
  ### More
20
- - Rename provider display name to "ClackyAI" for consistency
60
+ - ClackyAI provider updated to use the latest model name format (abs- prefix)
21
61
 
22
62
  ## [0.9.22] - 2026-03-31
23
63
 
@@ -128,7 +128,11 @@ module Clacky
128
128
  original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
129
129
 
130
130
  # Archive compressed messages to a chunk MD file before discarding them
131
- chunk_index = @compressed_summaries.size + 1
131
+ # Count existing compressed_summary messages in history to determine the next chunk index.
132
+ # Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
133
+ # chunk files, creating circular chunk references. Counting from history is always accurate.
134
+ existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
135
+ chunk_index = existing_chunk_count + 1
132
136
  chunk_path = save_compressed_chunk(
133
137
  original_messages,
134
138
  compression_context[:recent_messages],
@@ -305,9 +309,11 @@ module Clacky
305
309
  return nil unless @session_id && @created_at
306
310
 
307
311
  # Messages being compressed = original minus system message minus recent messages
312
+ # Also exclude system-injected scaffolding (session context, memory prompts, etc.)
313
+ # — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
308
314
  recent_set = recent_messages.to_a
309
315
  messages_to_archive = original_messages.reject do |m|
310
- m[:role] == "system" || recent_set.include?(m)
316
+ m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
311
317
  end
312
318
 
313
319
  return nil if messages_to_archive.empty?
@@ -374,9 +380,23 @@ module Clacky
374
380
  end
375
381
  lines << ""
376
382
  # Include tool calls summary if present
383
+ # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
377
384
  if msg[:tool_calls]&.any?
378
- tool_names = msg[:tool_calls].map { |tc| tc.dig(:function, :name) }.compact.join(", ")
379
- lines << "_Tool calls: #{tool_names}_"
385
+ tc_parts = msg[:tool_calls].map do |tc|
386
+ name = tc.dig(:function, :name) || tc[:name] || ""
387
+ next nil if name.empty?
388
+
389
+ args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
390
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
391
+ if args.is_a?(Hash) && !args.empty?
392
+ # Truncate large string values to keep chunk MD readable
393
+ compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
394
+ "#{name} | #{compact.to_json}"
395
+ else
396
+ name
397
+ end
398
+ end.compact
399
+ lines << "_Tool calls: #{tc_parts.join("; ")}_"
380
400
  lines << ""
381
401
  end
382
402
  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,29 @@ 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)
155
+ # After expanding, treat the last chunk round as the current round so that
156
+ # any orphaned assistant/tool messages that follow in session.json (belonging
157
+ # to the same task whose user message was compressed into the chunk) get
158
+ # appended here instead of being silently discarded.
159
+ current_round = rounds.last
160
+ elsif rounds.last
161
+ # Orphaned non-user message with no current_round yet (e.g. recent_messages
162
+ # after compression started mid-task with no leading user message).
163
+ # Attach to the last known round rather than drop silently.
164
+ rounds.last[:events] << msg
150
165
  end
151
166
  end
152
167
 
168
+ # Expand any compressed_summary assistant messages sitting inside a round's events.
169
+ # These occur when compression happened mid-round (rare) — expand them in-place.
170
+ rounds.each do |round|
171
+ round[:events].select! { |ev| !ev[:compressed_summary] }
172
+ end
173
+
153
174
  # Apply before-cursor filter: only rounds whose user message created_at < before
154
175
  if before
155
176
  rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
@@ -193,6 +214,176 @@ module Clacky
193
214
  { has_more: has_more }
194
215
  end
195
216
 
217
+ # Parse a chunk MD file into an array of rounds compatible with replay_history.
218
+ # Each round is { user_msg: Hash, events: Array<Hash> }.
219
+ # Timestamps are synthesised from the chunk's archived_at, spread backwards.
220
+ # Recursively expands nested chunk references (compressed summary inside a chunk).
221
+ #
222
+ # @param chunk_path [String] Path to the chunk md file
223
+ # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
224
+ private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new)
225
+ return [] unless chunk_path
226
+
227
+ # 1. Try the stored absolute path first (same machine, normal case).
228
+ # 2. If not found, fall back to basename + SESSIONS_DIR (cross-user / cross-machine).
229
+ resolved = chunk_path.to_s
230
+ unless File.exist?(resolved)
231
+ resolved = File.join(Clacky::SessionManager::SESSIONS_DIR, File.basename(resolved))
232
+ end
233
+
234
+ return [] unless File.exist?(resolved)
235
+
236
+ # Guard against circular chunk references (e.g. chunk-3 → chunk-2 → chunk-1 → chunk-9 → … → chunk-3)
237
+ canonical = File.expand_path(resolved)
238
+ if visited.include?(canonical)
239
+ Clacky::Logger.warn("parse_chunk_md_to_rounds: circular reference detected, skipping #{canonical}")
240
+ return []
241
+ end
242
+ visited = visited.dup.add(canonical)
243
+
244
+ raw = File.read(resolved)
245
+
246
+ # Parse YAML front matter to get archived_at for synthetic timestamps
247
+ archived_at = nil
248
+ if raw.start_with?("---")
249
+ fm_end = raw.index("\n---\n", 4)
250
+ if fm_end
251
+ fm_text = raw[4...fm_end]
252
+ fm_text.each_line do |line|
253
+ if line.start_with?("archived_at:")
254
+ archived_at = Time.parse(line.split(":", 2).last.strip) rescue nil
255
+ end
256
+ end
257
+ end
258
+ end
259
+ base_time = (archived_at || Time.now).to_f
260
+ chunk_dir = File.dirname(chunk_path.to_s)
261
+
262
+ # Split into sections by ## headings
263
+ sections = []
264
+ current_role = nil
265
+ current_lines = []
266
+ current_nested_chunk = nil # chunk reference from a Compressed Summary heading
267
+
268
+ raw.each_line do |line|
269
+ stripped = line.chomp
270
+ if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
271
+ # Nested chunk reference — record it, treat as assistant section
272
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
273
+ current_role = "assistant"
274
+ current_lines = []
275
+ current_nested_chunk = File.join(chunk_dir, m[1])
276
+ elsif stripped.match?(/\A## (User|Assistant)/)
277
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
278
+ current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
279
+ current_lines = []
280
+ current_nested_chunk = nil
281
+ elsif stripped.match?(/\A### Tool Result:/)
282
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
283
+ current_role = "tool"
284
+ current_lines = []
285
+ current_nested_chunk = nil
286
+ else
287
+ current_lines << line
288
+ end
289
+ end
290
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
291
+
292
+ # Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
293
+ sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
294
+
295
+ # Group into rounds: each user section starts a new round
296
+ rounds = []
297
+ current_round = nil
298
+ round_index = 0
299
+
300
+ sections.each do |sec|
301
+ text = sec[:lines].join.strip
302
+
303
+ # Nested chunk: expand it recursively, prepend before current rounds
304
+ if sec[:nested_chunk]
305
+ nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
306
+ rounds = nested + rounds unless nested.empty?
307
+ # Also render its summary text as an assistant event in current round if any
308
+ if current_round && !text.empty?
309
+ current_round[:events] << { role: "assistant", content: text }
310
+ end
311
+ next
312
+ end
313
+
314
+ next if text.empty?
315
+
316
+ if sec[:role] == "user"
317
+ round_index += 1
318
+ # Synthetic timestamp: spread rounds backwards from archived_at
319
+ synthetic_ts = base_time - (sections.size - round_index) * 1.0
320
+ current_round = {
321
+ user_msg: {
322
+ role: "user",
323
+ content: text,
324
+ created_at: synthetic_ts,
325
+ _from_chunk: true
326
+ },
327
+ events: []
328
+ }
329
+ rounds << current_round
330
+ elsif current_round
331
+ if sec[:role] == "assistant"
332
+ # Detect "_Tool calls: ..._" lines — convert to tool_calls events
333
+ # so _replay_single_message renders them as tool group UI (same as live).
334
+ #
335
+ # Formats supported:
336
+ # New: "_Tool calls: name | {"arg":"val"}; name2 | {"k":"v"}_"
337
+ # Old: "_Tool calls: name1, name2_" (backward compat)
338
+ remaining_lines = []
339
+ pending_tool_entries = [] # [{name:, args:}]
340
+
341
+ text.each_line do |line|
342
+ stripped = line.strip
343
+ if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
344
+ raw = m[1]
345
+ # New format uses ";" as separator between tools (each entry: "name | {json}")
346
+ # Old format uses "," with no JSON part.
347
+ entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
348
+ entries.each do |entry|
349
+ entry = entry.strip
350
+ if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
351
+ tool_name = parts[1].strip
352
+ args = JSON.parse(parts[2]) rescue {}
353
+ pending_tool_entries << { name: tool_name, args: args }
354
+ else
355
+ pending_tool_entries << { name: entry, args: {} }
356
+ end
357
+ end
358
+ else
359
+ remaining_lines << line
360
+ end
361
+ end
362
+
363
+ # Flush any plain text
364
+ plain_text = remaining_lines.join.strip
365
+ current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
366
+
367
+ # Emit one synthetic tool_calls message per detected tool
368
+ pending_tool_entries.each do |entry|
369
+ current_round[:events] << {
370
+ role: "assistant",
371
+ content: "",
372
+ tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
373
+ }
374
+ end
375
+ else
376
+ current_round[:events] << { role: "tool", content: text }
377
+ end
378
+ end
379
+ end
380
+
381
+ rounds
382
+ rescue => e
383
+ Clacky::Logger.warn("parse_chunk_md_to_rounds failed for #{chunk_path}: #{e.message}")
384
+ []
385
+ end
386
+
196
387
 
197
388
  # Render a single non-user message into the UI.
198
389
  # Used by both the normal round-based replay and the compressed-session fallback.
@@ -328,12 +519,14 @@ module Clacky
328
519
  next unless block[:type] == "image_url"
329
520
 
330
521
  url = block.dig(:image_url, :url)
331
- next unless url && url.start_with?("data:")
522
+ # image_path is stored at send-time so replay can reconstruct the image from tmp
523
+ path = block[:image_path]
524
+
525
+ next unless url&.start_with?("data:") || path
332
526
 
333
- # Derive mime_type from the data URL prefix (e.g. "data:image/jpeg;base64,...")
334
- mime_type = url[/\Adata:([^;]+);/, 1] || "image/jpeg"
527
+ mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
335
528
  ext = mime_type.split("/").last
336
- { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url }
529
+ { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
337
530
  end
338
531
  end
339
532
  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
- user_content = format_user_content(user_input, vision_images.map { |v| v[:url] })
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
- private def format_user_content(text, vision_urls)
1115
- vision_urls ||= []
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 vision_urls.empty?
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
- vision_urls.each do |url|
1122
- content << { type: "image_url", image_url: { url: url } }
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}]"
@@ -206,7 +206,7 @@ On success: "✅ WeCom channel configured. WeCom client → Contacts → Smart B
206
206
 
207
207
  Weixin uses a QR code login — no app_id/app_secret needed. The token from the QR scan is saved directly in `channels.yml`.
208
208
 
209
- #### Step 1 — Fetch QR code and open in browser
209
+ #### Step 1 — Fetch QR code
210
210
 
211
211
  Run the script in `--fetch-qr` mode to get the QR URL without blocking:
212
212
 
@@ -221,17 +221,29 @@ Parse the JSON output:
221
221
 
222
222
  If the output contains `"error"`, show it and stop.
223
223
 
224
- Tell the user:
225
- > Opening the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
224
+ #### Step 2 — Show QR code to user (browser or manual fallback)
226
225
 
227
- **Open the QR code page in browser** build a local URL and navigate to it:
226
+ Build the local QR page URL (include current Unix timestamp as `since` to detect new logins only):
227
+ ```
228
+ http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>&since=<current_unix_timestamp>
229
+ ```
228
230
 
231
+ **Try browser first** — attempt to open the QR page using the browser tool:
229
232
  ```
230
- http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>
233
+ browser(action="navigate", url="<qr_page_url>")
231
234
  ```
232
235
 
233
- Use the browser tool to open this URL. The page renders a proper scannable QR code image using qrcode.js.
234
- Do NOT open the raw `qrcode_url` directly that page shows "请使用微信扫码打开" with no actual QR image.
236
+ **If browser succeeds:** Tell the user:
237
+ > I've opened the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
238
+
239
+ **If browser fails (not configured or unavailable):** Fall back to manual — tell the user:
240
+ > Please open the following link in your browser to scan the WeChat QR code:
241
+ >
242
+ > `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
243
+ >
244
+ > Scan the QR code with WeChat, confirm in the app, then reply "done".
245
+
246
+ The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
235
247
 
236
248
  #### Step 3 — Wait for scan and save credentials
237
249
 
@@ -243,7 +255,11 @@ ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
243
255
 
244
256
  Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
245
257
 
246
- This command blocks until the user scans and confirms in WeChat (up to 5 minutes), then automatically saves the token via `POST /api/channels/weixin`.
258
+ Run this command with `timeout: 60`. If it doesn't succeed, **retry up to 3 times with the same `$QRCODE_ID`** — the QR code stays valid for 5 minutes. Only stop retrying if:
259
+ - Exit code is 0 → success
260
+ - Output contains "expired" → QR expired, offer to restart from Step 1
261
+ - Output contains "timed out" → offer to restart from Step 1
262
+ - 3 retries exhausted → show error and offer to restart from Step 1
247
263
 
248
264
  Tell the user while waiting:
249
265
  > Waiting for you to scan the QR code and confirm in WeChat... (this may take a moment)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: product-help
3
- description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, or troubleshooting. Do NOT trigger for general coding tasks unrelated to me.'
3
+ description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, troubleshooting, or restarting the server. Do NOT trigger for general coding tasks unrelated to me.'
4
4
  fork_agent: true
5
5
  user-invocable: false
6
6
  auto_summarize: true
@@ -90,3 +90,16 @@ web_fetch(url: "<URL>", max_length: 5000)
90
90
  - If the fetched page doesn't answer the question, try the next most relevant URL (max 2 fetches)
91
91
  - If still no answer, tell the user: "请访问 https://www.openclacky.com/docs 查看完整文档"
92
92
  - Keep answers concise — extract what's relevant, don't paste the whole page
93
+
94
+ ## Restarting the server
95
+
96
+ If the user asks to restart the clacky/openclacky server (e.g. "重启", "restart", "请重启openclacky"):
97
+
98
+ **Do NOT fetch any docs.** Just return this answer directly:
99
+
100
+ > To restart the server gracefully (hot restart, zero downtime):
101
+ > ```
102
+ > kill -USR1 $CLACKY_MASTER_PID
103
+ > ```
104
+ > This sends USR1 to the Master process, which spawns a new Worker and gracefully stops the old one.
105
+ > The `$CLACKY_MASTER_PID` environment variable is already set in the current session.
@@ -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
- [{ type: "text", text: content.to_s }]
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: block[: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
- [{ text: content.to_s }]
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
- { text: block[:text].to_s }
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]
@@ -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