openclacky 1.2.17 → 1.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/lib/clacky/agent/skill_manager.rb +1 -1
  4. data/lib/clacky/agent/time_machine.rb +256 -74
  5. data/lib/clacky/agent/tool_executor.rb +12 -0
  6. data/lib/clacky/agent.rb +21 -31
  7. data/lib/clacky/agent_config.rb +18 -0
  8. data/lib/clacky/cli.rb +55 -3
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
  10. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
  11. data/lib/clacky/media/base.rb +125 -0
  12. data/lib/clacky/media/dashscope.rb +243 -0
  13. data/lib/clacky/media/gemini.rb +10 -0
  14. data/lib/clacky/media/generator.rb +75 -0
  15. data/lib/clacky/media/openai_compat.rb +160 -0
  16. data/lib/clacky/message_history.rb +12 -7
  17. data/lib/clacky/providers.rb +28 -0
  18. data/lib/clacky/rich_ui_controller.rb +3 -1
  19. data/lib/clacky/server/backup_manager.rb +200 -0
  20. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  21. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  22. data/lib/clacky/server/channel/channel_manager.rb +180 -81
  23. data/lib/clacky/server/http_server.rb +348 -15
  24. data/lib/clacky/server/scheduler.rb +19 -0
  25. data/lib/clacky/server/session_registry.rb +8 -4
  26. data/lib/clacky/session_manager.rb +40 -2
  27. data/lib/clacky/skill.rb +3 -1
  28. data/lib/clacky/tools/trash_manager.rb +14 -0
  29. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  30. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  31. data/lib/clacky/ui2/ui_controller.rb +150 -19
  32. data/lib/clacky/utils/file_processor.rb +75 -4
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +2038 -1147
  35. data/lib/clacky/web/app.js +22 -1
  36. data/lib/clacky/web/backup.js +119 -0
  37. data/lib/clacky/web/billing.js +94 -7
  38. data/lib/clacky/web/channels.js +81 -11
  39. data/lib/clacky/web/design-sample.css +247 -0
  40. data/lib/clacky/web/design-sample.html +127 -0
  41. data/lib/clacky/web/favicon.svg +16 -0
  42. data/lib/clacky/web/i18n.js +159 -31
  43. data/lib/clacky/web/index.html +175 -55
  44. data/lib/clacky/web/logo_nav_dark.png +0 -0
  45. data/lib/clacky/web/onboard.js +114 -28
  46. data/lib/clacky/web/sessions.js +436 -192
  47. data/lib/clacky/web/settings.js +21 -1
  48. data/lib/clacky/web/skills.js +6 -6
  49. data/lib/clacky/web/tasks.js +129 -61
  50. data/lib/clacky/web/utils.js +72 -0
  51. data/lib/clacky/web/ws-dispatcher.js +6 -0
  52. data/lib/clacky.rb +1 -0
  53. metadata +8 -3
  54. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "channel_ui_controller"
4
- require_relative "group_message_buffer"
5
4
 
6
5
  module Clacky
7
6
  module Channel
@@ -28,14 +27,11 @@ module Clacky
28
27
  # @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
29
28
  # @param interrupt_session [Proc] (session_id) — from HttpServer
30
29
  # @param channel_config [Clacky::ChannelConfig]
31
- # @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
32
- # :chat_user (default) — one session per (chat, user) pair. Most natural:
33
- # private chat = that user's session; in a group each
34
- # user has their own session; the same user across
35
- # different groups keeps those contexts separate.
36
- # :chat — one session per chat (all users in a group share it).
37
- # :user — one session per user (merges DMs and all groups).
38
- def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
30
+ # @param binding_mode [:chat | :chat_user | :user] how to map IM identities to sessions.
31
+ # :chat (default) — one session per chat (all users in a chat share it).
32
+ # :chat_user — one session per (chat, user) pair.
33
+ # :user one session per user (merges all chats).
34
+ def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat)
39
35
  @registry = session_registry
40
36
  @session_builder = session_builder
41
37
  @run_agent_task = run_agent_task
@@ -46,8 +42,7 @@ module Clacky
46
42
  @adapter_threads = []
47
43
  @running = false
48
44
  @mutex = Mutex.new
49
- @session_counters = Hash.new(0) # platform => count, for short session names
50
- @group_buffer = GroupMessageBuffer.new
45
+ @session_counters = Hash.new(0)
51
46
  end
