activematrix 0.0.5 → 0.0.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -28
  3. data/app/jobs/active_matrix/application_job.rb +11 -0
  4. data/app/models/active_matrix/agent/jobs/memory_reaper.rb +87 -0
  5. data/app/models/active_matrix/agent.rb +166 -0
  6. data/app/models/active_matrix/agent_store.rb +80 -0
  7. data/app/models/active_matrix/application_record.rb +15 -0
  8. data/app/models/active_matrix/chat_session.rb +105 -0
  9. data/app/models/active_matrix/knowledge_base.rb +100 -0
  10. data/exe/activematrix +7 -0
  11. data/lib/active_matrix/agent_manager.rb +160 -121
  12. data/lib/active_matrix/agent_registry.rb +25 -21
  13. data/lib/active_matrix/api.rb +8 -2
  14. data/lib/active_matrix/async_query.rb +58 -0
  15. data/lib/active_matrix/bot/base.rb +3 -3
  16. data/lib/active_matrix/bot/builtin_commands.rb +188 -0
  17. data/lib/active_matrix/bot/command_parser.rb +175 -0
  18. data/lib/active_matrix/cli.rb +273 -0
  19. data/lib/active_matrix/client.rb +21 -6
  20. data/lib/active_matrix/client_pool.rb +38 -27
  21. data/lib/active_matrix/daemon/probe_server.rb +118 -0
  22. data/lib/active_matrix/daemon/signal_handler.rb +156 -0
  23. data/lib/active_matrix/daemon/worker.rb +109 -0
  24. data/lib/active_matrix/daemon.rb +236 -0
  25. data/lib/active_matrix/engine.rb +18 -0
  26. data/lib/active_matrix/errors.rb +1 -1
  27. data/lib/active_matrix/event_router.rb +61 -49
  28. data/lib/active_matrix/events.rb +1 -0
  29. data/lib/active_matrix/instrumentation.rb +148 -0
  30. data/lib/active_matrix/memory/agent_memory.rb +7 -21
  31. data/lib/active_matrix/memory/conversation_memory.rb +4 -20
  32. data/lib/active_matrix/memory/global_memory.rb +15 -30
  33. data/lib/active_matrix/message_dispatcher.rb +197 -0
  34. data/lib/active_matrix/metrics.rb +424 -0
  35. data/lib/active_matrix/presence_manager.rb +181 -0
  36. data/lib/active_matrix/railtie.rb +8 -0
  37. data/lib/active_matrix/telemetry.rb +134 -0
  38. data/lib/active_matrix/version.rb +1 -1
  39. data/lib/active_matrix.rb +18 -11
  40. data/lib/generators/active_matrix/install/install_generator.rb +3 -22
  41. data/lib/generators/active_matrix/install/templates/README +5 -2
  42. metadata +191 -31
  43. data/lib/generators/active_matrix/install/templates/agent_memory.rb +0 -47
  44. data/lib/generators/active_matrix/install/templates/conversation_context.rb +0 -72
  45. data/lib/generators/active_matrix/install/templates/create_agent_memories.rb +0 -17
  46. data/lib/generators/active_matrix/install/templates/create_conversation_contexts.rb +0 -21
  47. data/lib/generators/active_matrix/install/templates/create_global_memories.rb +0 -20
  48. data/lib/generators/active_matrix/install/templates/create_matrix_agents.rb +0 -26
  49. data/lib/generators/active_matrix/install/templates/global_memory.rb +0 -70
  50. data/lib/generators/active_matrix/install/templates/matrix_agent.rb +0 -127
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Rails/Exit
4
+ require 'thor'
5
+ require 'active_matrix'
6
+ require 'active_matrix/daemon'
7
+
8
+ module ActiveMatrix
9
+ # Command-line interface for ActiveMatrix daemon
10
+ #
11
+ # @example Start the daemon
12
+ # bundle exec activematrix start
13
+ #
14
+ # @example Start with options
15
+ # bundle exec activematrix start --workers 3 --probe-port 3042
16
+ #
17
+ class CLI < Thor
18
+ def self.exit_on_failure?
19
+ true
20
+ end
21
+
22
+ desc 'start', 'Start the ActiveMatrix daemon'
23
+ option :workers, type: :numeric, default: 1, desc: 'Number of worker processes'
24
+ option :probe_port, type: :numeric, default: 3042, desc: 'Health check probe port'
25
+ option :probe_host, type: :string, default: '127.0.0.1', desc: 'Health check bind address'
26
+ option :agents, type: :string, desc: 'Comma-separated list of agent names to start'
27
+ option :daemon, type: :boolean, default: false, desc: 'Run as background daemon'
28
+ option :pidfile, type: :string, desc: 'PID file path (implies --daemon)'
29
+ option :logfile, type: :string, desc: 'Log file path'
30
+ option :require, type: :string, aliases: '-r', desc: 'File to require before starting'
31
+ option :environment, type: :string, aliases: '-e', desc: 'Rails environment'
32
+ option :otel, type: :boolean, default: false, desc: 'Enable OpenTelemetry tracing'
33
+ option :otel_exporter, type: :string, default: 'otlp', desc: 'OTel exporter (otlp, console)'
34
+ def start
35
+ boot_rails
36
+ configure_from_options
37
+
38
+ daemonize if options[:daemon] || options[:pidfile]
39
+
40
+ run_daemon
41
+ end
42
+
43
+ desc 'stop', 'Stop the ActiveMatrix daemon'
44
+ option :pidfile, type: :string, default: 'tmp/pids/activematrix.pid', desc: 'PID file path'
45
+ option :timeout, type: :numeric, default: 30, desc: 'Shutdown timeout in seconds'
46
+ def stop
47
+ pidfile = options[:pidfile]
48
+
49
+ unless File.exist?(pidfile)
50
+ say "PID file not found: #{pidfile}", :red
51
+ exit 1
52
+ end
53
+
54
+ pid = File.read(pidfile).to_i
55
+ say "Stopping ActiveMatrix daemon (PID: #{pid})..."
56
+
57
+ begin
58
+ Process.kill('TERM', pid)
59
+ wait_for_shutdown(pid, options[:timeout])
60
+ say 'Daemon stopped successfully', :green
61
+ rescue Errno::ESRCH
62
+ say 'Process not running, cleaning up PID file', :yellow
63
+ File.delete(pidfile)
64
+ rescue Errno::EPERM
65
+ say "Permission denied to stop process #{pid}", :red
66
+ exit 1
67
+ end
68
+ end
69
+
70
+ desc 'status', 'Show daemon status'
71
+ option :pidfile, type: :string, default: 'tmp/pids/activematrix.pid', desc: 'PID file path'
72
+ option :probe_port, type: :numeric, default: 3042, desc: 'Health check probe port'
73
+ option :probe_host, type: :string, default: '127.0.0.1', desc: 'Health check host'
74
+ def status
75
+ # Try HTTP probe first
76
+ if (probe_status = fetch_probe_status)
77
+ display_status(probe_status)
78
+ elsif (pid = read_pid)
79
+ say "Daemon running (PID: #{pid}), but health probe not responding", :yellow
80
+ else
81
+ say 'Daemon not running', :red
82
+ exit 1
83
+ end
84
+ end
85
+
86
+ desc 'reload', 'Reload agent configuration'
87
+ option :pidfile, type: :string, default: 'tmp/pids/activematrix.pid', desc: 'PID file path'
88
+ def reload
89
+ pid = read_pid
90
+ unless pid
91
+ say 'Daemon not running', :red
92
+ exit 1
93
+ end
94
+
95
+ begin
96
+ Process.kill('HUP', pid)
97
+ say 'Reload signal sent', :green
98
+ rescue Errno::ESRCH
99
+ say 'Process not running', :red
100
+ exit 1
101
+ rescue Errno::EPERM
102
+ say 'Permission denied', :red
103
+ exit 1
104
+ end
105
+ end
106
+
107
+ desc 'version', 'Show ActiveMatrix version'
108
+ def version
109
+ say "ActiveMatrix #{ActiveMatrix::VERSION}"
110
+ end
111
+
112
+ map %w[-v --version] => :version
113
+
114
+ private
115
+
116
+ def boot_rails
117
+ ENV['RAILS_ENV'] ||= options[:environment] || 'development'
118
+
119
+ if options[:require]
120
+ require File.expand_path(options[:require])
121
+ elsif File.exist?('config/environment.rb')
122
+ require File.expand_path('config/environment.rb')
123
+ else
124
+ say 'No Rails application found. Use --require to specify a file.', :red
125
+ exit 1
126
+ end
127
+ end
128
+
129
+ def configure_from_options
130
+ ActiveMatrix.configure do |config|
131
+ config.daemon_workers = options[:workers] if options[:workers]
132
+ config.probe_port = options[:probe_port] if options[:probe_port]
133
+ config.probe_host = options[:probe_host] if options[:probe_host]
134
+ end
135
+
136
+ configure_telemetry if options[:otel]
137
+ end
138
+
139
+ def configure_telemetry
140
+ require 'active_matrix/telemetry'
141
+
142
+ exporter = options[:otel_exporter]&.to_sym
143
+ ActiveMatrix::Telemetry.configure!(exporter: exporter)
144
+ rescue LoadError => e
145
+ say "OpenTelemetry not available: #{e.message}", :yellow
146
+ say 'Install opentelemetry-sdk and opentelemetry-exporter-otlp gems', :yellow
147
+ end
148
+
149
+ def daemonize
150
+ pidfile = options[:pidfile] || 'tmp/pids/activematrix.pid'
151
+ logfile = options[:logfile] || 'log/activematrix.log'
152
+
153
+ # Ensure directories exist
154
+ FileUtils.mkdir_p(File.dirname(pidfile))
155
+ FileUtils.mkdir_p(File.dirname(logfile))
156
+
157
+ # Check if already running
158
+ if File.exist?(pidfile)
159
+ pid = File.read(pidfile).to_i
160
+ begin
161
+ Process.kill(0, pid)
162
+ say "Daemon already running (PID: #{pid})", :red
163
+ exit 1
164
+ rescue Errno::ESRCH
165
+ File.delete(pidfile)
166
+ end
167
+ end
168
+
169
+ Process.daemon(true, true)
170
+
171
+ # Redirect output
172
+ $stdout.reopen(logfile, 'a')
173
+ $stderr.reopen($stdout)
174
+ $stdout.sync = true
175
+
176
+ # Write PID file
177
+ File.write(pidfile, Process.pid.to_s)
178
+
179
+ at_exit { FileUtils.rm_f(pidfile) }
180
+ end
181
+
182
+ def run_daemon
183
+ daemon = ActiveMatrix::Daemon.new(
184
+ workers: options[:workers],
185
+ probe_port: options[:probe_port],
186
+ probe_host: options[:probe_host],
187
+ agent_names: parse_agent_names
188
+ )
189
+
190
+ daemon.run
191
+ end
192
+
193
+ def parse_agent_names
194
+ return nil unless options[:agents]
195
+
196
+ options[:agents].split(',').map(&:strip)
197
+ end
198
+
199
+ def read_pid
200
+ pidfile = options[:pidfile]
201
+ return nil unless File.exist?(pidfile)
202
+
203
+ pid = File.read(pidfile).to_i
204
+ begin
205
+ Process.kill(0, pid)
206
+ pid
207
+ rescue Errno::ESRCH
208
+ nil
209
+ end
210
+ end
211
+
212
+ def wait_for_shutdown(pid, timeout)
213
+ deadline = Time.zone.now + timeout
214
+
215
+ while Time.zone.now < deadline
216
+ begin
217
+ Process.kill(0, pid)
218
+ sleep 0.5
219
+ rescue Errno::ESRCH
220
+ return true
221
+ end
222
+ end
223
+
224
+ say 'Timeout waiting for graceful shutdown, sending SIGKILL', :yellow
225
+ Process.kill('KILL', pid)
226
+ end
227
+
228
+ def fetch_probe_status
229
+ require 'net/http'
230
+ require 'json'
231
+
232
+ uri = URI("http://#{options[:probe_host]}:#{options[:probe_port]}/status")
233
+ response = Net::HTTP.get_response(uri)
234
+
235
+ return nil unless response.is_a?(Net::HTTPSuccess)
236
+
237
+ JSON.parse(response.body, symbolize_names: true)
238
+ rescue StandardError
239
+ nil
240
+ end
241
+
242
+ def display_status(status)
243
+ say 'ActiveMatrix Daemon Status', :green
244
+ say '=' * 40
245
+ say "Status: #{status[:status]}"
246
+ say "Uptime: #{format_duration(status[:uptime])}"
247
+ say "Workers: #{status[:workers]}"
248
+ say ''
249
+ say 'Agents:'
250
+ say " Total: #{status.dig(:agents, :total) || 0}"
251
+ say " Online: #{status.dig(:agents, :online) || 0}"
252
+ say " Connecting: #{status.dig(:agents, :connecting) || 0}"
253
+ say " Error: #{status.dig(:agents, :error) || 0}"
254
+ end
255
+
256
+ def format_duration(seconds)
257
+ return 'N/A' unless seconds
258
+
259
+ hours = seconds / 3600
260
+ minutes = (seconds % 3600) / 60
261
+ secs = seconds % 60
262
+
263
+ if hours.positive?
264
+ "#{hours}h #{minutes}m #{secs}s"
265
+ elsif minutes.positive?
266
+ "#{minutes}m #{secs}s"
267
+ else
268
+ "#{secs}s"
269
+ end
270
+ end
271
+ end
272
+ end
273
+ # rubocop:enable Rails/Exit
@@ -18,7 +18,7 @@ module ActiveMatrix
18
18
  # @!attribute sync_filter [rw] The global sync filter
