activematrix 0.0.1 → 0.0.2

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +218 -51
  3. data/lib/active_matrix/agent_manager.rb +275 -0
  4. data/lib/active_matrix/agent_registry.rb +154 -0
  5. data/lib/active_matrix/bot/multi_instance_base.rb +189 -0
  6. data/lib/active_matrix/client.rb +5 -15
  7. data/lib/active_matrix/client_pool.rb +194 -0
  8. data/lib/active_matrix/event_router.rb +215 -0
  9. data/lib/active_matrix/memory/agent_memory.rb +128 -0
  10. data/lib/active_matrix/memory/base.rb +101 -0
  11. data/lib/active_matrix/memory/conversation_memory.rb +161 -0
  12. data/lib/active_matrix/memory/global_memory.rb +153 -0
  13. data/lib/active_matrix/memory.rb +28 -0
  14. data/lib/active_matrix/room.rb +131 -51
  15. data/lib/active_matrix/rooms/space.rb +1 -5
  16. data/lib/active_matrix/user.rb +10 -0
  17. data/lib/active_matrix/util/account_data_cache.rb +62 -24
  18. data/lib/active_matrix/util/cacheable.rb +73 -0
  19. data/lib/active_matrix/util/extensions.rb +4 -0
  20. data/lib/active_matrix/util/state_event_cache.rb +106 -31
  21. data/lib/active_matrix/version.rb +1 -1
  22. data/lib/active_matrix.rb +51 -3
  23. data/lib/generators/active_matrix/bot/bot_generator.rb +38 -0
  24. data/lib/generators/active_matrix/bot/templates/bot.rb.erb +111 -0
  25. data/lib/generators/active_matrix/bot/templates/bot_spec.rb.erb +68 -0
  26. data/lib/generators/active_matrix/install/install_generator.rb +44 -0
  27. data/lib/generators/active_matrix/install/templates/README +30 -0
  28. data/lib/generators/active_matrix/install/templates/active_matrix.rb +33 -0
  29. data/lib/generators/active_matrix/install/templates/agent_memory.rb +47 -0
  30. data/lib/generators/active_matrix/install/templates/conversation_context.rb +72 -0
  31. data/lib/generators/active_matrix/install/templates/create_agent_memories.rb +17 -0
  32. data/lib/generators/active_matrix/install/templates/create_conversation_contexts.rb +21 -0
  33. data/lib/generators/active_matrix/install/templates/create_global_memories.rb +20 -0
  34. data/lib/generators/active_matrix/install/templates/create_matrix_agents.rb +26 -0
  35. data/lib/generators/active_matrix/install/templates/global_memory.rb +70 -0
  36. data/lib/generators/active_matrix/install/templates/matrix_agent.rb +127 -0
  37. metadata +110 -4
  38. data/lib/active_matrix/util/rails_cache_adapter.rb +0 -37
  39. data/lib/active_matrix/util/tinycache.rb +0 -145
  40. data/lib/active_matrix/util/tinycache_adapter.rb +0 -87
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'concurrent'
5
+
6
+ module ActiveMatrix
7
+ # Thread-safe registry for managing running bot agents
8
+ class AgentRegistry
9
+ include Singleton
10
+ include ActiveMatrix::Logging
11
+
12
+ def initialize
13
+ @agents = Concurrent::Hash.new
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # Register a running agent
18
+ def register(agent_record, bot_instance)
19
+ @mutex.synchronize do
20
+ raise AgentAlreadyRunningError, "Agent #{agent_record.name} is already running" if @agents.key?(agent_record.id)
21
+
22
+ @agents[agent_record.id] = {
23
+ record: agent_record,
24
+ instance: bot_instance,
25
+ thread: Thread.current,
26
+ started_at: Time.current
27
+ }
28
+
29
+ logger.info "Registered agent: #{agent_record.name} (#{agent_record.id})"
30
+ end
31
+ end
32
+
33
+ # Unregister an agent
34
+ def unregister(agent_record)
35
+ @mutex.synchronize do
36
+ entry = @agents.delete(agent_record.id)
37
+ logger.info "Unregistered agent: #{agent_record.name} (#{agent_record.id})" if entry
38
+ entry
39
+ end
40
+ end
41
+
42
+ # Get a running agent by ID
43
+ def get(agent_id)
44
+ @agents[agent_id]
45
+ end
46
+
47
+ # Get agent by name
48
+ def get_by_name(name)
49
+ @agents.values.find { |entry| entry[:record].name == name }
50
+ end
51
+
52
+ # Get all running agents
53
+ def all
54
+ @agents.values
55
+ end
56
+
57
+ # Get all agent records
58
+ def all_records
59
+ @agents.values.map { |entry| entry[:record] }
60
+ end
61
+
62
+ # Get all bot instances
63
+ def all_instances
64
+ @agents.values.map { |entry| entry[:instance] }
65
+ end
66
+
67
+ # Check if an agent is running
68
+ def running?(agent_record)
69
+ @agents.key?(agent_record.id)
70
+ end
71
+
72
+ # Get agents by state
73
+ def by_state(state)
74
+ @agents.values.select { |entry| entry[:record].state == state.to_s }
75
+ end
76
+
77
+ # Get agents by bot class
78
+ def by_class(bot_class)
79
+ class_name = bot_class.is_a?(Class) ? bot_class.name : bot_class.to_s
80
+ @agents.values.select { |entry| entry[:record].bot_class == class_name }
81
+ end
82
+
83
+ # Get agents by homeserver
84
+ def by_homeserver(homeserver)
85
+ @agents.values.select { |entry| entry[:record].homeserver == homeserver }
86
+ end
87
+
88
+ # Broadcast to all agents
89
+ def broadcast
90
+ all_instances.each do |instance|
91
+ yield instance
92
+ rescue StandardError => e
93
+ logger.error "Error broadcasting to agent: #{e.message}"
94
+ end
95
+ end
96
+
97
+ # Broadcast to specific agents
98
+ def broadcast_to(selector)
99
+ agents = case selector
100
+ when Symbol
101
+ by_state(selector)
102
+ when String
103
+ by_name_pattern(selector)
104
+ when Class
105
+ by_class(selector)
106
+ when Proc
107
+ @agents.values.select { |entry| selector.call(entry[:record]) }
108
+ else
109
+ []
110
+ end
111
+
112
+ agents.each do |entry|
113
+ yield entry[:instance]
114
+ rescue StandardError => e
115
+ logger.error "Error broadcasting to agent #{entry[:record].name}: #{e.message}"
116
+ end
117
+ end
118
+
119
+ # Get count of running agents
120
+ def count
121
+ @agents.size
122
+ end
123
+
124
+ # Clear all agents (used for testing)
125
+ def clear!
126
+ @mutex.synchronize do
127
+ @agents.clear
128
+ end
129
+ end
130
+
131
+ # Get health status of all agents
132
+ def health_status
133
+ @agents.map do |id, entry|
134
+ {
135
+ id: id,
136
+ name: entry[:record].name,
137
+ state: entry[:record].state,
138
+ thread_alive: entry[:thread]&.alive?,
139
+ uptime: Time.current - entry[:started_at],
140
+ last_active: entry[:record].last_active_at
141
+ }
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def by_name_pattern(pattern)
148
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
149
+ @agents.values.select { |entry| entry[:record].name =~ regex }
150
+ end
151
+ end
152
+
153
+ class AgentAlreadyRunningError < StandardError; end
154
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveMatrix::Bot
4
+ # Base class for multi-instance bot support
5
+ class MultiInstanceBase < Base
6
+ attr_reader :agent_record
7
+
8
+ def initialize(client_or_agent, **params)
9
+ # Handle both client and agent record initialization
10
+ if client_or_agent.respond_to?(:homeserver) && !client_or_agent.respond_to?(:client) # It's a client
11
+ super
12
+ @agent_record = params[:agent_record]
13
+ else # It's an agent record
14
+ @agent_record = client_or_agent
15
+ super(@agent_record.client, **params)
16
+ end
17
+
18
+ setup_agent_context if @agent_record
19
+ end
20
+
21
+ # Access agent-specific memory
22
+ def memory
23
+ @memory ||= ActiveMatrix::Memory.for_agent(@agent_record)
24
+ end
25
+
26
+ # Access conversation memory
27
+ def conversation_memory(user_id = nil, room_id = nil)
28
+ user_id ||= event[:sender] if in_event?
29
+ room_id ||= event[:room_id] if in_event?
30
+
31
+ return nil unless user_id && room_id
32
+
33
+ @conversation_memories ||= {}
34
+ @conversation_memories["#{user_id}/#{room_id}"] ||=
35
+ ActiveMatrix::Memory.for_conversation(@agent_record, user_id, room_id)
36
+ end
37
+
38
+ # Access global memory
39
+ def global_memory
40
+ ActiveMatrix::Memory.global
41
+ end
42
+
43
+ # Get current conversation context
44
+ def conversation_context
45
+ conversation_memory&.context || {}
46
+ end
47
+
48
+ # Update conversation context
49
+ def update_context(data)
50
+ conversation_memory&.update_context(data)
51
+ end
52
+
53
+ # Remember something in conversation
54
+ def remember_in_conversation(key, &)
55
+ conversation_memory&.remember(key, &)
56
+ end
57
+
58
+ # Agent state helpers
59
+ def agent_name
60
+ @agent_record&.name || settings.bot_name
61
+ end
62
+
63
+ def agent_state
64
+ @agent_record&.state || 'unknown'
65
+ end
66
+
67
+ def mark_busy!
68
+ @agent_record&.start_processing! if @agent_record&.may_start_processing?
69
+ end
70
+
71
+ def mark_idle!
72
+ @agent_record&.finish_processing! if @agent_record&.may_finish_processing?
73
+ end
74
+
75
+ # Inter-agent communication
76
+ def broadcast_to_agents(selector, data)
77
+ return unless defined?(AgentRegistry)
78
+
79
+ registry = AgentRegistry.instance
80
+ registry.broadcast_to(selector) do |agent_bot|
81
+ agent_bot.receive_broadcast(data, from: self)
82
+ end
83
+ end
84
+
85
+ def send_to_agent(agent_name, data)
86
+ return unless defined?(AgentRegistry)
87
+
88
+ registry = AgentRegistry.instance
89
+ entry = registry.get_by_name(agent_name)
90
+
91
+ if entry
92
+ entry[:instance].receive_message(data, from: self)
93
+ true
94
+ else
95
+ logger.warn "Agent #{agent_name} not found or not running"
96
+ false
97
+ end
98
+ end
99
+
100
+ # Receive inter-agent messages (override in subclasses)
101
+ def receive_message(data, from:)
102
+ logger.debug "Received message from #{from.agent_name}: #{data.inspect}"
103
+ end
104
+
105
+ def receive_broadcast(data, from:)
106
+ logger.debug "Received broadcast from #{from.agent_name}: #{data.inspect}"
107
+ end
108
+
109
+ # Override event handling to track conversation
110
+ def _handle_message(event)
111
+ # Mark as busy while processing
112
+ mark_busy!
113
+
114
+ # Track conversation if we have an agent record
115
+ conversation_memory(event[:sender], event[:room_id])&.add_message(event) if @agent_record && event[:type] == 'm.room.message'
116
+
117
+ super
118
+ ensure
119
+ # Mark as idle when done
120
+ mark_idle!
121
+ end
122
+
123
+ def _handle_event(event)
124
+ mark_busy!
125
+ super
126
+ ensure
127
+ mark_idle!
128
+ end
129
+
130
+ # Enhanced settings with agent-specific overrides
131
+ def settings
132
+ return self.class unless @agent_record
133
+
134
+ # Merge agent-specific settings with class settings
135
+ @settings ||= begin
136
+ base_settings = self.class.settings
137
+ agent_settings = @agent_record.settings || {}
138
+
139
+ # Create a settings proxy that checks agent settings first
140
+ SettingsProxy.new(base_settings, agent_settings)
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def setup_agent_context
147
+ # Set up agent-specific logging
148
+ if @agent_record
149
+ @logger = ActiveMatrix.logger.dup
150
+ @logger.progname = "[#{@agent_record.name}]"
151
+ end
152
+
153
+ # Load any agent-specific configuration
154
+ return unless @agent_record.settings['commands_disabled']
155
+
156
+ @agent_record.settings['commands_disabled'].each do |cmd|
157
+ # Temporarily disable commands for this instance
158
+ @disabled_commands ||= []
159
+ @disabled_commands << cmd
160
+ end
161
+ end
162
+
163
+ # Settings proxy to merge class and instance settings
164
+ class SettingsProxy
165
+ def initialize(base_settings, agent_settings)
166
+ @base_settings = base_settings
167
+ @agent_settings = agent_settings
168
+ end
169
+
170
+ def method_missing(method, *, &)
171
+ method_name = method.to_s.gsub(/\?$/, '')
172
+
173
+ # Check agent settings first
174
+ if @agent_settings.key?(method_name)
175
+ value = @agent_settings[method_name]
176
+ return method.to_s.end_with?('?') ? !value.nil? : value
177
+ end
178
+
179
+ # Fall back to base settings
180
+ @base_settings.send(method, *, &)
181
+ end
182
+
183
+ def respond_to_missing?(method, include_private = false)
184
+ method_name = method.to_s.gsub(/\?$/, '')
185
+ @agent_settings.key?(method_name) || @base_settings.respond_to?(method, include_private)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -150,7 +150,7 @@ module ActiveMatrix
150
150
 
