openclacky 1.0.4 → 1.1.0
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 +99 -356
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/CHANGELOG.md +42 -0
- data/docs/system-skill-authoring-guide.md +1 -1
- data/lib/clacky/agent/tool_executor.rb +3 -1
- data/lib/clacky/agent.rb +12 -7
- data/lib/clacky/agent_config.rb +9 -3
- data/lib/clacky/brand_config.rb +19 -4
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
- data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
- data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
- data/lib/clacky/message_history.rb +26 -1
- data/lib/clacky/providers.rb +29 -4
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
- data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
- data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/clacky/server/channel/channel_config.rb +26 -0
- data/lib/clacky/server/channel.rb +3 -0
- data/lib/clacky/server/http_server.rb +75 -4
- data/lib/clacky/server/server_master.rb +35 -13
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +7 -1
- data/lib/clacky/telemetry.rb +1 -16
- data/lib/clacky/tools/browser.rb +8 -5
- data/lib/clacky/tools/glob.rb +11 -38
- data/lib/clacky/tools/grep.rb +7 -16
- data/lib/clacky/ui2/markdown_renderer.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_ignore_helper.rb +49 -0
- data/lib/clacky/utils/gitignore_parser.rb +27 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +248 -31
- data/lib/clacky/web/app.js +51 -1
- data/lib/clacky/web/channels.js +98 -28
- data/lib/clacky/web/datepicker.js +205 -0
- data/lib/clacky/web/i18n.js +48 -9
- data/lib/clacky/web/index.html +33 -6
- data/lib/clacky/web/onboard.js +46 -4
- data/lib/clacky/web/sessions.js +33 -72
- data/lib/clacky/web/settings.js +42 -4
- data/lib/clacky/web/version.js +52 -1
- metadata +21 -10
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/weixin_setup.rb +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# discord_setup.rb — Discord channel setup helper.
|
|
5
|
+
#
|
|
6
|
+
# Discord's developer portal requires manual interaction (hCaptcha + private API), so the
|
|
7
|
+
# Agent uses the browser only as a container — it navigates to the portal and the user
|
|
8
|
+
# creates the App manually, then pastes back the bot token and application id. This
|
|
9
|
+
# script handles everything a shell can do: emit the portal URL, validate the token
|
|
10
|
+
# against /users/@me, save to the clacky server, generate the OAuth2 invite URL, and
|
|
11
|
+
# poll until the bot is in at least one guild.
|
|
12
|
+
#
|
|
13
|
+
# Modes:
|
|
14
|
+
# --portal-url Print the Discord developer portal URL (stdout, single line)
|
|
15
|
+
# --validate <token> Validate bot_token via /users/@me, then POST to server
|
|
16
|
+
# --invite-url <client_id> Print the OAuth2 invite URL (stdout, single line)
|
|
17
|
+
# --watch-guild Long-poll /users/@me/guilds via the saved token
|
|
18
|
+
# until at least one guild appears (or timeout)
|
|
19
|
+
# --bot-info <token> Print {id, username} JSON for an unsaved token
|
|
20
|
+
#
|
|
21
|
+
# Environment:
|
|
22
|
+
# CLACKY_SERVER_HOST default 127.0.0.1
|
|
23
|
+
# CLACKY_SERVER_PORT default 7070
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require "net/http"
|
|
27
|
+
require "net/https"
|
|
28
|
+
require "uri"
|
|
29
|
+
require "openssl"
|
|
30
|
+
require "cgi"
|
|
31
|
+
require "yaml"
|
|
32
|
+
|
|
33
|
+
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
34
|
+
DISCORD_OAUTH_BASE = "https://discord.com/oauth2/authorize"
|
|
35
|
+
DISCORD_PORTAL_URL = "https://discord.com/developers/applications"
|
|
36
|
+
DEFAULT_BOT_PERMS = "274877990912"
|
|
37
|
+
DEFAULT_BOT_SCOPES = "bot applications.commands"
|
|
38
|
+
WATCH_GUILD_DEADLINE = 10 * 60
|
|
39
|
+
WATCH_GUILD_INTERVAL = 3
|
|
40
|
+
USER_AGENT = "DiscordBot (https://github.com/clackyai/openclacky, 1.0)"
|
|
41
|
+
|
|
42
|
+
CLACKY_SERVER_URL = begin
|
|
43
|
+
host = ENV.fetch("CLACKY_SERVER_HOST", "127.0.0.1")
|
|
44
|
+
port = ENV.fetch("CLACKY_SERVER_PORT", "7070")
|
|
45
|
+
"http://#{host}:#{port}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def step(msg); $stderr.puts("[discord-setup] #{msg}"); end
|
|
49
|
+
def ok(msg); $stderr.puts("[discord-setup] #{msg}"); end
|
|
50
|
+
def warn!(msg); $stderr.puts("[discord-setup] #{msg}"); end
|
|
51
|
+
|
|
52
|
+
def fail!(msg, json: false)
|
|
53
|
+
if json
|
|
54
|
+
$stdout.puts(JSON.generate({ error: msg }))
|
|
55
|
+
else
|
|
56
|
+
$stderr.puts("[discord-setup] #{msg}")
|
|
57
|
+
end
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def discord_get(path, bot_token:, timeout: 15)
|
|
62
|
+
uri = URI("#{DISCORD_API_BASE}#{path}")
|
|
63
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
64
|
+
http.use_ssl = true
|
|
65
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
66
|
+
http.read_timeout = timeout
|
|
67
|
+
http.open_timeout = 10
|
|
68
|
+
|
|
69
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
70
|
+
req["Authorization"] = "Bot #{bot_token}"
|
|
71
|
+
req["User-Agent"] = USER_AGENT
|
|
72
|
+
req["Accept"] = "application/json"
|
|
73
|
+
|
|
74
|
+
res = http.request(req)
|
|
75
|
+
body = res.body.to_s
|
|
76
|
+
parsed = (JSON.parse(body) rescue nil)
|
|
77
|
+
|
|
78
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
79
|
+
msg = parsed.is_a?(Hash) ? (parsed["message"] || body.slice(0, 200)) : body.slice(0, 200)
|
|
80
|
+
raise "Discord HTTP #{res.code} #{path}: #{msg}"
|
|
81
|
+
end
|
|
82
|
+
parsed
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def saved_bot_token
|
|
86
|
+
yml_path = File.expand_path("~/.clacky/channels.yml")
|
|
87
|
+
return nil unless File.exist?(yml_path)
|
|
88
|
+
data = YAML.safe_load_file(yml_path, permitted_classes: [Symbol], aliases: true) rescue nil
|
|
89
|
+
data&.dig("channels", "discord", "bot_token") || data&.dig(:channels, :discord, :bot_token)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def save_to_server(bot_token:)
|
|
93
|
+
uri = URI("#{CLACKY_SERVER_URL}/api/channels/discord")
|
|
94
|
+
body = JSON.generate({ bot_token: bot_token })
|
|
95
|
+
|
|
96
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
97
|
+
http.read_timeout = 30
|
|
98
|
+
http.open_timeout = 5
|
|
99
|
+
|
|
100
|
+
req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
101
|
+
req.body = body
|
|
102
|
+
|
|
103
|
+
res = http.request(req)
|
|
104
|
+
data = JSON.parse(res.body) rescue {}
|
|
105
|
+
|
|
106
|
+
unless res.is_a?(Net::HTTPSuccess) && data["ok"]
|
|
107
|
+
fail!("Failed to save Discord config: #{data["error"] || res.body.slice(0, 200)}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
mode_idx = ARGV.index { |a| a.start_with?("--") }
|
|
112
|
+
mode = mode_idx ? ARGV[mode_idx] : nil
|
|
113
|
+
arg = mode_idx ? ARGV[mode_idx + 1] : nil
|
|
114
|
+
|
|
115
|
+
case mode
|
|
116
|
+
when "--portal-url"
|
|
117
|
+
$stdout.puts(DISCORD_PORTAL_URL)
|
|
118
|
+
exit 0
|
|
119
|
+
|
|
120
|
+
when "--validate"
|
|
121
|
+
fail!("--validate requires <bot_token>") if arg.to_s.strip.empty?
|
|
122
|
+
bot_token = arg.strip
|
|
123
|
+
step("Validating bot token against Discord API...")
|
|
124
|
+
begin
|
|
125
|
+
me = discord_get("/users/@me", bot_token: bot_token)
|
|
126
|
+
rescue => e
|
|
127
|
+
fail!("Token validation failed: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
bot_id = me["id"].to_s
|
|
131
|
+
username = me["username"].to_s
|
|
132
|
+
fail!("Empty bot id from /users/@me") if bot_id.empty?
|
|
133
|
+
|
|
134
|
+
ok("Authenticated as #{username} (id=#{bot_id})")
|
|
135
|
+
step("Saving credentials via clacky server...")
|
|
136
|
+
save_to_server(bot_token: bot_token)
|
|
137
|
+
ok("Discord channel configured")
|
|
138
|
+
|
|
139
|
+
$stdout.puts(JSON.generate({ bot_id: bot_id, username: username }))
|
|
140
|
+
exit 0
|
|
141
|
+
|
|
142
|
+
when "--bot-info"
|
|
143
|
+
fail!("--bot-info requires <bot_token>", json: true) if arg.to_s.strip.empty?
|
|
144
|
+
begin
|
|
145
|
+
me = discord_get("/users/@me", bot_token: arg.strip)
|
|
146
|
+
rescue => e
|
|
147
|
+
fail!(e.message, json: true)
|
|
148
|
+
end
|
|
149
|
+
$stdout.puts(JSON.generate({ bot_id: me["id"], username: me["username"] }))
|
|
150
|
+
exit 0
|
|
151
|
+
|
|
152
|
+
when "--invite-url"
|
|
153
|
+
fail!("--invite-url requires <client_id>") if arg.to_s.strip.empty?
|
|
154
|
+
client_id = arg.strip
|
|
155
|
+
url = "#{DISCORD_OAUTH_BASE}?client_id=#{CGI.escape(client_id)}" \
|
|
156
|
+
"&permissions=#{DEFAULT_BOT_PERMS}" \
|
|
157
|
+
"&scope=#{CGI.escape(DEFAULT_BOT_SCOPES)}"
|
|
158
|
+
$stdout.puts(url)
|
|
159
|
+
exit 0
|
|
160
|
+
|
|
161
|
+
when "--watch-guild"
|
|
162
|
+
bot_token = saved_bot_token
|
|
163
|
+
fail!("No saved bot_token in ~/.clacky/channels.yml — run --validate first") if bot_token.to_s.empty?
|
|
164
|
+
|
|
165
|
+
step("Waiting for the bot to be added to a guild (timeout: #{WATCH_GUILD_DEADLINE / 60} min)...")
|
|
166
|
+
deadline = Time.now + WATCH_GUILD_DEADLINE
|
|
167
|
+
|
|
168
|
+
loop do
|
|
169
|
+
fail!("Timed out waiting for the bot to join a guild. Open the invite URL again to retry.") if Time.now > deadline
|
|
170
|
+
|
|
171
|
+
begin
|
|
172
|
+
guilds = discord_get("/users/@me/guilds", bot_token: bot_token)
|
|
173
|
+
rescue => e
|
|
174
|
+
warn!("Poll error (will retry): #{e.message}")
|
|
175
|
+
sleep WATCH_GUILD_INTERVAL
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if guilds.is_a?(Array) && !guilds.empty?
|
|
180
|
+
g = guilds.first
|
|
181
|
+
ok("Bot added to guild: #{g["name"]} (id=#{g["id"]})")
|
|
182
|
+
$stdout.puts(JSON.generate({ guild_id: g["id"], guild_name: g["name"], total: guilds.length }))
|
|
183
|
+
exit 0
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
sleep WATCH_GUILD_INTERVAL
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
else
|
|
190
|
+
$stderr.puts(<<~USAGE)
|
|
191
|
+
Usage:
|
|
192
|
+
ruby discord_setup.rb --portal-url
|
|
193
|
+
ruby discord_setup.rb --validate <bot_token>
|
|
194
|
+
ruby discord_setup.rb --bot-info <bot_token>
|
|
195
|
+
ruby discord_setup.rb --invite-url <client_id>
|
|
196
|
+
ruby discord_setup.rb --watch-guild
|
|
197
|
+
USAGE
|
|
198
|
+
exit 1
|
|
199
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative '../skill-add/scripts/install_from_zip'
|
|
8
|
+
|
|
9
|
+
# Install Feishu-related skills from the openclacky platform.
|
|
10
|
+
#
|
|
11
|
+
# Calls GET /api/v1/skills/feishu — same payload shape as /api/v1/skills/builtin:
|
|
12
|
+
# { "skills": [{ "name": "lark-doc", "download_url": "https://..." }, ...] }
|
|
13
|
+
#
|
|
14
|
+
# Each skill is installed sequentially via ZipSkillInstaller into ~/.clacky/skills/<name>/.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# ruby install_feishu_skills.rb
|
|
18
|
+
#
|
|
19
|
+
# Output:
|
|
20
|
+
# Diagnostics → STDERR
|
|
21
|
+
# Last line → JSON: {"installed":N,"attempted":N}
|
|
22
|
+
# Exit code → always 0
|
|
23
|
+
|
|
24
|
+
class FeishuSkillsInstaller
|
|
25
|
+
PRIMARY_HOST = ENV.fetch('CLACKY_LICENSE_SERVER', 'https://www.openclacky.com')
|
|
26
|
+
FALLBACK_HOST = 'https://openclacky.up.railway.app'
|
|
27
|
+
API_HOSTS = ENV['CLACKY_LICENSE_SERVER'] ? [PRIMARY_HOST] : [PRIMARY_HOST, FALLBACK_HOST]
|
|
28
|
+
API_PATH = '/api/v1/skills/feishu'
|
|
29
|
+
API_OPEN_TIMEOUT = 5
|
|
30
|
+
API_READ_TIMEOUT = 10
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@target_dir = File.join(Dir.home, '.clacky', 'skills')
|
|
34
|
+
@installed = 0
|
|
35
|
+
@attempted = 0
|
|
36
|
+
@errors = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run
|
|
40
|
+
skills = fetch_skill_list
|
|
41
|
+
if skills.nil? || skills.empty?
|
|
42
|
+
emit_summary
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
skills.each { |skill| install_one(skill) }
|
|
47
|
+
ensure
|
|
48
|
+
emit_summary
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private def fetch_skill_list
|
|
52
|
+
API_HOSTS.each do |host|
|
|
53
|
+
begin
|
|
54
|
+
uri = URI.parse(host + API_PATH)
|
|
55
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
56
|
+
use_ssl: uri.scheme == 'https',
|
|
57
|
+
open_timeout: API_OPEN_TIMEOUT,
|
|
58
|
+
read_timeout: API_READ_TIMEOUT) do |http|
|
|
59
|
+
response = http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
60
|
+
if response.code.to_i == 200
|
|
61
|
+
payload = JSON.parse(response.body)
|
|
62
|
+
return Array(payload['skills'])
|
|
63
|
+
else
|
|
64
|
+
@errors << "API #{host}: HTTP #{response.code}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
@errors << "API #{host}: #{e.class}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private def install_one(skill)
|
|
75
|
+
name = skill['name'].to_s
|
|
76
|
+
download_url = skill['download_url'].to_s
|
|
77
|
+
@attempted += 1
|
|
78
|
+
|
|
79
|
+
if name.empty? || download_url.empty?
|
|
80
|
+
@errors << "skill payload missing name or download_url: #{skill.inspect}"
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
result = ZipSkillInstaller.new(
|
|
85
|
+
download_url,
|
|
86
|
+
skill_name: name,
|
|
87
|
+
target_dir: @target_dir,
|
|
88
|
+
skip_if_exists: false
|
|
89
|
+
).perform
|
|
90
|
+
@installed += result[:installed].size
|
|
91
|
+
@errors.concat(result[:errors]) if result[:errors].any?
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
@errors << "#{name}: #{e.class}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def emit_summary
|
|
97
|
+
unless @errors.empty?
|
|
98
|
+
warn '[install-feishu-skills] non-fatal errors:'
|
|
99
|
+
@errors.each { |e| warn " - #{e}" }
|
|
100
|
+
end
|
|
101
|
+
puts JSON.generate(installed: @installed, attempted: @attempted)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
FeishuSkillsInstaller.new.run if __FILE__ == $0
|
|
@@ -226,8 +226,8 @@ If `yes`:
|
|
|
226
226
|
1. `ruby "SKILL_DIR/scripts/import_external_skills.rb" --source openclaw --dry-run`
|
|
227
227
|
2. Parse the skill count N.
|
|
228
228
|
3. Ask via `request_user_feedback`:
|
|
229
|
-
- zh: `{ "question": "检测到你安装过 OpenClaw,找到 N 个 Skills
|
|
230
|
-
- en: `{ "question": "OpenClaw detected. Found N skills.
|
|
229
|
+
- zh: `{ "question": "检测到你安装过 OpenClaw,找到 N 个 Skills。现在建议跳过,后续使用 /skill-add 按需安装。", "options": ["全部导入", "跳过"] }`
|
|
230
|
+
- en: `{ "question": "OpenClaw detected. Found N skills. We recommend skipping for now and installing only what you need later with /skill-add.", "options": ["Import all", "Skip"] }`
|
|
231
231
|
4. If confirmed: `ruby "SKILL_DIR/scripts/import_external_skills.rb" --source openclaw --yes`
|
|
232
232
|
|
|
233
233
|
### A.11. Celebrate soul setup & offer browser
|
|
@@ -8,7 +8,7 @@ require 'pathname'
|
|
|
8
8
|
#
|
|
9
9
|
# Supported sources:
|
|
10
10
|
# - OpenClaw: ~/.openclaw/skills/, ~/.openclaw/workspace/skills/,
|
|
11
|
-
# ~/.
|
|
11
|
+
# ~/.openclaw/workspace/.agents/skills/
|
|
12
12
|
#
|
|
13
13
|
# Each source is imported into a dedicated category subdirectory under ~/.clacky/skills/,
|
|
14
14
|
# e.g. ~/.clacky/skills/openclaw-imports/<skill-name>/. This keeps imported skills
|
|
@@ -178,16 +178,14 @@ class OpenClawImporter < ExternalSkillsImporter
|
|
|
178
178
|
# Returns all directories that may contain OpenClaw skills.
|
|
179
179
|
# Each entry is a hash: { root: Pathname, layout: :flat }
|
|
180
180
|
#
|
|
181
|
-
# Mirrors the
|
|
181
|
+
# Mirrors the sources from hermes openclaw_to_hermes.py:
|
|
182
182
|
# - ~/.openclaw/workspace/skills/ (workspace skills)
|
|
183
183
|
# - ~/.openclaw/skills/ (managed/shared skills)
|
|
184
|
-
# - ~/.agents/skills/ (personal cross-project skills)
|
|
185
184
|
# - ~/.openclaw/workspace/.agents/skills/ (project-level shared skills)
|
|
186
185
|
private def source_dirs
|
|
187
186
|
[
|
|
188
187
|
@openclaw_dir.join('workspace', 'skills'),
|
|
189
188
|
@openclaw_dir.join('skills'),
|
|
190
|
-
Pathname.new(Dir.home).join('.agents', 'skills'),
|
|
191
189
|
@openclaw_dir.join('workspace', '.agents', 'skills')
|
|
192
190
|
].select(&:exist?)
|
|
193
191
|
end
|
|
@@ -1,29 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Install builtin skills into ~/.clacky/skills/.
|
|
5
|
-
#
|
|
6
|
-
# Fetches the server-curated builtin list from GET /api/v1/skills/builtin on
|
|
7
|
-
# the openclacky platform (public, no auth), then downloads and installs each
|
|
8
|
-
# skill's zip package in parallel (5 workers, 30s total timeout).
|
|
9
|
-
#
|
|
10
|
-
# The "builtin" whitelist is enforced server-side — this script takes no
|
|
11
|
-
# filter flags. Admin toggles the `builtin` flag per skill on the platform.
|
|
12
|
-
#
|
|
13
|
-
# Called by onboard skill: `ruby install_builtin_skills.rb`
|
|
14
|
-
#
|
|
15
|
-
# Output:
|
|
16
|
-
# - Diagnostics → STDERR
|
|
17
|
-
# - Last line of STDOUT → JSON: {"installed":N,"attempted":N,"skipped_existing":N}
|
|
18
|
-
# - Exit code: always 0
|
|
19
|
-
|
|
20
4
|
require 'uri'
|
|
21
5
|
require 'net/http'
|
|
22
6
|
require 'json'
|
|
23
|
-
require 'timeout'
|
|
24
7
|
|
|
25
|
-
# Reuse the downloader/extractor/installer from the skill-add skill.
|
|
26
|
-
# Physical relocation to lib/clacky/ is deferred until a third caller appears.
|
|
27
8
|
require_relative '../../skill-add/scripts/install_from_zip'
|
|
28
9
|
|
|
29
10
|
class BuiltinSkillsInstaller
|
|
@@ -33,18 +14,13 @@ class BuiltinSkillsInstaller
|
|
|
33
14
|
API_PATH = '/api/v1/skills/builtin'
|
|
34
15
|
API_OPEN_TIMEOUT = 5
|
|
35
16
|
API_READ_TIMEOUT = 10
|
|
36
|
-
CONCURRENCY = 5
|
|
37
17
|
|
|
38
18
|
def initialize
|
|
39
|
-
@target_dir
|
|
40
|
-
@
|
|
41
|
-
@
|
|
42
|
-
|
|
43
|
-
@
|
|
44
|
-
@skipped_existing = 0
|
|
45
|
-
@attempted = 0
|
|
46
|
-
@errors = []
|
|
47
|
-
@mutex = Mutex.new
|
|
19
|
+
@target_dir = File.join(Dir.home, '.clacky', 'skills')
|
|
20
|
+
@installed = 0
|
|
21
|
+
@skipped_existing = 0
|
|
22
|
+
@attempted = 0
|
|
23
|
+
@errors = []
|
|
48
24
|
end
|
|
49
25
|
|
|
50
26
|
def run
|
|
@@ -54,14 +30,11 @@ class BuiltinSkillsInstaller
|
|
|
54
30
|
return
|
|
55
31
|
end
|
|
56
32
|
|
|
57
|
-
|
|
33
|
+
skills.each { |skill| install_one(skill) }
|
|
58
34
|
ensure
|
|
59
35
|
emit_summary
|
|
60
36
|
end
|
|
61
37
|
|
|
62
|
-
# --- Internals -------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
# Returns an array of skill hashes, or nil on total failure.
|
|
65
38
|
private def fetch_skill_list
|
|
66
39
|
API_HOSTS.each do |host|
|
|
67
40
|
begin
|
|
@@ -85,79 +58,29 @@ class BuiltinSkillsInstaller
|
|
|
85
58
|
nil
|
|
86
59
|
end
|
|
87
60
|
|
|
88
|
-
# Install skills in parallel, bounded by CONCURRENCY and @total_timeout.
|
|
89
|
-
# Workers pull from a shared queue and self-check the deadline, so the
|
|
90
|
-
# global timeout is enforced without killing threads mid-download (which
|
|
91
|
-
# would leak temp dirs). Whatever finishes before the deadline stays
|
|
92
|
-
# installed; the rest is recovered on the next onboard run via skip_if_exists.
|
|
93
|
-
private def install_concurrently(skills)
|
|
94
|
-
queue = Queue.new
|
|
95
|
-
skills.each { |s| queue << s }
|
|
96
|
-
|
|
97
|
-
deadline = Time.now + @total_timeout
|
|
98
|
-
worker_pool = [CONCURRENCY, skills.size].min
|
|
99
|
-
|
|
100
|
-
workers = Array.new(worker_pool) do
|
|
101
|
-
Thread.new do
|
|
102
|
-
loop do
|
|
103
|
-
break if Time.now >= deadline
|
|
104
|
-
skill = queue.pop(true) rescue nil # non-blocking pop
|
|
105
|
-
break if skill.nil?
|
|
106
|
-
install_one(skill)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
workers.each(&:join)
|
|
112
|
-
|
|
113
|
-
# If the deadline cut us off with items still in the queue, record it.
|
|
114
|
-
remaining = queue.size
|
|
115
|
-
if remaining.positive?
|
|
116
|
-
@mutex.synchronize do
|
|
117
|
-
@errors << "overall timeout after #{@total_timeout}s " \
|
|
118
|
-
"(installed=#{@installed}, attempted=#{@attempted}, remaining=#{remaining})"
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Install one skill entry (hash from the API payload).
|
|
124
|
-
# Bounded by @per_skill_timeout; any failure is swallowed into @errors.
|
|
125
|
-
# Thread-safe: all shared state writes go through @mutex.
|
|
126
61
|
private def install_one(skill)
|
|
127
62
|
name = skill['name'].to_s
|
|
128
63
|
download_url = skill['download_url'].to_s
|
|
129
|
-
|
|
130
|
-
@mutex.synchronize { @attempted += 1 }
|
|
64
|
+
@attempted += 1
|
|
131
65
|
|
|
132
66
|
if name.empty? || download_url.empty?
|
|
133
|
-
@
|
|
134
|
-
@errors << "skill payload missing name or download_url: #{skill.inspect}"
|
|
135
|
-
end
|
|
67
|
+
@errors << "skill payload missing name or download_url: #{skill.inspect}"
|
|
136
68
|
return
|
|
137
69
|
end
|
|
138
70
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@installed += result[:installed].size
|
|
149
|
-
@skipped_existing += result[:skipped].size
|
|
150
|
-
@errors.concat(result[:errors]) if result[:errors].any?
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
rescue Timeout::Error
|
|
154
|
-
@mutex.synchronize { @errors << "#{name}: install timeout after #{@per_skill_timeout}s" }
|
|
71
|
+
result = ZipSkillInstaller.new(
|
|
72
|
+
download_url,
|
|
73
|
+
skill_name: name,
|
|
74
|
+
target_dir: @target_dir,
|
|
75
|
+
skip_if_exists: true
|
|
76
|
+
).perform
|
|
77
|
+
@installed += result[:installed].size
|
|
78
|
+
@skipped_existing += result[:skipped].size
|
|
79
|
+
@errors.concat(result[:errors]) if result[:errors].any?
|
|
155
80
|
rescue StandardError => e
|
|
156
|
-
@
|
|
81
|
+
@errors << "#{name}: #{e.class}: #{e.message}"
|
|
157
82
|
end
|
|
158
83
|
|
|
159
|
-
# Diagnostics to stderr; single-line JSON summary to stdout.
|
|
160
|
-
# The caller (onboard) should parse the LAST stdout line.
|
|
161
84
|
private def emit_summary
|
|
162
85
|
unless @errors.empty?
|
|
163
86
|
warn '[install_builtin_skills] non-fatal errors:'
|
|
@@ -171,5 +94,4 @@ class BuiltinSkillsInstaller
|
|
|
171
94
|
end
|
|
172
95
|
end
|
|
173
96
|
|
|
174
|
-
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
175
97
|
BuiltinSkillsInstaller.new.run if __FILE__ == $0
|
|
@@ -91,9 +91,11 @@ web_fetch(url: "<URL>", max_length: 5000)
|
|
|
91
91
|
- If still no answer, tell the user: "请访问 https://www.openclacky.com/docs 查看完整文档"
|
|
92
92
|
- Keep answers concise — extract what's relevant, don't paste the whole page
|
|
93
93
|
|
|
94
|
-
##
|
|
94
|
+
## Server restart, upgrade, and downgrade
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
### Normal restart
|
|
97
|
+
|
|
98
|
+
If the user asks to restart the server normally (e.g. "重启", "restart", "请重启openclacky") — without mentioning failure or errors:
|
|
97
99
|
|
|
98
100
|
**Do NOT fetch any docs.** Just return this answer directly:
|
|
99
101
|
|
|
@@ -103,3 +105,9 @@ If the user asks to restart the clacky/openclacky server (e.g. "重启", "restar
|
|
|
103
105
|
> ```
|
|
104
106
|
> This sends USR1 to the Master process, which spawns a new Worker and gracefully stops the old one.
|
|
105
107
|
> The `$CLACKY_MASTER_PID` environment variable is already set in the current session.
|
|
108
|
+
|
|
109
|
+
### Restart failure, upgrade failure, or downgrade
|
|
110
|
+
|
|
111
|
+
If the user mentions restart failure, upgrade failure, or how to downgrade (e.g. "重启失败", "升级失败", "降级", "restart failed", "upgrade failed", "downgrade", "如何降级"):
|
|
112
|
+
|
|
113
|
+
→ Fetch the FAQ page: `https://www.openclacky.com/docs/faq` — it has a dedicated Troubleshooting section covering all three scenarios.
|
|
@@ -193,7 +193,7 @@ module Clacky
|
|
|
193
193
|
# thinking inline (e.g. MiniMax: <think>...</think> in content), so
|
|
194
194
|
# this bypass lets us recover on the retry without a server restart.
|
|
195
195
|
def to_api(force_reasoning_content_pad: false)
|
|
196
|
-
msgs = @messages.map { |m|
|
|
196
|
+
msgs = @messages.map { |m| strip_for_api(m) }
|
|
197
197
|
ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
|
|
198
198
|
end
|
|
199
199
|
|
|
@@ -248,6 +248,31 @@ module Clacky
|
|
|
248
248
|
@messages.pop
|
|
249
249
|
end
|
|
250
250
|
|
|
251
|
+
private def strip_for_api(message)
|
|
252
|
+
msg = strip_internal_fields(message)
|
|
253
|
+
content = msg[:content]
|
|
254
|
+
return msg unless content.is_a?(Array)
|
|
255
|
+
|
|
256
|
+
cleaned = content.filter_map do |block|
|
|
257
|
+
next block unless block.is_a?(Hash)
|
|
258
|
+
|
|
259
|
+
if block[:type] == "image_url" &&
|
|
260
|
+
block.dig(:image_url, :url) == "[image stripped]"
|
|
261
|
+
next nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
block.key?(:image_path) ? block.reject { |k, _| k == :image_path } : block
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
return msg if cleaned == content
|
|
268
|
+
|
|
269
|
+
if cleaned.empty?
|
|
270
|
+
msg.merge(content: "[images were shown to you in a previous turn]")
|
|
271
|
+
else
|
|
272
|
+
msg.merge(content: cleaned)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
251
276
|
private def strip_internal_fields(message)
|
|
252
277
|
message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
|
|
253
278
|
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Clacky
|
|
|
29
29
|
"name" => "OpenClacky",
|
|
30
30
|
"base_url" => "https://api.openclacky.com",
|
|
31
31
|
"api" => "bedrock",
|
|
32
|
-
"default_model" => "abs-claude-sonnet-4-
|
|
32
|
+
"default_model" => "abs-claude-sonnet-4-6",
|
|
33
33
|
"models" => [
|
|
34
34
|
"abs-claude-opus-4-7",
|
|
35
35
|
"abs-claude-opus-4-6",
|
|
@@ -80,7 +80,32 @@ module Clacky
|
|
|
80
80
|
"base_url" => "https://openrouter.ai/api/v1",
|
|
81
81
|
"api" => "openai-responses",
|
|
82
82
|
"default_model" => "anthropic/claude-sonnet-4-6",
|
|
83
|
-
|
|
83
|
+
# Curated default lineup. OpenRouter's full catalogue is enormous
|
|
84
|
+
# (hundreds of models) and the live /models endpoint isn't always
|
|
85
|
+
# reachable from every region — shipping a small list of the
|
|
86
|
+
# mainstream Claude + GPT entries gives users a working dropdown
|
|
87
|
+
# out of the box. Users can still type any other OpenRouter model
|
|
88
|
+
# ID manually; this list only seeds the picker.
|
|
89
|
+
"models" => [
|
|
90
|
+
"anthropic/claude-sonnet-4-6",
|
|
91
|
+
"anthropic/claude-opus-4-7",
|
|
92
|
+
"anthropic/claude-opus-4-6",
|
|
93
|
+
"anthropic/claude-haiku-4-5",
|
|
94
|
+
"openai/gpt-5.5",
|
|
95
|
+
"openai/gpt-5.4",
|
|
96
|
+
"openai/gpt-5.4-mini"
|
|
97
|
+
],
|
|
98
|
+
# Per-primary lite pairing — Claude family pairs with Haiku, GPT
|
|
99
|
+
# family pairs with the mini variant. Mirrors the openclacky and
|
|
100
|
+
# openai presets above so subagents on OpenRouter get a sensible
|
|
101
|
+
# cheap/fast sidekick automatically.
|
|
102
|
+
"lite_models" => {
|
|
103
|
+
"anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
|
|
104
|
+
"anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
|
|
105
|
+
"anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
|
|
106
|
+
"openai/gpt-5.5" => "openai/gpt-5.4-mini",
|
|
107
|
+
"openai/gpt-5.4" => "openai/gpt-5.4-mini"
|
|
108
|
+
},
|
|
84
109
|
# Per-model API type overrides. Matched by Regexp against the model name.
|
|
85
110
|
# Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
|
|
86
111
|
# /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
|
|
@@ -243,8 +268,8 @@ module Clacky
|
|
|
243
268
|
"name" => "MiMo (Xiaomi)",
|
|
244
269
|
"base_url" => "https://api.xiaomimimo.com/v1",
|
|
245
270
|
"api" => "openai-completions",
|
|
246
|
-
"default_model" => "mimo-v2-pro",
|
|
247
|
-
"models" => ["mimo-v2-pro", "mimo-v2-omni"],
|
|
271
|
+
"default_model" => "mimo-v2.5-pro",
|
|
272
|
+
"models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
|
|
248
273
|
# MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
|
|
249
274
|
"capabilities" => { "vision" => false }.freeze,
|
|
250
275
|
"model_capabilities" => {
|