openclacky 1.2.6 → 1.2.8
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 +34 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +7 -1
- data/lib/clacky/agent/message_compressor.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +6 -2
- data/lib/clacky/agent/session_serializer.rb +23 -4
- data/lib/clacky/agent.rb +46 -2
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/brand_config.rb +0 -6
- data/lib/clacky/cli.rb +107 -1
- data/lib/clacky/client.rb +56 -6
- data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/patch_loader.rb +282 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +11 -2
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +149 -13
- data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
- data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
- data/lib/clacky/server/channel.rb +5 -0
- data/lib/clacky/server/http_server.rb +135 -14
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/tools/terminal.rb +22 -26
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +392 -14
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +50 -6
- data/lib/clacky/web/index.html +33 -0
- data/lib/clacky/web/sessions.js +203 -14
- data/lib/clacky/web/settings.js +59 -17
- data/lib/clacky/web/workspace.js +204 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -3
- data/lib/clacky.rb +15 -0
- metadata +7 -2
|
@@ -234,7 +234,7 @@ module Clacky
|
|
|
234
234
|
return if (text.nil? || text.empty?) && files.empty?
|
|
235
235
|
|
|
236
236
|
# Handle built-in commands
|
|
237
|
-
if text&.
|
|
237
|
+
if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
|
|
238
238
|
handle_command(adapter, event, text)
|
|
239
239
|
return
|
|
240
240
|
end
|
|
@@ -249,7 +249,8 @@ module Clacky
|
|
|
249
249
|
return
|
|
250
250
|
end
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
sub_count = web_ui_for_session_diag(session_id)
|
|
253
|
+
Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]}, text=#{text.inspect}, channel_subs=#{sub_count})")
|
|
253
254
|
|
|
254
255
|
# If session is running, interrupt it automatically (mimics CLI behavior)
|
|
255
256
|
if session[:status] == :running
|
|
@@ -283,7 +284,12 @@ module Clacky
|
|
|
283
284
|
|
|
284
285
|
@run_agent_task.call(session_id, agent) do
|
|
285
286
|
begin
|
|
287
|
+
Clacky::Logger.info("[ChannelManager] agent.run START session=#{session_id[0, 8]} text=#{text.inspect}")
|
|
286
288
|
agent.run(text, files: files)
|
|
289
|
+
Clacky::Logger.info("[ChannelManager] agent.run END session=#{session_id[0, 8]} text=#{text.inspect}")
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
Clacky::Logger.error("[ChannelManager] agent.run RAISED session=#{session_id[0, 8]} #{e.class}: #{e.message}\n#{e.backtrace.first(8).join("\n")}")
|
|
292
|
+
raise
|
|
287
293
|
ensure
|
|
288
294
|
adapter.stop_typing_keepalive(chat_id) if adapter.respond_to?(:stop_typing_keepalive)
|
|
289
295
|
end
|
|
@@ -295,11 +301,24 @@ module Clacky
|
|
|
295
301
|
key = channel_key(event)
|
|
296
302
|
|
|
297
303
|
case text
|
|
304
|
+
when /\A([\?h]|help)\z/i
|
|
305
|
+
adapter.send_text(chat_id, COMMAND_HELP)
|
|
306
|
+
|
|
307
|
+
when "/new", "/clear"
|
|
308
|
+
session_id = auto_create_session(adapter, event)
|
|
309
|
+
adapter.send_text(chat_id, "New session `#{session_id[0, 8]}` created.") if session_id
|
|
310
|
+
|
|
311
|
+
when /\A\/model\b/i
|
|
312
|
+
handle_model_command(adapter, event, text)
|
|
313
|
+
|
|
314
|
+
when /\A\/skills\b/i
|
|
315
|
+
handle_skills_command(adapter, event)
|
|
316
|
+
|
|
298
317
|
when /\A\/bind\s+(\S+)\z/i
|
|
299
318
|
arg = Regexp.last_match(1)
|
|
300
319
|
# Support numeric index from /list (1-based)
|
|
301
320
|
session_id = if arg =~ /\A\d+\z/
|
|
302
|
-
recent = @registry.list.
|
|
321
|
+
recent = @registry.list.first(5)
|
|
303
322
|
idx = arg.to_i - 1
|
|
304
323
|
recent[idx]&.fetch(:id, nil)
|
|
305
324
|
else
|
|
@@ -351,7 +370,9 @@ module Clacky
|
|
|
351
370
|
session_id = resolve_session(event)
|
|
352
371
|
if session_id
|
|
353
372
|
session = @registry.get(session_id)
|
|
354
|
-
|
|
373
|
+
model = session&.dig(:agent)&.current_model_info
|
|
374
|
+
model_name = model&.dig(:model) || "unknown"
|
|
375
|
+
adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}` (status: #{session&.dig(:status) || "unknown"}, model: #{model_name})")
|
|
355
376
|
else
|
|
356
377
|
adapter.send_text(chat_id, "No session bound yet. Send any message to auto-create one.")
|
|
357
378
|
end
|
|
@@ -360,16 +381,118 @@ module Clacky
|
|
|
360
381
|
list_sessions(adapter, chat_id)
|
|
361
382
|
|
|
362
383
|
else
|
|
363
|
-
adapter.send_text(chat_id,
|
|
364
|
-
"Commands:\n" \
|
|
365
|
-
" /bind <n|session_id> - switch to a session (use /list to see numbers)\n" \
|
|
366
|
-
" /unbind - remove binding\n" \
|
|
367
|
-
" /stop - interrupt current task\n" \
|
|
368
|
-
" /status - show current binding\n" \
|
|
369
|
-
" /list - show recent sessions")
|
|
384
|
+
adapter.send_text(chat_id, "Unknown command. Type ? for help.")
|
|
370
385
|
end
|
|
371
386
|
end
|
|
372
387
|
|
|
388
|
+
KNOWN_COMMAND = %r{\A/(new|clear|model|skills|bind|stop|unbind|status|list)\b}i
|
|
389
|
+
|
|
390
|
+
COMMAND_HELP = <<~HELP.strip
|
|
391
|
+
Commands:
|
|
392
|
+
? / h / help - show this help
|
|
393
|
+
/new / /clear - start a new session
|
|
394
|
+
/model - show current model & available models
|
|
395
|
+
/model <n> - switch to model n
|
|
396
|
+
/skills - list available skills
|
|
397
|
+
/<skill> <args> - invoke a skill directly
|
|
398
|
+
/bind <n|session_id> - switch to a session (use /list to see numbers)
|
|
399
|
+
/unbind - remove binding
|
|
400
|
+
/stop - interrupt current task
|
|
401
|
+
/status - show current binding
|
|
402
|
+
/list - show recent sessions
|
|
403
|
+
HELP
|
|
404
|
+
|
|
405
|
+
def handle_model_command(adapter, event, text)
|
|
406
|
+
chat_id = event[:chat_id]
|
|
407
|
+
session_id = resolve_session(event)
|
|
408
|
+
|
|
409
|
+
unless session_id
|
|
410
|
+
adapter.send_text(chat_id, "No session bound. Send any message to auto-create one first.")
|
|
411
|
+
return
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
session = @registry.get(session_id)
|
|
415
|
+
agent = session&.dig(:agent)
|
|
416
|
+
unless agent
|
|
417
|
+
adapter.send_text(chat_id, "Session not ready.")
|
|
418
|
+
return
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
arg = text.sub(/\A\/model\s*/i, "").strip
|
|
422
|
+
|
|
423
|
+
if arg.empty?
|
|
424
|
+
# Show current model and available list
|
|
425
|
+
info = agent.current_model_info
|
|
426
|
+
current = info&.dig(:model) || "unknown"
|
|
427
|
+
sub = info&.dig(:sub_model)
|
|
428
|
+
card = info&.dig(:card_model)
|
|
429
|
+
header = "Current model: #{current}"
|
|
430
|
+
header += " (#{card} · #{sub})" if card && sub && sub != current
|
|
431
|
+
header += " (#{card})" if card && !sub
|
|
432
|
+
|
|
433
|
+
models = agent.available_models
|
|
434
|
+
if models.empty?
|
|
435
|
+
adapter.send_text(chat_id, "#{header}\nNo other models available.")
|
|
436
|
+
return
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
lines = models.each_with_index.map do |name, i|
|
|
440
|
+
marker = name == current ? " *" : ""
|
|
441
|
+
"#{i + 1}. #{name}#{marker}"
|
|
442
|
+
end
|
|
443
|
+
adapter.send_text(chat_id, "#{header}\n\nSwitch with /model <n>:\n#{lines.join("\n")}")
|
|
444
|
+
elsif arg =~ /\A\d+\z/
|
|
445
|
+
idx = arg.to_i - 1
|
|
446
|
+
models = agent.config.models
|
|
447
|
+
if idx < 0 || idx >= models.length
|
|
448
|
+
adapter.send_text(chat_id, "Invalid model number. Use /model to see available models.")
|
|
449
|
+
return
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
model_id = models[idx]["id"]
|
|
453
|
+
if agent.switch_model_by_id(model_id)
|
|
454
|
+
new_info = agent.current_model_info
|
|
455
|
+
adapter.send_text(chat_id, "Switched to #{new_info&.dig(:model) || model_id}.")
|
|
456
|
+
else
|
|
457
|
+
adapter.send_text(chat_id, "Failed to switch model.")
|
|
458
|
+
end
|
|
459
|
+
else
|
|
460
|
+
adapter.send_text(chat_id, "Usage: /model to list, /model <n> to switch.")
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def handle_skills_command(adapter, event)
|
|
465
|
+
chat_id = event[:chat_id]
|
|
466
|
+
session_id = resolve_session(event)
|
|
467
|
+
|
|
468
|
+
unless session_id
|
|
469
|
+
adapter.send_text(chat_id, "No session bound. Send any message to auto-create one first.")
|
|
470
|
+
return
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
session = @registry.get(session_id)
|
|
474
|
+
agent = session&.dig(:agent)
|
|
475
|
+
unless agent
|
|
476
|
+
adapter.send_text(chat_id, "Session not ready.")
|
|
477
|
+
return
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
skills = agent.skill_loader.user_invocable_skills
|
|
481
|
+
.reject { |s| s.source == :default }
|
|
482
|
+
.first(10)
|
|
483
|
+
if skills.empty?
|
|
484
|
+
adapter.send_text(chat_id, "No skills available.")
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
lines = skills.each_with_index.map do |s, i|
|
|
489
|
+
desc = s.description.to_s.strip
|
|
490
|
+
desc = desc.empty? ? "(no description)" : desc.length > 50 ? "#{desc[0..49]}..." : desc
|
|
491
|
+
"#{i + 1}. #{s.name} - #{desc}"
|
|
492
|
+
end
|
|
493
|
+
adapter.send_text(chat_id, "Skills:\n#{lines.join("\n")}")
|
|
494
|
+
end
|
|
495
|
+
|
|
373
496
|
def resolve_session(event)
|
|
374
497
|
key = channel_key(event)
|
|
375
498
|
@registry.list.each do |summary|
|
|
@@ -388,7 +511,7 @@ module Clacky
|
|
|
388
511
|
platform = event[:platform].to_s
|
|
389
512
|
count = @mutex.synchronize { @session_counters[platform] += 1 }
|
|
390
513
|
name = "#{platform}-#{count}"
|
|
391
|
-
session_id = @session_builder.call(name: name,
|
|
514
|
+
session_id = @session_builder.call(name: name, source: :channel)
|
|
392
515
|
bind_key_to_session(key, session_id)
|
|
393
516
|
|
|
394
517
|
# Create a long-lived ChannelUIController for this session and subscribe it
|
|
@@ -411,6 +534,19 @@ module Clacky
|
|
|
411
534
|
result
|
|
412
535
|
end
|
|
413
536
|
|
|
537
|
+
def web_ui_for_session_diag(session_id)
|
|
538
|
+
result = nil
|
|
539
|
+
@registry.with_session(session_id) do |s|
|
|
540
|
+
ui = s[:ui]
|
|
541
|
+
result = if ui.respond_to?(:channel_subscribed?)
|
|
542
|
+
ui.instance_variable_get(:@channel_subscribers)&.size || 0
|
|
543
|
+
else
|
|
544
|
+
-1
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
result
|
|
548
|
+
end
|
|
549
|
+
|
|
414
550
|
def bind_key_to_session(key, session_id)
|
|
415
551
|
@registry.list.each do |summary|
|
|
416
552
|
@registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }
|
|
@@ -422,7 +558,7 @@ module Clacky
|
|
|
422
558
|
end
|
|
423
559
|
|
|
424
560
|
def list_sessions(adapter, chat_id)
|
|
425
|
-
sessions = @registry.list.
|
|
561
|
+
sessions = @registry.list.first(5)
|
|
426
562
|
if sessions.empty?
|
|
427
563
|
adapter.send_text(chat_id, "No sessions available.")
|
|
428
564
|
return
|
|
@@ -145,8 +145,10 @@ module Clacky
|
|
|
145
145
|
send_text("Warning: #{message}")
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
-
def show_error(message)
|
|
149
|
-
|
|
148
|
+
def show_error(message, code: nil, top_up_url: nil)
|
|
149
|
+
text = "Error: #{message}"
|
|
150
|
+
text += "\n#{top_up_url}" if top_up_url
|
|
151
|
+
send_text(text)
|
|
150
152
|
end
|
|
151
153
|
|
|
152
154
|
def show_success(message)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Channel
|
|
7
|
+
module Adapters
|
|
8
|
+
# Loads user-defined channel adapters from ~/.clacky/channels/<name>/adapter.rb.
|
|
9
|
+
#
|
|
10
|
+
# Each adapter file is plain Ruby that defines a subclass of
|
|
11
|
+
# Clacky::Channel::Adapters::Base and self-registers via Adapters.register,
|
|
12
|
+
# exactly like the bundled adapters. This loader only discovers and requires
|
|
13
|
+
# those files after the built-in adapters are loaded — the existing
|
|
14
|
+
# self-registration mechanism then takes over with no further wiring.
|
|
15
|
+
#
|
|
16
|
+
# A broken adapter (syntax error, missing interface methods) is isolated:
|
|
17
|
+
# it is skipped with a logged warning and never aborts the load of others.
|
|
18
|
+
module UserAdapterLoader
|
|
19
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/channels")
|
|
20
|
+
|
|
21
|
+
# Required class/instance methods a user adapter must implement to be usable.
|
|
22
|
+
REQUIRED_CLASS_METHODS = %i[platform_id platform_config].freeze
|
|
23
|
+
REQUIRED_INSTANCE_METHODS = %i[start stop send_text].freeze
|
|
24
|
+
|
|
25
|
+
Result = Struct.new(:loaded, :skipped, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# @param dir [String] directory to scan (override for tests)
|
|
28
|
+
# @return [Result] names loaded and skipped (with reasons)
|
|
29
|
+
def self.load_all(dir: DEFAULT_DIR)
|
|
30
|
+
result = Result.new(loaded: [], skipped: [])
|
|
31
|
+
if Dir.exist?(dir)
|
|
32
|
+
Dir.glob(File.join(dir, "*", "adapter.rb")).sort.each do |path|
|
|
33
|
+
name = File.basename(File.dirname(path))
|
|
34
|
+
load_one(path, name, result)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
@last_result = result
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The result of the most recent load_all (set at startup). Lets `channel_verify`
|
|
42
|
+
# report status without re-requiring files (require is idempotent and would
|
|
43
|
+
# otherwise report already-loaded adapters as "did not register").
|
|
44
|
+
def self.last_result
|
|
45
|
+
@last_result || load_all
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.load_one(path, name, result)
|
|
49
|
+
before = Adapters.all.dup
|
|
50
|
+
|
|
51
|
+
require path
|
|
52
|
+
|
|
53
|
+
newly = Adapters.all - before
|
|
54
|
+
klass = newly.last
|
|
55
|
+
|
|
56
|
+
unless klass
|
|
57
|
+
result.skipped << [name, "did not register an adapter (missing Adapters.register?)"]
|
|
58
|
+
log_skip(name, result.skipped.last[1])
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if (missing = interface_gaps(klass)).any?
|
|
63
|
+
unregister(klass)
|
|
64
|
+
result.skipped << [name, "missing required methods: #{missing.join(", ")}"]
|
|
65
|
+
log_skip(name, result.skipped.last[1])
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result.loaded << name
|
|
70
|
+
Clacky::Logger.info("[UserAdapterLoader] Loaded channel adapter '#{name}' → :#{klass.platform_id}")
|
|
71
|
+
rescue StandardError, ScriptError => e
|
|
72
|
+
result.skipped << [name, e.message]
|
|
73
|
+
log_skip(name, e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.interface_gaps(klass)
|
|
77
|
+
missing = REQUIRED_CLASS_METHODS.reject { |m| klass.respond_to?(m) }
|
|
78
|
+
# Base defines stub instance methods that only raise NotImplementedError,
|
|
79
|
+
# so method_defined? alone passes via inheritance. Require the subclass to
|
|
80
|
+
# actually override them — i.e. the method's owner must not be Base.
|
|
81
|
+
missing += REQUIRED_INSTANCE_METHODS.reject do |m|
|
|
82
|
+
klass.method_defined?(m) && klass.instance_method(m).owner != Base
|
|
83
|
+
end
|
|
84
|
+
missing
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.unregister(klass)
|
|
88
|
+
platform = (klass.platform_id if klass.respond_to?(:platform_id))
|
|
89
|
+
return unless platform
|
|
90
|
+
|
|
91
|
+
Adapters.unregister(platform)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.log_skip(name, reason)
|
|
95
|
+
Clacky::Logger.warn("[UserAdapterLoader] Skipped channel adapter '#{name}': #{reason}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate a ready-to-edit adapter skeleton at ~/.clacky/channels/<name>/adapter.rb.
|
|
99
|
+
# The skeleton already self-registers and implements the full interface with
|
|
100
|
+
# TODO markers — the author only fills in the method bodies.
|
|
101
|
+
# @return [String] path to the generated adapter.rb
|
|
102
|
+
def self.scaffold(name, dir: DEFAULT_DIR)
|
|
103
|
+
slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
104
|
+
raise ArgumentError, "invalid channel name: #{name.inspect}" if slug.empty?
|
|
105
|
+
|
|
106
|
+
target_dir = File.join(dir, slug)
|
|
107
|
+
path = File.join(target_dir, "adapter.rb")
|
|
108
|
+
raise ArgumentError, "adapter already exists: #{path}" if File.exist?(path)
|
|
109
|
+
|
|
110
|
+
FileUtils.mkdir_p(target_dir)
|
|
111
|
+
File.write(path, skeleton(slug))
|
|
112
|
+
path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.skeleton(slug)
|
|
116
|
+
const = slug.split("_").map(&:capitalize).join
|
|
117
|
+
<<~RUBY
|
|
118
|
+
# frozen_string_literal: true
|
|
119
|
+
|
|
120
|
+
# User-defined channel adapter for ":#{slug}".
|
|
121
|
+
# Edit the TODO sections, then it loads automatically on next start.
|
|
122
|
+
# Verify with: clacky channel verify
|
|
123
|
+
|
|
124
|
+
module Clacky
|
|
125
|
+
module Channel
|
|
126
|
+
module Adapters
|
|
127
|
+
class #{const}Adapter < Base
|
|
128
|
+
def self.platform_id
|
|
129
|
+
:#{slug}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Map raw config (channels.yml `#{slug}` section) to a symbol-keyed hash.
|
|
133
|
+
def self.platform_config(data)
|
|
134
|
+
{
|
|
135
|
+
# TODO: pull your credentials out of `data`
|
|
136
|
+
# token: data["IM_#{slug.upcase}_TOKEN"] || data["token"]
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def initialize(config)
|
|
141
|
+
@config = config
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Begin receiving messages. Blocks until #stop — runs inside a Thread.
|
|
145
|
+
# Yield one standardized event Hash per inbound message.
|
|
146
|
+
def start(&on_message)
|
|
147
|
+
# TODO: connect to your platform and loop, calling on_message.call(event)
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def stop
|
|
152
|
+
# TODO: close connections / stop the read loop
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Send a plain text (or Markdown) message to a chat.
|
|
156
|
+
# @return [Hash] { message_id: String }
|
|
157
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
158
|
+
# TODO: call your platform's send API
|
|
159
|
+
raise NotImplementedError
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Optional: validate config; return array of error strings (empty = ok).
|
|
163
|
+
def validate_config(config)
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Adapters.register(platform_id, self)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
RUBY
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -31,3 +31,8 @@ require_relative "channel/adapters/dingtalk/adapter"
|
|
|
31
31
|
require_relative "channel/channel_config"
|
|
32
32
|
require_relative "channel/channel_ui_controller"
|
|
33
33
|
require_relative "channel/channel_manager"
|
|
34
|
+
|
|
35
|
+
# Discover and load user-defined adapters from ~/.clacky/channels/.
|
|
36
|
+
# Must run after the bundled adapters so user adapters can extend or override.
|
|
37
|
+
require_relative "channel/user_adapter_loader"
|
|
38
|
+
Clacky::Channel::Adapters::UserAdapterLoader.load_all
|
|
@@ -436,8 +436,8 @@ module Clacky
|
|
|
436
436
|
when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
|
|
437
437
|
when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
|
|
438
438
|
when ["GET", "/api/billing/records"] then api_billing_records(req, res)
|
|
439
|
-
when ["
|
|
440
|
-
when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
439
|
+
when ["GET", "/api/billing/sessions"] then api_billing_sessions(req, res)
|
|
440
|
+
when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res) when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
441
441
|
when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
|
|
442
442
|
else
|
|
443
443
|
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
|
|
@@ -481,6 +481,9 @@ module Clacky
|
|
|
481
481
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
482
482
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
483
483
|
api_session_skills(session_id, res)
|
|
484
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
|
|
485
|
+
session_id = path.sub("/api/sessions/", "").sub("/files", "")
|
|
486
|
+
api_session_files(session_id, req, res)
|
|
484
487
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
|
|
485
488
|
session_id = path.sub("/api/sessions/", "").sub("/export", "")
|
|
486
489
|
api_export_session(session_id, res)
|
|
@@ -496,6 +499,9 @@ module Clacky
|
|
|
496
499
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
|
|
497
500
|
session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
|
|
498
501
|
api_switch_session_reasoning_effort(session_id, req, res)
|
|
502
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/submodel$})
|
|
503
|
+
session_id = path.sub("/api/sessions/", "").sub("/submodel", "")
|
|
504
|
+
api_switch_session_submodel(session_id, req, res)
|
|
499
505
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
|
|
500
506
|
session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
|
|
501
507
|
api_benchmark_session_models(session_id, req, res)
|
|
@@ -1164,8 +1170,27 @@ module Clacky
|
|
|
1164
1170
|
})
|
|
1165
1171
|
end
|
|
1166
1172
|
|
|
1167
|
-
#
|
|
1168
|
-
#
|
|
1173
|
+
# GET /api/billing/sessions
|
|
1174
|
+
# Returns session-level billing summary
|
|
1175
|
+
# Query params: period (day|week|month|year|all, default: month), model, limit
|
|
1176
|
+
def api_billing_sessions(req, res)
|
|
1177
|
+
require_relative "../billing/billing_store"
|
|
1178
|
+
|
|
1179
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1180
|
+
period = (query["period"] || "month").to_sym
|
|
1181
|
+
model = query["model"]
|
|
1182
|
+
limit = [(query["limit"] || "50").to_i, 200].min
|
|
1183
|
+
|
|
1184
|
+
store = Clacky::Billing::BillingStore.new
|
|
1185
|
+
sessions = store.session_summary(period: period, model: model, limit: limit)
|
|
1186
|
+
|
|
1187
|
+
json_response(res, 200, {
|
|
1188
|
+
sessions: sessions,
|
|
1189
|
+
count: sessions.size
|
|
1190
|
+
})
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# DELETE /api/billing/clear # Clears billing records
|
|
1169
1194
|
# Query params: scope (today|all, default: today)
|
|
1170
1195
|
def api_billing_clear(req, res)
|
|
1171
1196
|
require_relative "../billing/billing_store"
|
|
@@ -2445,7 +2470,56 @@ module Clacky
|
|
|
2445
2470
|
json_response(res, 200, { skills: skill_data })
|
|
2446
2471
|
end
|
|
2447
2472
|
|
|
2448
|
-
#
|
|
2473
|
+
# GET /api/sessions/:id/files?path=<relative dir>
|
|
2474
|
+
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
2475
|
+
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
2476
|
+
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
2477
|
+
|
|
2478
|
+
def api_session_files(session_id, req, res)
|
|
2479
|
+
unless @registry.ensure(session_id)
|
|
2480
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2481
|
+
end
|
|
2482
|
+
session = @registry.get(session_id)
|
|
2483
|
+
agent = session && session[:agent]
|
|
2484
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
2485
|
+
|
|
2486
|
+
root = File.expand_path(agent.working_dir.to_s)
|
|
2487
|
+
return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
|
|
2488
|
+
|
|
2489
|
+
rel = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
2490
|
+
rel = rel.sub(%r{\A/+}, "").strip
|
|
2491
|
+
target = File.expand_path(File.join(root, rel))
|
|
2492
|
+
|
|
2493
|
+
# Reject traversal outside the working directory.
|
|
2494
|
+
unless target == root || target.start_with?("#{root}/")
|
|
2495
|
+
return json_response(res, 403, { error: "Path outside working directory" })
|
|
2496
|
+
end
|
|
2497
|
+
return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
|
|
2498
|
+
|
|
2499
|
+
entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
|
|
2500
|
+
|
|
2501
|
+
items = entries.filter_map do |name|
|
|
2502
|
+
full = File.join(target, name)
|
|
2503
|
+
is_dir = File.directory?(full)
|
|
2504
|
+
# Skip symlinks pointing outside the root, and anything unreadable.
|
|
2505
|
+
next unless File.exist?(full)
|
|
2506
|
+
{
|
|
2507
|
+
name: name,
|
|
2508
|
+
path: rel.empty? ? name : "#{rel}/#{name}",
|
|
2509
|
+
type: is_dir ? "dir" : "file",
|
|
2510
|
+
size: is_dir ? nil : (File.size(full) rescue nil)
|
|
2511
|
+
}
|
|
2512
|
+
rescue StandardError
|
|
2513
|
+
nil
|
|
2514
|
+
end
|
|
2515
|
+
|
|
2516
|
+
# Directories first, then files; both case-insensitive alphabetical.
|
|
2517
|
+
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
2518
|
+
|
|
2519
|
+
json_response(res, 200, { root: root, path: rel, entries: items })
|
|
2520
|
+
rescue StandardError => e
|
|
2521
|
+
json_response(res, 500, { error: e.message })
|
|
2522
|
+
end
|
|
2449
2523
|
# Body: { enabled: true/false }
|
|
2450
2524
|
def api_toggle_skill(name, req, res)
|
|
2451
2525
|
body = parse_json_body(req)
|
|
@@ -3601,6 +3675,47 @@ module Clacky
|
|
|
3601
3675
|
json_response(res, 500, { error: e.message })
|
|
3602
3676
|
end
|
|
3603
3677
|
|
|
3678
|
+
# PATCH /api/sessions/:id/submodel
|
|
3679
|
+
# Body: { "model_name": "dsk-deepseek-v4-pro" | null }
|
|
3680
|
+
#
|
|
3681
|
+
# Pin this session to a sub-model under its current card without
|
|
3682
|
+
# touching credentials or the global @models. Pass null/empty to clear
|
|
3683
|
+
# and fall back to the card default. The name must appear in the
|
|
3684
|
+
# provider preset's "models" list — anything else is rejected.
|
|
3685
|
+
def api_switch_session_submodel(session_id, req, res)
|
|
3686
|
+
body = parse_json_body(req)
|
|
3687
|
+
raw = body["model_name"]
|
|
3688
|
+
model_name = raw.is_a?(String) ? raw.strip : nil
|
|
3689
|
+
|
|
3690
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
3691
|
+
|
|
3692
|
+
agent = nil
|
|
3693
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
3694
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
3695
|
+
|
|
3696
|
+
if model_name && !model_name.empty?
|
|
3697
|
+
info = agent.current_model_info
|
|
3698
|
+
provider_id = info && Clacky::Providers.find_by_base_url(info[:base_url])
|
|
3699
|
+
allowed = provider_id ? Clacky::Providers.models(provider_id) : []
|
|
3700
|
+
if allowed.empty?
|
|
3701
|
+
return json_response(res, 400, { error: "Current model has no provider preset; sub-model switching unavailable" })
|
|
3702
|
+
end
|
|
3703
|
+
unless allowed.include?(model_name)
|
|
3704
|
+
return json_response(res, 400, { error: "Sub-model '#{model_name}' not listed under provider '#{provider_id}'" })
|
|
3705
|
+
end
|
|
3706
|
+
else
|
|
3707
|
+
model_name = nil
|
|
3708
|
+
end
|
|
3709
|
+
|
|
3710
|
+
agent.set_session_sub_model(model_name)
|
|
3711
|
+
@session_manager.save(agent.to_session_data)
|
|
3712
|
+
broadcast_session_update(session_id)
|
|
3713
|
+
|
|
3714
|
+
json_response(res, 200, { ok: true, sub_model: agent.current_model_info[:sub_model] })
|
|
3715
|
+
rescue => e
|
|
3716
|
+
json_response(res, 500, { error: e.message })
|
|
3717
|
+
end
|
|
3718
|
+
|
|
3604
3719
|
# POST /api/sessions/:id/benchmark
|
|
3605
3720
|
#
|
|
3606
3721
|
# Speed-test every configured model in one shot so the user can pick the
|
|
@@ -3706,11 +3821,9 @@ module Clacky
|
|
|
3706
3821
|
|
|
3707
3822
|
# Expand ~ to home directory
|
|
3708
3823
|
expanded_dir = File.expand_path(new_dir)
|
|
3709
|
-
|
|
3710
|
-
#
|
|
3711
|
-
|
|
3712
|
-
return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
|
|
3713
|
-
end
|
|
3824
|
+
|
|
3825
|
+
# Auto-create the directory if it doesn't exist yet.
|
|
3826
|
+
FileUtils.mkdir_p(expanded_dir)
|
|
3714
3827
|
|
|
3715
3828
|
agent = nil
|
|
3716
3829
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
@@ -4189,12 +4302,18 @@ module Clacky
|
|
|
4189
4302
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
4190
4303
|
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
4191
4304
|
rescue => e
|
|
4192
|
-
@registry.update(session_id, status: :error, error: e.message)
|
|
4193
|
-
broadcast_session_update(session_id)
|
|
4194
4305
|
# Route error through web_ui so channel subscribers (飞书/企微) receive it too.
|
|
4195
4306
|
web_ui = nil
|
|
4196
4307
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
4197
|
-
|
|
4308
|
+
code = e.is_a?(Clacky::InsufficientCreditError) ? e.error_code : nil
|
|
4309
|
+
top_up_url = nil
|
|
4310
|
+
if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
|
|
4311
|
+
preset = Clacky::Providers::PRESETS[e.provider_id]
|
|
4312
|
+
top_up_url = preset && preset["website_url"]
|
|
4313
|
+
end
|
|
4314
|
+
@registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
|
|
4315
|
+
broadcast_session_update(session_id)
|
|
4316
|
+
web_ui&.show_error(e.message, code: code, top_up_url: top_up_url)
|
|
4198
4317
|
@session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
|
|
4199
4318
|
end
|
|
4200
4319
|
@registry.with_session(session_id) { |s| s[:thread] = thread }
|
|
@@ -4286,7 +4405,9 @@ module Clacky
|
|
|
4286
4405
|
# @param working_dir [String] working directory for the agent
|
|
4287
4406
|
# @param permission_mode [Symbol] :confirm_all (default, human present) or
|
|
4288
4407
|
# :auto_approve (unattended — suppresses request_user_feedback waits)
|
|
4289
|
-
def build_session(name:, working_dir
|
|
4408
|
+
def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
|
|
4409
|
+
working_dir ||= default_working_dir
|
|
4410
|
+
FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
|
|
4290
4411
|
session_id = Clacky::SessionManager.generate_id
|
|
4291
4412
|
@registry.create(session_id: session_id)
|
|
4292
4413
|
|
|
@@ -223,11 +223,8 @@ module Clacky
|
|
|
223
223
|
prompt = read_task(task_name)
|
|
224
224
|
name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
|
|
225
225
|
|
|
226
|
-
working_dir = File.expand_path("~/clacky_workspace")
|
|
227
|
-
FileUtils.mkdir_p(working_dir)
|
|
228
|
-
|
|
229
226
|
# Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
|
|
230
|
-
session_id = @session_builder.call(name: name,
|
|
227
|
+
session_id = @session_builder.call(name: name, permission_mode: :auto_approve, source: :cron)
|
|
231
228
|
|
|
232
229
|
Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
|
|
233
230
|
|