19
19
  # @return [Hash,String] A filter definition, either as defined by the
20
20
  # Matrix spec, or as an identifier returned by a filter creation request
21
- attr_reader :api
21
+ attr_reader :api, :sync_thread
22
22
  attr_accessor :cache, :sync_filter, :next_batch
23
23
 
24
24
  events :error, :event, :account_data, :presence_event, :invite_event, :leave_event, :ephemeral_event, :state_event
@@ -499,11 +499,7 @@ module ActiveMatrix
499
499
  def stop_listener_thread
500
500
  return unless @sync_thread
501
501
 
502
- if @should_listen.is_a? Hash
503
- @should_listen[:run] = false
504
- else
505
- @should_listen = false
506
- end
502
+ stop_listener
507
503
 
508
504
  if @sync_thread.alive?
509
505
  ret = @sync_thread.join(0.1)
@@ -512,11 +508,30 @@ module ActiveMatrix
512
508
  @sync_thread = nil
513
509
  end
514
510
 
511
+ # Signal the listener to stop (works for both thread and async modes)
512
+ def stop_listener
513
+ if @should_listen.is_a? Hash
514
+ @should_listen[:run] = false
515
+ else
516
+ @should_listen = false
517
+ end
518
+ end
519
+
520
+ # Start listening without spawning a thread (for use with async)
521
+ def start_listener
522
+ @should_listen = true
523
+ end
524
+
515
525
  # Check if there's a thread listening for events
