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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +99 -356
  3. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  4. data/CHANGELOG.md +42 -0
  5. data/docs/system-skill-authoring-guide.md +1 -1
  6. data/lib/clacky/agent/tool_executor.rb +3 -1
  7. data/lib/clacky/agent.rb +12 -7
  8. data/lib/clacky/agent_config.rb +9 -3
  9. data/lib/clacky/brand_config.rb +19 -4
  10. data/lib/clacky/cli.rb +1 -1
  11. data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
  12. data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  13. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
  14. data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  15. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  16. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
  17. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
  18. data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
  19. data/lib/clacky/message_history.rb +26 -1
  20. data/lib/clacky/providers.rb +29 -4
  21. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
  22. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
  23. data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
  24. data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
  25. data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
  26. data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
  27. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
  28. data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
  29. data/lib/clacky/server/channel/channel_config.rb +26 -0
  30. data/lib/clacky/server/channel.rb +3 -0
  31. data/lib/clacky/server/http_server.rb +75 -4
  32. data/lib/clacky/server/server_master.rb +35 -13
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +7 -1
  35. data/lib/clacky/telemetry.rb +1 -16
  36. data/lib/clacky/tools/browser.rb +8 -5
  37. data/lib/clacky/tools/glob.rb +11 -38
  38. data/lib/clacky/tools/grep.rb +7 -16
  39. data/lib/clacky/ui2/markdown_renderer.rb +1 -1
  40. data/lib/clacky/ui2/ui_controller.rb +2 -1
  41. data/lib/clacky/utils/file_ignore_helper.rb +49 -0
  42. data/lib/clacky/utils/gitignore_parser.rb +27 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +248 -31
  45. data/lib/clacky/web/app.js +51 -1
  46. data/lib/clacky/web/channels.js +98 -28
  47. data/lib/clacky/web/datepicker.js +205 -0
  48. data/lib/clacky/web/i18n.js +48 -9
  49. data/lib/clacky/web/index.html +33 -6
  50. data/lib/clacky/web/onboard.js +46 -4
  51. data/lib/clacky/web/sessions.js +33 -72
  52. data/lib/clacky/web/settings.js +42 -4
  53. data/lib/clacky/web/version.js +52 -1
  54. metadata +21 -10
  55. data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
  56. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
  57. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
  58. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
  59. /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,导入到 Clacky 直接使用?", "options": ["导入", "跳过"] }`
230
- - en: `{ "question": "OpenClaw detected. Found N skills. Import them into Clacky?", "options": ["Import", "Skip"] }`
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
- # ~/.agents/skills/, ~/.openclaw/workspace/.agents/skills/
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 four sources from hermes openclaw_to_hermes.py:
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 = File.join(Dir.home, '.clacky', 'skills')
40
- @per_skill_timeout = 10
41
- @total_timeout = 30
42
-
43
- @installed = 0
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
- install_concurrently(skills)
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
- @mutex.synchronize do
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
- Timeout.timeout(@per_skill_timeout) do
140
- installer = ZipSkillInstaller.new(
141
- download_url,
142
- skill_name: name,
143
- target_dir: @target_dir,
144
- skip_if_exists: true
145
- )
146
- result = installer.perform
147
- @mutex.synchronize do
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
- @mutex.synchronize { @errors << "#{name}: #{e.class}: #{e.message}" }
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
- ## Restarting the server
94
+ ## Server restart, upgrade, and downgrade
95
95
 
96
- If the user asks to restart the clacky/openclacky server (e.g. "重启", "restart", "请重启openclacky"):
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| strip_internal_fields(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
@@ -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-5",
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
- "models" => [], # Dynamic - fetched from API
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" => {