52
47
 
53
48
  # Start all enabled adapters in background threads. Non-blocking.
@@ -82,15 +77,6 @@ module Clacky
82
77
  @mutex.synchronize { @adapters.map(&:platform_id) }
83
78
  end
84
79
 
85
- # Return all buffered group chat messages for a given chat.
86
- # @param chat_id [String]
87
- # @return [Array<Hash>] each entry has :user_id, :user_name, :text
88
- def group_history(chat_id)
89
- @group_buffer.peek(chat_id).map do |e|
90
- { user_id: e.user_id, user_name: e.user_name, text: e.text }
91
- end
92
- end
93
-
94
80
  # Proactively send a message to a user on the given platform.
95
81
  #
96
82
  # For Weixin (iLink protocol) a context_token is required for every outbound
@@ -244,7 +230,6 @@ module Clacky
244
230
 
245
231
  def route_message(adapter, event)
246
232
  if event[:observe_only]
247
- @group_buffer.push(event[:chat_id], user_id: event[:user_id], user_name: event[:user_name], text: event[:text].to_s)
248
233
  return
249
234
  end
250
235
 
@@ -320,7 +305,7 @@ module Clacky
320
305
  # Prepend buffered group history so the agent knows what was discussed
321
306
  # before it was @-mentioned. Buffer is cleared atomically on take.
322
307
  # WebUI always receives the raw user text — context is agent-only.
323
- prompt = build_prompt_with_context(event[:chat_id], text)
308
+ prompt = build_prompt_with_context(event, text)
324
309
 
325
310
  # Start typing keepalive BEFORE sending any message.
326
311
  # sendmessage cancels the typing indicator in WeChat protocol,
@@ -375,17 +360,28 @@ module Clacky
375
360
  else
376
361
  arg
377
362
  end
378
- unless session_id && @registry.get(session_id)
363
+ unless session_id && @registry.ensure(session_id)
379
364
  adapter.send_text(chat_id, "Session not found. Use /list to see available sessions.")
380
365
  return
381
366
  end
382
367
 
383
368
  # Detach channel_ui from the old session's web_ui, reattach to the new one.
369
+ # Also clear the old session's persisted agent.channel_info if it still
370
+ # matches this key — keeping channel_keys and channel_info strictly in sync
371
+ # so resolve_session never sees two sessions claim the same key via different
372
+ # sources (see comment in resolve_session).
384
373
  old_session_id = resolve_session(event)
385
374
  channel_ui = old_session_id ? channel_ui_for_session(old_session_id) : nil
386
375
 
387
376
  if channel_ui
388
- @registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
377
+ @registry.with_session(old_session_id) do |s|
378
+ s[:ui]&.unsubscribe_channel(channel_ui)
379
+ s.delete(:channel_ui)
380
+ if s[:agent]&.respond_to?(:channel_info=) && s[:agent].respond_to?(:channel_info) &&
381
+ s[:agent].channel_info && channel_key_from_info(s[:agent].channel_info) == key
382
+ s[:agent].channel_info = nil
383
+ end
384
+ end
389
385
  else
390
386
  channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
391
387
  end
@@ -412,7 +408,17 @@ module Clacky
412
408
  unbound = false
413
409
  @registry.list.each do |summary|
414
410
  @registry.with_session(summary[:id]) do |s|
415
- unbound = true if s[:channel_keys]&.delete(key)
411
+ if s[:channel_keys]&.delete(key)
412
+ unbound = true
413
+ # Keep agent.channel_info in sync with channel_keys (see resolve_session).
414
+ # Without this, after process restart + eviction, the fallback path would
415
+ # silently re-bind this key back to the unbinded session via stale
416
+ # channel_info, defeating /unbind.
417
+ if s[:agent]&.respond_to?(:channel_info=) && s[:agent].respond_to?(:channel_info) &&
418
+ s[:agent].channel_info && channel_key_from_info(s[:agent].channel_info) == key
419
+ s[:agent].channel_info = nil
420
+ end
421
+ end
416
422
  end
417
423
  end
418
424
  adapter.send_text(chat_id, unbound ? "Unbound." : "No binding found.")
@@ -442,8 +448,10 @@ module Clacky
442
448
  Commands:
443
449
  ? / h / help - show this help
444
450
  /new / /clear - start a new session
