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
@@ -1,419 +0,0 @@
1
- # robot_groups/lib/robot_group.rb
2
- # frozen_string_literal: true
3
- #
4
- # Application-level coordination for shared working memory
5
-
6
- class RobotGroup
7
- attr_reader :name, :max_tokens, :channel
8
-
9
- def initialize(name:, active: [], passive: [], max_tokens: 4000, db_config: nil)
10
- @name = name
11
- @max_tokens = max_tokens
12
- @active_robots = {} # name => HTM instance
13
- @passive_robots = {} # name => HTM instance
14
- @sync_stats = { nodes_synced: 0, evictions_synced: 0 }
15
- @mutex = Mutex.new
16
-
17
- # Setup pub/sub channel for real-time sync
18
- @db_config = db_config || HTM::Database.default_config
19
- @channel = WorkingMemoryChannel.new(name, @db_config)
20
-
21
- # Subscribe to working memory changes
22
- setup_sync_listener
23
-
24
- # Start listening for notifications
25
- @channel.start_listening
26
-
27
- # Initialize robots
28
- active.each { |robot_name| add_active(robot_name) }
29
- passive.each { |robot_name| add_passive(robot_name) }
30
- end
31
-
32
-
33
- # Shutdown the group (stop listener thread)
34
- def shutdown
35
- @channel.stop_listening
36
- end
37
-
38
- # ===========================================================================
39
- # Membership Management
40
- # ===========================================================================
41
-
42
- def add_active(robot_name)
43
- raise ArgumentError, "#{robot_name} is already a member" if member?(robot_name)
44
-
45
- htm = HTM.new(robot_name: robot_name, working_memory_size: @max_tokens)
46
- @active_robots[robot_name] = htm
47
-
48
- # Sync existing shared working memory to new member
49
- sync_robot(robot_name) if member_ids.length > 1
50
-
51
- htm.robot_id
52
- end
53
-
54
-
55
- def add_passive(robot_name)
56
- raise ArgumentError, "#{robot_name} is already a member" if member?(robot_name)
57
-
58
- htm = HTM.new(robot_name: robot_name, working_memory_size: @max_tokens)
59
- @passive_robots[robot_name] = htm
60
-
61
- # Sync existing shared working memory to new member
62
- sync_robot(robot_name) if member_ids.length > 1
63
-
64
- htm.robot_id
65
- end
66
-
67
-
68
- def remove(robot_name)
69
- htm = @active_robots.delete(robot_name) || @passive_robots.delete(robot_name)
70
- return unless htm
71
-
72
- # Clear working memory flags for this robot
73
- HTM::Models::RobotNode
74
- .where(robot_id: htm.robot_id, working_memory: true)
75
- .update_all(working_memory: false)
76
- end
77
-
78
-
79
- def promote(robot_name)
80
- raise ArgumentError, "#{robot_name} is not a passive member" unless passive?(robot_name)
81
-
82
- htm = @passive_robots.delete(robot_name)
83
- @active_robots[robot_name] = htm
84
- end
85
-
86
-
87
- def demote(robot_name)
88
- raise ArgumentError, "#{robot_name} is not an active member" unless active?(robot_name)
89
- raise ArgumentError, 'Cannot demote last active robot' if @active_robots.length == 1
90
-
91
- htm = @active_robots.delete(robot_name)
92
- @passive_robots[robot_name] = htm
93
- end
94
-
95
-
96
- def member?(robot_name)
97
- @active_robots.key?(robot_name) || @passive_robots.key?(robot_name)
98
- end
99
-
100
-
101
- def active?(robot_name)
102
- @active_robots.key?(robot_name)
103
- end
104
-
105
-
106
- def passive?(robot_name)
107
- @passive_robots.key?(robot_name)
108
- end
109
-
110
-
111
- def member_ids
112
- all_robots.values.map(&:robot_id)
113
- end
114
-
115
-
116
- def active_robot_names
117
- @active_robots.keys
118
- end
119
-
120
-
121
- def passive_robot_names
122
- @passive_robots.keys
123
- end
124
-
125
- # ===========================================================================
126
- # Shared Working Memory Operations
127
- # ===========================================================================
128
-
129
- # Add memory to shared working memory for all group members
130
- def remember(content, originator: nil, **options)
131
- raise 'No active robots in group' if @active_robots.empty?
132
-
133
- # Use first active robot (or specified originator) to create the memory
134
- primary = if originator && all_robots[originator]
135
- all_robots[originator]
136
- else
137
- @active_robots.values.first
138
- end
139
-
140
- node_id = primary.remember(content, **options)
141
-
142
- # Sync to database (robot_nodes table) for all other members
143
- sync_node_to_members(node_id, exclude: primary.robot_id)
144
-
145
- # Notify all listeners via PostgreSQL NOTIFY (triggers in-memory sync)
146
- @channel.notify(:added, node_id: node_id, robot_id: primary.robot_id)
147
-
148
- node_id
149
- end
150
-
151
-
152
- # Recall from shared working memory (uses first active robot)
153
- def recall(query, **options)
154
- raise 'No active robots in group' if @active_robots.empty?
155
-
156
- primary = @active_robots.values.first
157
- primary.recall(query, **options)
158
- end
159
-
160
-
161
- # Get shared working memory contents (union of all members)
162
- def working_memory_contents
163
- node_ids = HTM::Models::RobotNode
164
- .where(robot_id: member_ids, working_memory: true)
165
- .distinct
166
- .pluck(:node_id)
167
-
168
- HTM::Models::Node.where(id: node_ids).order(created_at: :desc)
169
- end
170
-
171
-
172
- # Clear shared working memory for all members
173
- def clear_working_memory
174
- count = HTM::Models::RobotNode
175
- .where(robot_id: member_ids, working_memory: true)
176
- .update_all(working_memory: false)
177
-
178
- # Clear in-memory working memory for primary robot
179
- primary = @active_robots.values.first || @passive_robots.values.first
180
- return 0 unless primary
181
-
182
- primary.clear_working_memory
183
-
184
- # Notify all listeners (will clear other in-memory caches via callback)
185
- @channel.notify(:cleared, node_id: nil, robot_id: primary.robot_id)
186
-
187
- count
188
- end
189
-
190
- # ===========================================================================
191
- # Synchronization
192
- # ===========================================================================
193
-
194
- # Sync a specific robot to match group's shared working memory
195
- def sync_robot(robot_name)
196
- htm = all_robots[robot_name]
197
- raise ArgumentError, "#{robot_name} is not a member" unless htm
198
-
199
- # Get all node_ids currently in any member's working memory
200
- shared_node_ids = HTM::Models::RobotNode
201
- .where(robot_id: member_ids, working_memory: true)
202
- .where.not(robot_id: htm.robot_id)
203
- .distinct
204
- .pluck(:node_id)
205
-
206
- synced = 0
207
- shared_node_ids.each do |node_id|
208
- # Create or update robot_node with working_memory=true
209
- robot_node = HTM::Models::RobotNode.find_or_initialize_by(
210
- robot_id: htm.robot_id,
211
- node_id: node_id
212
- )
213
- next if robot_node.working_memory?
214
-
215
- robot_node.working_memory = true
216
- robot_node.save!
217
- synced += 1
218
- end
219
-
220
- synced
221
- end
222
-
223
-
224
- # Sync all members to consistent state
225
- def sync_all
226
- members_updated = 0
227
- total_synced = 0
228
-
229
- all_robots.each_key do |robot_name|
230
- synced = sync_robot(robot_name)
231
- if synced > 0
232
- members_updated += 1
233
- total_synced += synced
234
- end
235
- end
236
-
237
- { synced_nodes: total_synced, members_updated: members_updated }
238
- end
239
-
240
-
241
- # Check if all members have identical working memory
242
- def in_sync?
243
- return true if member_ids.length <= 1
244
-
245
- # Get working memory node_ids for each robot
246
- working_memories = member_ids.map do |robot_id|
247
- HTM::Models::RobotNode
248
- .where(robot_id: robot_id, working_memory: true)
249
- .pluck(:node_id)
250
- .sort
251
- end
252
-
253
- # All should be identical
254
- working_memories.uniq.length == 1
255
- end
256
-
257
- # ===========================================================================
258
- # Failover
259
- # ===========================================================================
260
-
261
- # Transfer working memory from one robot to another
262
- def transfer_working_memory(from_robot, to_robot, clear_source: true)
263
- from_htm = all_robots[from_robot]
264
- to_htm = all_robots[to_robot]
265
-
266
- raise ArgumentError, "#{from_robot} is not a member" unless from_htm
267
- raise ArgumentError, "#{to_robot} is not a member" unless to_htm
268
-
269
- # Get source's working memory nodes
270
- source_node_ids = HTM::Models::RobotNode
271
- .where(robot_id: from_htm.robot_id, working_memory: true)
272
- .pluck(:node_id)
273
-
274
- transferred = 0
275
- source_node_ids.each do |node_id|
276
- robot_node = HTM::Models::RobotNode.find_or_initialize_by(
277
- robot_id: to_htm.robot_id,
278
- node_id: node_id
279
- )
280
- robot_node.working_memory = true
281
- robot_node.save!
282
- transferred += 1
283
- end
284
-
285
- # Clear source's working memory if requested
286
- if clear_source
287
- HTM::Models::RobotNode
288
- .where(robot_id: from_htm.robot_id, working_memory: true)
289
- .update_all(working_memory: false)
290
- end
291
-
292
- transferred
293
- end
294
-
295
-
296
- # Simulate failover: promote first passive robot
297
- def failover!
298
- raise 'No passive robots available for failover' if @passive_robots.empty?
299
-
300
- # Get first passive robot
301
- standby_name = @passive_robots.keys.first
302
-
303
- # Promote it
304
- promote(standby_name)
305
-
306
- puts " ⚡ Failover: #{standby_name} promoted to active"
307
- standby_name
308
- end
309
-
310
- # ===========================================================================
311
- # Status & Health
312
- # ===========================================================================
313
-
314
- def status
315
- wm_contents = working_memory_contents
316
- token_count = wm_contents.sum { |n| n.token_count || 0 }
317
-
318
- {
319
- name: @name,
320
- active: active_robot_names,
321
- passive: passive_robot_names,
322
- total_members: member_ids.length,
323
- working_memory_nodes: wm_contents.count,
324
- working_memory_tokens: token_count,
325
- max_tokens: @max_tokens,
326
- token_utilization: @max_tokens > 0 ? (token_count.to_f / @max_tokens).round(2) : 0,
327
- in_sync: in_sync?
328
- }
329
- end
330
-
331
-
332
- def sync_stats
333
- @mutex.synchronize { @sync_stats.dup }
334
- end
335
-
336
- private
337
-
338
- def all_robots
339
- @active_robots.merge(@passive_robots)
340
- end
341
-
342
-
343
- # Subscribe to working memory change notifications
344
- def setup_sync_listener
345
- @channel.on_change do |event, node_id, origin_robot_id|
346
- handle_sync_notification(event, node_id, origin_robot_id)
347
- end
348
- end
349
-
350
-
351
- # Handle incoming working memory change notifications
352
- def handle_sync_notification(event, node_id, origin_robot_id)
353
- @mutex.synchronize do
354
- case event
355
- when :added
356
- sync_node_to_in_memory_caches(node_id, origin_robot_id)
357
- @sync_stats[:nodes_synced] += 1
358
- when :evicted
359
- evict_from_in_memory_caches(node_id, origin_robot_id)
360
- @sync_stats[:evictions_synced] += 1
361
- when :cleared
362
- clear_all_in_memory_caches(origin_robot_id)
363
- end
364
- end
365
- end
366
-
367
-
368
- # Sync a node to in-memory WorkingMemory caches of other robots
369
- def sync_node_to_in_memory_caches(node_id, origin_robot_id)
370
- node = HTM::Models::Node.find_by(id: node_id)
371
- return unless node
372
-
373
- all_robots.each do |_name, htm|
374
- next if htm.robot_id == origin_robot_id
375
-
376
- # Add to in-memory working memory without triggering another notification
377
- htm.working_memory.add_from_sync(
378
- id: node.id,
379
- content: node.content,
380
- token_count: node.token_count || 0,
381
- created_at: node.created_at
382
- )
383
- end
384
- end
385
-
386
-
387
- # Evict a node from in-memory WorkingMemory caches
388
- def evict_from_in_memory_caches(node_id, origin_robot_id)
389
- all_robots.each do |_name, htm|
390
- next if htm.robot_id == origin_robot_id
391
-
392
- htm.working_memory.remove_from_sync(node_id)
393
- end
394
- end
395
-
396
-
397
- # Clear all in-memory WorkingMemory caches
398
- def clear_all_in_memory_caches(origin_robot_id)
399
- all_robots.each do |_name, htm|
400
- next if htm.robot_id == origin_robot_id
401
-
402
- htm.working_memory.clear_from_sync
403
- end
404
- end
405
-
406
-
407
- def sync_node_to_members(node_id, exclude: nil)
408
- member_ids.each do |robot_id|
409
- next if robot_id == exclude
410
-
411
- robot_node = HTM::Models::RobotNode.find_or_initialize_by(
412
- robot_id: robot_id,
413
- node_id: node_id
414
- )
415
- robot_node.working_memory = true
416
- robot_node.save!
417
- end
418
- end
419
- end
@@ -1,140 +0,0 @@
1
- # robot_groups/lib/working_memory_channel.rb
2
- # frozen_string_literal: true
3
- #
4
- # PostgreSQL LISTEN/NOTIFY for real-time sync
5
- #
6
-
7
- class WorkingMemoryChannel
8
- CHANNEL_PREFIX = 'htm_wm'
9
-
10
- attr_reader :notifications_received
11
-
12
- def initialize(group_name, db_config)
13
- @group_name = group_name
14
- @channel = "#{CHANNEL_PREFIX}_#{group_name.gsub(/[^a-z0-9_]/i, '_')}"
15
- @db_config = db_config
16
- @listeners = []
17
- @listen_thread = nil
18
- @stop_requested = false
19
- @notifications_received = 0
20
- @mutex = Mutex.new
21
- end
22
-
23
- # ===========================================================================
24
- # Publishing (called by the robot that adds/evicts memory)
25
- # ===========================================================================
26
-
27
- # Notify all group members of a working memory change
28
- #
29
- # @param event [Symbol] :added, :evicted, :cleared
30
- # @param node_id [Integer, nil] Node ID (nil for :cleared)
31
- # @param robot_id [Integer] Robot that triggered the change
32
- #
33
- def notify(event, node_id:, robot_id:)
34
- payload = {
35
- event: event,
36
- node_id: node_id,
37
- robot_id: robot_id,
38
- timestamp: Time.now.iso8601
39
- }.to_json
40
-
41
- with_connection do |conn|
42
- conn.exec_params('SELECT pg_notify($1, $2)', [@channel, payload])
43
- end
44
- end
45
-
46
- # ===========================================================================
47
- # Subscribing (called by robots to receive updates)
48
- # ===========================================================================
49
-
50
- # Register a callback for working memory events
51
- #
52
- # @param callback [Proc] Called with (event, node_id, robot_id)
53
- #
54
- def on_change(&callback)
55
- @mutex.synchronize { @listeners << callback }
56
- end
57
-
58
-
59
- # Start listening for notifications in a background thread
60
- #
61
- # @return [Thread] The listener thread
62
- #
63
- def start_listening
64
- @stop_requested = false
65
- @listen_thread = Thread.new do
66
- listen_loop
67
- end
68
- @listen_thread.abort_on_exception = true
69
- @listen_thread
70
- end
71
-
72
-
73
- # Stop the listener thread
74
- #
75
- def stop_listening
76
- @stop_requested = true
77
- # Give the thread a moment to exit cleanly
78
- @listen_thread&.join(0.5)
79
- @listen_thread&.kill if @listen_thread&.alive?
80
- @listen_thread = nil
81
- end
82
-
83
-
84
- def listening?
85
- @listen_thread&.alive? || false
86
- end
87
-
88
-
89
- def channel_name
90
- @channel
91
- end
92
-
93
- private
94
-
95
- def listen_loop
96
- conn = PG.connect(@db_config)
97
- conn.exec("LISTEN #{conn.escape_identifier(@channel)}")
98
-
99
- until @stop_requested
100
- # Wait for notification with timeout (allows checking @stop_requested)
101
- conn.wait_for_notify(0.5) do |_channel, _pid, payload|
102
- handle_notification(payload)
103
- end
104
- end
105
- rescue PG::Error => e
106
- unless @stop_requested
107
- HTM.logger.error "WorkingMemoryChannel error: #{e.message}"
108
- sleep 1
109
- retry
110
- end
111
- ensure
112
- conn&.close
113
- end
114
-
115
-
116
- def handle_notification(payload)
117
- data = JSON.parse(payload, symbolize_names: true)
118
-
119
- @mutex.synchronize do
120
- @notifications_received += 1
121
- @listeners.each do |callback|
122
- callback.call(
123
- data[:event].to_sym,
124
- data[:node_id],
125
- data[:robot_id]
126
- )
127
- end
128
- end
129
- rescue JSON::ParserError => e
130
- HTM.logger.error "Invalid notification payload: #{e.message}"
131
- end
132
-
133
-
134
- def with_connection
135
- conn = PG.connect(@db_config)
136
- yield conn
137
- ensure
138
- conn&.close
139
- end
140
- end