516
526
  def listening?
517
527
  @sync_thread&.alive? == true
518
528
  end
519
529
 
530
+ # Check if listening is enabled (may or may not have active thread)
531
+ def listening_enabled?
532
+ @should_listen == true
533
+ end
534
+
520
535
  # Run a message sync round, triggering events as necessary
521
536
  #
522
537
  # @param skip_store_batch [Boolean] Should this sync skip storing the returned next_batch token,
@@ -1,26 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'singleton'
4
- require 'concurrent'
4
+ require 'async'
5
+ require 'async/semaphore'
6
+ require 'async/condition'
5
7
 
6
8
  module ActiveMatrix
7
- # Manages a pool of Matrix client connections for efficiency
9
+ # Manages Matrix client connections per homeserver with rate limiting.
10
+ #
11
+ # NOTE: Despite the name, this is not a traditional connection pool.
12
+ # Each agent gets a dedicated long-lived client. The "pool" provides:
13
+ # - Semaphore-based rate limiting on client creation per homeserver
14
+ # - Tracking of active clients for health monitoring
15
+ #
16
+ # Clients are NOT returned to the pool after use - they remain active
17
+ # for the agent's lifetime. The semaphore prevents too many agents
18
+ # from connecting to a single homeserver simultaneously.
19
+ #
8
20
  class ClientPool