151
151
  break if data[:next_batch].nil?
152
152
 
153
- since = data.next_batch
153
+ since = data[:next_batch]
154
154
  end
155
155
 
156
156
  rooms
@@ -617,10 +617,9 @@ module ActiveMatrix
617
617
 
618
618
  def handle_sync_response(data)
619
619
  data.dig(:account_data, :events)&.each do |account_data|
620
- if cache != :none
621
- adapter = self.account_data.tinycache_adapter
622
- adapter.write(account_data[:type], account_data[:content], expires_in: self.account_data.cache_time)
623
- end
620
+ # Store the account data in cache
621
+ self.account_data.write(account_data[:type], account_data[:content]) if account_data[:type]
622
+
624
623
  fire_account_data(MatrixEvent.new(self, account_data))
625
624
  end
626
625
 
@@ -676,16 +675,7 @@ module ActiveMatrix
676
675
  end
677
676
  end
678
677
 
679
- unless cache == :none
680
- account_data.tinycache_adapter.cleanup if instance_variable_defined?(:@account_data) && @account_data
681
- @rooms.each_value do |room|
682
- # Clean up old cache data after every sync
683
- # TODO Run this in a thread?
684
- room.tinycache_adapter.cleanup
685
- room.account_data.tinycache_adapter.cleanup
686
- room.room_state.tinycache_adapter.cleanup
687
- end
688
- end
678
+ # Rails.cache handles its own cleanup/expiration
689
679
 
