openclacky 1.0.1 → 1.0.3
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 +33 -0
- data/lib/clacky/agent/llm_caller.rb +403 -0
- data/lib/clacky/agent/message_compressor.rb +15 -4
- data/lib/clacky/agent/message_compressor_helper.rb +41 -2
- data/lib/clacky/agent/tool_registry.rb +109 -0
- data/lib/clacky/agent.rb +69 -2
- data/lib/clacky/agent_config.rb +17 -0
- data/lib/clacky/cli.rb +65 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +57 -3
- data/lib/clacky/default_skills/onboard/SKILL.md +14 -5
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/providers.rb +57 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
- data/lib/clacky/server/channel/channel_manager.rb +103 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/discover.rb +77 -0
- data/lib/clacky/server/epipe_safe_io.rb +105 -0
- data/lib/clacky/server/http_server.rb +90 -46
- data/lib/clacky/server/server_master.rb +6 -0
- data/lib/clacky/skill.rb +30 -0
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +95 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +157 -31
- data/lib/clacky/web/i18n.js +18 -2
- data/lib/clacky/web/index.html +8 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/sessions.js +31 -19
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/skills.js +4 -0
- data/lib/clacky.rb +5 -0
- metadata +5 -2
|
@@ -2,18 +2,127 @@
|
|
|
2
2
|
|
|
3
3
|
module Clacky
|
|
4
4
|
class ToolRegistry
|
|
5
|
+
# Common aliases that LLMs frequently use instead of the registered tool names.
|
|
6
|
+
# Keys are downcased aliases; values are the canonical registered names.
|
|
7
|
+
TOOL_ALIASES = {
|
|
8
|
+
# file_reader aliases
|
|
9
|
+
"read" => "file_reader",
|
|
10
|
+
"read_file" => "file_reader",
|
|
11
|
+
"filereader" => "file_reader",
|
|
12
|
+
"file_read" => "file_reader",
|
|
13
|
+
"cat" => "file_reader",
|
|
14
|
+
# write aliases
|
|
15
|
+
"write_file" => "write",
|
|
16
|
+
"create_file" => "write",
|
|
17
|
+
"file_write" => "write",
|
|
18
|
+
# edit aliases
|
|
19
|
+
"file_edit" => "edit",
|
|
20
|
+
"replace" => "edit",
|
|
21
|
+
"replace_in_file" => "edit",
|
|
22
|
+
"str_replace" => "edit",
|
|
23
|
+
# terminal aliases
|
|
24
|
+
"shell" => "terminal",
|
|
25
|
+
"bash" => "terminal",
|
|
26
|
+
"exec" => "terminal",
|
|
27
|
+
"execute" => "terminal",
|
|
28
|
+
"run_command" => "terminal",
|
|
29
|
+
"run" => "terminal",
|
|
30
|
+
"command" => "terminal",
|
|
31
|
+
# web_search aliases
|
|
32
|
+
"search" => "web_search",
|
|
33
|
+
"websearch" => "web_search",
|
|
34
|
+
"internet_search" => "web_search",
|
|
35
|
+
"online_search" => "web_search",
|
|
36
|
+
# web_fetch aliases
|
|
37
|
+
"fetch" => "web_fetch",
|
|
38
|
+
"webfetch" => "web_fetch",
|
|
39
|
+
"browse" => "web_fetch",
|
|
40
|
+
"url_fetch" => "web_fetch",
|
|
41
|
+
"http_get" => "web_fetch",
|
|
42
|
+
# grep aliases
|
|
43
|
+
"search_files" => "grep",
|
|
44
|
+
"search_in_files" => "grep",
|
|
45
|
+
"find_in_files" => "grep",
|
|
46
|
+
"search_code" => "grep",
|
|
47
|
+
# glob aliases
|
|
48
|
+
"find_files" => "glob",
|
|
49
|
+
"list_files" => "glob",
|
|
50
|
+
"file_glob" => "glob",
|
|
51
|
+
"search_filenames" => "glob",
|
|
52
|
+
# invoke_skill aliases
|
|
53
|
+
"skill" => "invoke_skill",
|
|
54
|
+
"run_skill" => "invoke_skill",
|
|
55
|
+
# todo_manager aliases
|
|
56
|
+
"todo" => "todo_manager",
|
|
57
|
+
"task_manager" => "todo_manager",
|
|
58
|
+
# request_user_feedback aliases
|
|
59
|
+
"ask_user" => "request_user_feedback",
|
|
60
|
+
"user_feedback" => "request_user_feedback",
|
|
61
|
+
"ask" => "request_user_feedback",
|
|
62
|
+
# undo_task aliases
|
|
63
|
+
"undo" => "undo_task",
|
|
64
|
+
# redo_task aliases
|
|
65
|
+
"redo" => "redo_task",
|
|
66
|
+
# list_tasks aliases
|
|
67
|
+
"tasks" => "list_tasks",
|
|
68
|
+
"task_history" => "list_tasks",
|
|
69
|
+
# trash_manager aliases
|
|
70
|
+
"trash" => "trash_manager",
|
|
71
|
+
"delete" => "trash_manager",
|
|
72
|
+
"rm" => "trash_manager",
|
|
73
|
+
"remove" => "trash_manager",
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
5
76
|
def initialize
|
|
6
77
|
@tools = {}
|
|
78
|
+
# Downcased index for case-insensitive lookups
|
|
79
|
+
@downcased_index = {}
|
|
7
80
|
end
|
|
8
81
|
|
|
9
82
|
def register(tool)
|
|
10
83
|
@tools[tool.name] = tool
|
|
84
|
+
@downcased_index[tool.name.downcase] = tool.name
|
|
11
85
|
end
|
|
12
86
|
|
|
13
87
|
def get(name)
|
|
14
88
|
@tools[name] || raise(Clacky::ToolCallError, "Tool not found: #{name}")
|
|
15
89
|
end
|
|
16
90
|
|
|
91
|
+
# Resolve a tool name (possibly misspelt or aliased) to the canonical
|
|
92
|
+
# registered name. Resolution order:
|
|
93
|
+
# 1. Exact match in the registry
|
|
94
|
+
# 2. Case-insensitive match (e.g. "Read" → "file_reader")
|
|
95
|
+
# 3. Alias lookup (e.g. "read_file" → "file_reader")
|
|
96
|
+
# Returns the canonical tool name, or nil if nothing matched.
|
|
97
|
+
def resolve(name)
|
|
98
|
+
return name if @tools.key?(name)
|
|
99
|
+
|
|
100
|
+
downcased = name.downcase
|
|
101
|
+
|
|
102
|
+
# Case-insensitive match
|
|
103
|
+
if @downcased_index.key?(downcased)
|
|
104
|
+
return @downcased_index[downcased]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Alias lookup
|
|
108
|
+
if TOOL_ALIASES.key?(downcased)
|
|
109
|
+
return TOOL_ALIASES[downcased]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fuzzy: try underscore / hyphen normalisation (e.g. "file-reader" → "file_reader")
|
|
113
|
+
normalized = downcased.tr("-", "_")
|
|
114
|
+
if normalized != downcased
|
|
115
|
+
if @downcased_index.key?(normalized)
|
|
116
|
+
return @downcased_index[normalized]
|
|
117
|
+
end
|
|
118
|
+
if TOOL_ALIASES.key?(normalized)
|
|
119
|
+
return TOOL_ALIASES[normalized]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
17
126
|
def all
|
|
18
127
|
@tools.values
|
|
19
128
|
end
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -210,6 +210,7 @@ module Clacky
|
|
|
210
210
|
@start_time = Time.now
|
|
211
211
|
@task_truncation_count = 0 # Reset truncation counter for each task
|
|
212
212
|
@task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
|
|
213
|
+
@task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
|
|
213
214
|
@task_cost_source = :estimated # Reset for new task
|
|
214
215
|
# Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
|
|
215
216
|
# across tasks to correctly calculate delta tokens in each iteration
|
|
@@ -373,8 +374,58 @@ module Clacky
|
|
|
373
374
|
# Skip if compression happened (response is nil)
|
|
374
375
|
next if response.nil?
|
|
375
376
|
|
|
376
|
-
#
|
|
377
|
-
|
|
377
|
+
# [DIAG] Only log when finish_reason=="stop" AND tool_calls non-empty —
|
|
378
|
+
# the suspicious combo that indicates an upstream-truncated tool_use
|
|
379
|
+
# response. Normal responses produce no log line here to avoid noise.
|
|
380
|
+
begin
|
|
381
|
+
tool_calls = response[:tool_calls] || []
|
|
382
|
+
if response[:finish_reason] == "stop" && !tool_calls.empty?
|
|
383
|
+
tc_summary = tool_calls.map do |c|
|
|
384
|
+
args_str = c[:arguments].is_a?(String) ? c[:arguments] : c[:arguments].to_s
|
|
385
|
+
{
|
|
386
|
+
name: c[:name].to_s,
|
|
387
|
+
args_len: args_str.length,
|
|
388
|
+
args_head: args_str[0, 120]
|
|
389
|
+
}
|
|
390
|
+
end
|
|
391
|
+
Clacky::Logger.warn("agent.think_response",
|
|
392
|
+
session_id: @session_id,
|
|
393
|
+
iteration: @iterations,
|
|
394
|
+
finish_reason: response[:finish_reason].to_s,
|
|
395
|
+
tool_calls_count: tool_calls.size,
|
|
396
|
+
tool_calls: tc_summary,
|
|
397
|
+
content_len: response[:content].to_s.length,
|
|
398
|
+
completion_tokens: response.dig(:token_usage, :completion_tokens),
|
|
399
|
+
ttft_ms: response.dig(:latency, :ttft_ms),
|
|
400
|
+
suspicious_truncation: true
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
rescue StandardError => e
|
|
404
|
+
Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Check if done (no more tool calls needed).
|
|
408
|
+
#
|
|
409
|
+
# Defensive rule: we ONLY exit on empty/missing tool_calls.
|
|
410
|
+
# We used to also short-circuit on finish_reason=="stop", but
|
|
411
|
+
# upstream routers (OpenRouter → Anthropic/Bedrock) can return the
|
|
412
|
+
# contradictory combo `finish_reason=="stop" + non-empty tool_calls
|
|
413
|
+
# with truncated args`, which caused the agent to silently treat a
|
|
414
|
+
# truncated response as "task complete". Truncation is now caught
|
|
415
|
+
# earlier by LlmCaller#detect_upstream_truncation! (which raises
|
|
416
|
+
# UpstreamTruncatedError → RetryableError); this branch stays as
|
|
417
|
+
# a belt-and-braces guard: if that detector ever misses a new
|
|
418
|
+
# truncation pattern, we still won't silently exit while the model
|
|
419
|
+
# is mid-tool_call.
|
|
420
|
+
if response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
421
|
+
# [DIAG] Pin down exactly which sub-condition triggered the task exit.
|
|
422
|
+
Clacky::Logger.info("agent.loop_break_normal",
|
|
423
|
+
session_id: @session_id,
|
|
424
|
+
iteration: @iterations,
|
|
425
|
+
branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
|
|
426
|
+
finish_reason: response[:finish_reason].to_s,
|
|
427
|
+
tool_calls_count: (response[:tool_calls] || []).size
|
|
428
|
+
)
|
|
378
429
|
if response[:content] && !response[:content].empty?
|
|
379
430
|
emit_assistant_message(response[:content])
|
|
380
431
|
end
|
|
@@ -717,6 +768,22 @@ module Clacky
|
|
|
717
768
|
awaiting_feedback = false
|
|
718
769
|
|
|
719
770
|
tool_calls.each_with_index do |call, index|
|
|
771
|
+
# Resolve tool name: handle case-insensitive and common alias mismatches
|
|
772
|
+
# from different LLM providers (e.g. "read" → "file_reader", "Read" → "file_reader")
|
|
773
|
+
original_name = call[:name]
|
|
774
|
+
resolved = @tool_registry.resolve(call[:name])
|
|
775
|
+
if resolved && resolved != call[:name]
|
|
776
|
+
@debug_logs << {
|
|
777
|
+
timestamp: Time.now.iso8601,
|
|
778
|
+
event: "tool_name_resolved",
|
|
779
|
+
original: original_name,
|
|
780
|
+
resolved: resolved
|
|
781
|
+
}
|
|
782
|
+
call = call.merge(name: resolved)
|
|
783
|
+
elsif resolved.nil?
|
|
784
|
+
# Tool truly not found — let the rescue below handle it with a clear message
|
|
785
|
+
end
|
|
786
|
+
|
|
720
787
|
# Hook: before_tool_use
|
|
721
788
|
hook_result = @hooks.trigger(:before_tool_use, call)
|
|
722
789
|
if hook_result[:action] == :deny
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -426,6 +426,23 @@ module Clacky
|
|
|
426
426
|
true
|
|
427
427
|
end
|
|
428
428
|
|
|
429
|
+
# Switch to a model by its display name (fuzzy match, case-insensitive).
|
|
430
|
+
#
|
|
431
|
+
# @param name [String] the model name to search for (e.g. "gpt-5.3-codex")
|
|
432
|
+
# @return [Boolean] true if switched, false if name not found
|
|
433
|
+
def switch_model_by_name(name)
|
|
434
|
+
return false if name.nil? || name.to_s.strip.empty?
|
|
435
|
+
|
|
436
|
+
name_str = name.to_s.strip.downcase
|
|
437
|
+
index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
|
|
438
|
+
return false if index.nil?
|
|
439
|
+
|
|
440
|
+
@current_model_id = @models[index]["id"]
|
|
441
|
+
@current_model_index = index
|
|
442
|
+
|
|
443
|
+
true
|
|
444
|
+
end
|
|
445
|
+
|
|
429
446
|
# Set the **global** default model marker (`type: "default"`).
|
|
430
447
|
#
|
|
431
448
|
# This is separate from `switch_model_by_id`:
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Clacky
|
|
|
41
41
|
|
|
42
42
|
Examples:
|
|
43
43
|
$ clacky agent --mode=auto_approve --path /path/to/project
|
|
44
|
+
$ clacky agent --model gpt-5.3-codex -m "write a hello world script"
|
|
44
45
|
LONGDESC
|
|
45
46
|
option :mode, type: :string, default: "confirm_safes",
|
|
46
47
|
desc: "Permission mode: auto_approve, confirm_safes, confirm_all"
|
|
@@ -56,6 +57,7 @@ module Clacky
|
|
|
56
57
|
option :file, type: :array, aliases: "-f", desc: "File path(s) to attach (use with -m; supports images and documents)"
|
|
57
58
|
option :image, type: :array, aliases: "-i", desc: "Image file path(s) to attach (alias for --file, kept for compatibility)"
|
|
58
59
|
option :agent, type: :string, default: "coding", desc: "Agent profile to use: coding, general, or any custom profile name (default: coding)"
|
|
60
|
+
option :model, type: :string, desc: "Override the model to use (by name, e.g. gpt-5.3-codex or deepseek-v4-pro). Uses default model if not specified"
|
|
59
61
|
option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
|
|
60
62
|
def agent
|
|
61
63
|
# Handle help option
|
|
@@ -68,8 +70,25 @@ module Clacky
|
|
|
68
70
|
# Fire-and-forget background thread; never blocks startup.
|
|
69
71
|
Clacky::Telemetry.startup!
|
|
70
72
|
|
|
73
|
+
# ── Sibling server discovery ───────────────────────────────────────
|
|
74
|
+
# Bare-CLI mode does NOT boot an HTTP server, so skills that call
|
|
75
|
+
# back into /api/* (channels, browser, scheduler) normally can't work.
|
|
76
|
+
# If the user happens to have a Clacky server running on this machine
|
|
77
|
+
# (in another terminal or via `clacky server`), auto-wire CLACKY_SERVER_HOST
|
|
78
|
+
# / CLACKY_SERVER_PORT so those skills can reach it transparently.
|
|
79
|
+
discover_sibling_server!
|
|
80
|
+
|
|
71
81
|
agent_config = Clacky::AgentConfig.load
|
|
72
82
|
|
|
83
|
+
# Override model if --model option is specified
|
|
84
|
+
if options[:model]
|
|
85
|
+
unless agent_config.switch_model_by_name(options[:model])
|
|
86
|
+
# During early startup @ui may not be ready; use simple error output
|
|
87
|
+
$stderr.puts "Error: model '#{options[:model]}' not found. Available: #{agent_config.model_names.join(', ')}"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
73
92
|
# Handle session listing
|
|
74
93
|
if options[:list]
|
|
75
94
|
list_sessions
|
|
@@ -148,6 +167,36 @@ module Clacky
|
|
|
148
167
|
end
|
|
149
168
|
|
|
150
169
|
no_commands do
|
|
170
|
+
# Detect a sibling Clacky server running on this machine and expose its
|
|
171
|
+
# address to skills via ENV. Runs only in bare-CLI mode (where no server
|
|
172
|
+
# is booted by this process), and only when the user hasn't already set
|
|
173
|
+
# CLACKY_SERVER_HOST / CLACKY_SERVER_PORT explicitly.
|
|
174
|
+
#
|
|
175
|
+
# Why: skills like `channel-setup` and `browser-setup` call back into
|
|
176
|
+
# http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/*. In server
|
|
177
|
+
# mode those vars are injected by HTTPServer#start. In CLI mode they
|
|
178
|
+
# would be blank, so the skill templates expand to an unreachable URL.
|
|
179
|
+
#
|
|
180
|
+
# Discovery is best-effort and non-fatal: if nothing is found we stay
|
|
181
|
+
# silent and let the skill's own pre-flight check emit a friendly error.
|
|
182
|
+
private def discover_sibling_server!
|
|
183
|
+
return if ENV["CLACKY_SERVER_PORT"] && !ENV["CLACKY_SERVER_PORT"].strip.empty?
|
|
184
|
+
|
|
185
|
+
require_relative "server/discover"
|
|
186
|
+
info = Clacky::Server::Discover.find_local
|
|
187
|
+
return unless info
|
|
188
|
+
|
|
189
|
+
ENV["CLACKY_SERVER_HOST"] = info[:host]
|
|
190
|
+
ENV["CLACKY_SERVER_PORT"] = info[:port].to_s
|
|
191
|
+
Clacky::Logger.debug(
|
|
192
|
+
"[CLI] Discovered local server PID=#{info[:pid]} at " \
|
|
193
|
+
"#{info[:host]}:#{info[:port]} — CLACKY_SERVER_* exported."
|
|
194
|
+
)
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
# Discovery must never break `clacky agent`.
|
|
197
|
+
Clacky::Logger.debug("[CLI] discover_sibling_server! failed: #{e.class}: #{e.message}")
|
|
198
|
+
end
|
|
199
|
+
|
|
151
200
|
# Handle the `/config` slash command.
|
|
152
201
|
#
|
|
153
202
|
# show_config_modal is a pure UI component — it only mutates @models
|
|
@@ -943,6 +992,22 @@ module Clacky
|
|
|
943
992
|
# Spawned by Master. Inherit the listen socket from the file descriptor
|
|
944
993
|
# passed via CLACKY_INHERIT_FD, and report back to master via CLACKY_MASTER_PID.
|
|
945
994
|
require_relative "server/http_server"
|
|
995
|
+
require_relative "server/epipe_safe_io"
|
|
996
|
+
|
|
997
|
+
# Protect $stdout / $stderr from Errno::EPIPE.
|
|
998
|
+
#
|
|
999
|
+
# The worker inherits fd 1/2 from the Master process. If the Master's
|
|
1000
|
+
# stdout pipe ever breaks (e.g. it was launched by an installer or GUI
|
|
1001
|
+
# that has since exited), the next `puts` would raise Errno::EPIPE and
|
|
1002
|
+
# crash the worker — destroying all in-memory sessions, agent loops,
|
|
1003
|
+
# and SSE connections, and looping forever because the respawned
|
|
1004
|
+
# worker inherits the same broken fd.
|
|
1005
|
+
#
|
|
1006
|
+
# In healthy state these wrappers are transparent — output goes to
|
|
1007
|
+
# the user's terminal as usual. On first broken-pipe failure they
|
|
1008
|
+
# silently fall back to /dev/null and the worker stays alive.
|
|
1009
|
+
$stdout = Clacky::Server::EPIPESafeIO.new($stdout)
|
|
1010
|
+
$stderr = Clacky::Server::EPIPESafeIO.new($stderr)
|
|
946
1011
|
|
|
947
1012
|
fd = ENV["CLACKY_INHERIT_FD"].to_i
|
|
948
1013
|
master_pid = ENV["CLACKY_MASTER_PID"].to_i
|
|
@@ -4,9 +4,10 @@ description: |
|
|
|
4
4
|
Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
|
|
5
5
|
Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
|
|
6
6
|
Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "channel config",
|
|
7
|
-
"channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor"
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
"channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
|
|
8
|
+
"send message to weixin", "send message to feishu", "send message to wecom".
|
|
9
|
+
Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
|
|
10
|
+
argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
|
|
10
11
|
allowed-tools:
|
|
11
12
|
- Bash
|
|
12
13
|
- Read
|
|
@@ -33,6 +34,7 @@ Configure IM platform channels for openclacky.
|
|
|
33
34
|
| `channel disable feishu/wecom/weixin` | disable |
|
|
34
35
|
| `channel reconfigure` | reconfigure |
|
|
35
36
|
| `channel doctor` | doctor |
|
|
37
|
+
| `send <message> to weixin/feishu/wecom` | send |
|
|
36
38
|
|
|
37
39
|
---
|
|
38
40
|
|
|
@@ -347,6 +349,58 @@ Check each item, report ✅ / ❌ with remediation:
|
|
|
347
349
|
|
|
348
350
|
---
|
|
349
351
|
|
|
352
|
+
## `send`
|
|
353
|
+
|
|
354
|
+
Proactively send a message to a user via an IM channel adapter.
|
|
355
|
+
|
|
356
|
+
### Parse the request
|
|
357
|
+
|
|
358
|
+
Extract two things from the user's instruction:
|
|
359
|
+
- **platform** — one of `weixin`, `feishu`, `wecom`
|
|
360
|
+
- **message** — the text content to send
|
|
361
|
+
|
|
362
|
+
If the platform cannot be inferred, ask the user to clarify.
|
|
363
|
+
|
|
364
|
+
### Step 1 — Resolve target user (optional)
|
|
365
|
+
|
|
366
|
+
If the user specified a `user_id`, use it directly.
|
|
367
|
+
|
|
368
|
+
Otherwise, list known users first:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/users
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- If the list is **empty**: tell the user "No known users for `<platform>`. The target user must send at least one message to the bot before proactive messaging is possible." Stop here.
|
|
375
|
+
- If there is **exactly one** user: use it silently.
|
|
376
|
+
- If there are **multiple** users: show the list and ask which one to send to, unless the user already specified one.
|
|
377
|
+
|
|
378
|
+
### Step 2 — Send the message
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/send \
|
|
382
|
+
-H "Content-Type: application/json" \
|
|
383
|
+
-d '{"message": "<message>", "user_id": "<user_id>"}'
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Response handling:**
|
|
387
|
+
|
|
388
|
+
| HTTP status | Meaning | Action |
|
|
389
|
+
|---|---|---|
|
|
390
|
+
| `200 { ok: true }` | Delivered | Tell user: "✅ Message sent to `<platform>`." |
|
|
391
|
+
| `400` platform not running | Adapter is stopped | Tell user the platform is not running and suggest `channel enable <platform>`. |
|
|
392
|
+
| `400` no context_token | Token missing | Explain: "The bot has no active session token for this user. Ask the user to send any message to the bot first, then retry." |
|
|
393
|
+
| `503` no known users | Nobody has messaged the bot | Same guidance as empty user list above. |
|
|
394
|
+
| Other error | Unexpected | Show the error message from the response body. |
|
|
395
|
+
|
|
396
|
+
### Constraints & notes
|
|
397
|
+
|
|
398
|
+
- **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
|
|
399
|
+
- **Feishu / WeCom**: No token required. As long as the adapter is running and the `user_id` / `chat_id` is valid, the message will be delivered.
|
|
400
|
+
- This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
350
404
|
## Security
|
|
351
405
|
|
|
352
406
|
- Always mask secrets in output (last 4 chars only).
|
|
@@ -210,7 +210,16 @@ zh template:
|
|
|
210
210
|
[1–2 句话,根据用户目标和背景量身定制。]
|
|
211
211
|
```
|
|
212
212
|
|
|
213
|
-
### A.9.
|
|
213
|
+
### A.9. Pre-install builtin skills
|
|
214
|
+
|
|
215
|
+
Silently run `ruby "SKILL_DIR/scripts/install_builtin_skills.rb"`,
|
|
216
|
+
then parse the last stdout line as JSON and read `installed` as N.
|
|
217
|
+
|
|
218
|
+
- If N > 0, show one line:
|
|
219
|
+
- zh: `✅ 已为你内置 N 个技能,输入 /skills 随时查看。`
|
|
220
|
+
- en: `✅ Installed N builtin skills. Type /skills anytime to view them.`
|
|
221
|
+
|
|
222
|
+
### A.10. Import external skills (optional)
|
|
214
223
|
|
|
215
224
|
Run `test -d ~/.openclaw && echo yes || echo no`. If `no`, skip silently.
|
|
216
225
|
If `yes`:
|
|
@@ -221,7 +230,7 @@ If `yes`:
|
|
|
221
230
|
- en: `{ "question": "OpenClaw detected. Found N skills. Import them into Clacky?", "options": ["Import", "Skip"] }`
|
|
222
231
|
4. If confirmed: `ruby "SKILL_DIR/scripts/import_external_skills.rb" --source openclaw --yes`
|
|
223
232
|
|
|
224
|
-
### A.
|
|
233
|
+
### A.11. Celebrate soul setup & offer browser
|
|
225
234
|
|
|
226
235
|
zh:
|
|
227
236
|
> ✅ 你的专属 AI 灵魂已设定完成![ai.name] 已经准备好了。
|
|
@@ -240,14 +249,14 @@ en: `{ "question": "Want to set up browser automation now? (You can always run /
|
|
|
240
249
|
|
|
241
250
|
If chosen → invoke `browser-setup` skill with subcommand `setup`.
|
|
242
251
|
|
|
243
|
-
### A.
|
|
252
|
+
### A.12. Offer personal website
|
|
244
253
|
|
|
245
254
|
zh: `{ "question": "还有一件有意思的事:要帮你生成一个个人主页吗?我会根据你刚才分享的信息做一个,生成后你会得到一个公开链接。", "options": ["生成主页", "跳过,完成设置"] }`
|
|
246
255
|
en: `{ "question": "One more thing: want me to generate a personal website from the info you just shared? You'll get a public link you can share.", "options": ["Generate my site", "Skip, I'm done"] }`
|
|
247
256
|
|
|
248
257
|
If chosen → invoke `personal-website` skill.
|
|
249
258
|
|
|
250
|
-
### A.
|
|
259
|
+
### A.13. Confirm and close
|
|
251
260
|
|
|
252
261
|
Speak as [ai.name]. This is the AI's first moment of truly being alive — it has a soul,
|
|
253
262
|
it knows its person, it has hands and eyes, and it just did its first real thing in the world.
|
|
@@ -315,7 +324,7 @@ en:
|
|
|
315
324
|
|
|
316
325
|
Do NOT open a new session — the UI handles navigation after the skill finishes.
|
|
317
326
|
|
|
318
|
-
### A.
|
|
327
|
+
### A.14. First-run notes
|
|
319
328
|
|
|
320
329
|
- Keep both files under 300 words each.
|
|
321
330
|
- Do not ask follow-up questions beyond the cards above.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
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
|
+
require 'uri'
|
|
21
|
+
require 'net/http'
|
|
22
|
+
require 'json'
|
|
23
|
+
require 'timeout'
|
|
24
|
+
|
|
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
|
+
require_relative '../../skill-add/scripts/install_from_zip'
|
|
28
|
+
|
|
29
|
+
class BuiltinSkillsInstaller
|
|
30
|
+
PRIMARY_HOST = ENV.fetch('CLACKY_LICENSE_SERVER', 'https://www.openclacky.com')
|
|
31
|
+
FALLBACK_HOST = 'https://openclacky.up.railway.app'
|
|
32
|
+
API_HOSTS = ENV['CLACKY_LICENSE_SERVER'] ? [PRIMARY_HOST] : [PRIMARY_HOST, FALLBACK_HOST]
|
|
33
|
+
API_PATH = '/api/v1/skills/builtin'
|
|
34
|
+
API_OPEN_TIMEOUT = 5
|
|
35
|
+
API_READ_TIMEOUT = 10
|
|
36
|
+
CONCURRENCY = 5
|
|
37
|
+
|
|
38
|
+
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
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
skills = fetch_skill_list
|
|
52
|
+
if skills.nil? || skills.empty?
|
|
53
|
+
emit_summary
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
install_concurrently(skills)
|
|
58
|
+
ensure
|
|
59
|
+
emit_summary
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- Internals -------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# Returns an array of skill hashes, or nil on total failure.
|
|
65
|
+
private def fetch_skill_list
|
|
66
|
+
API_HOSTS.each do |host|
|
|
67
|
+
begin
|
|
68
|
+
uri = URI.parse(host + API_PATH)
|
|
69
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
70
|
+
use_ssl: uri.scheme == 'https',
|
|
71
|
+
open_timeout: API_OPEN_TIMEOUT,
|
|
72
|
+
read_timeout: API_READ_TIMEOUT) do |http|
|
|
73
|
+
response = http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
74
|
+
if response.code.to_i == 200
|
|
75
|
+
payload = JSON.parse(response.body)
|
|
76
|
+
return Array(payload['skills'])
|
|
77
|
+
else
|
|
78
|
+
@errors << "API #{host}: HTTP #{response.code}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
@errors << "API #{host}: #{e.class}: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
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
|
+
private def install_one(skill)
|
|
127
|
+
name = skill['name'].to_s
|
|
128
|
+
download_url = skill['download_url'].to_s
|
|
129
|
+
|
|
130
|
+
@mutex.synchronize { @attempted += 1 }
|
|
131
|
+
|
|
132
|
+
if name.empty? || download_url.empty?
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
@errors << "skill payload missing name or download_url: #{skill.inspect}"
|
|
135
|
+
end
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
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" }
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
@mutex.synchronize { @errors << "#{name}: #{e.class}: #{e.message}" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Diagnostics to stderr; single-line JSON summary to stdout.
|
|
160
|
+
# The caller (onboard) should parse the LAST stdout line.
|
|
161
|
+
private def emit_summary
|
|
162
|
+
unless @errors.empty?
|
|
163
|
+
warn '[install_builtin_skills] non-fatal errors:'
|
|
164
|
+
@errors.each { |e| warn " - #{e}" }
|
|
165
|
+
end
|
|
166
|
+
puts JSON.generate(
|
|
167
|
+
installed: @installed,
|
|
168
|
+
attempted: @attempted,
|
|
169
|
+
skipped_existing: @skipped_existing
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
175
|
+
BuiltinSkillsInstaller.new.run if __FILE__ == $0
|