htm 0.0.14 → 0.0.15

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +269 -79
  4. data/db/migrate/00003_create_file_sources.rb +5 -0
  5. data/db/migrate/00004_create_nodes.rb +17 -0
  6. data/db/migrate/00005_create_tags.rb +7 -0
  7. data/db/migrate/00006_create_node_tags.rb +2 -0
  8. data/db/migrate/00007_create_robot_nodes.rb +7 -0
  9. data/db/schema.sql +41 -29
  10. data/docs/api/yard/HTM/Configuration.md +54 -0
  11. data/docs/api/yard/HTM/Database.md +13 -10
  12. data/docs/api/yard/HTM/EmbeddingService.md +5 -1
  13. data/docs/api/yard/HTM/LongTermMemory.md +18 -277
  14. data/docs/api/yard/HTM/PropositionError.md +18 -0
  15. data/docs/api/yard/HTM/PropositionService.md +66 -0
  16. data/docs/api/yard/HTM/QueryCache.md +88 -0
  17. data/docs/api/yard/HTM/RobotGroup.md +481 -0
  18. data/docs/api/yard/HTM/SqlBuilder.md +108 -0
  19. data/docs/api/yard/HTM/TagService.md +4 -0
  20. data/docs/api/yard/HTM/Telemetry/NullInstrument.md +13 -0
  21. data/docs/api/yard/HTM/Telemetry/NullMeter.md +15 -0
  22. data/docs/api/yard/HTM/Telemetry.md +109 -0
  23. data/docs/api/yard/HTM/WorkingMemoryChannel.md +176 -0
  24. data/docs/api/yard/HTM.md +11 -23
  25. data/docs/api/yard/index.csv +102 -25
  26. data/docs/api/yard-reference.md +8 -0
  27. data/docs/assets/images/multi-provider-failover.svg +51 -0
  28. data/docs/assets/images/robot-group-architecture.svg +65 -0
  29. data/docs/database/README.md +3 -3
  30. data/docs/database/public.file_sources.svg +29 -21
  31. data/docs/database/public.node_tags.md +2 -0
  32. data/docs/database/public.node_tags.svg +53 -41
  33. data/docs/database/public.nodes.md +2 -0
  34. data/docs/database/public.nodes.svg +52 -40
  35. data/docs/database/public.robot_nodes.md +2 -0
  36. data/docs/database/public.robot_nodes.svg +30 -22
  37. data/docs/database/public.robots.svg +16 -12
  38. data/docs/database/public.tags.md +3 -0
  39. data/docs/database/public.tags.svg +41 -33
  40. data/docs/database/schema.json +66 -0
  41. data/docs/database/schema.svg +60 -48
  42. data/docs/development/index.md +13 -0
  43. data/docs/development/rake-tasks.md +1068 -0
  44. data/docs/getting-started/quick-start.md +144 -155
  45. data/docs/guides/adding-memories.md +2 -3
  46. data/docs/guides/context-assembly.md +185 -184
  47. data/docs/guides/getting-started.md +154 -148
  48. data/docs/guides/index.md +7 -0
  49. data/docs/guides/long-term-memory.md +60 -92
  50. data/docs/guides/mcp-server.md +617 -0
  51. data/docs/guides/multi-robot.md +249 -345
  52. data/docs/guides/recalling-memories.md +153 -163
  53. data/docs/guides/robot-groups.md +604 -0
  54. data/docs/guides/search-strategies.md +61 -58
  55. data/docs/guides/working-memory.md +103 -136
  56. data/docs/index.md +30 -26
  57. data/examples/robot_groups/robot_worker.rb +1 -2
  58. data/examples/robot_groups/same_process.rb +1 -4
  59. data/lib/htm/robot_group.rb +721 -0
  60. data/lib/htm/version.rb +1 -1
  61. data/lib/htm/working_memory_channel.rb +250 -0
  62. data/lib/htm.rb +2 -0
  63. data/mkdocs.yml +2 -0
  64. metadata +18 -9
  65. data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +0 -12
  66. data/db/migrate/00010_add_soft_delete_to_associations.rb +0 -29
  67. data/db/migrate/00011_add_performance_indexes.rb +0 -21
  68. data/db/migrate/00012_add_tags_trigram_index.rb +0 -18
  69. data/db/migrate/00013_enable_lz4_compression.rb +0 -43
  70. data/examples/robot_groups/lib/robot_group.rb +0 -419
  71. data/examples/robot_groups/lib/working_memory_channel.rb +0 -140