445
- /model - show current model & available models
446
- /model <n> - switch to model n
451
+ /model - show current model, cards & quick-switch list
452
+ /model <n> - switch card by number
453
+ /model s<n> - quick-switch model under current card
454
+ /model off - reset to card default
447
455
  /skills - list available skills
448
456
  /<skill> <args> - invoke a skill directly
449
457
  /bind <n|session_id> - switch to a session (use /list to see numbers)
@@ -472,44 +480,126 @@ module Clacky
472
480
  arg = text.sub(/\A\/model\s*/i, "").strip
473
481
 
474
482
  if arg.empty?
475
- # Show current model and available list
476
- info = agent.current_model_info
477
- current = info&.dig(:model) || "unknown"
478
- sub = info&.dig(:sub_model)
479
- card = info&.dig(:card_model)
480
- header = "Current model: #{current}"
481
- header += " (#{card} · #{sub})" if card && sub && sub != current
482
- header += " (#{card})" if card && !sub
483
-
484
- models = agent.available_models
485
- if models.empty?
486
- adapter.send_text(chat_id, "#{header}\nNo other models available.")
487
- return
488
- end
483
+ show_model_list(adapter, chat_id, agent)
484
+ elsif arg =~ /\A\d+\z/
485
+ switch_model_by_index(adapter, chat_id, agent, arg.to_i - 1)
486
+ elsif arg =~ /\As(\d+)\z/i
487
+ switch_quick_by_index(adapter, chat_id, agent, $1.to_i - 1)
488
+ else
489
+ switch_model_by_name(adapter, chat_id, agent, arg)
490
+ end
491
+ end
492
+
493
+ def show_model_list(adapter, chat_id, agent)
494
+ info = agent.current_model_info
495
+ current = info&.dig(:model) || "unknown"
496
+ sub = info&.dig(:sub_model)
497
+ card = info&.dig(:card_model)
498
+
499
+ header = "Current: #{current}"
500
+ header += " (#{card})" if card && sub && sub != current
501
+ header += " (#{card})" if card && !sub
489
502
 
503
+ result = header
504
+
505
+ # Card list
506
+ models = agent.available_models
507
+ unless models.empty?
490
508
  lines = models.each_with_index.map do |name, i|
491
509
  marker = name == current ? " *" : ""
492
510
  "#{i + 1}. #{name}#{marker}"
493
511
  end
494
- adapter.send_text(chat_id, "#{header}\n\nSwitch with /model <n>:\n#{lines.join("\n")}")
495
- elsif arg =~ /\A\d+\z/
496
- idx = arg.to_i - 1
497
- models = agent.config.models
498
- if idx < 0 || idx >= models.length
499
- adapter.send_text(chat_id, "Invalid model number. Use /model to see available models.")
500
- return
512
+ result += "\n\nCards (/model <n>):\n#{lines.join("\n")}"
513
+ end
514
+
515
+ # Quick-switch models under current provider
516
+ info = agent.current_model_info
517
+ provider_id = Clacky::Providers.find_by_base_url(info&.dig(:base_url))
518
+ if provider_id
519
+ quick = Clacky::Providers.models(provider_id)
520
+ unless quick.empty?
521
+ current_for_quick = sub || current
522
+ quick_lines = quick.each_with_index.map do |name, i|
523
+ marker = name == current_for_quick ? " *" : ""
524
+ " s#{i + 1}. #{name}#{marker}"
525
+ end
526
+ result += "\n\nQuick switch (/model s<n>):\n#{quick_lines.join("\n")}"
527
+ unless quick.include?(current_for_quick)
528
+ result += "\n(#{current_for_quick} not in this provider; switch card first)"
529
+ end
501
530
  end
531
+ end
502
532
 
503
- model_id = models[idx]["id"]
504
- if agent.switch_model_by_id(model_id)
505
- new_info = agent.current_model_info
506
- adapter.send_text(chat_id, "Switched to #{new_info&.dig(:model) || model_id}.")
507
- else
508
- adapter.send_text(chat_id, "Failed to switch model.")
509
- end
533
+ adapter.send_text(chat_id, result)
534
+ end
535
+
536
+ def switch_model_by_index(adapter, chat_id, agent, idx)
537
+ models = agent.config.models
538
+ if idx < 0 || idx >= models.length
539
+ adapter.send_text(chat_id, "Invalid number. Use /model to see available cards.")
540
+ return
541
+ end
542
+
543
+ model_id = models[idx]["id"]
544
+ if agent.switch_model_by_id(model_id)
545
+ new_info = agent.current_model_info
546
+ adapter.send_text(chat_id, "Switched to #{new_info&.dig(:model) || model_id}.")
510
547
  else
