openclacky 0.9.2 → 0.9.4

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.
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: activate-license
3
+ description: Guide the user through activating their brand license key interactively.
4
+ disable-model-invocation: true
5
+ user-invocable: true
6
+ ---
7
+
8
+ # Skill: activate-license
9
+
10
+ ## Purpose
11
+ Guide the user to enter and submit their brand license key to activate the software.
12
+ All structured input is gathered through `request_user_feedback` cards — no free-form interrogation.
13
+
14
+ ## Steps
15
+
16
+ ### 0. Detect language
17
+
18
+ The skill is invoked with a `lang:` argument, e.g. `/activate-license lang:zh` or `/activate-license lang:en`.
19
+ Check the invocation message:
20
+ - If `lang:zh` is present → conduct entirely in **Chinese**.
21
+ - Otherwise → use **English** throughout.
22
+
23
+ Also check for a `name:` argument (e.g. `name:MyBrand`). Store as `brand_name` (default empty).
24
+
25
+ ### 1. Greet the user
26
+
27
+ Send a short, warm welcome message. Use the language determined in Step 0.
28
+ Do NOT ask for the key yet.
29
+
30
+ Example (Chinese):
31
+ > 👋 欢迎使用{{brand_name}}!
32
+ > 只需输入您的授权码,即可解锁全部功能。授权码格式为:`XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX`
33
+
34
+ Example (English):
35
+ > 👋 Welcome to {{brand_name}}!
36
+ > Enter your license key to unlock all features. The format is: `XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX`
37
+
38
+ ### 2. Ask for the license key (card)
39
+
40
+ Call `request_user_feedback` to collect the license key.
41
+
42
+ If `lang == "zh"`, use:
43
+ ```json
44
+ {
45
+ "question": "请输入您的授权码:",
46
+ "options": []
47
+ }
48
+ ```
49
+
50
+ Otherwise (English):
51
+ ```json
52
+ {
53
+ "question": "Please enter your license key:",
54
+ "options": []
55
+ }
56
+ ```
57
+
58
+ Store the user's reply as `license_key` (trimmed).
59
+
60
+ ### 3. Submit the license key via API
61
+
62
+ Call the activation API using the shell tool:
63
+
64
+ ```bash
65
+ curl -s -X POST http://localhost:PORT/api/brand/activate \
66
+ -H "Content-Type: application/json" \
67
+ -d '{"license_key": "LICENSE_KEY_HERE"}'
68
+ ```
69
+
70
+ To find the running port, check the environment or use `http://localhost:7002` as the default.
71
+ Try ports 7002, 7003, 7004 if the first fails. Parse the JSON response:
72
+ - `ok: true` → activation succeeded, `brand_name` may be in response
73
+ - `ok: false` → activation failed, `error` field contains the reason
74
+
75
+ ### 4a. On success
76
+
77
+ If `lang == "zh"`, reply:
78
+ > 🎉 授权激活成功!欢迎使用 {{brand_name}}。
79
+ > 关闭此会话,即可开始使用全部功能。
80
+
81
+ Otherwise:
82
+ > 🎉 License activated successfully! Welcome to {{brand_name}}.
83
+ > Close this session to start using all features.
84
+
85
+ ### 4b. On failure
86
+
87
+ If the key format is invalid (doesn't match `XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX`):
88
+
89
+ If `lang == "zh"`, reply and go back to Step 2:
90
+ > ❌ 授权码格式不正确。正确格式为:`XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX`
91
+ > 请重新输入。
92
+
93
+ If the API returns an error:
94
+
95
+ If `lang == "zh"`, reply and go back to Step 2:
96
+ > ❌ 激活失败:{{error}}
97
+ > 请检查授权码后重试,或联系您的品牌服务商。
98
+
99
+ Otherwise (English):
100
+ > ❌ Activation failed: {{error}}
101
+ > Please double-check your license key and try again, or contact your brand provider.
102
+
103
+ ### 5. Retry loop
104
+
105
+ After a failure, call `request_user_feedback` again to let the user enter a corrected key.
106
+ Repeat up to 3 times. If all 3 attempts fail, close gracefully:
107
+
108
+ If `lang == "zh"`:
109
+ > 多次尝试均未成功。请联系您的品牌服务商获取有效授权码。
110
+
111
+ Otherwise:
112
+ > Too many failed attempts. Please contact your brand provider for a valid license key.
113
+
114
+ ## Notes
115
+ - Do NOT ask any questions beyond the license key card.
116
+ - The key format is exactly: `[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}` (5 groups of 8 hex chars).
117
+ - Never log or display the license key in cleartext beyond what the user already entered.
118
+ - If the port cannot be determined, ask the user with `request_user_feedback`.
@@ -21,17 +21,6 @@ allowed-tools:
21
21
 
22
22
  Configure IM platform channels for open-clacky. Config is stored at `~/.clacky/channels.yml`.
23
23
 
24
- ## Browser Automation Principles
25
-
26
- - **Always use built-in browser**: Pass `isolated: true` on every browser tool call. Do NOT ask the user to choose — use the built-in browser only.
27
- - **Never use `screenshot`**: Use `snapshot -i` instead to get page structure as text. Do NOT generate image files.
28
- - Use `open <url>` for navigation.
29
- - AI navigates; user performs form fills, clicks, and pastes when instructed.
30
- - If a login page or QR code appears, tell the user to log in and wait for "done" before continuing.
31
- - If stuck (CAPTCHA, unexpected page, dialog, cannot find a UI element), **guide the user to help** — ask the user to perform the specific step manually and reply "done" when ready.
32
-
33
- ---
34
-
35
24
  ## Command Parsing
36
25
 
37
26
  | User says | Subcommand |
@@ -77,7 +66,7 @@ Ask:
77
66
  #### Phase 1 — Open Feishu Open Platform
78
67
 
79
68
  1. Navigate: `open https://open.feishu.cn/app`. Pass `isolated: true`.
80
- 2. Use `snapshot -i` to check page state. If a login page or QR code is shown, tell the user to log in and wait for "done".
69
+ 2. If a login page or QR code is shown, tell the user to log in and wait for "done".
81
70
  3. Confirm the app list is visible.
82
71
 
83
72
  #### Phase 2 — Create a new app
@@ -142,15 +131,13 @@ On success: "✅ Feishu channel configured. The channel is already active."
142
131
  ### WeCom setup
143
132
 
144
133
  1. Navigate: `open https://work.weixin.qq.com/wework_admin/frame#/aiHelper/create`. Pass `isolated: true`.
145
- 2. Use `snapshot -i` to check page state. If a login page or QR code is shown, tell the user to log in and wait for "done".
134
+ 2. If a login page or QR code is shown, tell the user to log in and wait for "done".
146
135
  3. Steps 3–7: Do NOT take snapshots. Guide the user: "Scroll to the bottom of the right panel and click 'API mode creation'. Reply done." Wait for "done".
147
136
  4. Guide the user: "Click 'Add' next to 'Visible Range'. In the scope dialog, select the top-level company node (or specific users/departments). Click Confirm. Reply done." Wait for "done".
148
137
  5. Guide the user: "If Secret is not visible, click 'Get Secret'. Copy Bot ID and Secret **before** clicking Save — do NOT click 'Get Secret' again after copying (it invalidates the previous secret). Paste here. Reply with: Bot ID: xxx, Secret: xxx" Wait for "done".
149
138
  6. Guide the user: "Click Save. In the dialog, enter name (e.g. Open Clacky) and description (e.g. AI assistant powered by open-clacky). Click Confirm. Click Save again. Reply done." Wait for "done".
150
139
  7. **Apply config and hot-reload** — Parse credentials from step 5. Trim leading/trailing whitespace from bot_id and secret. Run `curl -X POST http://localhost:7070/api/channels/wecom -H "Content-Type: application/json" -d '{"bot_id":"...","secret":"..."}'`. Ensure bot_id (starts with `aib`) and secret (longer string) are not swapped.
151
140
 
152
- On success: "✅ WeCom channel configured."
153
-
154
141
  On success: "✅ WeCom channel configured. To use the bot: WeCom client → Contacts → select Smart Bot to see the newly created bot.".
155
142
 
156
143
  ---
@@ -188,13 +175,16 @@ Say: "❌ `<platform>` channel disabled. Restart `clacky server` to deactivate."
188
175
  Check each item, report ✅ / ❌ with remediation:
189
176
 
190
177
  1. **Config file** — does `~/.clacky/channels.yml` exist and is it readable?
191
- 2. **Permissions** — `stat ~/.clacky/channels.yml`, warn if not 600.
192
- 3. **Required keys** — for each enabled platform:
178
+ 2. **Required keys** — for each enabled platform:
193
179
  - Feishu: `app_id`, `app_secret` present and non-empty
194
180
  - WeCom: `bot_id`, `secret` present and non-empty
195
- 4. **Feishu credentials** (if enabled) — run the token API call, check `code=0`.
196
- 5. **WeCom** no REST check (verified at connect), just confirm keys are present.
197
- 6. **Server running** — `pgrep -f "clacky server"`. Channels only activate when the server is running.
181
+ 3. **Feishu credentials** (if enabled) — run the token API call, check `code=0`.
182
+ 4. **WeCom credentials** (if enabled) search today's log for auth-related lines:
183
+ ```bash
184
+ grep -iE "wecom adapter loop started|WeCom authentication failed|WeCom WS error response|WecomAdapter" ~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
185
+ ```
186
+ - If output contains `WeCom authentication failed` or `WeCom WS error response` with non-zero errcode: ❌ "WeCom Bot ID or Secret is incorrect — re-run `/channel-setup reconfigure`"
187
+ - If output contains `[ChannelManager] :wecom adapter loop started` with no auth error after it: ✅
198
188
 
199
189
  ---
200
190
 
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module MessageFormat
5
+ # Static helpers for Anthropic API message format.
6
+ #
7
+ # Responsibilities:
8
+ # - Identify Anthropic-style messages stored in @messages
9
+ # - Convert internal @messages → Anthropic API request body
10
+ # - Parse Anthropic API response → internal format
11
+ # - Format tool results for the next turn
12
+ #
13
+ # Internal @messages always use OpenAI-style canonical format:
14
+ # assistant tool_calls: { role: "assistant", tool_calls: [{id:, function:{name:,arguments:}}] }
15
+ # tool result: { role: "tool", tool_call_id:, content: }
16
+ #
17
+ # This module converts that canonical format to Anthropic native on the way OUT,
18
+ # and converts Anthropic native back to canonical on the way IN.
19
+ module Anthropic
20
+ module_function
21
+
22
+ # ── Message type identification ───────────────────────────────────────────
23
+
24
+ # Returns true if the message is an Anthropic-native tool result stored in
25
+ # @messages (role: "user" with content array containing tool_result blocks).
26
+ # NOTE: After the refactor, new tool results are stored in canonical format
27
+ # (role: "tool"). This helper handles legacy messages that might exist in
28
+ # older sessions.
29
+ def tool_result_message?(msg)
30
+ msg[:role] == "user" &&
31
+ msg[:content].is_a?(Array) &&
32
+ msg[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
33
+ end
34
+
35
+ # Returns the tool_use_ids referenced in an Anthropic-native tool result message.
36
+ def tool_use_ids(msg)
37
+ return [] unless tool_result_message?(msg)
38
+
39
+ msg[:content].select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
40
+ end
41
+
42
+ # ── Request building ──────────────────────────────────────────────────────
43
+
44
+ # Convert canonical @messages + tools into an Anthropic API request body.
45
+ # @param messages [Array<Hash>] canonical messages (may include system)
46
+ # @param model [String]
47
+ # @param tools [Array<Hash>] OpenAI-style tool definitions
48
+ # @param max_tokens [Integer]
49
+ # @param caching_enabled [Boolean]
50
+ # @return [Hash] ready to serialize as JSON body
51
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled)
52
+ system_messages = messages.select { |m| m[:role] == "system" }
53
+ regular_messages = messages.reject { |m| m[:role] == "system" }
54
+
55
+ system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")
56
+
57
+ api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled) }
58
+ api_tools = tools&.map { |t| to_api_tool(t) }
59
+
60
+ if caching_enabled && api_tools&.any?
61
+ api_tools.last[:cache_control] = { type: "ephemeral" }
62
+ end
63
+
64
+ body = { model: model, max_tokens: max_tokens, messages: api_messages }
65
+ body[:system] = system_text unless system_text.empty?
66
+ body[:tools] = api_tools if api_tools&.any?
67
+ body
68
+ end
69
+
70
+ # ── Response parsing ──────────────────────────────────────────────────────
71
+
72
+ # Parse Anthropic API response into canonical internal format.
73
+ # @param data [Hash] parsed JSON response body
74
+ # @return [Hash] canonical response: { content:, tool_calls:, finish_reason:, usage: }
75
+ def parse_response(data)
76
+ blocks = data["content"] || []
77
+ usage = data["usage"] || {}
78
+
79
+ content = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("")
80
+
81
+ # tool_calls use canonical format (id, function: {name, arguments})
82
+ tool_calls = blocks.select { |b| b["type"] == "tool_use" }.map do |tc|
83
+ args = tc["input"].is_a?(String) ? tc["input"] : tc["input"].to_json
84
+ { id: tc["id"], type: "function", name: tc["name"], arguments: args }
85
+ end
86
+
87
+ finish_reason = case data["stop_reason"]
88
+ when "end_turn" then "stop"
89
+ when "tool_use" then "tool_calls"
90
+ when "max_tokens" then "length"
91
+ else data["stop_reason"]
92
+ end
93
+
94
+ usage_data = {
95
+ prompt_tokens: usage["input_tokens"],
96
+ completion_tokens: usage["output_tokens"],
97
+ total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i
98
+ }
99
+ usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"] if usage["cache_read_input_tokens"]
100
+ usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"] if usage["cache_creation_input_tokens"]
101
+
102
+ { content: content, tool_calls: tool_calls, finish_reason: finish_reason,
103
+ usage: usage_data, raw_api_usage: usage }
104
+ end
105
+
106
+ # ── Tool result formatting ────────────────────────────────────────────────
107
+
108
+ # Format tool results into canonical messages to append to @messages.
109
+ # Input: response (canonical, has :tool_calls), tool_results array
110
+ # Output: canonical messages: [{ role: "tool", tool_call_id:, content: }]
111
+ def format_tool_results(response, tool_results)
112
+ results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
113
+
114
+ response[:tool_calls].map do |tc|
115
+ result = results_map[tc[:id]]
116
+ {
117
+ role: "tool",
118
+ tool_call_id: tc[:id],
119
+ content: result ? result[:content] : { error: "Tool result missing" }.to_json
120
+ }
121
+ end
122
+ end
123
+
124
+ # ── Private helpers ───────────────────────────────────────────────────────
125
+
126
+ # Convert a single canonical message to Anthropic API format.
127
+ # caching_enabled is kept for signature compatibility but is no longer used here —
128
+ # cache_control markers are embedded into messages by Client#apply_message_caching
129
+ # before build_request_body is called.
130
+ private_class_method def self.to_api_message(msg, _caching_enabled)
131
+ role = msg[:role]
132
+ content = msg[:content]
133
+ tool_calls = msg[:tool_calls]
134
+
135
+ # assistant with tool_calls → content blocks with tool_use
136
+ if role == "assistant" && tool_calls&.any?
137
+ blocks = []
138
+ blocks << { type: "text", text: content } if content.is_a?(String) && !content.empty?
139
+ blocks.concat(content_to_blocks(content)) if content.is_a?(Array)
140
+
141
+ tool_calls.each do |tc|
142
+ func = tc[:function] || tc
143
+ name = func[:name] || tc[:name]
144
+ raw_args = func[:arguments] || tc[:arguments]
145
+ input = raw_args.is_a?(String) ? JSON.parse(raw_args) : raw_args
146
+ blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
147
+ end
148
+
149
+ return { role: "assistant", content: blocks }
150
+ end
151
+
152
+ # canonical tool result (role: "tool") → Anthropic user message with tool_result block
153
+ if role == "tool"
154
+ block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: msg[:content] }
155
+ return { role: "user", content: [block] }
156
+ end
157
+
158
+ # legacy Anthropic-native tool result already in user+tool_result format — pass through
159
+ if role == "user" && content.is_a?(Array) && content.any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
160
+ return { role: "user", content: content }
161
+ end
162
+
163
+ # regular user/assistant message
164
+ # NOTE: cache_control markers are applied by Client#apply_message_caching before
165
+ # build_request_body is called. We must NOT add extra cache_control here, because:
166
+ # 1. apply_message_caching already placed the marker on the correct breakpoint message.
167
+ # 2. Adding cache_control to every user message causes Anthropic to treat every
168
+ # user message as a cache breakpoint, which invalidates the intended cache boundary
169
+ # and results in cache misses (cache_read=0) every turn.
170
+ blocks = content_to_blocks(content)
171
+ { role: role, content: blocks }
172
+ end
173
+
174
+ # Convert content (String or Array) to Anthropic content block array.
175
+ # cache_control markers already embedded by Client#apply_message_caching are preserved.
176
+ private_class_method def self.content_to_blocks(content)
177
+ case content
178
+ when String
179
+ [{ type: "text", text: content }]
180
+ when Array
181
+ content.map { |b| normalize_block(b) }.compact
182
+ else
183
+ [{ type: "text", text: content.to_s }]
184
+ end
185
+ end
186
+
187
+ # Normalize a single content block to Anthropic format.
188
+ private_class_method def self.normalize_block(block)
189
+ return block unless block.is_a?(Hash)
190
+
191
+ case block[:type]
192
+ when "text"
193
+ # Preserve cache_control if present (placed by Client#apply_message_caching)
194
+ result = { type: "text", text: block[:text] }
195
+ result[:cache_control] = block[:cache_control] if block[:cache_control]
196
+ result
197
+ when "image_url"
198
+ url = block.dig(:image_url, :url) || block[:url]
199
+ url_to_image_block(url)
200
+ when "image"
201
+ block # already Anthropic format
202
+ when "tool_result", "tool_use"
203
+ block # pass through
204
+ else
205
+ block
206
+ end
207
+ end
208
+
209
+ # Convert an image URL to Anthropic image block.
210
+ private_class_method def self.url_to_image_block(url)
211
+ return nil unless url
212
+
213
+ if url.start_with?("data:")
214
+ match = url.match(/^data:([^;]+);base64,(.*)$/)
215
+ if match
216
+ { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
217
+ else
218
+ { type: "image", source: { type: "url", url: url } }
219
+ end
220
+ else
221
+ { type: "image", source: { type: "url", url: url } }
222
+ end
223
+ end
224
+
225
+ # Convert OpenAI-style tool definition to Anthropic format.
226
+ private_class_method def self.to_api_tool(tool)
227
+ func = tool[:function] || tool
228
+ { name: func[:name], description: func[:description], input_schema: func[:parameters] }
229
+ end
230
+
231
+ # Extract plain text from content (String or Array).
232
+ private_class_method def self.extract_text(content)
233
+ case content
234
+ when String then content
235
+ when Array then content.map { |b| b.is_a?(Hash) ? (b[:text] || "") : b.to_s }.join("\n")
236
+ else content.to_s
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module MessageFormat
5
+ # Static helpers for OpenAI-compatible API message format.
6
+ #
7
+ # The canonical internal @messages format IS OpenAI format, so this module
8
+ # mainly handles response parsing, tool result formatting, and message
9
+ # type identification — minimal transformation needed.
10
+ module OpenAI
11
+ module_function
12
+
13
+ # ── Message type identification ───────────────────────────────────────────
14
+
15
+ # Returns true if the message is a canonical tool result.
16
+ def tool_result_message?(msg)
17
+ msg[:role] == "tool" && !msg[:tool_call_id].nil?
18
+ end
19
+
20
+ # Returns the tool_call_ids referenced in a tool result message.
21
+ def tool_call_ids(msg)
22
+ return [] unless tool_result_message?(msg)
23
+
24
+ [msg[:tool_call_id]]
25
+ end
26
+
27
+ # ── Request building ──────────────────────────────────────────────────────
28
+
29
+ # Build an OpenAI-compatible request body.
30
+ # Canonical messages are already in OpenAI format — no conversion needed.
31
+ # @param messages [Array<Hash>] canonical messages
32
+ # @param model [String]
33
+ # @param tools [Array<Hash>] OpenAI-style tool definitions
34
+ # @param max_tokens [Integer]
35
+ # @param caching_enabled [Boolean] (only effective for Claude via OpenRouter)
36
+ # @return [Hash]
37
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled)
38
+ body = { model: model, max_tokens: max_tokens, messages: messages }
39
+
40
+ if tools&.any?
41
+ if caching_enabled
42
+ cached_tools = deep_clone(tools)
43
+ cached_tools.last[:cache_control] = { type: "ephemeral" }
44
+ body[:tools] = cached_tools
45
+ else
46
+ body[:tools] = tools
47
+ end
48
+ end
49
+
50
+ body
51
+ end
52
+
53
+ # ── Response parsing ──────────────────────────────────────────────────────
54
+
55
+ # Parse OpenAI-compatible API response into canonical internal format.
56
+ # @param data [Hash] parsed JSON response body
57
+ # @return [Hash]
58
+ def parse_response(data)
59
+ message = data["choices"].first["message"]
60
+ usage = data["usage"] || {}
61
+ raw_api_usage = usage.dup
62
+
63
+ usage_data = {
64
+ prompt_tokens: usage["prompt_tokens"],
65
+ completion_tokens: usage["completion_tokens"],
66
+ total_tokens: usage["total_tokens"]
67
+ }
68
+
69
+ usage_data[:api_cost] = usage["cost"] if usage["cost"]
70
+ usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"] if usage["cache_creation_input_tokens"]
71
+ usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"] if usage["cache_read_input_tokens"]
72
+
73
+ # OpenRouter stores cache info under prompt_tokens_details
74
+ if (details = usage["prompt_tokens_details"])
75
+ usage_data[:cache_read_input_tokens] = details["cached_tokens"] if details["cached_tokens"].to_i > 0
76
+ usage_data[:cache_creation_input_tokens] = details["cache_write_tokens"] if details["cache_write_tokens"].to_i > 0
77
+ end
78
+
79
+ result = {
80
+ content: message["content"],
81
+ tool_calls: parse_tool_calls(message["tool_calls"]),
82
+ finish_reason: data["choices"].first["finish_reason"],
83
+ usage: usage_data,
84
+ raw_api_usage: raw_api_usage
85
+ }
86
+
87
+ # Preserve reasoning_content (e.g. Kimi/Moonshot extended thinking)
88
+ result[:reasoning_content] = message["reasoning_content"] if message["reasoning_content"]
89
+
90
+ result
91
+ end
92
+
93
+ # ── Tool result formatting ────────────────────────────────────────────────
94
+
95
+ # Format tool results into canonical messages to append to @messages.
96
+ # @return [Array<Hash>] canonical tool messages
97
+ def format_tool_results(response, tool_results)
98
+ results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
99
+
100
+ response[:tool_calls].map do |tc|
101
+ result = results_map[tc[:id]]
102
+ {
103
+ role: "tool",
104
+ tool_call_id: tc[:id],
105
+ content: result ? result[:content] : { error: "Tool result missing" }.to_json
106
+ }
107
+ end
108
+ end
109
+
110
+ # ── Private helpers ───────────────────────────────────────────────────────
111
+
112
+ private_class_method def self.parse_tool_calls(raw)
113
+ return nil if raw.nil? || raw.empty?
114
+
115
+ raw.filter_map do |call|
116
+ func = call["function"] || {}
117
+ name = func["name"]
118
+ arguments = func["arguments"]
119
+ # Skip malformed tool calls where name or arguments is nil (broken API response)
120
+ next if name.nil? || arguments.nil?
121
+
122
+ { id: call["id"], type: call["type"], name: name, arguments: arguments }
123
+ end
124
+ end
125
+
126
+ private_class_method def self.deep_clone(obj)
127
+ case obj
128
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_clone(v) }
129
+ when Array then obj.map { |item| deep_clone(item) }
130
+ else obj
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -50,6 +50,8 @@ module Clacky
50
50
  @ws_client.start do |raw|