9
21
  include Singleton
10
22
  include ActiveMatrix::Logging
11
23
 
12
24
  def initialize
13
- @pools = Concurrent::Hash.new
25
+ @pools = {}
14
26
  @config = ActiveMatrix.config
15
27
  @mutex = Mutex.new
16
28
  end
17
29
 
18
30
  # Get or create a client for a homeserver
19
31
  def get_client(homeserver, **)
20
- @mutex.synchronize do
21
- pool = get_or_create_pool(homeserver)
22
- pool.checkout(**)
23
- end
32
+ pool = @mutex.synchronize { get_or_create_pool(homeserver) }
33
+ pool.checkout(**)
24
34
  end
25
35
 
26
36
  # Return a client to the pool
@@ -78,32 +88,37 @@ module ActiveMatrix
78
88
  @available = []
79
89
  @in_use = {}
80
90
  @mutex = Mutex.new
81
- @condition = ConditionVariable.new
91
+ @semaphore = Async::Semaphore.new(max_size)
82
92
  end
83
93
 
84
94
  def checkout(**)
85
- @mutex.synchronize do
86
- # Try to find an available client
87
- client = find_available_client
95
+ # Acquire semaphore temporarily to rate-limit client creation
96
+ @semaphore.acquire
88
97
 
89
- # Create new client if needed and pool not full
90
- client = create_client(**) if client.nil? && @in_use.size < @max_size
98
+ client = @mutex.synchronize do
99
+ # Try to find an available client
100
+ existing = find_available_client
91
101
 
