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
@@ -10,7 +10,7 @@ module ActiveMatrix
10
10
  extend ActiveMatrix::Extensions
11
11
  include ActiveMatrix::Logging
12
12
 
13
- USER_AGENT = "Ruby Matrix SDK v#{ActiveMatrix::VERSION}".freeze
13
+ USER_AGENT = "ActiveMatrix v#{ActiveMatrix::VERSION}".freeze
14
14
  DEFAULT_HEADERS = {
15
15
  'accept' => 'application/json',
16
16
  'user-agent' => USER_AGENT
@@ -287,7 +287,7 @@ module ActiveMatrix
287
287
  end
288
288
 
289
289
  failures = 0
290
- loop do
290
+ loop do # rubocop:disable Metrics/BlockLength
291
291
  raise MatrixConnectionError, "Server still too busy to handle request after #{failures} attempts, try again later" if failures >= 10
292
292
 
293
293
  req_id = ('A'..'Z').to_a.sample(4).join
@@ -343,6 +343,12 @@ module ActiveMatrix
343
343
  end
344
344
  raise MatrixRequestError.new_by_code(data, response.code) if data
345
345
 
346
+ # For 4xx errors without JSON body, construct a synthetic error
347
+ if response.code.to_i >= 400 && response.code.to_i < 500
348
+ synthetic_error = { errcode: 'M_UNKNOWN', error: "HTTP #{response.code} #{response.message}" }
349
+ raise MatrixRequestError.new_by_code(synthetic_error, response.code)
350
+ end
351
+
346
352
  raise MatrixConnectionError.class_by_code(response.code), response
347
353
  end
348
354
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveMatrix
4
+ # Helper methods for async ActiveRecord queries (Rails 8.0+)
5
+ module AsyncQuery
6
+ module_function
7
+
8
+ # Load records asynchronously
9
+ # @param relation [ActiveRecord::Relation] The relation to load
10
+ # @return [Array] The loaded records
11
+ def load_async(relation)
12
+ relation.load_async.to_a
13
+ end
14
+
15
+ # Count records asynchronously
16
+ # @param relation [ActiveRecord::Relation] The relation to count
17
+ # @return [Integer] The count
18
+ def async_count(relation)
19
+ relation.async_count.value
20
+ end
21
+
22
+ # Sum column asynchronously
23
+ # @param relation [ActiveRecord::Relation] The relation
24
+ # @param column [Symbol] The column to sum
25
+ # @return [Numeric] The sum
26
+ def async_sum(relation, column)
27
+ relation.async_sum(column).value
28
+ end
29
+
30
+ # Pluck columns asynchronously
31
+ # @param relation [ActiveRecord::Relation] The relation
32
+ # @param columns [Array<Symbol>] The columns to pluck
33
+ # @return [Array] The plucked values
34
+ def async_pluck(relation, *columns)
35
+ relation.async_pluck(*columns).value
36
+ end
37
+
38
+ # Check existence asynchronously
39
+ # @param relation [ActiveRecord::Relation] The relation
40
+ # @return [Boolean] Whether records exist
41
+ def async_exists?(relation)
42
+ relation.async_count.value.positive?
43
+ end
44
+
45
+ # Execute multiple async queries in parallel and wait for all results
46
+ # @param queries [Hash<Symbol, Proc>] Named queries to execute
47
+ # @return [Hash<Symbol, Object>] Results keyed by query name
48
+ # @example
49
+ # results = AsyncQuery.parallel(
50
+ # agents: -> { MatrixAgent.where(state: :online).load_async },
51
+ # count: -> { MatrixAgent.async_count }
52
+ # )
53
+ def parallel(**queries)
54
+ promises = queries.transform_values(&:call)
55
+ promises.transform_values { |result| result.respond_to?(:value) ? result.value : result }
56
+ end
57
+ end
58
+ end
@@ -377,7 +377,7 @@ module ActiveMatrix::Bot
377
377
  if settings.store_sync_token
378
378
  begin
379
379
  active_bot.client.api.set_account_data(
380
- active_bot.client.mxid, "dev.ananace.ruby-sdk.#{settings.bot_name}",
380
+ active_bot.client.mxid, "com.seuros.active_matrix.#{settings.bot_name}",
381
381
  { sync_token: active_bot.client.sync_token }
382
382
  )
383
383
  rescue StandardError => e
@@ -466,7 +466,7 @@ module ActiveMatrix::Bot
466
466
  bot.client.instance_variable_set(:@next_batch, settings.sync_token)
467
467
  elsif settings.store_sync_token?
468
468
  begin
469
- data = bot.client.api.get_account_data(bot.client.mxid, "dev.ananace.ruby-sdk.#{bot_name}")
469
+ data = bot.client.api.get_account_data(bot.client.mxid, "com.seuros.active_matrix.#{bot_name}")
470
470
  bot.client.sync_token = data[:sync_token]
471
471
  rescue ActiveMatrix::MatrixNotFoundError
472
472
  # Valid
@@ -479,7 +479,7 @@ module ActiveMatrix::Bot
479
479
 
480
480
  bot.client.start_listener_thread
481
481
 
482
- bot.client.instance_variable_get(:@sync_thread).join
482
+ bot.client.sync_thread&.join
483
483
  rescue Interrupt
484
484
  # Happens when killed
485
485
  rescue StandardError => e
@@ -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