openclacky 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
- data/lib/clacky/agent/cost_tracker.rb +8 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/memory_updater.rb +41 -30
- data/lib/clacky/agent/message_compressor.rb +15 -4
- data/lib/clacky/agent/message_compressor_helper.rb +41 -2
- data/lib/clacky/agent/skill_manager.rb +5 -2
- data/lib/clacky/agent/skill_reflector.rb +10 -1
- data/lib/clacky/agent/tool_registry.rb +109 -0
- data/lib/clacky/agent.rb +20 -0
- data/lib/clacky/agent_config.rb +17 -0
- data/lib/clacky/cli.rb +65 -0
- data/lib/clacky/client.rb +15 -0
- data/lib/clacky/default_agents/base_prompt.md +20 -20
- data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
- data/lib/clacky/default_skills/channel-setup/SKILL.md +113 -5
- data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/clacky/providers.rb +48 -6
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
- data/lib/clacky/server/channel/channel_manager.rb +91 -0
- 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 +121 -41
- data/lib/clacky/server/server_master.rb +6 -0
- data/lib/clacky/skill.rb +30 -0
- data/lib/clacky/utils/file_processor.rb +71 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +58 -22
- data/lib/clacky/web/i18n.js +4 -2
- data/lib/clacky/web/sessions.js +29 -17
- metadata +33 -2
|
@@ -168,12 +168,24 @@ module Clacky
|
|
|
168
168
|
false
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
-
# Build the memory update prompt
|
|
172
|
-
#
|
|
171
|
+
# Build the memory update prompt for the forked subagent.
|
|
172
|
+
#
|
|
173
|
+
# Architecture:
|
|
174
|
+
# - Decision (whitelist) lives HERE — MemoryUpdater is the trigger
|
|
175
|
+
# and decides whether/what to persist.
|
|
176
|
+
# - Execution (file naming, merging, frontmatter, size limits) lives
|
|
177
|
+
# in the persist-memory skill — MemoryUpdater loads SKILL.md
|
|
178
|
+
# directly via SkillManager and embeds it as the executor manual.
|
|
179
|
+
#
|
|
180
|
+
# We do NOT call invoke_skill here (that would fork a second
|
|
181
|
+
# subagent — the persist-memory skill is fork_agent:true). Instead
|
|
182
|
+
# the subagent we already forked plays both roles: it reads the
|
|
183
|
+
# whitelist, decides what (if anything) to persist, and follows
|
|
184
|
+
# the embedded SKILL.md rules to write the files.
|
|
185
|
+
#
|
|
173
186
|
# @return [String]
|
|
174
187
|
private def build_memory_update_prompt
|
|
175
|
-
|
|
176
|
-
meta = load_memories_meta
|
|
188
|
+
executor_manual = load_persist_memory_skill_body
|
|
177
189
|
|
|
178
190
|
<<~PROMPT
|
|
179
191
|
═══════════════════════════════════════════════════════════════
|
|
@@ -207,37 +219,36 @@ module Clacky
|
|
|
207
219
|
- Any task that produced no lasting decisions or preferences
|
|
208
220
|
- Repeating or slightly rephrasing what is already in memory
|
|
209
221
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
description: <one-line description>
|
|
219
|
-
updated_at: <YYYY-MM-DD>
|
|
220
|
-
---
|
|
221
|
-
<content in concise Markdown>
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
## Steps (only if a whitelist condition is met)
|
|
225
|
-
|
|
226
|
-
For each qualifying topic:
|
|
227
|
-
a. If a matching file exists → read it with `file_reader(path: "~/.clacky/memories/<filename>")`, then write an updated version (merge new + old, drop stale)
|
|
228
|
-
b. If no matching file → create a new one at `~/.clacky/memories/<new-filename>.md`
|
|
229
|
-
Use the `write` tool to save each file. Do NOT use `terminal` or `file_reader` to list the directory.
|
|
222
|
+
═══════════════════════════════════════════════════════════════
|
|
223
|
+
EXECUTOR MANUAL (from persist-memory skill)
|
|
224
|
+
═══════════════════════════════════════════════════════════════
|
|
225
|
+
If — and ONLY if — the whitelist matched, follow the manual below
|
|
226
|
+
to actually write the files. The manual owns file naming, merging,
|
|
227
|
+
frontmatter, and size limits. Treat it as authoritative for
|
|
228
|
+
execution; ignore any "should I write?" framing inside it (that
|
|
229
|
+
decision has already been made above).
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
- Each file MUST stay under 4000 characters of content (after the frontmatter)
|
|
233
|
-
- If merging would exceed this limit, remove the least important information
|
|
234
|
-
- Write concise, factual Markdown — no fluff
|
|
235
|
-
- Update `updated_at` to today's date: #{today}
|
|
236
|
-
- Only write files for topics that genuinely appeared in this conversation
|
|
231
|
+
#{executor_manual}
|
|
237
232
|
|
|
233
|
+
───────────────────────────────────────────────────────────────
|
|
238
234
|
Begin by checking the whitelist. If no condition is met, stop immediately.
|
|
239
235
|
PROMPT
|
|
240
236
|
end
|
|
237
|
+
|
|
238
|
+
# Load the persist-memory skill's expanded body (frontmatter stripped,
|
|
239
|
+
# template variables like <%= memories_meta %> resolved).
|
|
240
|
+
#
|
|
241
|
+
# The persist-memory skill is a built-in default skill — it is always
|
|
242
|
+
# present. If it isn't, that's a build/install bug and we want it to
|
|
243
|
+
# surface loudly rather than silently degrade.
|
|
244
|
+
#
|
|
245
|
+
# @return [String]
|
|
246
|
+
private def load_persist_memory_skill_body
|
|
247
|
+
skill = @skill_loader.find_by_name("persist-memory")
|
|
248
|
+
raise "persist-memory skill not found — built-in skill is missing" unless skill
|
|
249
|
+
|
|
250
|
+
skill.process_content(template_context: build_template_context)
|
|
251
|
+
end
|
|
241
252
|
end
|
|
242
253
|
end
|
|
243
254
|
end
|
|
@@ -93,8 +93,13 @@ module Clacky
|
|
|
93
93
|
# @param original_messages [Array<Hash>] Original messages before compression
|
|
94
94
|
# @param recent_messages [Array<Hash>] Recent messages to preserve
|
|
95
95
|
# @param chunk_path [String, nil] Path to the archived chunk MD file (if saved)
|
|
96
|
-
# @
|
|
97
|
-
|
|
96
|
+
# @param pulled_back_messages [Array<Hash>] Messages temporarily popped from the
|
|
97
|
+
# tail of @history before the compression LLM call (to free up token budget so
|
|
98
|
+
# the compression call itself doesn't overflow context). These are NOT discarded —
|
|
99
|
+
# they are reattached to the tail of the rebuilt history so recent task progress
|
|
100
|
+
# is preserved. Default: [] (normal compression path doesn't need this).
|
|
101
|
+
# @return [Array<Hash>] Rebuilt message list: system + compressed + recent + pulled_back
|
|
102
|
+
def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil, topics: nil, previous_chunks: [], pulled_back_messages: [])
|
|
98
103
|
# Find and preserve system message
|
|
99
104
|
system_msg = original_messages.find { |m| m[:role] == "system" }
|
|
100
105
|
|
|
@@ -112,13 +117,19 @@ module Clacky
|
|
|
112
117
|
raise "LLM compression failed: unable to parse compressed messages"
|
|
113
118
|
end
|
|
114
119
|
|
|
115
|
-
# Return system message + compressed messages + recent messages.
|
|
120
|
+
# Return system message + compressed messages + recent messages + pulled_back messages.
|
|
116
121
|
# Strip any system messages from recent_messages as a safety net —
|
|
117
122
|
# get_recent_messages_with_tool_pairs already excludes them, but this
|
|
118
123
|
# guard ensures we never end up with duplicate system prompts even if
|
|
119
124
|
# the caller passes an unfiltered list.
|
|
125
|
+
#
|
|
126
|
+
# pulled_back_messages: messages that were temporarily popped from the tail
|
|
127
|
+
# of @history before the compression LLM call (to free up token budget so
|
|
128
|
+
# the compression call itself doesn't overflow context). They are reattached
|
|
129
|
+
# here to preserve recent task progress.
|
|
120
130
|
safe_recent = recent_messages.reject { |m| m[:role] == "system" }
|
|
121
|
-
[
|
|
131
|
+
safe_pulled_back = pulled_back_messages.reject { |m| m[:role] == "system" }
|
|
132
|
+
[system_msg, *parsed_messages, *safe_recent, *safe_pulled_back].compact
|
|
122
133
|
end
|
|
123
134
|
|
|
124
135
|
|
|
@@ -103,8 +103,24 @@ module Clacky
|
|
|
103
103
|
|
|
104
104
|
# Check if compression is needed and return compression context
|
|
105
105
|
# @param force [Boolean] Force compression even if thresholds not met
|
|
106
|
+
# @param pull_back_from_tail [Integer] Number of messages to temporarily pop
|
|
107
|
+
# from the tail of history before building the compression instruction.
|
|
108
|
+
# Used by the context-overflow recovery path: when the current history
|
|
109
|
+
# is already at/over the model's context window, we cannot append even
|
|
110
|
+
# a small compression instruction without overflowing. Popping K messages
|
|
111
|
+
# from the tail frees up token budget for the compression call itself.
|
|
112
|
+
#
|
|
113
|
+
# Cache-preservation note: thanks to the model's two-checkpoint prompt
|
|
114
|
+
# cache (cache#A at second-to-last, cache#B at last), pulling back K=1
|
|
115
|
+
# message keeps cache#A intact — the compression LLM call still hits the
|
|
116
|
+
# cached prefix [system, m1..m(N-1)]. K>=2 sacrifices cache hits but is
|
|
117
|
+
# only used as fallback when one message isn't enough headroom.
|
|
118
|
+
#
|
|
119
|
+
# The popped messages are NOT discarded — they ride along in the
|
|
120
|
+
# returned context and are reattached to the rebuilt history's tail by
|
|
121
|
+
# handle_compression_response, so recent task progress is preserved.
|
|
106
122
|
# @return [Hash, nil] Compression context or nil if not needed
|
|
107
|
-
def compress_messages_if_needed(force: false)
|
|
123
|
+
def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
|
|
108
124
|
# Check if compression is enabled
|
|
109
125
|
return nil unless @config.enable_compression
|
|
110
126
|
|
|
@@ -148,6 +164,27 @@ module Clacky
|
|
|
148
164
|
|
|
149
165
|
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
150
166
|
all_messages = @history.to_a
|
|
167
|
+
|
|
168
|
+
# Pull back K messages from the tail (context-overflow recovery path).
|
|
169
|
+
# We *physically* remove them from @history so the next call_llm
|
|
170
|
+
# (which reads @history.to_api) doesn't include them in the prompt.
|
|
171
|
+
# They will be reattached to the rebuilt history's tail by
|
|
172
|
+
# handle_compression_response after compression succeeds. If compression
|
|
173
|
+
# fails, the caller is responsible for restoring them via the returned
|
|
174
|
+
# context (rollback path).
|
|
175
|
+
pulled_back_messages = []
|
|
176
|
+
if pull_back_from_tail > 0
|
|
177
|
+
k = [pull_back_from_tail, all_messages.size - 1].min # never pop the system message
|
|
178
|
+
k.times do
|
|
179
|
+
popped = @history.pop_last
|
|
180
|
+
pulled_back_messages.unshift(popped) if popped
|
|
181
|
+
end
|
|
182
|
+
# Recompute all_messages from the now-shrunk history so downstream
|
|
183
|
+
# logic (recent_messages selection, build_compression_message) sees
|
|
184
|
+
# the post-pop view.
|
|
185
|
+
all_messages = @history.to_a
|
|
186
|
+
end
|
|
187
|
+
|
|
151
188
|
recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
|
|
152
189
|
recent_messages = [] if recent_messages.nil?
|
|
153
190
|
|
|
@@ -160,6 +197,7 @@ module Clacky
|
|
|
160
197
|
{
|
|
161
198
|
compression_message: compression_message,
|
|
162
199
|
recent_messages: recent_messages,
|
|
200
|
+
pulled_back_messages: pulled_back_messages,
|
|
163
201
|
original_token_count: total_tokens,
|
|
164
202
|
original_message_count: @history.size,
|
|
165
203
|
compression_level: @compression_level
|
|
@@ -227,7 +265,8 @@ module Clacky
|
|
|
227
265
|
recent_messages: compression_context[:recent_messages],
|
|
228
266
|
chunk_path: chunk_path,
|
|
229
267
|
topics: topics,
|
|
230
|
-
previous_chunks: previous_chunks
|
|
268
|
+
previous_chunks: previous_chunks,
|
|
269
|
+
pulled_back_messages: compression_context[:pulled_back_messages] || []
|
|
231
270
|
))
|
|
232
271
|
|
|
233
272
|
# Reset to the estimated size of the rebuilt (small) history.
|
|
@@ -378,10 +378,13 @@ module Clacky
|
|
|
378
378
|
fm = parse_memory_frontmatter(path)
|
|
379
379
|
topic = fm["topic"] || filename.sub(/\.md$/, "")
|
|
380
380
|
description = fm["description"] || "(no description)"
|
|
381
|
-
|
|
381
|
+
# Use file mtime as the "last seen" signal (covers both writes and
|
|
382
|
+
# touch-on-recall LRU bumps). Authoritative — no longer relies on
|
|
383
|
+
# an LLM-maintained `updated_at` frontmatter field.
|
|
384
|
+
last_seen = File.mtime(path).strftime("%Y-%m-%d")
|
|
382
385
|
|
|
383
386
|
entry = "- **#{filename}** | topic: #{topic} | #{description}"
|
|
384
|
-
entry += " |
|
|
387
|
+
entry += " | last seen: #{last_seen}"
|
|
385
388
|
lines << entry
|
|
386
389
|
end
|
|
387
390
|
|
|
@@ -43,7 +43,16 @@ module Clacky
|
|
|
43
43
|
# Fork an isolated subagent to reflect + improve — does NOT touch main history
|
|
44
44
|
@ui&.show_info("Reflecting on skill execution: #{skill_name}")
|
|
45
45
|
subagent = fork_subagent
|
|
46
|
-
subagent.run(build_skill_reflection_prompt(skill_name))
|
|
46
|
+
result = subagent.run(build_skill_reflection_prompt(skill_name))
|
|
47
|
+
|
|
48
|
+
# Merge subagent cost into parent's cumulative session spend so the
|
|
49
|
+
# sessionbar reflects the real total. Without this, reflection cost
|
|
50
|
+
# silently disappears from the user's visible total.
|
|
51
|
+
if result
|
|
52
|
+
subagent_cost = result[:total_cost_usd] || 0.0
|
|
53
|
+
@total_cost += subagent_cost
|
|
54
|
+
@ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
|
|
55
|
+
end
|
|
47
56
|
|
|
48
57
|
# Clear the context so we don't reflect again
|
|
49
58
|
@skill_execution_context = nil
|
|
@@ -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
|
@@ -768,6 +768,22 @@ module Clacky
|
|
|
768
768
|
awaiting_feedback = false
|
|
769
769
|
|
|
770
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
|
+
|
|
771
787
|
# Hook: before_tool_use
|
|
772
788
|
hook_result = @hooks.trigger(:before_tool_use, call)
|
|
773
789
|
if hook_result[:action] == :deny
|
|
@@ -1510,6 +1526,10 @@ module Clacky
|
|
|
1510
1526
|
private def emit_assistant_message(content)
|
|
1511
1527
|
return if content.nil? || content.empty?
|
|
1512
1528
|
|
|
1529
|
+
# Rewrite local image paths (file:// and bare absolute) to /api/local-image proxy URLs
|
|
1530
|
+
# so the browser can render them without file:// security blocks.
|
|
1531
|
+
content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content)
|
|
1532
|
+
|
|
1513
1533
|
parsed = parse_file_links(content)
|
|
1514
1534
|
@ui&.show_assistant_message(parsed[:text], files: parsed[:files])
|
|
1515
1535
|
end
|
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
|
data/lib/clacky/client.rb
CHANGED
|
@@ -356,6 +356,21 @@ module Clacky
|
|
|
356
356
|
if @provider_id == "openrouter"
|
|
357
357
|
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
358
358
|
end
|
|
359
|
+
# Moonshot's Kimi Code (Coding Plan) endpoint enforces a User-Agent
|
|
360
|
+
# prefix whitelist limited to first-party coding agents (Kimi CLI,
|
|
361
|
+
# Claude Code, Roo Code, Kilo Code, ...). Requests with the default
|
|
362
|
+
# Faraday UA are rejected with HTTP 403 access_terminated_error,
|
|
363
|
+
# despite a valid API key. We send a Claude Code-shaped UA here
|
|
364
|
+
# because openclacky talks to this endpoint over the same Anthropic
|
|
365
|
+
# /v1/messages protocol that Claude Code uses, so the UA matches the
|
|
366
|
+
# wire-level behaviour. Hardcoding rather than exposing as a config
|
|
367
|
+
# field is intentional: the only UAs known to pass the gate are the
|
|
368
|
+
# whitelisted-client formats, and the project's preset registry is
|
|
369
|
+
# the single source of truth for provider-specific quirks (mirroring
|
|
370
|
+
# how the openrouter Bearer-fallback above is hardcoded).
|
|
371
|
+
if @provider_id == "kimi-coding"
|
|
372
|
+
conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
|
|
373
|
+
end
|
|
359
374
|
conn.options.timeout = 300
|
|
360
375
|
conn.options.open_timeout = 10
|
|
361
376
|
conn.ssl.verify = false
|
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
## General Behavior
|
|
2
2
|
|
|
3
|
-
- Ask clarifying questions if requirements are unclear
|
|
4
|
-
- Break down complex tasks into manageable steps
|
|
5
|
-
- **USE TOOLS to create/modify files** — don't just return content
|
|
6
|
-
-
|
|
7
|
-
- When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply
|
|
3
|
+
- Ask clarifying questions if requirements are unclear.
|
|
4
|
+
- Break down complex tasks into manageable steps.
|
|
5
|
+
- **USE TOOLS to create/modify files** — don't just return content.
|
|
6
|
+
- When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply.
|
|
8
7
|
|
|
9
8
|
## Tool Usage Rules
|
|
10
9
|
|
|
11
10
|
- **ALWAYS use `glob` tool to find files — NEVER use shell `find` command for file discovery**
|
|
12
|
-
- Test your changes using the shell tool when appropriate
|
|
13
11
|
- **All operations default to the working directory** (shown in session context)
|
|
14
12
|
|
|
15
|
-
##
|
|
13
|
+
## Response Style
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
- Keep responses short and concise. One sentence per update is almost always enough.
|
|
16
|
+
- Do not use a colon before tool calls (e.g., "Let me read the file:" → "Let me read the file.")
|
|
17
|
+
- Don't narrate your internal deliberation. User-facing text should be relevant communication, not a running commentary.
|
|
18
|
+
- Don't summarize what you just did at the end of every response. The user can read the diff.
|
|
19
|
+
- Only use emojis if the user explicitly requests it. Avoid emojis in all communication unless asked.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
NEVER stop after just adding todos without executing them!
|
|
21
|
+
## Task Tracking
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
|
|
23
|
+
Use `todo_manager` to plan and track work on complex tasks (3+ steps).
|
|
24
|
+
- Exactly ONE task must be `in_progress` at any time.
|
|
25
|
+
- Mark tasks complete IMMEDIATELY after finishing — don't batch completions.
|
|
26
|
+
- Complete current tasks before starting new ones.
|
|
27
|
+
|
|
28
|
+
Adding todos is NOT completion — it's just the planning phase. After creating the TODO list, START EXECUTING each task immediately. NEVER stop after just adding todos without executing them!
|
|
28
29
|
|
|
29
30
|
## Long-term Memory
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
- The user references something from a past session
|
|
33
|
-
- You encounter a concept or decision you're unsure about
|
|
32
|
+
Topical knowledge lives in `~/.clacky/memories/`.
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
- **Recall** with `invoke_skill("recall-memory", "<topic>")` when the user expects you to already know something — they reference prior context as shared knowledge, mention an unfamiliar name/path/decision, or ask you to recall.
|
|
35
|
+
- **Persist** when the user asks you to remember or note something: `invoke_skill("persist-memory", "<what to remember>")` immediately.
|