activematrix 0.0.7 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -28
  3. data/app/models/active_matrix/agent.rb +36 -1
  4. data/app/models/active_matrix/agent_store.rb +29 -0
  5. data/app/models/active_matrix/application_record.rb +8 -0
  6. data/app/models/active_matrix/chat_session.rb +29 -0
  7. data/app/models/active_matrix/knowledge_base.rb +26 -0
  8. data/exe/activematrix +7 -0
  9. data/lib/active_matrix/agent_manager.rb +160 -121
  10. data/lib/active_matrix/agent_registry.rb +25 -21
  11. data/lib/active_matrix/api.rb +8 -2
  12. data/lib/active_matrix/async_query.rb +58 -0
  13. data/lib/active_matrix/bot/base.rb +3 -3
  14. data/lib/active_matrix/bot/builtin_commands.rb +188 -0
  15. data/lib/active_matrix/bot/command_parser.rb +175 -0
  16. data/lib/active_matrix/cli.rb +273 -0
  17. data/lib/active_matrix/client.rb +21 -6
  18. data/lib/active_matrix/client_pool.rb +38 -27
  19. data/lib/active_matrix/daemon/probe_server.rb +118 -0
  20. data/lib/active_matrix/daemon/signal_handler.rb +156 -0
  21. data/lib/active_matrix/daemon/worker.rb +109 -0
  22. data/lib/active_matrix/daemon.rb +236 -0
  23. data/lib/active_matrix/engine.rb +5 -1
  24. data/lib/active_matrix/errors.rb +1 -1
  25. data/lib/active_matrix/event_router.rb +61 -49
  26. data/lib/active_matrix/events.rb +1 -0
  27. data/lib/active_matrix/instrumentation.rb +148 -0
  28. data/lib/active_matrix/memory/agent_memory.rb +7 -21
  29. data/lib/active_matrix/memory/conversation_memory.rb +4 -20
  30. data/lib/active_matrix/memory/global_memory.rb +15 -30
  31. data/lib/active_matrix/message_dispatcher.rb +197 -0
  32. data/lib/active_matrix/metrics.rb +424 -0
  33. data/lib/active_matrix/presence_manager.rb +181 -0
  34. data/lib/active_matrix/telemetry.rb +134 -0
  35. data/lib/active_matrix/version.rb +1 -1
  36. data/lib/active_matrix.rb +12 -2
  37. data/lib/generators/active_matrix/install/install_generator.rb +3 -15
  38. data/lib/generators/active_matrix/install/templates/README +5 -2
  39. metadata +141 -45
  40. data/lib/active_matrix/protocols/cs/message_relationships.rb +0 -318
  41. data/lib/generators/active_matrix/install/templates/create_agent_memories.rb +0 -17
  42. data/lib/generators/active_matrix/install/templates/create_conversation_contexts.rb +0 -21
  43. data/lib/generators/active_matrix/install/templates/create_global_memories.rb +0 -20
  44. data/lib/generators/active_matrix/install/templates/create_matrix_agents.rb +0 -26
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveMatrix
4
+ module Bot
5
+ # Built-in commands that can be included in bot classes
6
+ #
7
+ # @example Include in a bot
8
+ # class MyBot < ActiveMatrix::Bot::Base
9
+ # include ActiveMatrix::Bot::BuiltinCommands
10
+ # end
11
+ #
12
+ module BuiltinCommands
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ base.register_builtin_commands
16
+ end
17
+
18
+ module ClassMethods
19
+ def register_builtin_commands
20
+ # Ping command - connectivity test
21
+ command(
22
+ :ping,
23
+ desc: 'Test bot connectivity and response time'
24
+ ) do
25
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+
27
+ response_parts = [
28
+ '**Pong!**',
29
+ '',
30
+ 'Bot is online and responding',
31
+ "Server time: #{Time.current.strftime('%Y-%m-%d %H:%M:%S %Z')}"
32
+ ]
33
+
34
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ response_time_ms = ((end_time - start_time) * 1000).round(2)
36
+ response_parts.insert(2, "Response time: #{response_time_ms}ms")
37
+
38
+ room.send_notice(response_parts.join("\n"))
39
+ end
40
+
41
+ # Version command - show bot version
42
+ command(
43
+ :version,
44
+ desc: 'Show bot version information'
45
+ ) do
46
+ response_parts = [
47
+ '**Version Information**',
48
+ '',
49
+ "ActiveMatrix: #{ActiveMatrix::VERSION}",
50
+ "Ruby: #{RUBY_VERSION}",
51
+ "Platform: #{RUBY_PLATFORM}"
52
+ ]
53
+
54
+ # Add application version if available
55
+ response_parts << "Application: #{Rails.application.version}" if defined?(Rails) && Rails.application.respond_to?(:version)
56
+
57
+ room.send_notice(response_parts.join("\n"))
58
+ end
59
+
60
+ # Status command - show bot status
61
+ command(
62
+ :status,
63
+ desc: 'Show bot status and health information'
64
+ ) do
65
+ response_parts = [
66
+ '**Bot Status**',
67
+ '',
68
+ 'State: Online',
69
+ "Uptime: #{format_uptime}",
70
+ "User ID: #{client.mxid}",
71
+ "Homeserver: #{client.api.homeserver}"
72
+ ]
73
+
74
+ # Add room count if available
75
+ if client.respond_to?(:rooms)
76
+ room_count = client.rooms.size
77
+ response_parts << "Joined rooms: #{room_count}"
78
+ end
79
+
80
+ # Add metrics if available
81
+ if defined?(ActiveMatrix::Metrics)
82
+ metrics = ActiveMatrix::Metrics.instance.get_health_summary
83
+ if metrics[:total_operations].positive?
84
+ response_parts += [
85
+ '',
86
+ '**Metrics**',
87
+ "Total operations: #{metrics[:total_operations]}",
88
+ "Success rate: #{metrics[:overall_success_rate]}%"
89
+ ]
90
+ end
91
+ end
92
+
93
+ room.send_notice(response_parts.join("\n"))
94
+ end
95
+
96
+ # Time command - show current time
97
+ command(
98
+ :time,
99
+ desc: 'Show current time in specified timezone',
100
+ notes: 'Usage: !time [TIMEZONE]. Examples: !time UTC, !time America/New_York'
101
+ ) do |timezone = nil|
102
+ time = if timezone && defined?(ActiveSupport::TimeZone)
103
+ tz = ActiveSupport::TimeZone[timezone]
104
+ if tz
105
+ tz.now
106
+ else
107
+ room.send_notice("Unknown timezone: #{timezone}. Using server time.")
108
+ Time.current
109
+ end
110
+ else
111
+ Time.current
112
+ end
113
+
114
+ formatted = time.strftime('%Y-%m-%d %H:%M:%S %Z')
115
+ unix_timestamp = time.to_i
116
+
117
+ response_parts = [
118
+ '**Current Time**',
119
+ '',
120
+ formatted,
121
+ "Unix timestamp: #{unix_timestamp}"
122
+ ]
123
+
124
+ room.send_notice(response_parts.join("\n"))
125
+ end
126
+
127
+ # Echo command - echo back message
128
+ command(
129
+ :echo,
130
+ desc: 'Echo back the provided message'
131
+ ) do |message = nil|
132
+ if message.nil? || message.strip.empty?
133
+ room.send_notice('Nothing to echo. Usage: !echo <message>')
134
+ else
135
+ room.send_text(message)
136
+ end
137
+ end
138
+
139
+ # Rooms command - list joined rooms (admin only)
140
+ command(
141
+ :rooms,
142
+ desc: 'List joined rooms',
143
+ only: :admin
144
+ ) do
145
+ rooms_list = client.rooms.map do |r|
146
+ name = r.display_name || r.id
147
+ "- #{name}"
148
+ end
149
+
150
+ if rooms_list.empty?
151
+ room.send_notice('Not joined to any rooms.')
152
+ else
153
+ response = [
154
+ "**Joined Rooms** (#{rooms_list.size})",
155
+ '',
156
+ *rooms_list.first(20)
157
+ ]
158
+
159
+ response << "... and #{rooms_list.size - 20} more" if rooms_list.size > 20
160
+
161
+ room.send_notice(response.join("\n"))
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def format_uptime
170
+ return 'Unknown' unless defined?(@start_time)
171
+
172
+ seconds = (Time.current - @start_time).to_i
173
+ days = seconds / 86_400
174
+ hours = (seconds % 86_400) / 3600
175
+ minutes = (seconds % 3600) / 60
176
+ secs = seconds % 60
177
+
178
+ parts = []
179
+ parts << "#{days}d" if days.positive?
180
+ parts << "#{hours}h" if hours.positive?
181
+ parts << "#{minutes}m" if minutes.positive?
182
+ parts << "#{secs}s" if secs.positive? || parts.empty?
183
+
184
+ parts.join(' ')
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveMatrix
4
+ module Bot
5
+ # Parses command arguments with support for flags and quoted strings
6
+ #
7
+ # @example Basic parsing
8
+ # parser = CommandParser.new('search "hello world" --verbose')
9
+ # parser.args # => ["search", "hello world"]
10
+ # parser.flags # => { "verbose" => true }
11
+ #
12
+ # @example With key-value flags
13
+ # parser = CommandParser.new('greet --name=Alice --formal')
14
+ # parser.positional_args # => []
15
+ # parser.flags # => { "name" => "Alice", "formal" => true }
16
+ #
17
+ class CommandParser
18
+ attr_reader :raw_input, :command_name, :args, :raw_args
19
+
20
+ # Command prefixes recognized by the parser
21
+ COMMAND_PREFIXES = ['/', '!'].freeze
22
+
23
+ # @param input [String] Raw command input
24
+ # @param prefixes [Array<String>] Command prefixes to recognize
25
+ def initialize(input, prefixes: COMMAND_PREFIXES)
26
+ @raw_input = input.to_s.strip
27
+ @prefixes = prefixes
28
+ @command_name = nil
29
+ @args = []
30
+ @raw_args = ''
31
+ @parsed_flags = nil
32
+ parse!
33
+ end
34
+
35
+ # Check if input is a valid command (has prefix and command name)
36
+ #
37
+ # @return [Boolean]
38
+ def command?
39
+ !@command_name.nil? && @prefixes.any? { |prefix| @raw_input.start_with?(prefix) }
40
+ end
41
+
42
+ # Get the prefix used in the command
43
+ #
44
+ # @return [String, nil]
45
+ def prefix
46
+ @prefixes.find { |p| @raw_input.start_with?(p) }
47
+ end
48
+
49
+ # Parse flags and positional arguments
50
+ #
51
+ # @return [Hash] Hash with :flags and :args keys
52
+ def parse_flags
53
+ @parse_flags ||= begin
54
+ flags = {}
55
+ positional = []
56
+
57
+ @args.each do |arg|
58
+ case arg
59
+ when /\A--([^=]+)=(.+)\z/
60
+ # --key=value format
61
+ flags[Regexp.last_match(1)] = Regexp.last_match(2)
62
+ when /\A--(.+)\z/
63
+ # --flag format (boolean)
64
+ flags[Regexp.last_match(1)] = true
65
+ when /\A-([a-zA-Z]+)\z/
66
+ # Short flags like -v, -abc (multiple)
67
+ Regexp.last_match(1).chars.each { |c| flags[c] = true }
68
+ else
69
+ positional << arg
70
+ end
71
+ end
72
+
73
+ { flags: flags, args: positional }
74
+ end
75
+ end
76
+
77
+ # Get only positional arguments (no flags)
78
+ #
79
+ # @return [Array<String>]
80
+ def positional_args
81
+ parse_flags[:args]
82
+ end
83
+
84
+ # Get only flag arguments
85
+ #
86
+ # @return [Hash<String, Object>]
87
+ def flags
88
+ parse_flags[:flags]
89
+ end
90
+
91
+ # Check if a specific flag is set
92
+ #
93
+ # @param name [String] Flag name (without dashes)
94
+ # @return [Boolean]
95
+ def flag?(name)
96
+ flags.key?(name.to_s)
97
+ end
98
+
99
+ # Get a flag value
100
+ #
101
+ # @param name [String] Flag name
102
+ # @param default [Object] Default value if flag not set
103
+ # @return [Object]
104
+ def flag(name, default = nil)
105
+ flags.fetch(name.to_s, default)
106
+ end
107
+
108
+ # Formatted command string
109
+ #
110
+ # @return [String]
111
+ def formatted_command
112
+ return '' unless command?
113
+
114
+ [@command_name, *@args].join(' ')
115
+ end
116
+
117
+ private
118
+
119
+ def parse!
120
+ # Check if input starts with a valid prefix
121
+ found_prefix = @prefixes.find { |p| @raw_input.start_with?(p) }
122
+ return unless found_prefix
123
+
124
+ # Remove prefix
125
+ content = @raw_input[found_prefix.length..].strip
126
+ return if content.empty?
127
+
128
+ # Parse respecting quoted strings
129
+ parts = parse_with_quotes(content)
130
+ return if parts.empty?
131
+
132
+ @command_name = parts.first.downcase
133
+ @args = parts[1..] || []
134
+ @raw_args = @args.join(' ')
135
+ end
136
+
137
+ def parse_with_quotes(input)
138
+ parts = []
139
+ current = +''
140
+ in_quotes = false
141
+ quote_char = nil
142
+
143
+ input.each_char do |char|
144
+ case char
145
+ when '"', "'"
146
+ if in_quotes && char == quote_char
147
+ # End of quoted section
148
+ in_quotes = false
149
+ quote_char = nil
150
+ elsif !in_quotes
151
+ # Start of quoted section
152
+ in_quotes = true
153
+ quote_char = char
154
+ else
155
+ # Different quote inside quotes, treat as literal
156
+ current << char
157
+ end
158
+ when ' ', "\t"
159
+ if in_quotes
160
+ current << char
161
+ elsif current.length.positive?
162
+ parts << current
163
+ current = +''
164
+ end
165
+ else
166
+ current << char
167
+ end
168
+ end
169
+
170
+ parts << current if current.length.positive?
171
+ parts
172
+ end
173
+ end
174
+ end
175
+ end
@@ -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,