openclacky 1.1.6 → 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 +37 -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 +108 -6
- 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 +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -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 +36 -5
- 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 +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- 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 +14 -1
- 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],
|
|
@@ -366,6 +367,27 @@ module Clacky
|
|
|
366
367
|
to_evict.each { |id, session| persist_and_release(id, session) }
|
|
367
368
|
end
|
|
368
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
|
+
|
|
369
391
|
private def persist_and_release(id, session)
|
|
370
392
|
agent = session[:agent]
|
|
371
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
|
|
@@ -139,31 +151,50 @@ module Clacky
|
|
|
139
151
|
load_skills_from_directory(project_clacky_dir, :project_clacky)
|
|
140
152
|
end
|
|
141
153
|
|
|
142
|
-
# Get all loaded skills
|
|
154
|
+
# Get all loaded skills (including virtual skills supplied by attached providers)
|
|
143
155
|
# @return [Array<Skill>]
|
|
144
156
|
def all_skills
|
|
145
|
-
@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) }
|
|
146
165
|
end
|
|
147
166
|
|
|
148
167
|
# Get a skill by its identifier
|
|
149
168
|
# @param identifier [String] Skill name or directory name
|
|
150
169
|
# @return [Skill, nil]
|
|
151
170
|
def [](identifier)
|
|
152
|
-
@skills[identifier]
|
|
171
|
+
@skills[identifier] || virtual_skill(identifier)
|
|
153
172
|
end
|
|
154
173
|
|
|
155
174
|
# Find a skill by its slash command
|
|
156
175
|
# @param command [String] e.g., "/explain-code"
|
|
157
176
|
# @return [Skill, nil]
|
|
158
177
|
def find_by_command(command)
|
|
159
|
-
@skills_by_command[command]
|
|
178
|
+
@skills_by_command[command] || collect_virtual_skills.find { |s| s.slash_command == command }
|
|
160
179
|
end
|
|
161
180
|
|
|
162
181
|
# Find a skill by its name (identifier)
|
|
163
182
|
# @param name [String] Skill identifier (e.g., "code-explorer", "pptx")
|
|
164
183
|
# @return [Skill, nil]
|
|
165
184
|
def find_by_name(name)
|
|
166
|
-
@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
|
+
[]
|
|
167
198
|
end
|
|
168
199
|
|
|
169
200
|
# Get skills that can be invoked by user
|