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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +269 -79
- data/db/migrate/00003_create_file_sources.rb +5 -0
- data/db/migrate/00004_create_nodes.rb +17 -0
- data/db/migrate/00005_create_tags.rb +7 -0
- data/db/migrate/00006_create_node_tags.rb +2 -0
- data/db/migrate/00007_create_robot_nodes.rb +7 -0
- data/db/schema.sql +41 -29
- data/docs/api/yard/HTM/Configuration.md +54 -0
- data/docs/api/yard/HTM/Database.md +13 -10
- data/docs/api/yard/HTM/EmbeddingService.md +5 -1
- data/docs/api/yard/HTM/LongTermMemory.md +18 -277
- data/docs/api/yard/HTM/PropositionError.md +18 -0
- data/docs/api/yard/HTM/PropositionService.md +66 -0
- data/docs/api/yard/HTM/QueryCache.md +88 -0
- data/docs/api/yard/HTM/RobotGroup.md +481 -0
- data/docs/api/yard/HTM/SqlBuilder.md +108 -0
- data/docs/api/yard/HTM/TagService.md +4 -0
- data/docs/api/yard/HTM/Telemetry/NullInstrument.md +13 -0
- data/docs/api/yard/HTM/Telemetry/NullMeter.md +15 -0
- data/docs/api/yard/HTM/Telemetry.md +109 -0
- data/docs/api/yard/HTM/WorkingMemoryChannel.md +176 -0
- data/docs/api/yard/HTM.md +11 -23
- data/docs/api/yard/index.csv +102 -25
- data/docs/api/yard-reference.md +8 -0
- data/docs/assets/images/multi-provider-failover.svg +51 -0
- data/docs/assets/images/robot-group-architecture.svg +65 -0
- data/docs/database/README.md +3 -3
- data/docs/database/public.file_sources.svg +29 -21
- data/docs/database/public.node_tags.md +2 -0
- data/docs/database/public.node_tags.svg +53 -41
- data/docs/database/public.nodes.md +2 -0
- data/docs/database/public.nodes.svg +52 -40
- data/docs/database/public.robot_nodes.md +2 -0
- data/docs/database/public.robot_nodes.svg +30 -22
- data/docs/database/public.robots.svg +16 -12
- data/docs/database/public.tags.md +3 -0
- data/docs/database/public.tags.svg +41 -33
- data/docs/database/schema.json +66 -0
- data/docs/database/schema.svg +60 -48
- data/docs/development/index.md +13 -0
- data/docs/development/rake-tasks.md +1068 -0
- data/docs/getting-started/quick-start.md +144 -155
- data/docs/guides/adding-memories.md +2 -3
- data/docs/guides/context-assembly.md +185 -184
- data/docs/guides/getting-started.md +154 -148
- data/docs/guides/index.md +7 -0
- data/docs/guides/long-term-memory.md +60 -92
- data/docs/guides/mcp-server.md +617 -0
- data/docs/guides/multi-robot.md +249 -345
- data/docs/guides/recalling-memories.md +153 -163
- data/docs/guides/robot-groups.md +604 -0
- data/docs/guides/search-strategies.md +61 -58
- data/docs/guides/working-memory.md +103 -136
- data/docs/index.md +30 -26
- data/examples/robot_groups/robot_worker.rb +1 -2
- data/examples/robot_groups/same_process.rb +1 -4
- data/lib/htm/robot_group.rb +721 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm/working_memory_channel.rb +250 -0
- data/lib/htm.rb +2 -0
- data/mkdocs.yml +2 -0
- metadata +18 -9
- data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +0 -12
- data/db/migrate/00010_add_soft_delete_to_associations.rb +0 -29
- data/db/migrate/00011_add_performance_indexes.rb +0 -21
- data/db/migrate/00012_add_tags_trigram_index.rb +0 -18
- data/db/migrate/00013_enable_lz4_compression.rb +0 -43
- data/examples/robot_groups/lib/robot_group.rb +0 -419
- 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
|