690
680
  nil
691
681
  end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'concurrent'
5
+
6
+ module ActiveMatrix
7
+ # Manages a pool of Matrix client connections for efficiency
8
+ class ClientPool
9
+ include Singleton
10
+ include ActiveMatrix::Logging
11
+
12
+ def initialize
13
+ @pools = Concurrent::Hash.new
14
+ @config = ActiveMatrix.config
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Get or create a client for a homeserver
19
+ def get_client(homeserver, **)
20
+ @mutex.synchronize do
21
+ pool = get_or_create_pool(homeserver)
22
+ pool.checkout(**)
23
+ end
24
+ end
25
+
26
+ # Return a client to the pool
27
+ def checkin(client)
28
+ homeserver = client.homeserver
29
+ pool = @pools[homeserver]
30
+ pool&.checkin(client)
31
+ end
32
+
33
+ # Get pool statistics
34
+ def stats
35
+ @pools.map do |homeserver, pool|
36
+ {
37
+ homeserver: homeserver,
38
+ size: pool.size,
39
+ available: pool.available_count,
40
+ in_use: pool.in_use_count
41
+ }
42
+ end
43
+ end
44
+
45
+ # Clear all pools
46
+ def clear!
47
+ @mutex.synchronize do
48
+ @pools.each_value(&:clear!)
49
+ @pools.clear
50
+ end
51
+ end
52
+
53
+ # Shutdown all pools
54
+ def shutdown
55
+ clear!
56
+ end
57
+
58
+ private
59
+
60
+ def get_or_create_pool(homeserver)
61
+ @pools[homeserver] ||= HomeserverPool.new(
62
+ homeserver,
63
+ max_size: @config&.max_clients_per_homeserver || 5,
64
+ timeout: @config&.client_idle_timeout || 5.minutes
65
+ )
66
+ end
67
+
68
+ # Pool for a specific homeserver
69
+ class HomeserverPool
70
+ include ActiveMatrix::Logging
71
+
72
+ attr_reader :homeserver, :max_size, :timeout
73
+
74
+ def initialize(homeserver, max_size:, timeout:)
75
+ @homeserver = homeserver
76
+ @max_size = max_size
77
+ @timeout = timeout
78
+ @available = []
79
+ @in_use = {}
80
+ @mutex = Mutex.new
81
+ @condition = ConditionVariable.new
82
+ end
83
+
84
+ def checkout(**)
85
+ @mutex.synchronize do
86
+ # Try to find an available client
87
+ client = find_available_client
88
+
89
+ # Create new client if needed and pool not full
90
+ client = create_client(**) if client.nil? && @in_use.size < @max_size
91
+
92
+ # Wait for a client if pool is full
93
+ while client.nil?
94
+ @condition.wait(@mutex, 1)
95
+ client = find_available_client
96
+ end
97
+
98
+ # Mark as in use
99
+ @available.delete(client)
100
+ @in_use[client.object_id] = {
101
+ client: client,
102
+ checked_out_at: Time.current
103
+ }
104
+
105
+ client
106
+ end
107
+ end
108
+
109
+ def checkin(client)
110
+ @mutex.synchronize do
111
+ entry = @in_use.delete(client.object_id)
112
+ return unless entry
113
+
114
+ # Add back to available pool if still valid
115
+ if client_valid?(client)
116
+ @available << client
117
+ else
118
+ # Client is no longer valid, don't return to pool
119
+ logger.debug "Discarding invalid client for #{@homeserver}"
120
+ end
121
+
122
+ # Signal waiting threads
123
+ @condition.signal
124
+ end
125
+ end
126
+
127
+ def size
128
+ @mutex.synchronize { @available.size + @in_use.size }
129
+ end
130
+
131
+ def available_count
132
+ @mutex.synchronize { @available.size }
133
+ end
134
+
135
+ def in_use_count
136
+ @mutex.synchronize { @in_use.size }
137
+ end
138
+
139
+ def clear!
140
+ @mutex.synchronize do
141
+ # Stop all clients
142
+ (@available + @in_use.values.map { |e| e[:client] }).each do |client|
143
+ client.stop_listener_thread if client.listening?
144
+ client.logout if client.logged_in?
145
+ rescue StandardError => e
146
+ logger.error "Error cleaning up client: #{e.message}"
147
+ end
148
+
149
+ @available.clear
150
+ @in_use.clear
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def find_available_client
157
+ # Remove any expired clients
158
+ @available.select! { |client| client_valid?(client) }
159
+
160
+ # Return first available
161
+ @available.first
162
+ end
163
+
164
+ def create_client(**)
165
+ logger.debug "Creating new client for #{@homeserver}"
166
+
167
+ ActiveMatrix::Client.new(
168
+ @homeserver,
169
+ client_cache: :some,
170
+ sync_filter_limit: 20,
171
+ **
172
+ )
173
+ end
174
+
175
+ def client_valid?(client)
176
+ # Check if client is still connected and responsive
177
+ return false unless client
178
+
179
+ # Could add more validation here
180
+ true
181
+ rescue StandardError
182
+ false
183
+ end
184
+ end
185
+ end
186
+
187
+ # Monkey patch Client to support pooling
188
+ class Client
189
+ # Checkin this client back to the pool
190
+ def checkin_to_pool
191
+ ClientPool.instance.checkin(self) if defined?(ClientPool)
192
+ end
193
+ end
194
+ end