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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/docs/security-design.md +109 -0
- data/lib/clacky/agent/message_compressor_helper.rb +82 -69
- data/lib/clacky/agent/session_serializer.rb +9 -1
- data/lib/clacky/agent/skill_manager.rb +7 -0
- data/lib/clacky/agent.rb +11 -3
- data/lib/clacky/banner.rb +65 -0
- data/lib/clacky/block_font.rb +331 -0
- data/lib/clacky/brand_config.rb +73 -5
- data/lib/clacky/client.rb +129 -633
- data/lib/clacky/default_skills/activate-license/SKILL.md +118 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +10 -20
- data/lib/clacky/message_format/anthropic.rb +241 -0
- data/lib/clacky/message_format/open_ai.rb +135 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -0
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +13 -0
- data/lib/clacky/server/http_server.rb +12 -2
- data/lib/clacky/session_manager.rb +7 -2
- data/lib/clacky/tools/browser.rb +109 -280
- data/lib/clacky/ui2/block_font.rb +10 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -22
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +588 -6
- data/lib/clacky/web/app.js +30 -15
- data/lib/clacky/web/brand.js +141 -9
- data/lib/clacky/web/i18n.js +28 -2
- data/lib/clacky/web/index.html +142 -127
- data/lib/clacky/web/onboard.js +192 -225
- data/lib/clacky/web/sessions.js +12 -8
- data/lib/clacky/web/settings.js +57 -4
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +60 -15
- metadata +8 -1
|
@@ -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.
|
|
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.
|
|
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. **
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|