511
- adapter.send_text(chat_id, "Usage: /model to list, /model <n> to switch.")
548
+ adapter.send_text(chat_id, "Failed to switch model.")
549
+ end
550
+ end
551
+
552
+ def switch_quick_by_index(adapter, chat_id, agent, idx)
553
+ info = agent.current_model_info
554
+ provider_id = Clacky::Providers.find_by_base_url(info&.dig(:base_url))
555
+
556
+ unless provider_id
557
+ adapter.send_text(chat_id, "No quick-switch models. Use /model <n> to switch card.")
558
+ return
512
559
  end
560
+
561
+ quick = Clacky::Providers.models(provider_id)
562
+ if idx < 0 || idx >= quick.length
563
+ adapter.send_text(chat_id, "Invalid s#{idx + 1}. Use /model to see quick-switch list.")
564
+ return
565
+ end
566
+
567
+ agent.set_session_sub_model(quick[idx])
568
+ new_info = agent.current_model_info
569
+ adapter.send_text(chat_id, "Switched to #{new_info&.dig(:sub_model) || new_info&.dig(:model)}.")
570
+ end
571
+
572
+ def switch_model_by_name(adapter, chat_id, agent, name)
573
+ info = agent.current_model_info
574
+ provider_id = Clacky::Providers.find_by_base_url(info&.dig(:base_url))
575
+
576
+ unless provider_id
577
+ adapter.send_text(chat_id, "Current card has no quick-switch models. Use /model <n> to switch card.")
578
+ return
579
+ end
580
+
581
+ allowed = Clacky::Providers.models(provider_id)
582
+ if allowed.empty?
583
+ adapter.send_text(chat_id, "No quick-switch models available. Use /model <n> to switch card.")
584
+ return
585
+ end
586
+
587
+ # Clear override
588
+ if name =~ /\A(off|clear|none)\z/i
589
+ agent.set_session_sub_model(nil)
590
+ new_info = agent.current_model_info
591
+ adapter.send_text(chat_id, "Back to card default (#{new_info&.dig(:model)}).")
592
+ return
593
+ end
594
+
595
+ unless allowed.include?(name)
596
+ adapter.send_text(chat_id, "'#{name}' not available. Use /model to see quick-switch list.")
597
+ return
598
+ end
599
+
600
+ agent.set_session_sub_model(name)
601
+ new_info = agent.current_model_info
602
+ adapter.send_text(chat_id, "Switched to #{new_info&.dig(:sub_model) || new_info&.dig(:model)}.")
513
603
  end
514
604
 
515
605
  def handle_skills_command(adapter, event)
@@ -546,12 +636,20 @@ module Clacky
546
636
 
547
637
  def resolve_session(event)
548
638
  key = channel_key(event)
639
+
640
+ # Resolve order per session:
641
+ # 1. explicit in-memory channel_keys (set by /bind or auto_create_session)
642
+ # 2. fallback to persisted agent.channel_info for evicted channel sessions
643
+ # (process restart with in-memory channel_keys lost)
644
+ #
645
+ # /bind and /unbind keep agent.channel_info strictly in sync with channel_keys
646
+ # (see handle_bind / handle_unbind), so the two sources never disagree on the
647
+ # same key for two different sessions — a single pass is sufficient.
549
648
  @registry.list.each do |summary|
550
649
  found = nil
551
650
  @registry.with_session(summary[:id]) { |s| found = s[:channel_keys]&.include?(key) }
552
651
  return summary[:id] if found
553
652
 
554
- # Check evicted channel sessions via persisted channel_info
555
653
  next unless summary[:source] == "channel"
556
654
  next unless @registry.ensure(summary[:id])
557
655
  agent = nil
@@ -561,6 +659,7 @@ module Clacky
561
659
  bind_key_to_session(key, summary[:id])
562
660
  return summary[:id]
563
661
  end
662
+
564
663
  nil
565
664
  rescue StandardError => e
566
665
  Clacky::Logger.error("[ChannelManager] Session resolve failed: #{e.message}")