@@ -0,0 +1,721 @@
1
+ # examples/robot_groups/lib/htm/robot_group.rb
2
+ # frozen_string_literal: true
3
+
4
+ class HTM
5
+ # Coordinates multiple robots with shared working memory and automatic failover.
6
+ #
7
+ # RobotGroup provides application-level coordination for multiple HTM robots,
8
+ # enabling them to share a common working memory context. Key capabilities include:
9
+ #
10
+ # - **Shared Working Memory**: All group members have access to the same context
11
+ # - **Active/Passive Roles**: Active robots participate in conversations; passive
12
+ # robots maintain synchronized context for instant failover
13
+ # - **Real-time Sync**: PostgreSQL LISTEN/NOTIFY enables immediate synchronization
14
+ # - **Failover**: When an active robot fails, a passive robot takes over instantly
15
+ # - **Dynamic Scaling**: Add or remove robots at runtime
16
+ #
17
+ # @example High-availability customer support setup
18
+ # group = HTM::RobotGroup.new(
19
+ # name: 'customer-support',
20
+ # active: ['primary-agent'],
21
+ # passive: ['standby-agent'],
22
+ # max_tokens: 8000
23
+ # )
24
+ #
25
+ # # Add shared context
26
+ # group.remember('Customer prefers email communication.')
27
+ # group.remember('Open ticket #789 regarding billing issue.')
28
+ #
29
+ # # Query shared memory
30
+ # results = group.recall('billing', limit: 5)
31
+ #
32
+ # # Simulate failover
33
+ # group.failover! # Promotes standby to active
34
+ #
35
+ # # Cleanup
36
+ # group.shutdown
37
+ #
38
+ # @see HTM::WorkingMemoryChannel Low-level pub/sub mechanism
39
+ #
40
+ class RobotGroup
41
+ # Name of the robot group
42
+ # @return [String]
43
+ attr_reader :name
44
+
45
+ # Maximum token budget for working memory
46
+ # @return [Integer]
47
+ attr_reader :max_tokens
48
+
49
+ # The pub/sub channel used for real-time synchronization
50
+ # @return [HTM::WorkingMemoryChannel]
51
+ attr_reader :channel
52
+
53
+ # Creates a new robot group with optional initial members.
54
+ #
55
+ # Initializes the group, sets up the PostgreSQL pub/sub channel for real-time
56
+ # synchronization, and registers initial active and passive robots.
57
+ #
58
+ # @param name [String] Unique name for this robot group
59
+ # @param active [Array<String>] Names of robots to add as active members
60
+ # @param passive [Array<String>] Names of robots to add as passive (standby) members
61
+ # @param max_tokens [Integer] Maximum token budget for shared working memory
62
+ # @param db_config [Hash, nil] PostgreSQL connection config (defaults to HTM::Database.default_config)
63
+ #
64
+ # @example Create a group with one active and one passive robot
65
+ # group = HTM::RobotGroup.new(
66
+ # name: 'support-team',
67
+ # active: ['agent-1'],
68
+ # passive: ['agent-2'],
69
+ # max_tokens: 4000
70
+ # )
71
+ #
72
+ # @example Create an empty group and add members later
73
+ # group = HTM::RobotGroup.new(name: 'dynamic-team')
74
+ # group.add_active('agent-1')
75
+ # group.add_passive('agent-2')
76
+ #
77
+ def initialize(name:, active: [], passive: [], max_tokens: 4000, db_config: nil)
78
+ @name = name
79
+ @max_tokens = max_tokens
80
+ @active_robots = {} # name => HTM instance
81
+ @passive_robots = {} # name => HTM instance
82
+ @sync_stats = { nodes_synced: 0, evictions_synced: 0 }
83
+ @mutex = Mutex.new
84
+
85
+ # Setup pub/sub channel for real-time sync
86
+ @db_config = db_config || HTM::Database.default_config
87
+ @channel = HTM::WorkingMemoryChannel.new(name, @db_config)
88
+
89
+ # Subscribe to working memory changes
90
+ setup_sync_listener
91
+
92
+ # Start listening for notifications
93
+ @channel.start_listening
94
+
95
+ # Initialize robots
96
+ active.each { |robot_name| add_active(robot_name) }
97
+ passive.each { |robot_name| add_passive(robot_name) }
98
+ end
99
+
100
+ # Shuts down the group by stopping the listener thread.
101
+ #
102
+ # Should be called when the group is no longer needed to release resources
103
+ # and close the PostgreSQL listener connection.
104
+ #
105
+ # @return [void]
106
+ #
107
+ # @example
108
+ # group.shutdown
109
+ #
110
+ def shutdown
111
+ @channel.stop_listening
112
+ end
113
+
114
+ # @!group Membership Management
115
+
116
+ # Adds a robot as an active member of the group.
117
+ #
118
+ # Active robots can add memories and respond to queries. The new robot
119
+ # is automatically synchronized with existing shared working memory.
120
+ #
121
+ # @param robot_name [String] Unique name for the robot
122
+ # @return [Integer] The robot's database ID
123
+ # @raise [ArgumentError] if robot_name is already a member
124
+ #
125
+ # @example
126
+ # robot_id = group.add_active('new-agent')
127
+ # puts "Added robot with ID: #{robot_id}"
128
+ #
129
+ def add_active(robot_name)
130
+ raise ArgumentError, "#{robot_name} is already a member" if member?(robot_name)
131
+
132
+ htm = HTM.new(robot_name: robot_name, working_memory_size: @max_tokens)
133
+ @active_robots[robot_name] = htm
134
+
135
+ # Sync existing shared working memory to new member
136
+ sync_robot(robot_name) if member_ids.length > 1
137
+
138
+ htm.robot_id
139
+ end
140
+
141
+ # Adds a robot as a passive (standby) member of the group.
142
+ #
143
+ # Passive robots maintain synchronized working memory but don't actively
144
+ # participate in conversations. They serve as warm standbys for failover.
145
+ #
146
+ # @param robot_name [String] Unique name for the robot
147
+ # @return [Integer] The robot's database ID
148
+ # @raise [ArgumentError] if robot_name is already a member
149
+ #
150
+ # @example
151
+ # robot_id = group.add_passive('standby-agent')
152
+ #
153
+ def add_passive(robot_name)
154
+ raise ArgumentError, "#{robot_name} is already a member" if member?(robot_name)
155
+
156
+ htm = HTM.new(robot_name: robot_name, working_memory_size: @max_tokens)
157
+ @passive_robots[robot_name] = htm
158
+
159
+ # Sync existing shared working memory to new member
160
+ sync_robot(robot_name) if member_ids.length > 1
161
+
162
+ htm.robot_id
163
+ end
164
+
165
+ # Removes a robot from the group.
166
+ #
167
+ # Clears the robot's working memory flags in the database. The robot can
168
+ # be either active or passive.
169
+ #
170
+ # @param robot_name [String] Name of the robot to remove
171
+ # @return [void]
172
+ #
173
+ # @example
174
+ # group.remove('departing-agent')
175
+ #
176
+ def remove(robot_name)
177
+ htm = @active_robots.delete(robot_name) || @passive_robots.delete(robot_name)
178
+ return unless htm
179
+
180
+ # Clear working memory flags for this robot
181
+ HTM::Models::RobotNode
182
+ .where(robot_id: htm.robot_id, working_memory: true)
183
+ .update_all(working_memory: false)
184
+ end
185
+
186
+ # Promotes a passive robot to active status.
187
+ #
188
+ # The robot retains its synchronized working memory and becomes eligible
189
+ # to handle queries and add memories.
190
+ #
191
+ # @param robot_name [String] Name of the passive robot to promote
192
+ # @return [void]
193
+ # @raise [ArgumentError] if robot_name is not a passive member
194
+ #
195
+ # @example
196
+ # group.promote('standby-agent')
197
+ # group.active?('standby-agent') # => true
198
+ #
199
+ def promote(robot_name)
200
+ raise ArgumentError, "#{robot_name} is not a passive member" unless passive?(robot_name)
201
+
202
+ htm = @passive_robots.delete(robot_name)
203
+ @active_robots[robot_name] = htm
204
+ end
205
+
206
+ # Demotes an active robot to passive status.
207
+ #
208
+ # The robot retains its working memory but stops handling queries.
209
+ # Cannot demote the last active robot.
210
+ #
211
+ # @param robot_name [String] Name of the active robot to demote
212
+ # @return [void]
213
+ # @raise [ArgumentError] if robot_name is not an active member
214
+ # @raise [ArgumentError] if this is the last active robot
215
+ #
216
+ # @example
217
+ # group.demote('primary-agent')
218
+ # group.passive?('primary-agent') # => true
219
+ #
220
+ def demote(robot_name)
221
+ raise ArgumentError, "#{robot_name} is not an active member" unless active?(robot_name)
222
+ raise ArgumentError, 'Cannot demote last active robot' if @active_robots.length == 1
223
+
224
+ htm = @active_robots.delete(robot_name)
225
+ @passive_robots[robot_name] = htm
226
+ end
227
+
228
+ # Checks if a robot is a member of this group.
229
+ #
230
+ # @param robot_name [String] Name of the robot to check
231
+ # @return [Boolean] true if the robot is an active or passive member
232
+ #
233
+ # @example
234
+ # group.member?('agent-1') # => true
235
+ # group.member?('unknown') # => false
236
+ #
237
+ def member?(robot_name)
238
+ @active_robots.key?(robot_name) || @passive_robots.key?(robot_name)
239
+ end
240
+
241
+ # Checks if a robot is an active member of this group.
242
+ #
243
+ # @param robot_name [String] Name of the robot to check
244
+ # @return [Boolean] true if the robot is an active member
245
+ #
246
+ # @example
247
+ # group.active?('primary-agent') # => true
248
+ #
249
+ def active?(robot_name)
250
+ @active_robots.key?(robot_name)
251
+ end
252
+
253
+ # Checks if a robot is a passive member of this group.
254
+ #
255
+ # @param robot_name [String] Name of the robot to check
256
+ # @return [Boolean] true if the robot is a passive member
257
+ #
258
+ # @example
259
+ # group.passive?('standby-agent') # => true
260
+ #
261
+ def passive?(robot_name)
262
+ @passive_robots.key?(robot_name)
263
+ end
264
+
265
+ # Returns database IDs of all group members.
266
+ #
267
+ # @return [Array<Integer>] Array of robot IDs (both active and passive)
268
+ #
269
+ # @example
270
+ # group.member_ids # => [1, 2, 3]
271
+ #
272
+ def member_ids
273
+ all_robots.values.map(&:robot_id)
274
+ end
275
+
276
+ # Returns names of all active robots.
277
+ #
278
+ # @return [Array<String>] Array of active robot names
279
+ #
280
+ # @example
281
+ # group.active_robot_names # => ['primary-agent', 'secondary-agent']
282
+ #
283
+ def active_robot_names
284
+ @active_robots.keys
285
+ end
286
+
287
+ # Returns names of all passive robots.
288
+ #
289
+ # @return [Array<String>] Array of passive robot names
290
+ #
291
+ # @example
292
+ # group.passive_robot_names # => ['standby-agent']
293
+ #
294
+ def passive_robot_names
295
+ @passive_robots.keys
296
+ end
297
+
298
+ # @!endgroup
299
+
300
+ # @!group Shared Working Memory Operations
301
+
302
+ # Adds content to shared working memory for all group members.
303
+ #
304
+ # The memory is created by the specified originator (or first active robot)
305
+ # and automatically synchronized to all other members via database and
306
+ # real-time notifications.
307
+ #
308
+ # @param content [String] The content to remember
309
+ # @param originator [String, nil] Name of the robot creating the memory (optional)
310
+ # @param options [Hash] Additional options passed to HTM#remember
311
+ # @return [Integer] The node ID of the created memory
312
+ # @raise [RuntimeError] if no active robots exist in the group
313
+ #
314
+ # @example Add memory with default originator
315
+ # node_id = group.remember('Customer prefers morning appointments.')
316
+ #
317
+ # @example Add memory with specific originator
318
+ # node_id = group.remember(
319
+ # 'Escalated to billing department.',
320
+ # originator: 'agent-2'
321
+ # )
322
+ #
323
+ def remember(content, originator: nil, **options)
324
+ raise 'No active robots in group' if @active_robots.empty?
325
+
326
+ # Use first active robot (or specified originator) to create the memory
327
+ primary = if originator && all_robots[originator]
328
+ all_robots[originator]
329
+ else
330
+ @active_robots.values.first
331
+ end
332
+
333
+ node_id = primary.remember(content, **options)
334
+
335
+ # Sync to database (robot_nodes table) for all other members
336
+ sync_node_to_members(node_id, exclude: primary.robot_id)
337
+
338
+ # Notify all listeners via PostgreSQL NOTIFY (triggers in-memory sync)
339
+ @channel.notify(:added, node_id: node_id, robot_id: primary.robot_id)
340
+
341
+ node_id
342
+ end
343
+
344
+ # Recalls memories from shared working memory.
345
+ #
346
+ # Uses the first active robot to perform the query against the shared
347
+ # working memory context.
348
+ #
349
+ # @param query [String] The search query
350
+ # @param options [Hash] Additional options passed to HTM#recall
351
+ # @option options [Integer] :limit Maximum number of results
352
+ # @option options [Symbol] :strategy Search strategy (:vector, :fulltext, :hybrid)
353
+ # @return [Array] Array of matching memories
354
+ # @raise [RuntimeError] if no active robots exist in the group
355
+ #
356
+ # @example
357
+ # results = group.recall('billing issue', limit: 5, strategy: :fulltext)
358
+ #
359
+ def recall(query, **options)
360
+ raise 'No active robots in group' if @active_robots.empty?
361
+
362
+ primary = @active_robots.values.first
363
+ primary.recall(query, **options)
364
+ end
365
+
366
+ # Returns all nodes currently in shared working memory.
367
+ #
368
+ # Queries the database for the union of all members' working memory,
369
+ # returning nodes sorted by creation date (newest first).
370
+ #
371
+ # @return [ActiveRecord::Relation<HTM::Models::Node>] Collection of nodes
372
+ #
373
+ # @example
374
+ # nodes = group.working_memory_contents
375
+ # nodes.each { |n| puts n.content }
376
+ #
377
+ def working_memory_contents
378
+ node_ids = HTM::Models::RobotNode
379
+ .where(robot_id: member_ids, working_memory: true)
380
+ .distinct
381
+ .pluck(:node_id)
382
+
383
+ HTM::Models::Node.where(id: node_ids).order(created_at: :desc)
384
+ end
385
+
386
+ # Clears shared working memory for all group members.
387
+ #
388
+ # Updates database flags and notifies all members to clear their
389
+ # in-memory caches.
390
+ #
391
+ # @return [Integer] Number of robot_node records updated
392
+ #
393
+ # @example
394
+ # cleared_count = group.clear_working_memory
395
+ # puts "Cleared #{cleared_count} working memory entries"
396
+ #
397
+ def clear_working_memory
398
+ count = HTM::Models::RobotNode
399
+ .where(robot_id: member_ids, working_memory: true)
400
+ .update_all(working_memory: false)
401
+
402
+ # Clear in-memory working memory for primary robot
403
+ primary = @active_robots.values.first || @passive_robots.values.first
404
+ return 0 unless primary
405
+
406
+ primary.clear_working_memory
407
+
408
+ # Notify all listeners (will clear other in-memory caches via callback)
409
+ @channel.notify(:cleared, node_id: nil, robot_id: primary.robot_id)
410
+
411
+ count
412
+ end
413
+
414
+ # @!endgroup
415
+
416
+ # @!group Synchronization
417
+
418
+ # Synchronizes a specific robot to match the group's shared working memory.
419
+ #
420
+ # Copies working memory flags from other members to the specified robot,
421
+ # ensuring it has access to all shared context.
422
+ #
423
+ # @param robot_name [String] Name of the robot to synchronize
424
+ # @return [Integer] Number of nodes synchronized
425
+ # @raise [ArgumentError] if robot_name is not a member
426
+ #
427
+ # @example
428
+ # synced = group.sync_robot('new-agent')
429
+ # puts "Synchronized #{synced} nodes"
430
+ #
431
+ def sync_robot(robot_name)
432
+ htm = all_robots[robot_name]
433
+ raise ArgumentError, "#{robot_name} is not a member" unless htm
434
+
435
+ # Get all node_ids currently in any member's working memory
436
+ shared_node_ids = HTM::Models::RobotNode
437
+ .where(robot_id: member_ids, working_memory: true)
438
+ .where.not(robot_id: htm.robot_id)
439
+ .distinct
440
+ .pluck(:node_id)
441
+
442
+ synced = 0
443
+ shared_node_ids.each do |node_id|
444
+ # Create or update robot_node with working_memory=true
445
+ robot_node = HTM::Models::RobotNode.find_or_initialize_by(
446
+ robot_id: htm.robot_id,
447
+ node_id: node_id
448
+ )
449
+ next if robot_node.working_memory?
450
+
451
+ robot_node.working_memory = true
452
+ robot_node.save!
453
+ synced += 1
454
+ end
455
+
456
+ synced
457
+ end
458
+
459
+ # Synchronizes all members to a consistent state.
460
+ #
461
+ # Ensures every member has access to all shared working memory nodes.
462
+ #
463
+ # @return [Hash] Sync results with :synced_nodes and :members_updated counts
464
+ #
465
+ # @example
466
+ # result = group.sync_all
467
+ # puts "Synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
468
+ #
469
+ def sync_all
470
+ members_updated = 0
471
+ total_synced = 0
472
+
473
+ all_robots.each_key do |robot_name|
474
+ synced = sync_robot(robot_name)
475
+ if synced > 0
476
+ members_updated += 1
477
+ total_synced += synced
478
+ end
479
+ end
480
+
481
+ { synced_nodes: total_synced, members_updated: members_updated }
482
+ end
483
+
484
+ # Checks if all members have identical working memory.
485
+ #
486
+ # Compares the set of working memory node IDs across all members.
487
+ #
488
+ # @return [Boolean] true if all members have the same working memory nodes
489
+ #
490
+ # @example
491
+ # if group.in_sync?
492
+ # puts "All robots synchronized"
493
+ # else
494
+ # group.sync_all
495
+ # end
496
+ #
497
+ def in_sync?
498
+ return true if member_ids.length <= 1
499
+
500
+ # Get working memory node_ids for each robot
501
+ working_memories = member_ids.map do |robot_id|
502
+ HTM::Models::RobotNode
503
+ .where(robot_id: robot_id, working_memory: true)
504
+ .pluck(:node_id)
505
+ .sort
506
+ end
507
+
508
+ # All should be identical
509
+ working_memories.uniq.length == 1
510
+ end
511
+
512
+ # @!endgroup
513
+
514
+ # @!group Failover
515
+
516
+ # Transfers working memory from one robot to another.
517
+ #
518
+ # Copies all working memory node references from the source robot to the
519
+ # target robot, optionally clearing the source.
520
+ #
521
+ # @param from_robot [String] Name of the source robot
522
+ # @param to_robot [String] Name of the destination robot
523
+ # @param clear_source [Boolean] Whether to clear source's working memory after transfer
524
+ # @return [Integer] Number of nodes transferred
525
+ # @raise [ArgumentError] if either robot is not a member
526
+ #
527
+ # @example Transfer with source clearing
528
+ # transferred = group.transfer_working_memory('failing-agent', 'backup-agent')
529
+ #
530
+ # @example Transfer without clearing source
531
+ # transferred = group.transfer_working_memory(
532
+ # 'agent-1', 'agent-2',
533
+ # clear_source: false
534
+ # )
535
+ #
536
+ def transfer_working_memory(from_robot, to_robot, clear_source: true)
537
+ from_htm = all_robots[from_robot]
538
+ to_htm = all_robots[to_robot]
539
+
540
+ raise ArgumentError, "#{from_robot} is not a member" unless from_htm
541
+ raise ArgumentError, "#{to_robot} is not a member" unless to_htm
542
+
543
+ # Get source's working memory nodes
544
+ source_node_ids = HTM::Models::RobotNode
545
+ .where(robot_id: from_htm.robot_id, working_memory: true)
546
+ .pluck(:node_id)
547
+
548
+ transferred = 0
549
+ source_node_ids.each do |node_id|
550
+ robot_node = HTM::Models::RobotNode.find_or_initialize_by(
551
+ robot_id: to_htm.robot_id,
552
+ node_id: node_id
553
+ )
554
+ robot_node.working_memory = true
555
+ robot_node.save!
556
+ transferred += 1
557
+ end
558
+
559
+ # Clear source's working memory if requested
560
+ if clear_source
561
+ HTM::Models::RobotNode
562
+ .where(robot_id: from_htm.robot_id, working_memory: true)
563
+ .update_all(working_memory: false)
564
+ end
565
+
566
+ transferred
567
+ end
568
+
569
+ # Performs automatic failover to the first passive robot.
570
+ #
571
+ # Promotes the first passive robot to active status. The promoted robot
572
+ # already has synchronized working memory and can immediately handle requests.
573
+ #
574
+ # @return [String] Name of the promoted robot
575
+ # @raise [RuntimeError] if no passive robots are available
576
+ #
577
+ # @example
578
+ # promoted = group.failover!
579
+ # puts "#{promoted} is now active"
580
+ #
581
+ def failover!
582
+ raise 'No passive robots available for failover' if @passive_robots.empty?
583
+
584
+ # Get first passive robot
585
+ standby_name = @passive_robots.keys.first
586
+
587
+ # Promote it
588
+ promote(standby_name)
589
+
590
+ puts " ⚡ Failover: #{standby_name} promoted to active"
591
+ standby_name
592
+ end
593
+
594
+ # @!endgroup
595
+
596
+ # @!group Status & Health
597
+
598
+ # Returns comprehensive status information about the group.
599
+ #
600
+ # @return [Hash] Status hash with the following keys:
601
+ # @option return [String] :name Group name
602
+ # @option return [Array<String>] :active Names of active robots
603
+ # @option return [Array<String>] :passive Names of passive robots
604
+ # @option return [Integer] :total_members Total number of members
605
+ # @option return [Integer] :working_memory_nodes Number of nodes in shared memory
606
+ # @option return [Integer] :working_memory_tokens Total tokens in shared memory
607
+ # @option return [Integer] :max_tokens Maximum token budget
608
+ # @option return [Float] :token_utilization Ratio of used to max tokens (0.0-1.0)
609
+ # @option return [Boolean] :in_sync Whether all members are synchronized
610
+ #
611
+ # @example
612
+ # status = group.status
613
+ # puts "Group: #{status[:name]}"
614
+ # puts "Active: #{status[:active].join(', ')}"
615
+ # puts "Utilization: #{(status[:token_utilization] * 100).round(1)}%"
616
+ #
617
+ def status
618
+ wm_contents = working_memory_contents
619
+ token_count = wm_contents.sum { |n| n.token_count || 0 }
620
+
621
+ {
622
+ name: @name,
623
+ active: active_robot_names,
624
+ passive: passive_robot_names,
625
+ total_members: member_ids.length,
626
+ working_memory_nodes: wm_contents.count,
627
+ working_memory_tokens: token_count,
628
+ max_tokens: @max_tokens,
629
+ token_utilization: @max_tokens > 0 ? (token_count.to_f / @max_tokens).round(2) : 0,
630
+ in_sync: in_sync?
631
+ }
632
+ end
633
+
634
+ # Returns statistics about real-time synchronization.
635
+ #
636
+ # @return [Hash] Stats hash with :nodes_synced and :evictions_synced counts
637
+ #
638
+ # @example
639
+ # stats = group.sync_stats
640
+ # puts "Nodes synced: #{stats[:nodes_synced]}"
641
+ # puts "Evictions synced: #{stats[:evictions_synced]}"
642
+ #
643
+ def sync_stats
644
+ @mutex.synchronize { @sync_stats.dup }
645
+ end
646
+
647
+ # @!endgroup
648
+
649
+ private
650
+
651
+ def all_robots
652
+ @active_robots.merge(@passive_robots)
653
+ end
654
+
655
+ def setup_sync_listener
656
+ @channel.on_change do |event, node_id, origin_robot_id|
657
+ handle_sync_notification(event, node_id, origin_robot_id)
658
+ end
659
+ end
660
+
661
+ def handle_sync_notification(event, node_id, origin_robot_id)
662
+ @mutex.synchronize do
663
+ case event
664
+ when :added
665
+ sync_node_to_in_memory_caches(node_id, origin_robot_id)
666
+ @sync_stats[:nodes_synced] += 1
667
+ when :evicted
668
+ evict_from_in_memory_caches(node_id, origin_robot_id)
669
+ @sync_stats[:evictions_synced] += 1
670
+ when :cleared
671
+ clear_all_in_memory_caches(origin_robot_id)
672
+ end
673
+ end
674
+ end
675
+
676
+ def sync_node_to_in_memory_caches(node_id, origin_robot_id)
677
+ node = HTM::Models::Node.find_by(id: node_id)
678
+ return unless node
679
+
680
+ all_robots.each do |_name, htm|
681
+ next if htm.robot_id == origin_robot_id
682
+
683
+ htm.working_memory.add_from_sync(
684
+ id: node.id,
685
+ content: node.content,
686
+ token_count: node.token_count || 0,
687
+ created_at: node.created_at
688
+ )
689
+ end
690
+ end
691
+
692
+ def evict_from_in_memory_caches(node_id, origin_robot_id)
693
+ all_robots.each do |_name, htm|
694
+ next if htm.robot_id == origin_robot_id
695
+
696
+ htm.working_memory.remove_from_sync(node_id)
697
+ end
698
+ end
699
+
700
+ def clear_all_in_memory_caches(origin_robot_id)
701
+ all_robots.each do |_name, htm|
702
+ next if htm.robot_id == origin_robot_id
703
+
704
+ htm.working_memory.clear_from_sync
705
+ end
706
+ end
707
+
708
+ def sync_node_to_members(node_id, exclude: nil)
709
+ member_ids.each do |robot_id|
710
+ next if robot_id == exclude
711
+
712
+ robot_node = HTM::Models::RobotNode.find_or_initialize_by(
713
+ robot_id: robot_id,
714
+ node_id: node_id
715
+ )
716
+ robot_node.working_memory = true
717
+ robot_node.save!
718
+ end
719
+ end
720
+ end
721
+ end