92
- # Wait for a client if pool is full
93
- while client.nil?
94
- @condition.wait(@mutex, 1)
95
- client = find_available_client
102
+ if existing
103
+ @available.delete(existing)
104
+ existing
105
+ else
106
+ create_client(**)
96
107
  end
108
+ end
97
109
 
98
- # Mark as in use
99
- @available.delete(client)
110
+ # Track as in use
111
+ @mutex.synchronize do
100
112
  @in_use[client.object_id] = {
101
113
  client: client,
102
114
  checked_out_at: Time.current
103
115
  }
104
-
105
- client
106
116
  end
117
+
118
+ client
119
+ ensure
120
+ # Release immediately - semaphore only rate-limits creation, not usage
121
+ @semaphore.release
107
122
  end
108
123
 
109
124
  def checkin(client)
@@ -115,12 +130,8 @@ module ActiveMatrix
115
130
  if client_valid?(client)
116
131
  @available << client
117
132
  else
118
- # Client is no longer valid, don't return to pool
119
133
  logger.debug "Discarding invalid client for #{@homeserver}"
120
134
  end
121
-
122
- # Signal waiting threads
123
- @condition.signal
124
135
  end
125
136
  end
126
137
 
@@ -140,7 +151,7 @@ module ActiveMatrix
140
151
  @mutex.synchronize do
141
152
  # Stop all clients
142
153
  (@available + @in_use.values.map { |e| e[:client] }).each do |client|
143
- client.stop_listener_thread if client.listening?
154
+ client.stop_listener if client.listening?
144
155
  client.logout if client.logged_in?
145
156
  rescue StandardError => e
146
157
  logger.error "Error cleaning up client: #{e.message}"
@@ -158,7 +169,7 @@ module ActiveMatrix
158
169
  @available.select! { |client| client_valid?(client) }
159
170
 
160
171
  # Return first available
161
- @available.first
172
+ @available.shift
162
173
  end
163
174
 
164
175
  def create_client(**)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/http'