51
51
  handle_raw_message(raw)
52
52
  end
53
+ rescue WSClient::AuthError => e
54
+ Clacky::Logger.warn("[WecomAdapter] Authentication failed, not retrying: #{e.message}")
53
55
  end
54
56
 
55
57
  def stop
@@ -23,6 +23,9 @@ module Clacky
23
23
  HEARTBEAT_INTERVAL = 30 # seconds
24
24
  RECONNECT_DELAY = 5 # seconds
25
25
 
26
+ # Raised when WeCom rejects credentials — signals caller not to retry.
27
+ class AuthError < StandardError; end
28
+
26
29
  def initialize(bot_id:, secret:, ws_url: WS_URL)
27
30
  @bot_id = bot_id
28
31
  @secret = secret
@@ -39,6 +42,10 @@ module Clacky
39
42
  while @running
40
43
  begin
41
44
  connect_and_listen
45
+ rescue AuthError => e
46
+ warn "WeCom authentication error (not retrying): #{e.message}"
47
+ @running = false
48
+ raise
42
49
  rescue => e
43
50
  warn "WeCom WebSocket error: #{e.message}"
44
51
  sleep RECONNECT_DELAY if @running
@@ -139,6 +146,12 @@ module Clacky
139
146
  errcode = frame["errcode"] || body["errcode"]
