openclacky 1.1.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +113 -63
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +521 -17
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +30 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +41 -6
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +51 -40
- data/lib/clacky/web/i18n.js +248 -24
- data/lib/clacky/web/index.html +362 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +299 -18
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +15 -2
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
|
@@ -26,9 +26,6 @@ module Clacky
|
|
|
26
26
|
RESTART_EXIT_CODE = 75
|
|
27
27
|
MAX_CONSECUTIVE_FAILURES = 5
|
|
28
28
|
|
|
29
|
-
# How long (seconds) to wait for a new worker to become ready before killing the old one.
|
|
30
|
-
NEW_WORKER_BOOT_WAIT = 3
|
|
31
|
-
|
|
32
29
|
def initialize(host:, port:, argv: nil, extra_flags: [])
|
|
33
30
|
@host = host
|
|
34
31
|
@port = port
|
|
@@ -158,22 +155,16 @@ module Clacky
|
|
|
158
155
|
pid
|
|
159
156
|
end
|
|
160
157
|
|
|
161
|
-
#
|
|
158
|
+
# Gracefully stop the old worker (so it can persist in-memory sessions),
|
|
159
|
+
# wait for it to exit, then spawn a new one.
|
|
162
160
|
def hot_restart
|
|
163
161
|
old_pid = @worker_pid
|
|
164
|
-
Clacky::Logger.info("[Master] Hot restart:
|
|
165
|
-
|
|
166
|
-
new_pid = spawn_worker
|
|
167
|
-
@worker_pid = new_pid
|
|
162
|
+
Clacky::Logger.info("[Master] Hot restart: stopping old worker PID=#{old_pid}...")
|
|
168
163
|
|
|
169
|
-
#
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
# Gracefully stop old worker — TERM the whole process group first so
|
|
173
|
-
# grandchildren (node MCP, etc.) also get a chance to shut down cleanly.
|
|
164
|
+
# TERM the old worker's process group so grandchildren (node MCP, etc.)
|
|
165
|
+
# also get a chance to shut down cleanly (triggering interrupt_all_agents).
|
|
174
166
|
begin
|
|
175
167
|
Process.kill("TERM", -old_pid)
|
|
176
|
-
# Reap it (non-blocking loop so we don't block the monitor)
|
|
177
168
|
deadline = Time.now + 5
|
|
178
169
|
loop do
|
|
179
170
|
pid, = Process.waitpid2(old_pid, Process::WNOHANG)
|
|
@@ -186,6 +177,9 @@ module Clacky
|
|
|
186
177
|
# already gone — fine
|
|
187
178
|
end
|
|
188
179
|
|
|
180
|
+
# Old worker is gone; now spawn the replacement.
|
|
181
|
+
new_pid = spawn_worker
|
|
182
|
+
@worker_pid = new_pid
|
|
189
183
|
Clacky::Logger.info("[Master] Hot restart complete. New worker PID=#{new_pid}")
|
|
190
184
|
end
|
|
191
185
|
|
|
@@ -166,7 +166,7 @@ module Clacky
|
|
|
166
166
|
live_name = s[:agent]&.name
|
|
167
167
|
live_name = nil if live_name&.empty?
|
|
168
168
|
live_cost_source = s[:agent]&.cost_source
|
|
169
|
-
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
|
|
169
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
|
|
170
170
|
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
|
|
171
171
|
cost_source: live_cost_source,
|
|
172
172
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
@@ -239,7 +239,7 @@ module Clacky
|
|
|
239
239
|
model_info = s[:agent]&.current_model_info
|
|
240
240
|
live_name = s[:agent]&.name
|
|
241
241
|
live_name = nil if live_name&.empty?
|
|
242
|
-
{ status: s[:status], error: s[:error], model: model_info&.dig(:model),
|
|
242
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id),
|
|
243
243
|
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
244
244
|
total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
|
|
245
245
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
@@ -260,6 +260,7 @@ module Clacky
|
|
|
260
260
|
status: ls ? ls[:status].to_s : "idle",
|
|
261
261
|
error: ls ? ls[:error] : nil,
|
|
262
262
|
model: ls&.dig(:model),
|
|
263
|
+
model_id: ls&.dig(:model_id),
|
|
263
264
|
source: s_source(s),
|
|
264
265
|
agent_profile: (s[:agent_profile] || "general").to_s,
|
|
265
266
|
working_dir: s[:working_dir],
|
|
@@ -288,6 +289,12 @@ module Clacky
|
|
|
288
289
|
|
|
289
290
|
public
|
|
290
291
|
|
|
292
|
+
# Count all cron sessions on disk (not filtered by pagination).
|
|
293
|
+
def cron_count
|
|
294
|
+
return 0 unless @session_manager
|
|
295
|
+
@session_manager.all_sessions.count { |s| s_source(s) == "cron" }
|
|
296
|
+
end
|
|
297
|
+
|
|
291
298
|
# Delete a session from registry (and interrupt its thread).
|
|
292
299
|
def delete(session_id)
|
|
293
300
|
@mutex.synchronize do
|
|
@@ -360,6 +367,27 @@ module Clacky
|
|
|
360
367
|
to_evict.each { |id, session| persist_and_release(id, session) }
|
|
361
368
|
end
|
|
362
369
|
|
|
370
|
+
# Yield [session_id, agent, thread] for each session that currently has
|
|
371
|
+
# an in-memory agent. Used by the worker's graceful-shutdown path to
|
|
372
|
+
# flush any unsaved @history (e.g. a user message added at the start
|
|
373
|
+
# of Agent#run that hasn't yet reached the save-on-completion branch
|
|
374
|
+
# in run_agent_task).
|
|
375
|
+
#
|
|
376
|
+
# The session id list is snapshotted under the mutex so concurrent
|
|
377
|
+
# mutations don't disturb iteration; the yield happens outside the
|
|
378
|
+
# mutex so callers can do slow I/O (JSON serialization, File.write)
|
|
379
|
+
# without blocking other registry operations.
|
|
380
|
+
def each_live_agent
|
|
381
|
+
snapshot = @mutex.synchronize do
|
|
382
|
+
@sessions.filter_map do |id, s|
|
|
383
|
+
agent = s[:agent]
|
|
384
|
+
next nil unless agent
|
|
385
|
+
[id, agent, s[:thread]]
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
snapshot.each { |id, agent, thread| yield id, agent, thread }
|
|
389
|
+
end
|
|
390
|
+
|
|
363
391
|
private def persist_and_release(id, session)
|
|
364
392
|
agent = session[:agent]
|
|
365
393
|
@session_manager&.save(agent.to_session_data(status: :success)) if agent
|
|
@@ -97,6 +97,10 @@ module Clacky
|
|
|
97
97
|
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files) }
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
def show_feedback_request(question, context, options)
|
|
101
|
+
emit("request_feedback", question: question, context: context, options: options)
|
|
102
|
+
end
|
|
103
|
+
|
|
100
104
|
def show_tool_call(name, args)
|
|
101
105
|
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
102
106
|
|
|
@@ -51,15 +51,10 @@ module Clacky
|
|
|
51
51
|
all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
# Returns true if found and
|
|
54
|
+
# Soft-delete: move session JSON + chunks to the session trash directory.
|
|
55
|
+
# Returns true if found and moved, false if not found.
|
|
56
56
|
def delete(session_id)
|
|
57
|
-
|
|
58
|
-
return false unless session
|
|
59
|
-
|
|
60
|
-
filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
|
|
61
|
-
delete_session_with_chunks(filepath)
|
|
62
|
-
true
|
|
57
|
+
soft_delete(session_id)
|
|
63
58
|
end
|
|
64
59
|
|
|
65
60
|
# Return the on-disk files associated with a session: the main JSON file
|
|
@@ -177,7 +172,7 @@ module Clacky
|
|
|
177
172
|
session = load_session_file(filepath)
|
|
178
173
|
next unless session
|
|
179
174
|
if Time.parse(session[:updated_at]) < cutoff
|
|
180
|
-
|
|
175
|
+
_hard_delete_session_with_chunks(filepath)
|
|
181
176
|
deleted += 1
|
|
182
177
|
end
|
|
183
178
|
end
|
|
@@ -192,10 +187,44 @@ module Clacky
|
|
|
192
187
|
|
|
193
188
|
sessions[keep..].each do |session|
|
|
194
189
|
filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
|
|
195
|
-
|
|
190
|
+
_hard_delete_session_with_chunks(filepath) if File.exist?(filepath)
|
|
196
191
|
end.size
|
|
197
192
|
end
|
|
198
193
|
|
|
194
|
+
# ── Session trash (delegates to Tools::TrashManager) ──────────────
|
|
195
|
+
# All business logic lives in Clacky::Tools::TrashManager; SessionManager
|
|
196
|
+
# only provides the sessions_dir context and filesystem helpers used there.
|
|
197
|
+
|
|
198
|
+
# Soft-delete: stamp deleted_at, move JSON + chunks to sessions-trash/.
|
|
199
|
+
def soft_delete(session_id)
|
|
200
|
+
require_relative "tools/trash_manager"
|
|
201
|
+
Clacky::Tools::TrashManager.soft_delete_session(session_id, sessions_dir: @sessions_dir)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Restore a soft-deleted session back to the active sessions directory.
|
|
205
|
+
def restore_session(session_id)
|
|
206
|
+
require_relative "tools/trash_manager"
|
|
207
|
+
Clacky::Tools::TrashManager.restore_session(session_id, sessions_dir: @sessions_dir)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# List all soft-deleted sessions (newest-first).
|
|
211
|
+
def list_trash_sessions
|
|
212
|
+
require_relative "tools/trash_manager"
|
|
213
|
+
Clacky::Tools::TrashManager.list_trash_sessions(sessions_dir: @sessions_dir)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Permanently delete one session from the trash — cannot be undone.
|
|
217
|
+
def permanent_delete_trash_session(session_id)
|
|
218
|
+
require_relative "tools/trash_manager"
|
|
219
|
+
Clacky::Tools::TrashManager.permanent_delete_trash_session(session_id, sessions_dir: @sessions_dir)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Clean up soft-deleted sessions older than :days (default: 90).
|
|
223
|
+
def cleanup_trash(days: 90)
|
|
224
|
+
require_relative "tools/trash_manager"
|
|
225
|
+
Clacky::Tools::TrashManager.empty_trash_sessions(sessions_dir: @sessions_dir, days: days)
|
|
226
|
+
end
|
|
227
|
+
|
|
199
228
|
|
|
200
229
|
def ensure_sessions_dir
|
|
201
230
|
FileUtils.mkdir_p(@sessions_dir) unless Dir.exist?(@sessions_dir)
|
|
@@ -251,10 +280,10 @@ module Clacky
|
|
|
251
280
|
end
|
|
252
281
|
|
|
253
282
|
# Delete a session JSON file and all its associated chunk MD files.
|
|
254
|
-
def
|
|
283
|
+
private def _hard_delete_session_with_chunks(json_filepath)
|
|
255
284
|
File.delete(json_filepath) if File.exist?(json_filepath)
|
|
256
285
|
base = File.basename(json_filepath, ".json")
|
|
257
|
-
Dir.glob(File.join(
|
|
286
|
+
Dir.glob(File.join(File.dirname(json_filepath), "#{base}-chunk-*.md")).each { |f| File.delete(f) }
|
|
258
287
|
end
|
|
259
288
|
|
|
260
289
|
def load_session_file(filepath)
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -174,11 +174,7 @@ module Clacky
|
|
|
174
174
|
# budget — a good description is a trigger hint, not a tutorial. Authors
|
|
175
175
|
# still see their full description via `skill.description`; only the
|
|
176
176
|
# system-prompt rendering is truncated.
|
|
177
|
-
|
|
178
|
-
# Anthropic's hard limit is 1024, but empirically ~300 chars is enough for
|
|
179
|
-
# reliable triggering (including trigger-phrase lists); longer content
|
|
180
|
-
# belongs in the SKILL.md body.
|
|
181
|
-
DESCRIPTION_MAX_CHARS = 300
|
|
177
|
+
DESCRIPTION_MAX_CHARS = 340
|
|
182
178
|
|
|
183
179
|
# Get the description for context loading.
|
|
184
180
|
# Returns the description from frontmatter (or first paragraph of content),
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "set"
|
|
5
6
|
require "clacky"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
@@ -34,10 +35,21 @@ module Clacky
|
|
|
34
35
|
@skills_by_command = {} # Map slash_command -> Skill
|
|
35
36
|
@errors = [] # Store loading errors
|
|
36
37
|
@loaded_from = {} # Track which location each skill was loaded from
|
|
38
|
+
@virtual_skill_providers = [] # Objects responding to #virtual_skills (e.g. Mcp::Registry)
|
|
37
39
|
|
|
38
40
|
load_all
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Register an object that supplies virtual (in-memory) skills. The object
|
|
44
|
+
# must respond to #virtual_skills returning an Array<Skill>. Virtual skills
|
|
45
|
+
# are merged on every #all_skills / #find_by_name call so a registry can
|
|
46
|
+
# add or remove servers at runtime without forcing a full reload.
|
|
47
|
+
# @param provider [#virtual_skills]
|
|
48
|
+
def attach_virtual_skill_provider(provider)
|
|
49
|
+
return unless provider.respond_to?(:virtual_skills)
|
|
50
|
+
@virtual_skill_providers << provider unless @virtual_skill_providers.include?(provider)
|
|
51
|
+
end
|
|
52
|
+
|
|
41
53
|
# Load all skills from configured locations
|
|
42
54
|
# Clears previously loaded skills before loading to ensure idempotency
|
|
43
55
|
# @return [Array<Skill>] Loaded skills
|
|
@@ -74,9 +86,11 @@ module Clacky
|
|
|
74
86
|
# own (editable, up-to-date) copy rather than the encrypted distribution copy.
|
|
75
87
|
# @return [Array<Skill>]
|
|
76
88
|
def load_brand_skills
|
|
77
|
-
return [] unless @brand_config
|
|
89
|
+
return [] unless @brand_config && (@brand_config.branded? || @brand_config.activated?)
|
|
78
90
|
return [] if ENV["CLACKY_TEST"] == "1"
|
|
79
91
|
|
|
92
|
+
activated = @brand_config.activated?
|
|
93
|
+
|
|
80
94
|
# Use brand_config#brand_skills_dir so the path respects CONFIG_DIR,
|
|
81
95
|
# which is important for test isolation via stub_const.
|
|
82
96
|
brand_skills_dir = Pathname.new(@brand_config.brand_skills_dir)
|
|
@@ -93,6 +107,8 @@ module Clacky
|
|
|
93
107
|
plain = skill_dir.join("SKILL.md").exist?
|
|
94
108
|
next unless encrypted || plain
|
|
95
109
|
|
|
110
|
+
next if encrypted && !activated
|
|
111
|
+
|
|
96
112
|
skill_name = skill_dir.basename.to_s
|
|
97
113
|
|
|
98
114
|
# Skip brand skill when a local plain skill with the same name is already
|
|
@@ -135,31 +151,50 @@ module Clacky
|
|
|
135
151
|
load_skills_from_directory(project_clacky_dir, :project_clacky)
|
|
136
152
|
end
|
|
137
153
|
|
|
138
|
-
# Get all loaded skills
|
|
154
|
+
# Get all loaded skills (including virtual skills supplied by attached providers)
|
|
139
155
|
# @return [Array<Skill>]
|
|
140
156
|
def all_skills
|
|
141
|
-
@skills.values
|
|
157
|
+
base = @skills.values
|
|
158
|
+
virtuals = collect_virtual_skills
|
|
159
|
+
return base if virtuals.empty?
|
|
160
|
+
|
|
161
|
+
# Real skills always shadow virtuals when names collide — protects against
|
|
162
|
+
# an MCP server accidentally named after a real skill.
|
|
163
|
+
seen = base.map(&:identifier).to_set
|
|
164
|
+
base + virtuals.reject { |s| seen.include?(s.identifier) }
|
|
142
165
|
end
|
|
143
166
|
|
|
144
167
|
# Get a skill by its identifier
|
|
145
168
|
# @param identifier [String] Skill name or directory name
|
|
146
169
|
# @return [Skill, nil]
|
|
147
170
|
def [](identifier)
|
|
148
|
-
@skills[identifier]
|
|
171
|
+
@skills[identifier] || virtual_skill(identifier)
|
|
149
172
|
end
|
|
150
173
|
|
|
151
174
|
# Find a skill by its slash command
|
|
152
175
|
# @param command [String] e.g., "/explain-code"
|
|
153
176
|
# @return [Skill, nil]
|
|
154
177
|
def find_by_command(command)
|
|
155
|
-
@skills_by_command[command]
|
|
178
|
+
@skills_by_command[command] || collect_virtual_skills.find { |s| s.slash_command == command }
|
|
156
179
|
end
|
|
157
180
|
|
|
158
181
|
# Find a skill by its name (identifier)
|
|
159
182
|
# @param name [String] Skill identifier (e.g., "code-explorer", "pptx")
|
|
160
183
|
# @return [Skill, nil]
|
|
161
184
|
def find_by_name(name)
|
|
162
|
-
@skills[name]
|
|
185
|
+
@skills[name] || virtual_skill(name)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private def virtual_skill(name)
|
|
189
|
+
collect_virtual_skills.find { |s| s.identifier == name }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private def collect_virtual_skills
|
|
193
|
+
return [] if @virtual_skill_providers.empty?
|
|
194
|
+
@virtual_skill_providers.flat_map { |p| Array(p.virtual_skills) }
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
@errors << "Virtual skill provider failed: #{e.message}"
|
|
197
|
+
[]
|
|
163
198
|
end
|
|
164
199
|
|
|
165
200
|
# Get skills that can be invoked by user
|