@@ -655,7 +754,7 @@ module Clacky
655
754
  case @binding_mode
656
755
  when :chat then "#{platform}:chat:#{event[:chat_id]}"
657
756
  when :user then "#{platform}:user:#{event[:user_id]}"
658
- else # :chat_user (default)
757
+ else # :chat_user
659
758
  "#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
660
759
  end
661
760
  end
@@ -667,17 +766,16 @@ module Clacky
667
766
  case @binding_mode
668
767
  when :chat then "#{platform}:chat:#{chat_id}"
669
768
  when :user then "#{platform}:user:#{user_id}"
670
- else # :chat_user (default)
769
+ else # :chat_user
671
770
  "#{platform}:chat:#{chat_id}:user:#{user_id}"
672
771
  end
673
772
  end
674
773
 
675
774
  private def extract_channel_info(event)
676
775
  {
677
- platform: event[:platform],
678
- user_id: event[:user_id],
679
- user_name: event[:user_name],
680
- chat_id: event[:chat_id]
776
+ platform: event[:platform],
777
+ user_id: event[:user_id],
778
+ chat_id: event[:chat_id]
681
779
  }
682
780
  end
683
781
 
@@ -721,12 +819,16 @@ module Clacky
721
819
 
722
820
  key = channel_key_from_info(info)
723
821
 
724
- event = { platform: info[:platform], chat_id: info[:chat_id] }
725
- ensure_channel_ui_subscribed(summary[:id], event)
726
-
822
+ # Arbitrate first: skip duplicate keys before attaching any channel_ui.
823
+ # Attaching channel_ui to a loser session would leave an orphan in its
824
+ # web_ui subscriber list (it cannot be detached later), which a subsequent
825
+ # /bind onto that session would then double up — causing duplicate broadcasts.
727
826
  next unless bound_keys.add?(key)
728
827
  bind_key_to_session(key, summary[:id])
729
828
 
829
+ event = { platform: info[:platform], chat_id: info[:chat_id] }
830
+ ensure_channel_ui_subscribed(summary[:id], event)
831
+
730
832
  Clacky::Logger.info("[ChannelManager] Restored channel binding #{key} -> session #{summary[:id][0, 8]}")
731
833
  restored_count += 1
732
834
  end
@@ -739,23 +841,20 @@ module Clacky
739
841
  Clacky::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
740
842
  end
741
843
 
742
- # Prepend recent group chat history to the user's message so the agent
743
- # has context for questions like "what were we just discussing?".
744
- # Returns the original text unchanged when there is no buffered history.
745
- private def build_prompt_with_context(chat_id, text)
746
- entries = @group_buffer.peek(chat_id, limit: GroupMessageBuffer::PROMPT_LIMIT)
747
- return text if entries.empty?
748
-
749
- total = @group_buffer.peek(chat_id).size
750
- lines = entries.map { |e| "#{e.user_name || e.user_id}: #{e.text}" }.join("\n")
844
+ # Prepend sender identity and optional group chat history to the user's message.
845
+ # Returns the original text unchanged when there is no extra context.
846
+ private def build_prompt_with_context(event, text)
847
+ user_id = event[:user_id].to_s
848
+ sender = user_id
751
849
 
752
- partial = total > entries.size
753
- header = partial ? "[Group chat partial history (latest #{entries.size} of #{total}, max buffer #{GroupMessageBuffer::MAX_MESSAGES} messages)]" : "[Group chat history (#{total} messages, max buffer #{GroupMessageBuffer::MAX_MESSAGES})]"
754
- footer = if partial
755
- "[INSTRUCTION: If the above partial history is insufficient to answer, MUST web_fetch http://#{ENV["CLACKY_SERVER_HOST"]}:#{ENV["CLACKY_SERVER_PORT"]}/api/channels/group_history/#{chat_id} for full context.]"
756
- end
850
+ history = event[:group_history]
851
+ if history.nil? || history.empty?
852
+ return "[Sender: #{sender}]\n#{text}"
853
+ end
757
854
 
758
- [header, lines, footer, "---", text].compact.join("\n")
855
+ lines = history.map { |e| "#{e[:user_id]}: #{e[:text]}" }.join("\n")
856
+ header = "[Group chat history (#{history.size} messages)]"
857
+ [header, lines, "---", "[Sender: #{sender}]", text].join("\n")
759
858
  end
760
859
  end
761
860
  end