140
147
  if errcode && errcode != 0
141
148
  warn "WeCom WS error response: #{frame.inspect}"
149
+ # Auth failures are fatal — stop reconnecting and surface the error
150
+ if req_id.start_with?("subscribe_")
151
+ errmsg = frame["errmsg"] || body["errmsg"] || "unknown error"
152
+ @running = false
153
+ raise AuthError, "WeCom authentication failed (errcode=#{errcode}): #{errmsg}"
154
+ end
142
155
  end
143
156
  end
144
157
  rescue JSON::ParserError => e
@@ -14,6 +14,7 @@ require_relative "web_ui_controller"
14
14
  require_relative "scheduler"
15
15
  require_relative "../brand_config"
16
16
  require_relative "channel"
17
+ require_relative "../banner"
17
18
 
18
19
  module Clacky
19
20
  module Server
@@ -220,7 +221,11 @@ module Clacky
220
221
  end
221
222
  end
222
223
 
223
- puts "🌐 Clacky Web UI running at http://#{@host}:#{@port}"
224
+ banner = Clacky::Banner.new
225
+ puts banner.colored_cli_logo
226
+ puts banner.colored_tagline
227
+ puts ""
228
+ puts " Web UI: #{banner.highlight("http://#{@host}:#{@port}")}"
224
229
  puts " Version: #{Clacky::VERSION}"