5
+ require 'json'
6
+
7
+ module ActiveMatrix
8
+ class Daemon
9
+ # Lightweight HTTP health probe server using Async::HTTP
10
+ #
11
+ # Endpoints:
12
+ # - GET /health - Returns 200 if healthy, 503 if shutting down
13
+ # - GET /status - Returns detailed JSON status
14
+ # - GET /metrics - Prometheus-compatible metrics
15
+ #
16
+ class ProbeServer
17
+ attr_reader :host, :port, :daemon
18
+
19
+ def initialize(host:, port:, daemon:)
20
+ @host = host
21
+ @port = port
22
+ @daemon = daemon
23
+ @thread = nil
24
+ end
25
+
26
+ def start
27
+ @thread = Thread.new do
28
+ Sync do
29
+ endpoint = Async::HTTP::Endpoint.parse("http://#{host}:#{port}")
30
+
31
+ @server = Async::HTTP::Server.for(endpoint) do |request|
32
+ handle_request(request)
33
+ end
34
+
35
+ logger.info "Probe server listening on #{host}:#{port}"
36
+ @server.run
37
+ end
38
+ rescue StandardError => e
39
+ logger.error "Probe server error: #{e.message}"
40
+ end
41
+ end
42
+
43
+ def stop
44
+ @thread&.kill
45
+ @thread&.join(1)
46
+ end
47
+
48
+ private
49
+
50
+ def handle_request(request)
51
+ path = request.path
52
+
53
+ case path
54
+ when '/health'
55
+ health_response
56
+ when '/status'
57
+ status_response
58
+ when '/metrics'
59
+ metrics_response
60
+ else
61
+ not_found_response
62
+ end
63
+ end
64
+
65
+ def health_response
66
+ status = daemon.status
67
+ code = status[:status] == 'ok' ? 200 : 503
68
+
69
+ ::Protocol::HTTP::Response[code, { 'content-type' => 'text/plain' }, [status[:status]]]
70
+ end
71
+
72
+ def status_response
73
+ body = JSON.pretty_generate(daemon.status)
74
+
75
+ ::Protocol::HTTP::Response[200, { 'content-type' => 'application/json' }, [body]]
76
+ end
77
+
78
+ def metrics_response
79
+ status = daemon.status
80
+
81
+ lines = [
82
+ '# HELP activematrix_up Is the daemon running',
83
+ '# TYPE activematrix_up gauge',
84
+ "activematrix_up #{status[:status] == 'ok' ? 1 : 0}",
85
+ '',
86
+ '# HELP activematrix_uptime_seconds Daemon uptime in seconds',
87
+ '# TYPE activematrix_uptime_seconds counter',
88
+ "activematrix_uptime_seconds #{status[:uptime]}",
89
+ '',
90
+ '# HELP activematrix_workers Number of worker processes',
91
+ '# TYPE activematrix_workers gauge',
92
+ "activematrix_workers #{status[:workers]}",
93
+ '',
94
+ '# HELP activematrix_agents_total Total number of agents',
95
+ '# TYPE activematrix_agents_total gauge',
96
+ "activematrix_agents_total #{status.dig(:agents, :total) || 0}",
97
+ '',
98
+ '# HELP activematrix_agents Agent count by state',
99
+ '# TYPE activematrix_agents gauge',
100
+ "activematrix_agents{state=\"online\"} #{status.dig(:agents, :online) || 0}",
101
+ "activematrix_agents{state=\"connecting\"} #{status.dig(:agents, :connecting) || 0}",
102
+ "activematrix_agents{state=\"error\"} #{status.dig(:agents, :error) || 0}",
103
+ "activematrix_agents{state=\"offline\"} #{status.dig(:agents, :offline) || 0}"
104
+ ]
105
+
106
+ ::Protocol::HTTP::Response[200, { 'content-type' => 'text/plain; version=0.0.4' }, [lines.join("\n")]]
107
+ end
108
+
109
+ def not_found_response
110
+ ::Protocol::HTTP::Response[404, { 'content-type' => 'text/plain' }, ['Not Found']]
111
+ end
112
+
113
+ def logger
114
+ ActiveMatrix.logger
115
+ end
116
+ end
117
+ end
118
+ end