openclacky 1.2.10 → 1.2.13
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/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +56 -1
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/session_serializer.rb +1 -1
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent/tool_registry.rb +10 -0
- data/lib/clacky/agent.rb +68 -23
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/client.rb +25 -3
- data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
- data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/message_history.rb +57 -0
- data/lib/clacky/openai_stream_aggregator.rb +30 -3
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
- data/lib/clacky/server/channel/channel_manager.rb +65 -4
- data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
- data/lib/clacky/server/http_server.rb +190 -10
- data/lib/clacky/server/session_registry.rb +34 -14
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +230 -7
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/apple-touch-icon-180.png +0 -0
- data/lib/clacky/web/brand.js +22 -2
- data/lib/clacky/web/favicon.ico +0 -0
- data/lib/clacky/web/i18n.js +22 -4
- data/lib/clacky/web/index.html +6 -4
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/model-tester.js +8 -1
- data/lib/clacky/web/sessions.js +576 -120
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/theme.js +1 -0
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- data/scripts/build/lib/gem.sh +9 -2
- data/scripts/build/src/install_full.sh.cc +2 -0
- data/scripts/build/src/uninstall.sh.cc +1 -1
- data/scripts/install.ps1 +19 -5
- data/scripts/install.sh +9 -2
- data/scripts/install_full.sh +11 -2
- data/scripts/install_rails_deps.sh +9 -2
- data/scripts/uninstall.sh +10 -3
- metadata +9 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module FeishuSetup
|
|
8
|
+
ENDPOINT = "/oauth/v1/app/registration"
|
|
9
|
+
DEFAULT_DOMAIN = "https://accounts.feishu.cn"
|
|
10
|
+
DEFAULT_LARK_DOMAIN = "https://accounts.larksuite.com"
|
|
11
|
+
SDK_NAME = "ruby-sdk"
|
|
12
|
+
|
|
13
|
+
class SetupError < StandardError
|
|
14
|
+
attr_reader :code, :description
|
|
15
|
+
def initialize(code, description)
|
|
16
|
+
@code = code
|
|
17
|
+
@description = description
|
|
18
|
+
super("#{code}: #{description}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class AppAccessDeniedError < SetupError; end
|
|
23
|
+
class AppExpiredError < SetupError; end
|
|
24
|
+
|
|
25
|
+
def self.run(app_name: nil, app_desc: nil, on_qr_code:, on_status_change: nil,
|
|
26
|
+
domain: DEFAULT_DOMAIN, lark_domain: DEFAULT_LARK_DOMAIN)
|
|
27
|
+
base_url = domain
|
|
28
|
+
domain_switched = false
|
|
29
|
+
|
|
30
|
+
init_res = post(base_url, action: "init")
|
|
31
|
+
methods = init_res["supported_auth_methods"] || []
|
|
32
|
+
unless methods.include?("client_secret")
|
|
33
|
+
raise SetupError.new("unsupported_auth_method", "client_secret not supported")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
begin_res = post(base_url,
|
|
37
|
+
action: "begin",
|
|
38
|
+
archetype: "PersonalAgent",
|
|
39
|
+
auth_method: "client_secret",
|
|
40
|
+
request_user_info: "open_id"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
device_code = begin_res["device_code"]
|
|
44
|
+
interval = (begin_res["interval"] || 5).to_i
|
|
45
|
+
expire_in = (begin_res["expires_in"] || 600).to_i
|
|
46
|
+
qr_url = build_qr_url(begin_res["verification_uri_complete"], app_name: app_name, app_desc: app_desc)
|
|
47
|
+
|
|
48
|
+
on_qr_code.call(qr_url, expire_in)
|
|
49
|
+
|
|
50
|
+
deadline = Time.now + expire_in
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
raise AppExpiredError.new("expired_token", "polling timed out") if Time.now >= deadline
|
|
54
|
+
|
|
55
|
+
poll_res = post(base_url, action: "poll", device_code: device_code)
|
|
56
|
+
|
|
57
|
+
if poll_res["client_id"] && poll_res["client_secret"]
|
|
58
|
+
return { client_id: poll_res["client_id"], client_secret: poll_res["client_secret"] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
user_info = poll_res["user_info"] || {}
|
|
62
|
+
if user_info["tenant_brand"] == "lark" && !domain_switched
|
|
63
|
+
base_url = lark_domain
|
|
64
|
+
domain_switched = true
|
|
65
|
+
on_status_change&.call("domain_switched")
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
case poll_res["error"]
|
|
70
|
+
when "authorization_pending"
|
|
71
|
+
on_status_change&.call("polling")
|
|
72
|
+
sleep interval
|
|
73
|
+
when "slow_down"
|
|
74
|
+
interval += 5
|
|
75
|
+
on_status_change&.call("slow_down")
|
|
76
|
+
sleep interval
|
|
77
|
+
when "access_denied"
|
|
78
|
+
raise AppAccessDeniedError.new("access_denied", poll_res["error_description"].to_s)
|
|
79
|
+
when "expired_token"
|
|
80
|
+
raise AppExpiredError.new("expired_token", poll_res["error_description"].to_s)
|
|
81
|
+
else
|
|
82
|
+
err = poll_res["error"].to_s
|
|
83
|
+
raise SetupError.new(err, poll_res["error_description"].to_s) unless err.empty?
|
|
84
|
+
sleep interval
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method def self.post(base_url, params)
|
|
90
|
+
uri = URI("#{base_url}#{ENDPOINT}")
|
|
91
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
92
|
+
http.use_ssl = uri.scheme == "https"
|
|
93
|
+
http.open_timeout = 10
|
|
94
|
+
http.read_timeout = 30
|
|
95
|
+
req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/x-www-form-urlencoded")
|
|
96
|
+
req.body = URI.encode_www_form(params)
|
|
97
|
+
JSON.parse(http.request(req).body)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method def self.build_qr_url(uri_complete, app_name: nil, app_desc: nil)
|
|
101
|
+
uri = URI.parse(uri_complete)
|
|
102
|
+
params = URI.decode_www_form(uri.query.to_s).to_h
|
|
103
|
+
params["from"] = "sdk"
|
|
104
|
+
params["tp"] = "sdk"
|
|
105
|
+
params["source"] = SDK_NAME
|
|
106
|
+
params["name"] = app_name if app_name
|
|
107
|
+
params["desc"] = app_desc if app_desc
|
|
108
|
+
uri.query = URI.encode_www_form(params)
|
|
109
|
+
uri.to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if __FILE__ == $PROGRAM_NAME
|
|
114
|
+
product_name = ENV.fetch("CLACKY_PRODUCT_NAME", "OpenClacky")
|
|
115
|
+
date_suffix = Time.now.strftime("%Y%m%d")
|
|
116
|
+
app_desc = "Your personal assistant powered by #{product_name}"
|
|
117
|
+
|
|
118
|
+
result = FeishuSetup.run(
|
|
119
|
+
app_name: "#{product_name} #{date_suffix}",
|
|
120
|
+
app_desc: app_desc,
|
|
121
|
+
on_qr_code: lambda { |url, expire_in|
|
|
122
|
+
puts "SCAN_URL:#{url}"
|
|
123
|
+
puts "EXPIRE_IN:#{expire_in}"
|
|
124
|
+
$stdout.flush
|
|
125
|
+
},
|
|
126
|
+
on_status_change: lambda { |status|
|
|
127
|
+
$stderr.puts "[feishu-setup] status=#{status}"
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
puts "APP_ID:#{result[:client_id]}"
|
|
132
|
+
puts "APP_SECRET:#{result[:client_secret]}"
|
|
133
|
+
$stdout.flush
|
|
134
|
+
end
|
|
@@ -32,6 +32,11 @@ Do NOT try to fall back to `terminal` + a hand-written `curl https://api.openai.
|
|
|
32
32
|
|
|
33
33
|
## Step 2 — Generate the image
|
|
34
34
|
|
|
35
|
+
### ⚠️ Important: generation speed & concurrency
|
|
36
|
+
|
|
37
|
+
- **Image generation can be slow — up to 2 minutes per image depending on the model.** Before calling the API, warn the user that it may take a minute or two. The curl request blocks until the image is ready; do NOT run it in the background.
|
|
38
|
+
- **One at a time only.** Never generate multiple images concurrently (e.g. by running several `curl` commands simultaneously or in a script loop). Each call consumes significant server-side resources, and parallel requests will almost certainly cause timeouts. If the user wants several images, generate them **sequentially**, one after another.
|
|
39
|
+
|
|
35
40
|
```bash
|
|
36
41
|
curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image \
|
|
37
42
|
-H "Content-Type: application/json" \
|
|
@@ -48,10 +48,11 @@ Scan the list above:
|
|
|
48
48
|
|
|
49
49
|
Use the `write` tool. Always include the YAML frontmatter shown above.
|
|
50
50
|
|
|
51
|
-
##
|
|
51
|
+
## Guidelines
|
|
52
52
|
|
|
53
|
-
-
|
|
54
|
-
- If
|
|
53
|
+
- Aim for around 4000 characters of content (after the frontmatter). This is a soft target — moderate overshoot is fine, do NOT iterate writes just to shave characters.
|
|
54
|
+
- If a file grows much larger than that (say, well past 8000), trim the least important information rather than splitting one topic across multiple files.
|
|
55
|
+
- Prefer merging into an existing file over creating a new one. Only create a new file when no existing topic genuinely covers the area.
|
|
55
56
|
- Write concise, factual Markdown — no fluff, no redundant headings.
|
|
56
57
|
- One topic per file. Don't bundle unrelated facts together.
|
|
57
58
|
- Do NOT use `terminal` or `file_reader` to list the memories directory — the list above is authoritative.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: search-skills
|
|
3
|
+
description: 'Search ALL installed skills (including ones not shown in AVAILABLE SKILLS) by keyword. Use this whenever you suspect a fitting skill might exist but is not listed in your system prompt — for example before building a new skill, when the user mentions a domain not covered by visible skills, or after seeing the (N more skills installed) hint. Triggers on phrases like search skills, find a skill for, is there a skill that, 查找skill, 有没有skill做.'
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
user-invocable: true
|
|
6
|
+
fork_agent: true
|
|
7
|
+
auto_summarize: true
|
|
8
|
+
forbidden_tools:
|
|
9
|
+
- write
|
|
10
|
+
- edit
|
|
11
|
+
- terminal
|
|
12
|
+
- web_search
|
|
13
|
+
- web_fetch
|
|
14
|
+
- browser
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Search Skills Subagent
|
|
18
|
+
|
|
19
|
+
You are a Skill Search Subagent. Given a keyword or topic from the parent agent, scan the complete list of installed skills below and return the best matches.
|
|
20
|
+
|
|
21
|
+
The AVAILABLE SKILLS section in the parent agent system prompt is capped (~30 entries). The list below is the FULL list — pre-loaded for you, no scanning required. Your job is to look beyond that cap so the parent does not redundantly create a new skill when one already exists.
|
|
22
|
+
|
|
23
|
+
## Complete Skill Inventory
|
|
24
|
+
|
|
25
|
+
This list was pre-loaded — do NOT re-scan the filesystem or call any tools.
|
|
26
|
+
|
|
27
|
+
<%= all_skills_meta %>
|
|
28
|
+
|
|
29
|
+
## Workflow
|
|
30
|
+
|
|
31
|
+
### Step 1 — Extract keywords
|
|
32
|
+
|
|
33
|
+
Pull 2-4 keywords from the input task. Both English and Chinese terms are valid (skill descriptions are bilingual).
|
|
34
|
+
|
|
35
|
+
### Step 2 — Match against the inventory above
|
|
36
|
+
|
|
37
|
+
For each skill in the inventory, judge relevance against the keywords:
|
|
38
|
+
- Strong match: keyword appears in the skill `name` or clearly in the `description`'s purpose statement
|
|
39
|
+
- Weak match: keyword appears only in the trigger examples or peripheral mentions
|
|
40
|
+
|
|
41
|
+
### Step 3 — Return a ranked summary
|
|
42
|
+
|
|
43
|
+
Return at most 5 results, strongest matches first:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Found N matching skill(s) for: <keywords>
|
|
47
|
+
|
|
48
|
+
1. <name> (<source>)
|
|
49
|
+
<description trimmed to ~200 chars>
|
|
50
|
+
|
|
51
|
+
2. ...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If nothing genuinely matches, return exactly: `No installed skill matches: <task>`
|
|
55
|
+
|
|
56
|
+
## Rules
|
|
57
|
+
|
|
58
|
+
- Do NOT invoke any tool. The inventory above is authoritative; just match and return.
|
|
59
|
+
- Do NOT recommend creating a new skill — that is the parent agent's call.
|
|
60
|
+
- If the task is vague, return what genuinely matched, do not invent relevance.
|
|
61
|
+
- Default skills (built-in) are part of the inventory but typically also visible to the parent — flagging them is still useful as a reminder.
|
|
@@ -17,7 +17,7 @@ module Clacky
|
|
|
17
17
|
# Seconds of inactivity before idle compression is triggered.
|
|
18
18
|
# Kept under the 5-minute prompt cache TTL so the compression call itself
|
|
19
19
|
# still hits the existing prefix cache.
|
|
20
|
-
IDLE_DELAY =
|
|
20
|
+
IDLE_DELAY = 266
|
|
21
21
|
|
|
22
22
|
# @param agent [Clacky::Agent] the agent whose messages will be compressed
|
|
23
23
|
# @param session_manager [Clacky::SessionManager, nil] used to persist session after compression
|
|
@@ -206,7 +206,13 @@ module Clacky
|
|
|
206
206
|
# Skip malformed tool calls where name or arguments is nil (broken API response)
|
|
207
207
|
next if name.nil? || arguments.nil?
|
|
208
208
|
|
|
209
|
-
{ id: call["id"], type: call["type"], name: name, arguments: arguments }
|
|
209
|
+
tc = { id: call["id"], type: call["type"], name: name, arguments: arguments }
|
|
210
|
+
# Vertex Gemini's OpenAI shim returns thought_signature inside
|
|
211
|
+
# tool_calls[i].extra_content.google and requires it echoed back on
|
|
212
|
+
# replay, otherwise the next turn 400s with "Function call is missing
|
|
213
|
+
# a thought_signature". Preserve it through the canonical layer.
|
|
214
|
+
tc[:extra_content] = call["extra_content"] if call["extra_content"]
|
|
215
|
+
tc
|
|
210
216
|
end
|
|
211
217
|
end
|
|
212
218
|
|
|
@@ -193,6 +193,7 @@ module Clacky
|
|
|
193
193
|
# this bypass lets us recover on the retry without a server restart.
|
|
194
194
|
def to_api(force_reasoning_content_pad: false)
|
|
195
195
|
msgs = @messages.map { |m| strip_for_api(m) }
|
|
196
|
+
msgs = repair_tool_call_pairing(msgs)
|
|
196
197
|
ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
|
|
197
198
|
end
|
|
198
199
|
|
|
@@ -276,6 +277,62 @@ module Clacky
|
|
|
276
277
|
message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
|
|
277
278
|
end
|
|
278
279
|
|
|
280
|
+
# Defensive integrity pass before sending history to the LLM.
|
|
281
|
+
# OpenAI-compat protocols (incl. DeepSeek) require every assistant.tool_calls
|
|
282
|
+
# to be followed by a tool message for each tool_call_id. If a previous turn
|
|
283
|
+
# was interrupted mid-act() — e.g. a channel user sent a second message
|
|
284
|
+
# before the first finished — the tool segment may be missing entries,
|
|
285
|
+
# producing HTTP 400 "insufficient tool messages following tool_calls".
|
|
286
|
+
# This pass scans the whole history and inserts placeholder tool messages
|
|
287
|
+
# for any unmatched ids. It is deterministic (same input → same output)
|
|
288
|
+
# so prompt caching is not disturbed.
|
|
289
|
+
private def repair_tool_call_pairing(msgs)
|
|
290
|
+
result = []
|
|
291
|
+
i = 0
|
|
292
|
+
while i < msgs.size
|
|
293
|
+
msg = msgs[i]
|
|
294
|
+
result << msg
|
|
295
|
+
|
|
296
|
+
if msg[:role] == "assistant" && msg[:tool_calls].is_a?(Array) && !msg[:tool_calls].empty?
|
|
297
|
+
expected_ids = msg[:tool_calls].map { |tc| tc[:id] }.compact
|
|
298
|
+
seen_ids = []
|
|
299
|
+
|
|
300
|
+
j = i + 1
|
|
301
|
+
while j < msgs.size && tool_result_message?(msgs[j])
|
|
302
|
+
result << msgs[j]
|
|
303
|
+
seen_ids.concat(tool_result_ids(msgs[j]))
|
|
304
|
+
j += 1
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
(expected_ids - seen_ids).each do |id|
|
|
308
|
+
result << {
|
|
309
|
+
role: "tool",
|
|
310
|
+
tool_call_id: id,
|
|
311
|
+
content: '{"error":"Tool result missing (interrupted)"}'
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
i = j
|
|
316
|
+
else
|
|
317
|
+
i += 1
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
result
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private def tool_result_message?(msg)
|
|
324
|
+
MessageFormat::OpenAI.tool_result_message?(msg) ||
|
|
325
|
+
MessageFormat::Anthropic.tool_result_message?(msg)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private def tool_result_ids(msg)
|
|
329
|
+
if MessageFormat::OpenAI.tool_result_message?(msg)
|
|
330
|
+
MessageFormat::OpenAI.tool_call_ids(msg)
|
|
331
|
+
else
|
|
332
|
+
MessageFormat::Anthropic.tool_use_ids(msg)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
279
336
|
# Detect thinking-mode providers purely from history content and pad
|
|
280
337
|
# synthetic assistant messages with an empty reasoning_content when needed.
|
|
281
338
|
#
|
|
@@ -28,10 +28,25 @@ module Clacky
|
|
|
28
28
|
@usage = nil
|
|
29
29
|
@last_input_tokens = 0
|
|
30
30
|
@last_output_tokens = 0
|
|
31
|
+
@parse_failures = 0
|
|
32
|
+
@frames_seen = 0
|
|
33
|
+
@bytes_seen = 0
|
|
34
|
+
@saw_done = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
38
|
+
|
|
39
|
+
def saw_done?
|
|
40
|
+
@saw_done
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
def handle(data_str)
|
|
34
|
-
|
|
44
|
+
@bytes_seen += data_str.bytesize
|
|
45
|
+
if data_str == "[DONE]"
|
|
46
|
+
@saw_done = true
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
@frames_seen += 1
|
|
35
50
|
data = parse_or_nil(data_str)
|
|
36
51
|
return unless data
|
|
37
52
|
|
|
@@ -57,7 +72,7 @@ module Clacky
|
|
|
57
72
|
def to_h
|
|
58
73
|
tool_calls = @tool_calls.keys.sort.map do |idx|
|
|
59
74
|
tc = @tool_calls[idx]
|
|
60
|
-
{
|
|
75
|
+
out = {
|
|
61
76
|
"id" => tc[:id],
|
|
62
77
|
"type" => tc[:type] || "function",
|
|
63
78
|
"function" => {
|
|
@@ -65,6 +80,8 @@ module Clacky
|
|
|
65
80
|
"arguments" => tc[:arguments].to_s
|
|
66
81
|
}
|
|
67
82
|
}
|
|
83
|
+
out["extra_content"] = tc[:extra_content] if tc[:extra_content]
|
|
84
|
+
out
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
message = {
|
|
@@ -89,11 +106,21 @@ module Clacky
|
|
|
89
106
|
slot[:name] ||= fn["name"] if fn["name"]
|
|
90
107
|
slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
|
|
91
108
|
end
|
|
109
|
+
slot[:extra_content] = tc["extra_content"] if tc["extra_content"]
|
|
92
110
|
end
|
|
93
111
|
|
|
94
112
|
private def parse_or_nil(s)
|
|
95
113
|
JSON.parse(s)
|
|
96
|
-
rescue JSON::ParserError
|
|
114
|
+
rescue JSON::ParserError => e
|
|
115
|
+
@parse_failures += 1
|
|
116
|
+
if @parse_failures == 1
|
|
117
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
118
|
+
provider: "openai",
|
|
119
|
+
error: "#{e.class}: #{e.message}",
|
|
120
|
+
frame_head: s.to_s[0, 200],
|
|
121
|
+
frame_bytes: s.to_s.bytesize
|
|
122
|
+
)
|
|
123
|
+
end
|
|
97
124
|
nil
|
|
98
125
|
end
|
|
99
126
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -39,18 +39,25 @@ module Clacky
|
|
|
39
39
|
"abs-claude-haiku-4-5",
|
|
40
40
|
"dsk-deepseek-v4-pro",
|
|
41
41
|
"dsk-deepseek-v4-flash",
|
|
42
|
-
"or-gemini-3-1-pro"
|
|
42
|
+
"or-gemini-3-1-pro",
|
|
43
|
+
"or-gemini-3-5-flash"
|
|
43
44
|
],
|
|
44
45
|
# Image generation models served by the openclacky platform
|
|
45
46
|
# gateway. The gateway exposes a standard OpenAI-compatible
|
|
46
47
|
# /v1/images/generations endpoint, so the same OpenAICompat
|
|
47
|
-
# provider class handles them. `or-` prefix
|
|
48
|
-
#
|
|
49
|
-
#
|
|
48
|
+
# provider class handles them. `or-` prefix is a routing alias
|
|
49
|
+
# only — the platform may dispatch to OpenRouter or Vertex AI
|
|
50
|
+
# (Gemini Nano Banana family) depending on the model.
|
|
50
51
|
"image_models" => [
|
|
51
52
|
"or-gemini-3-pro-image",
|
|
53
|
+
"or-gemini-3-1-flash-image",
|
|
52
54
|
"or-gpt-image-2"
|
|
53
55
|
],
|
|
56
|
+
"image_model_aliases" => {
|
|
57
|
+
"or-gemini-3-pro-image" => "Nano Banana Pro",
|
|
58
|
+
"or-gemini-3-1-flash-image" => "Nano Banana 2",
|
|
59
|
+
"or-gpt-image-2" => "GPT Image 2"
|
|
60
|
+
},
|
|
54
61
|
"default_image_model" => "or-gpt-image-2",
|
|
55
62
|
# Provider-level default: the Claude family served here is vision-capable.
|
|
56
63
|
"capabilities" => { "vision" => true }.freeze,
|
|
@@ -65,20 +72,17 @@ module Clacky
|
|
|
65
72
|
# Per-primary lite pairing: keys are "strong" primary models, values
|
|
66
73
|
# are the lite sidekick to auto-inject when that primary is the
|
|
67
74
|
# default. Lite is consumed by some subagents for cheap/fast work;
|
|
68
|
-
# weak models (haiku / v4-flash) ARE the lite tier
|
|
69
|
-
# they're intentionally not listed here
|
|
70
|
-
# the default model is already lite-class.
|
|
71
|
-
#
|
|
72
|
-
# or-gemini-3-1-pro is intentionally absent: Gemini has no lite
|
|
73
|
-
# sibling wired up (yet) on this provider; subagents using the
|
|
74
|
-
# Gemini default will just reuse it for lite work until we add one.
|
|
75
|
+
# weak models (haiku / v4-flash / 3-5-flash) ARE the lite tier
|
|
76
|
+
# themselves, so they're intentionally not listed here as keys —
|
|
77
|
+
# no injection happens when the default model is already lite-class.
|
|
75
78
|
"lite_models" => {
|
|
76
79
|
"abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
|
|
77
80
|
"abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
|
|
78
81
|
"abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
|
|
79
82
|
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
80
83
|
"abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
|
|
81
|
-
"dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash"
|
|
84
|
+
"dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
|
|
85
|
+
"or-gemini-3-1-pro" => "or-gemini-3-5-flash"
|
|
82
86
|
},
|
|
83
87
|
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
84
88
|
# Keys are primary model names; values are the fallback model to use instead.
|
|
@@ -487,6 +491,30 @@ module Clacky
|
|
|
487
491
|
preset&.dig("image_models") || []
|
|
488
492
|
end
|
|
489
493
|
|
|
494
|
+
def image_model_aliases(provider_id)
|
|
495
|
+
preset = PRESETS[provider_id]
|
|
496
|
+
preset&.dig("image_model_aliases") || {}
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def video_model_aliases(provider_id)
|
|
500
|
+
preset = PRESETS[provider_id]
|
|
501
|
+
preset&.dig("video_model_aliases") || {}
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def audio_model_aliases(provider_id)
|
|
505
|
+
preset = PRESETS[provider_id]
|
|
506
|
+
preset&.dig("audio_model_aliases") || {}
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def media_model_aliases(provider_id, kind)
|
|
510
|
+
case kind.to_s
|
|
511
|
+
when "image" then image_model_aliases(provider_id)
|
|
512
|
+
when "video" then video_model_aliases(provider_id)
|
|
513
|
+
when "audio" then audio_model_aliases(provider_id)
|
|
514
|
+
else {}
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
490
518
|
# Video generation models — placeholder. No provider supports video
|
|
491
519
|
# via Clacky yet; once they do, declare "video_models" alongside
|
|
492
520
|
# "image_models" in the relevant PRESETS entry and this returns it.
|
|
@@ -181,6 +181,10 @@ module Clacky
|
|
|
181
181
|
at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
|
|
182
182
|
bot_id = data.dig("chatbotUserId").to_s
|
|
183
183
|
unless at_users.include?(bot_id) || content.include?("@")
|
|
184
|
+
observe_text = content.strip
|
|
185
|
+
unless observe_text.empty?
|
|
186
|
+
@on_message&.call({ platform: :dingtalk, chat_id: chat_id, user_id: sender_id, text: observe_text, observe_only: true })
|
|
187
|
+
end
|
|
184
188
|
return
|
|
185
189
|
end
|
|
186
190
|
end
|
|
@@ -188,7 +192,11 @@ module Clacky
|
|
|
188
192
|
allowed = @config[:allowed_users]
|
|
189
193
|
return if allowed && !allowed.empty? && !allowed.include?(sender_id)
|
|
190
194
|
|
|
191
|
-
text, files = extract_payload(data, robot_code)
|
|
195
|
+
text, files, unsupported = extract_payload(data, robot_code)
|
|
196
|
+
if unsupported
|
|
197
|
+
@on_message&.call({ platform: :dingtalk, chat_id: chat_id, unsupported: true })
|
|
198
|
+
return
|
|
199
|
+
end
|
|
192
200
|
return if text.strip.empty? && files.empty?
|
|
193
201
|
|
|
194
202
|
event = {
|
|
@@ -242,6 +250,7 @@ module Clacky
|
|
|
242
250
|
end
|
|
243
251
|
else
|
|
244
252
|
Clacky::Logger.info("[dingtalk] unsupported msgtype=#{msgtype}, ignoring")
|
|
253
|
+
return ["", [], true]
|
|
245
254
|
end
|
|
246
255
|
|
|
247
256
|
[text, files]
|
|
@@ -142,7 +142,10 @@ module Clacky
|
|
|
142
142
|
Clacky::Logger.warn("[DiscordAdapter] bot_user_id unavailable; dropping group message")
|
|
143
143
|
return
|
|
144
144
|
end
|
|
145
|
-
|
|
145
|
+
unless mentioned_ids.include?(@bot_user_id)
|
|
146
|
+
@on_message&.call(event.merge(observe_only: true))
|
|
147
|
+
return
|
|
148
|
+
end
|
|
146
149
|
end
|
|
147
150
|
|
|
148
151
|
allowed_users = @config[:allowed_users]
|
|
@@ -153,7 +156,10 @@ module Clacky
|
|
|
153
156
|
text = strip_bot_mention(msg["content"].to_s, @bot_user_id)
|
|
154
157
|
files = process_attachments(Array(msg["attachments"]), chat_id)
|
|
155
158
|
|
|
156
|
-
|
|
159
|
+
if text.strip.empty? && files.empty?
|
|
160
|
+
@on_message&.call({ type: :message, platform: :discord, chat_id: chat_id, unsupported: true })
|
|
161
|
+
return
|
|
162
|
+
end
|
|
157
163
|
|
|
158
164
|
event = {
|
|
159
165
|
type: :message,
|
|
@@ -177,7 +177,11 @@ module Clacky
|
|
|
177
177
|
Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
|
|
178
178
|
return
|
|
179
179
|
end
|
|
180
|
-
|
|
180
|
+
unless Array(event[:mentioned_open_ids]).include?(bot_id)
|
|
181
|
+
user_name = @bot.fetch_user_name(event[:user_id])
|
|
182
|
+
@on_message&.call(event.merge(observe_only: true, user_name: user_name))
|
|
183
|
+
return
|
|
184
|
+
end
|
|
181
185
|
end
|
|
182
186
|
|
|
183
187
|
allowed_users = @config[:allowed_users]
|
|
@@ -185,6 +189,11 @@ module Clacky
|
|
|
185
189
|
return unless allowed_users.include?(event[:user_id])
|
|
186
190
|
end
|
|
187
191
|
|
|
192
|
+
if event[:unsupported]
|
|
193
|
+
@on_message&.call(event)
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
|
|
188
197
|
# Download images and attach as file hashes
|
|
189
198
|
image_files = []
|
|
190
199
|
if event[:image_keys] && !event[:image_keys].empty?
|
|
@@ -42,6 +42,7 @@ module Clacky
|
|
|
42
42
|
@domain = domain
|
|
43
43
|
@token_cache = nil
|
|
44
44
|
@token_expires_at = nil
|
|
45
|
+
@user_name_cache = {}
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Send plain text message
|
|
@@ -236,6 +237,17 @@ module Clacky
|
|
|
236
237
|
nil
|
|
237
238
|
end
|
|
238
239
|
|
|
240
|
+
def fetch_user_name(open_id)
|
|
241
|
+
return @user_name_cache[open_id] if @user_name_cache.key?(open_id)
|
|
242
|
+
|
|
243
|
+
name = get("/open-apis/contact/v3/users/#{open_id}", params: { user_id_type: "open_id" })
|
|
244
|
+
.dig("data", "user", "name")
|
|
245
|
+
@user_name_cache[open_id] = name.to_s.strip.then { |n| n.empty? ? open_id : n }
|
|
246
|
+
rescue => e
|
|
247
|
+
Clacky::Logger.warn("[feishu] Failed to fetch user name for #{open_id}: #{e.message}")
|
|
248
|
+
@user_name_cache[open_id] = open_id
|
|
249
|
+
end
|
|
250
|
+
|
|
239
251
|
# Get tenant access token (cached)
|
|
240
252
|
# @return [String] Access token
|
|
241
253
|
def tenant_access_token
|
|
@@ -56,8 +56,16 @@ module Clacky
|
|
|
56
56
|
msg_type = message["message_type"]
|
|
57
57
|
Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
|
|
58
58
|
unless %w[text image file post].include?(msg_type)
|
|
59
|
-
Clacky::Logger.info("[feishu]
|
|
60
|
-
|
|
59
|
+
Clacky::Logger.info("[feishu] unsupported msg_type=#{msg_type}")
|
|
60
|
+
chat_type = message["chat_type"] == "p2p" ? :direct : :group
|
|
61
|
+
return {
|
|
62
|
+
type: :message,
|
|
63
|
+
platform: :feishu,
|
|
64
|
+
chat_id: message["chat_id"],
|
|
65
|
+
chat_type: chat_type,
|
|
66
|
+
mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
|
|
67
|
+
unsupported: true
|
|
68
|
+
}
|
|
61
69
|
end
|
|
62
70
|
|
|
63
71
|
content_raw = message["content"]
|
|
@@ -70,7 +78,7 @@ module Clacky
|
|
|
70
78
|
|
|
71
79
|
case msg_type
|
|
72
80
|
when "text"
|
|
73
|
-
text =
|
|
81
|
+
text = resolve_mentions(content["text"].to_s.strip, message["mentions"])
|
|
74
82
|
return nil if text.empty?
|
|
75
83
|
when "image"
|
|
76
84
|
image_keys = [content["image_key"]].compact
|
|
@@ -131,6 +139,18 @@ module Clacky
|
|
|
131
139
|
text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
# Replace @_user_N placeholders in text with real display names from mentions array.
|
|
143
|
+
# Falls back to stripping unresolved placeholders via strip_mentions.
|
|
144
|
+
def resolve_mentions(text, mentions)
|
|
145
|
+
mapping = Array(mentions).each_with_object({}) do |m, h|
|
|
146
|
+
key = m["key"].to_s
|
|
147
|
+
name = m.dig("name").to_s
|
|
148
|
+
h[key] = name unless key.empty? || name.empty?
|
|
149
|
+
end
|
|
150
|
+
result = text.gsub(/@_user_\d+/) { |k| mapping[k] ? "@#{mapping[k]}" : k }
|
|
151
|
+
strip_mentions(result).strip
|
|
152
|
+
end
|
|
153
|
+
|
|
134
154
|
# Parse a Feishu post content body into text and image_keys.
|
|
135
155
|
# post content structure from event payloads:
|
|
136
156
|
# {"title": "", "content": [[{tag, text, ...}, ...], ...]}
|
|
@@ -243,7 +243,13 @@ module Clacky
|
|
|
243
243
|
text = msg["text"].to_s
|
|
244
244
|
|
|
245
245
|
if is_group
|
|
246
|
-
|
|
246
|
+
unless group_mention?(msg, text)
|
|
247
|
+
observe_text = text.strip
|
|
248
|
+
unless observe_text.empty?
|
|
249
|
+
@on_message&.call({ type: :message, platform: :telegram, chat_id: chat_id.to_s, user_id: user_id.to_s, text: observe_text, observe_only: true })
|
|
250
|
+
end
|
|
251
|
+
return
|
|
252
|
+
end
|
|
247
253
|
text = strip_bot_mention(text)
|
|
248
254
|
end
|
|
249
255
|
|
|
@@ -255,7 +261,11 @@ module Clacky
|
|
|
255
261
|
files = collect_files(msg)
|
|
256
262
|
caption = msg["caption"].to_s
|
|
257
263
|
text = caption if text.empty? && !caption.empty?
|
|
258
|
-
|
|
264
|
+
|
|
265
|
+
if text.strip.empty? && files.empty?
|
|
266
|
+
@on_message&.call({ type: :message, platform: :telegram, chat_id: chat_id.to_s, unsupported: true })
|
|
267
|
+
return
|
|
268
|
+
end
|
|
259
269
|
|
|
260
270
|
event = {
|
|
261
271
|
type: :message,
|