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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. 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
- # Spawn a new worker, wait for it to boot, then gracefully stop the old one.
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: spawning new worker (old PID=#{old_pid})...")
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
- # Give the new worker time to bind and start serving
170
- sleep NEW_WORKER_BOOT_WAIT
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
- # Physical delete removes disk file + associated chunk files.
55
- # Returns true if found and deleted, false if not found.
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
- session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
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
- delete_session_with_chunks(filepath)
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
- delete_session_with_chunks(filepath) if File.exist?(filepath)
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 delete_session_with_chunks(json_filepath)
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(@sessions_dir, "#{base}-chunk-*.md")).each { |f| File.delete(f) }
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),
@@ -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