225
230
  puts " Press Ctrl-C to stop."
226
231
 
@@ -229,7 +234,7 @@ module Clacky
229
234
 
230
235
  # Start the background scheduler
231
236
  @scheduler.start
232
- puts " Scheduler started (#{@scheduler.schedules.size} schedule(s) loaded)"
237
+ puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
233
238
 
234
239
  # Start IM channel adapters (non-blocking — each platform runs in its own thread)
235
240
  @channel_manager.start
@@ -443,6 +448,11 @@ module Clacky
443
448
  warning = "Your #{brand.brand_name} license has expired. Please renew to continue."
444
449
  elsif brand.grace_period_exceeded?
445
450
  warning = "License server unreachable for more than 3 days. Please check your connection."
451
+ elsif brand.license_expires_at && !brand.expired?
452
+ days_remaining = ((brand.license_expires_at - Time.now.utc) / 86_400).ceil
453
+ if days_remaining <= 7
454
+ warning = "Your #{brand.brand_name} license expires in #{days_remaining} day#{"s" if days_remaining != 1}. Please renew soon."
455
+ end
446
456
  end
447
457
 
448
458
  json_response(res, 200, {
@@ -31,8 +31,13 @@ module Clacky
31
31
 
32
32
  @last_saved_path = filepath
33
33
 
34
- # Keep only the most recent 10 sessions
35
- cleanup_by_count(keep: 10)
34
+ # Keep only the most recent 10 sessions (best-effort, never block save)
35
+ begin
36
+ cleanup_by_count(keep: 10)
37
+ rescue Exception # rubocop:disable Lint/RescueException
38
+ # Cleanup is non-critical; swallow all errors (including AgentInterrupted)
39
+ # so that the session file is always saved successfully
40
+ end
36
41
 
37
42
  filepath
38
43
  end