htm 0.0.2 → 0.0.11
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/.aigcm_msg +1 -0
- data/.architecture/reviews/comprehensive-codebase-review.md +577 -0
- data/.claude/settings.local.json +95 -0
- data/.irbrc +283 -80
- data/.tbls.yml +2 -1
- data/CHANGELOG.md +327 -26
- data/CLAUDE.md +603 -0
- data/README.md +83 -12
- data/Rakefile +5 -0
- data/bin/htm_mcp.rb +527 -0
- data/db/migrate/{20250101000001_enable_extensions.rb → 00001_enable_extensions.rb} +0 -1
- data/db/migrate/00002_create_robots.rb +11 -0
- data/db/migrate/00003_create_file_sources.rb +20 -0
- data/db/migrate/00004_create_nodes.rb +65 -0
- data/db/migrate/00005_create_tags.rb +13 -0
- data/db/migrate/00006_create_node_tags.rb +18 -0
- data/db/migrate/00007_create_robot_nodes.rb +26 -0
- data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +12 -0
- data/db/schema.sql +172 -1
- data/docs/api/database.md +1 -2
- data/docs/api/htm.md +197 -2
- data/docs/api/yard/HTM/ActiveRecordConfig.md +23 -0
- data/docs/api/yard/HTM/AuthorizationError.md +11 -0
- data/docs/api/yard/HTM/CircuitBreaker.md +92 -0
- data/docs/api/yard/HTM/CircuitBreakerOpenError.md +34 -0
- data/docs/api/yard/HTM/Configuration.md +175 -0
- data/docs/api/yard/HTM/Database.md +99 -0
- data/docs/api/yard/HTM/DatabaseError.md +14 -0
- data/docs/api/yard/HTM/EmbeddingError.md +18 -0
- data/docs/api/yard/HTM/EmbeddingService.md +58 -0
- data/docs/api/yard/HTM/Error.md +11 -0
- data/docs/api/yard/HTM/JobAdapter.md +39 -0
- data/docs/api/yard/HTM/LongTermMemory.md +342 -0
- data/docs/api/yard/HTM/NotFoundError.md +17 -0
- data/docs/api/yard/HTM/Observability.md +107 -0
- data/docs/api/yard/HTM/QueryTimeoutError.md +19 -0
- data/docs/api/yard/HTM/Railtie.md +27 -0
- data/docs/api/yard/HTM/ResourceExhaustedError.md +13 -0
- data/docs/api/yard/HTM/TagError.md +18 -0
- data/docs/api/yard/HTM/TagService.md +67 -0
- data/docs/api/yard/HTM/Timeframe/Result.md +24 -0
- data/docs/api/yard/HTM/Timeframe.md +40 -0
- data/docs/api/yard/HTM/TimeframeExtractor/Result.md +24 -0
- data/docs/api/yard/HTM/TimeframeExtractor.md +45 -0
- data/docs/api/yard/HTM/ValidationError.md +20 -0
- data/docs/api/yard/HTM/WorkingMemory.md +131 -0
- data/docs/api/yard/HTM.md +80 -0
- data/docs/api/yard/index.csv +179 -0
- data/docs/api/yard-reference.md +51 -0
- data/docs/database/README.md +128 -128
- data/docs/database/public.file_sources.md +42 -0
- data/docs/database/public.file_sources.svg +211 -0
- data/docs/database/public.node_tags.md +4 -4
- data/docs/database/public.node_tags.svg +212 -79
- data/docs/database/public.nodes.md +22 -12
- data/docs/database/public.nodes.svg +246 -127
- data/docs/database/public.robot_nodes.md +11 -9
- data/docs/database/public.robot_nodes.svg +220 -98
- data/docs/database/public.robots.md +2 -2
- data/docs/database/public.robots.svg +136 -81
- data/docs/database/public.tags.md +3 -3
- data/docs/database/public.tags.svg +118 -39
- data/docs/database/schema.json +850 -771
- data/docs/database/schema.svg +256 -197
- data/docs/development/schema.md +67 -2
- data/docs/guides/adding-memories.md +93 -7
- data/docs/guides/recalling-memories.md +36 -1
- data/examples/README.md +405 -0
- data/examples/cli_app/htm_cli.rb +65 -5
- data/examples/cli_app/temp.log +93 -0
- data/examples/file_loader_usage.rb +177 -0
- data/examples/mcp_client.rb +529 -0
- data/examples/robot_groups/lib/robot_group.rb +419 -0
- data/examples/robot_groups/lib/working_memory_channel.rb +140 -0
- data/examples/robot_groups/multi_process.rb +286 -0
- data/examples/robot_groups/robot_worker.rb +136 -0
- data/examples/robot_groups/same_process.rb +229 -0
- data/examples/timeframe_demo.rb +276 -0
- data/lib/htm/active_record_config.rb +1 -1
- data/lib/htm/circuit_breaker.rb +202 -0
- data/lib/htm/configuration.rb +59 -13
- data/lib/htm/database.rb +67 -36
- data/lib/htm/embedding_service.rb +39 -2
- data/lib/htm/errors.rb +131 -11
- data/lib/htm/jobs/generate_embedding_job.rb +5 -4
- data/lib/htm/jobs/generate_tags_job.rb +4 -0
- data/lib/htm/loaders/markdown_loader.rb +263 -0
- data/lib/htm/loaders/paragraph_chunker.rb +112 -0
- data/lib/htm/long_term_memory.rb +460 -343
- data/lib/htm/models/file_source.rb +99 -0
- data/lib/htm/models/node.rb +80 -5
- data/lib/htm/models/robot.rb +24 -1
- data/lib/htm/models/robot_node.rb +1 -0
- data/lib/htm/models/tag.rb +254 -4
- data/lib/htm/observability.rb +395 -0
- data/lib/htm/tag_service.rb +60 -3
- data/lib/htm/tasks.rb +26 -1
- data/lib/htm/timeframe.rb +194 -0
- data/lib/htm/timeframe_extractor.rb +307 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm/working_memory.rb +165 -70
- data/lib/htm.rb +328 -130
- data/lib/tasks/doc.rake +300 -0
- data/lib/tasks/files.rake +299 -0
- data/lib/tasks/htm.rake +158 -3
- data/lib/tasks/jobs.rake +3 -9
- data/lib/tasks/tags.rake +166 -6
- data/mkdocs.yml +36 -1
- data/notes/ARCHITECTURE_REVIEW.md +1167 -0
- data/notes/IMPLEMENTATION_SUMMARY.md +606 -0
- data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +451 -0
- data/notes/next_steps.md +100 -0
- data/notes/plan.md +627 -0
- data/notes/tag_ontology_enhancement_ideas.md +222 -0
- data/notes/timescaledb_removal_summary.md +200 -0
- metadata +158 -17
- data/db/migrate/20250101000002_create_robots.rb +0 -14
- data/db/migrate/20250101000003_create_nodes.rb +0 -42
- data/db/migrate/20250101000005_create_tags.rb +0 -38
- data/db/migrate/20250101000007_add_node_vector_indexes.rb +0 -30
- data/db/migrate/20250125000001_add_content_hash_to_nodes.rb +0 -14
- data/db/migrate/20250125000002_create_robot_nodes.rb +0 -35
- data/db/migrate/20250125000003_remove_source_and_robot_id_from_nodes.rb +0 -28
- data/db/migrate/20250126000001_create_working_memories.rb +0 -19
- data/db/migrate/20250126000002_remove_unused_columns.rb +0 -12
- data/docs/database/public.working_memories.md +0 -40
- data/docs/database/public.working_memories.svg +0 -112
- data/lib/htm/models/working_memory_entry.rb +0 -88
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Multi-Process Robot Group Demo
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates real-time synchronization across SEPARATE PROCESSES
|
|
7
|
+
# using PostgreSQL LISTEN/NOTIFY. Spawns robot_worker.rb as child processes.
|
|
8
|
+
#
|
|
9
|
+
# Key concepts:
|
|
10
|
+
# 1. Cross-process sync via PostgreSQL LISTEN/NOTIFY
|
|
11
|
+
# 2. Each robot runs as an independent process
|
|
12
|
+
# 3. Failover when a process dies
|
|
13
|
+
# 4. Dynamic scaling by spawning new processes
|
|
14
|
+
#
|
|
15
|
+
# Prerequisites:
|
|
16
|
+
# 1. Set HTM_DBURL environment variable
|
|
17
|
+
# 2. Initialize database schema: rake db_setup
|
|
18
|
+
|
|
19
|
+
require_relative '../../lib/htm'
|
|
20
|
+
require 'json'
|
|
21
|
+
require 'timeout'
|
|
22
|
+
require 'open3'
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Robot Process Manager
|
|
26
|
+
# =============================================================================
|
|
27
|
+
|
|
28
|
+
class RobotProcess
|
|
29
|
+
WORKER_SCRIPT = File.expand_path('robot_worker.rb', __dir__)
|
|
30
|
+
|
|
31
|
+
attr_reader :name, :pid
|
|
32
|
+
|
|
33
|
+
def initialize(name, group_name)
|
|
34
|
+
@name = name
|
|
35
|
+
@group_name = group_name
|
|
36
|
+
|
|
37
|
+
# Spawn the worker process
|
|
38
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(
|
|
39
|
+
{ 'HTM_DBURL' => ENV['HTM_DBURL'] },
|
|
40
|
+
'ruby', WORKER_SCRIPT, name, group_name
|
|
41
|
+
)
|
|
42
|
+
@pid = @wait_thread.pid
|
|
43
|
+
|
|
44
|
+
# Start thread to read stderr (logs)
|
|
45
|
+
@log_thread = Thread.new do
|
|
46
|
+
@stderr.each_line { |line| print line }
|
|
47
|
+
rescue IOError
|
|
48
|
+
# Pipe closed
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def send_command(cmd, **params)
|
|
53
|
+
command = { cmd: cmd }.merge(params)
|
|
54
|
+
@stdin.puts(command.to_json)
|
|
55
|
+
@stdin.flush
|
|
56
|
+
|
|
57
|
+
# Read response, skipping non-JSON lines
|
|
58
|
+
Timeout.timeout(10) do
|
|
59
|
+
loop do
|
|
60
|
+
line = @stdout.gets
|
|
61
|
+
return nil unless line
|
|
62
|
+
|
|
63
|
+
line = line.strip
|
|
64
|
+
next if line.empty?
|
|
65
|
+
next unless line.start_with?('{')
|
|
66
|
+
|
|
67
|
+
return JSON.parse(line, symbolize_names: true)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
rescue Timeout::Error
|
|
71
|
+
{ status: 'error', message: 'timeout' }
|
|
72
|
+
rescue Errno::EPIPE, IOError
|
|
73
|
+
{ status: 'error', message: 'pipe closed' }
|
|
74
|
+
rescue JSON::ParserError => e
|
|
75
|
+
{ status: 'error', message: "JSON parse error: #{e.message}" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def shutdown
|
|
79
|
+
send_command('shutdown')
|
|
80
|
+
sleep 0.2
|
|
81
|
+
cleanup_pipes
|
|
82
|
+
@wait_thread.value
|
|
83
|
+
rescue Errno::EPIPE, IOError
|
|
84
|
+
# Already closed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def kill!
|
|
88
|
+
Process.kill('TERM', @pid)
|
|
89
|
+
cleanup_pipes
|
|
90
|
+
Process.wait(@pid)
|
|
91
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
92
|
+
# Already dead
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def alive?
|
|
96
|
+
@wait_thread && !@wait_thread.stop?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def cleanup_pipes
|
|
102
|
+
@stdin.close rescue nil
|
|
103
|
+
@stdout.close rescue nil
|
|
104
|
+
@stderr.close rescue nil
|
|
105
|
+
@log_thread.kill rescue nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# =============================================================================
|
|
110
|
+
# Demo
|
|
111
|
+
# =============================================================================
|
|
112
|
+
|
|
113
|
+
def run_demo
|
|
114
|
+
puts <<~BANNER
|
|
115
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
116
|
+
║ HTM Multi-Process Robot Group Demo ║
|
|
117
|
+
║ Real-time Sync via PostgreSQL LISTEN/NOTIFY ║
|
|
118
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
119
|
+
|
|
120
|
+
BANNER
|
|
121
|
+
|
|
122
|
+
unless ENV['HTM_DBURL']
|
|
123
|
+
puts 'ERROR: HTM_DBURL not set.'
|
|
124
|
+
puts ' export HTM_DBURL="postgresql://user@localhost:5432/htm_development"'
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
group_name = "demo-#{Time.now.to_i}"
|
|
129
|
+
robots = []
|
|
130
|
+
|
|
131
|
+
begin
|
|
132
|
+
# =========================================================================
|
|
133
|
+
# Scenario 1: Start robot processes
|
|
134
|
+
# =========================================================================
|
|
135
|
+
puts '━' * 60
|
|
136
|
+
puts "SCENARIO 1: Starting Robot Processes"
|
|
137
|
+
puts '━' * 60
|
|
138
|
+
puts
|
|
139
|
+
|
|
140
|
+
%w[robot-alpha robot-beta robot-gamma].each do |name|
|
|
141
|
+
robots << RobotProcess.new(name, group_name)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
sleep 1.5
|
|
145
|
+
|
|
146
|
+
robots.each do |robot|
|
|
147
|
+
result = robot.send_command('ping')
|
|
148
|
+
status = result&.dig(:status) == 'ok' ? '✓' : '✗'
|
|
149
|
+
puts " #{status} #{robot.name} (PID #{robot.pid})"
|
|
150
|
+
end
|
|
151
|
+
puts
|
|
152
|
+
|
|
153
|
+
# =========================================================================
|
|
154
|
+
# Scenario 2: Cross-process memory sharing
|
|
155
|
+
# =========================================================================
|
|
156
|
+
puts '━' * 60
|
|
157
|
+
puts "SCENARIO 2: Cross-Process Memory Sharing"
|
|
158
|
+
puts '━' * 60
|
|
159
|
+
puts
|
|
160
|
+
puts " Alpha adds memories, others receive notifications..."
|
|
161
|
+
puts
|
|
162
|
+
|
|
163
|
+
alpha, beta, gamma = robots
|
|
164
|
+
|
|
165
|
+
alpha.send_command('remember', content: 'Customer John Smith prefers morning appointments.')
|
|
166
|
+
sleep 0.5
|
|
167
|
+
alpha.send_command('remember', content: 'Account #A-789 has a pending refund for $150.')
|
|
168
|
+
sleep 0.5
|
|
169
|
+
alpha.send_command('remember', content: 'Escalate billing issues to finance team.')
|
|
170
|
+
sleep 1.0
|
|
171
|
+
|
|
172
|
+
puts
|
|
173
|
+
puts " Working memory status:"
|
|
174
|
+
robots.each do |robot|
|
|
175
|
+
status = robot.send_command('status')
|
|
176
|
+
next unless status&.dig(:status) == 'ok'
|
|
177
|
+
|
|
178
|
+
puts " #{robot.name}: #{status[:working_memory_nodes]} nodes, #{status[:notifications_received]} notifications"
|
|
179
|
+
end
|
|
180
|
+
puts
|
|
181
|
+
|
|
182
|
+
# =========================================================================
|
|
183
|
+
# Scenario 3: Collaborative memory
|
|
184
|
+
# =========================================================================
|
|
185
|
+
puts '━' * 60
|
|
186
|
+
puts "SCENARIO 3: Collaborative Memory"
|
|
187
|
+
puts '━' * 60
|
|
188
|
+
puts
|
|
189
|
+
|
|
190
|
+
beta.send_command('remember', content: 'Customer confirmed refund was processed.')
|
|
191
|
+
sleep 0.5
|
|
192
|
+
gamma.send_command('remember', content: 'Customer wants email confirmation.')
|
|
193
|
+
sleep 1.0
|
|
194
|
+
|
|
195
|
+
puts " Each robot recalls 'refund':"
|
|
196
|
+
robots.each do |robot|
|
|
197
|
+
result = robot.send_command('recall', query: 'refund', limit: 3)
|
|
198
|
+
next unless result&.dig(:status) == 'ok'
|
|
199
|
+
|
|
200
|
+
puts " #{robot.name}: found #{result[:count]} memories"
|
|
201
|
+
end
|
|
202
|
+
puts
|
|
203
|
+
|
|
204
|
+
# =========================================================================
|
|
205
|
+
# Scenario 4: Failover
|
|
206
|
+
# =========================================================================
|
|
207
|
+
puts '━' * 60
|
|
208
|
+
puts "SCENARIO 4: Simulated Failover"
|
|
209
|
+
puts '━' * 60
|
|
210
|
+
puts
|
|
211
|
+
|
|
212
|
+
puts " Killing robot-alpha..."
|
|
213
|
+
alpha.kill!
|
|
214
|
+
robots.delete(alpha)
|
|
215
|
+
sleep 0.5
|
|
216
|
+
puts " ⚠ robot-alpha terminated"
|
|
217
|
+
puts
|
|
218
|
+
|
|
219
|
+
puts " Remaining robots retain context:"
|
|
220
|
+
robots.each do |robot|
|
|
221
|
+
status = robot.send_command('status')
|
|
222
|
+
result = robot.send_command('recall', query: 'customer', limit: 5)
|
|
223
|
+
next unless status && result
|
|
224
|
+
|
|
225
|
+
puts " #{robot.name}: #{status[:working_memory_nodes]} nodes, recalls #{result[:count]}"
|
|
226
|
+
end
|
|
227
|
+
puts
|
|
228
|
+
puts " ✓ Failover successful"
|
|
229
|
+
puts
|
|
230
|
+
|
|
231
|
+
# =========================================================================
|
|
232
|
+
# Scenario 5: Dynamic scaling
|
|
233
|
+
# =========================================================================
|
|
234
|
+
puts '━' * 60
|
|
235
|
+
puts "SCENARIO 5: Dynamic Scaling"
|
|
236
|
+
puts '━' * 60
|
|
237
|
+
puts
|
|
238
|
+
|
|
239
|
+
puts " Adding robot-delta..."
|
|
240
|
+
delta = RobotProcess.new('robot-delta', group_name)
|
|
241
|
+
robots << delta
|
|
242
|
+
sleep 1.5
|
|
243
|
+
|
|
244
|
+
result = delta.send_command('ping')
|
|
245
|
+
puts " ✓ robot-delta (PID #{delta.pid}) joined" if result&.dig(:status) == 'ok'
|
|
246
|
+
|
|
247
|
+
delta.send_command('remember', content: 'New robot ready to assist.')
|
|
248
|
+
sleep 1.0
|
|
249
|
+
|
|
250
|
+
puts
|
|
251
|
+
puts " Notifications received:"
|
|
252
|
+
robots.each do |robot|
|
|
253
|
+
status = robot.send_command('status')
|
|
254
|
+
next unless status&.dig(:status) == 'ok'
|
|
255
|
+
|
|
256
|
+
puts " #{robot.name}: #{status[:notifications_received]}"
|
|
257
|
+
end
|
|
258
|
+
puts
|
|
259
|
+
|
|
260
|
+
# =========================================================================
|
|
261
|
+
# Summary
|
|
262
|
+
# =========================================================================
|
|
263
|
+
puts '━' * 60
|
|
264
|
+
puts "DEMO COMPLETE"
|
|
265
|
+
puts '━' * 60
|
|
266
|
+
puts
|
|
267
|
+
puts " Demonstrated:"
|
|
268
|
+
puts " • Real-time sync across #{robots.length} processes"
|
|
269
|
+
puts " • PostgreSQL LISTEN/NOTIFY pub/sub"
|
|
270
|
+
puts " • Failover with context preservation"
|
|
271
|
+
puts " • Dynamic scaling"
|
|
272
|
+
puts
|
|
273
|
+
|
|
274
|
+
ensure
|
|
275
|
+
puts "Cleaning up..."
|
|
276
|
+
robots.each do |robot|
|
|
277
|
+
next unless robot.alive?
|
|
278
|
+
|
|
279
|
+
robot.shutdown
|
|
280
|
+
puts " Stopped #{robot.name}"
|
|
281
|
+
end
|
|
282
|
+
puts "Done."
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
run_demo if __FILE__ == $PROGRAM_NAME
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Robot Worker - Standalone process that participates in a robot group
|
|
5
|
+
#
|
|
6
|
+
# Usage: ruby robot_worker.rb <robot_name> <group_name>
|
|
7
|
+
#
|
|
8
|
+
# Communication:
|
|
9
|
+
# - Receives JSON commands via stdin
|
|
10
|
+
# - Sends JSON responses via stdout
|
|
11
|
+
# - Logs to stderr
|
|
12
|
+
#
|
|
13
|
+
# Commands:
|
|
14
|
+
# { "cmd": "ping" }
|
|
15
|
+
# { "cmd": "remember", "content": "..." }
|
|
16
|
+
# { "cmd": "recall", "query": "...", "limit": 5 }
|
|
17
|
+
# { "cmd": "status" }
|
|
18
|
+
# { "cmd": "shutdown" }
|
|
19
|
+
|
|
20
|
+
require 'logger'
|
|
21
|
+
require 'json'
|
|
22
|
+
require_relative '../../lib/htm'
|
|
23
|
+
require_relative 'lib/working_memory_channel'
|
|
24
|
+
|
|
25
|
+
robot_name = ARGV[0]
|
|
26
|
+
group_name = ARGV[1]
|
|
27
|
+
|
|
28
|
+
unless robot_name && group_name
|
|
29
|
+
$stderr.puts "Usage: ruby robot_worker.rb <robot_name> <group_name>"
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log(robot_name, message)
|
|
34
|
+
timestamp = Time.now.strftime('%H:%M:%S.%L')
|
|
35
|
+
$stderr.puts "[#{timestamp}] [#{robot_name}] #{message}"
|
|
36
|
+
$stderr.flush
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Configure HTM with logger to stderr (keep stdout clean for JSON)
|
|
40
|
+
HTM.configure do |config|
|
|
41
|
+
config.embedding_provider = :ollama
|
|
42
|
+
config.embedding_model = 'nomic-embed-text:latest'
|
|
43
|
+
config.embedding_dimensions = 768
|
|
44
|
+
config.tag_provider = :ollama
|
|
45
|
+
config.tag_model = 'gemma3:latest'
|
|
46
|
+
config.logger = Logger.new($stderr, level: Logger::WARN)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
log(robot_name, 'Starting up...')
|
|
50
|
+
|
|
51
|
+
# Create HTM instance for this robot
|
|
52
|
+
htm = HTM.new(robot_name: robot_name, working_memory_size: 8000)
|
|
53
|
+
db_config = HTM::Database.default_config
|
|
54
|
+
|
|
55
|
+
# Setup channel for cross-process notifications
|
|
56
|
+
channel = WorkingMemoryChannel.new(group_name, db_config)
|
|
57
|
+
|
|
58
|
+
# Track notifications received
|
|
59
|
+
notifications_count = 0
|
|
60
|
+
channel.on_change do |event, node_id, origin_robot_id|
|
|
61
|
+
next if origin_robot_id == htm.robot_id
|
|
62
|
+
|
|
63
|
+
notifications_count += 1
|
|
64
|
+
log(robot_name, "Received #{event} for node #{node_id}")
|
|
65
|
+
|
|
66
|
+
case event
|
|
67
|
+
when :added
|
|
68
|
+
node = HTM::Models::Node.find_by(id: node_id)
|
|
69
|
+
if node
|
|
70
|
+
htm.working_memory.add_from_sync(
|
|
71
|
+
id: node.id,
|
|
72
|
+
content: node.content,
|
|
73
|
+
token_count: node.token_count || 0,
|
|
74
|
+
created_at: node.created_at
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
when :evicted
|
|
78
|
+
htm.working_memory.remove_from_sync(node_id)
|
|
79
|
+
when :cleared
|
|
80
|
+
htm.working_memory.clear_from_sync
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
channel.start_listening
|
|
85
|
+
log(robot_name, "Listening on channel: #{channel.channel_name}")
|
|
86
|
+
|
|
87
|
+
# Process commands from stdin
|
|
88
|
+
$stdin.each_line do |line|
|
|
89
|
+
begin
|
|
90
|
+
command = JSON.parse(line.strip, symbolize_names: true)
|
|
91
|
+
|
|
92
|
+
result = case command[:cmd]
|
|
93
|
+
when 'remember'
|
|
94
|
+
log(robot_name, "Remembering: #{command[:content][0..40]}...")
|
|
95
|
+
node_id = htm.remember(command[:content])
|
|
96
|
+
channel.notify(:added, node_id: node_id, robot_id: htm.robot_id)
|
|
97
|
+
log(robot_name, "Sent notification for node #{node_id}")
|
|
98
|
+
{ status: 'ok', node_id: node_id }
|
|
99
|
+
|
|
100
|
+
when 'recall'
|
|
101
|
+
log(robot_name, "Recalling: #{command[:query]}")
|
|
102
|
+
results = htm.recall(command[:query], limit: command[:limit] || 5, strategy: :fulltext, raw: true)
|
|
103
|
+
{ status: 'ok', count: results.length }
|
|
104
|
+
|
|
105
|
+
when 'status'
|
|
106
|
+
{
|
|
107
|
+
status: 'ok',
|
|
108
|
+
robot_id: htm.robot_id,
|
|
109
|
+
working_memory_nodes: htm.working_memory.node_count,
|
|
110
|
+
working_memory_tokens: htm.working_memory.token_count,
|
|
111
|
+
notifications_received: notifications_count
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
when 'ping'
|
|
115
|
+
{ status: 'ok', message: 'pong', robot: robot_name }
|
|
116
|
+
|
|
117
|
+
when 'shutdown'
|
|
118
|
+
channel.stop_listening
|
|
119
|
+
log(robot_name, 'Shutting down.')
|
|
120
|
+
puts({ status: 'ok', message: 'bye' }.to_json)
|
|
121
|
+
$stdout.flush
|
|
122
|
+
exit 0
|
|
123
|
+
|
|
124
|
+
else
|
|
125
|
+
{ status: 'error', message: "Unknown command: #{command[:cmd]}" }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
puts result.to_json
|
|
129
|
+
$stdout.flush
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
puts({ status: 'error', message: e.message }.to_json)
|
|
132
|
+
$stdout.flush
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
channel.stop_listening
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Robot Group Demo - Shared Working Memory & Failover
|
|
5
|
+
#
|
|
6
|
+
# This example demonstrates an application-level pattern for coordinating
|
|
7
|
+
# multiple robots with shared working memory. Key concepts:
|
|
8
|
+
#
|
|
9
|
+
# 1. **Shared Working Memory**: Multiple robots can share the same working
|
|
10
|
+
# memory by having working_memory=true for the same nodes in robot_nodes.
|
|
11
|
+
#
|
|
12
|
+
# 2. **Active/Passive Roles**: Active robots participate in conversations;
|
|
13
|
+
# passive robots maintain synchronized context for instant failover.
|
|
14
|
+
#
|
|
15
|
+
# 3. **Failover**: When an active robot fails, a passive robot can take over
|
|
16
|
+
# with full context already loaded (warm standby).
|
|
17
|
+
#
|
|
18
|
+
# 4. **Real-time Sync**: PostgreSQL LISTEN/NOTIFY enables real-time
|
|
19
|
+
# synchronization of in-memory working memory across robots.
|
|
20
|
+
#
|
|
21
|
+
# Prerequisites:
|
|
22
|
+
# 1. Set HTM_DBURL environment variable
|
|
23
|
+
# 2. Initialize database schema: rake db_setup
|
|
24
|
+
# 3. Install dependencies: bundle install
|
|
25
|
+
|
|
26
|
+
require_relative '../../lib/htm'
|
|
27
|
+
require 'json'
|
|
28
|
+
|
|
29
|
+
require_relative 'lib/working_memory_channel'
|
|
30
|
+
require_relative 'lib/robot_group'
|
|
31
|
+
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# Demo Script
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
puts 'HTM Robot Group Demo - Shared Working Memory & Failover'
|
|
37
|
+
puts '=' * 60
|
|
38
|
+
|
|
39
|
+
unless ENV['HTM_DBURL']
|
|
40
|
+
puts 'ERROR: HTM_DBURL not set. Please set it:'
|
|
41
|
+
puts ' export HTM_DBURL="postgresql://postgres@localhost:5432/htm_development"'
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
# Configure HTM
|
|
47
|
+
puts "\n1. Configuring HTM..."
|
|
48
|
+
HTM.configure do |config|
|
|
49
|
+
config.embedding_provider = :ollama
|
|
50
|
+
config.embedding_model = 'nomic-embed-text:latest'
|
|
51
|
+
config.embedding_dimensions = 768
|
|
52
|
+
config.tag_provider = :ollama
|
|
53
|
+
config.tag_model = 'gemma3:latest'
|
|
54
|
+
end
|
|
55
|
+
puts '✓ HTM configured'
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Scenario 1: Create a high-availability robot group
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
puts "\n2. Creating robot group with primary + standby..."
|
|
61
|
+
|
|
62
|
+
group = RobotGroup.new(
|
|
63
|
+
name: 'customer-support-ha',
|
|
64
|
+
active: ['support-primary'],
|
|
65
|
+
passive: ['support-standby'],
|
|
66
|
+
max_tokens: 8000
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
status = group.status
|
|
70
|
+
puts "✓ Group created: #{status[:name]}"
|
|
71
|
+
puts " Active: #{status[:active].join(', ')}"
|
|
72
|
+
puts " Passive: #{status[:passive].join(', ')}"
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Scenario 2: Add shared memories
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
puts "\n3. Adding memories to shared working memory..."
|
|
78
|
+
|
|
79
|
+
group.remember(
|
|
80
|
+
'Customer account #12345 prefers email communication over phone calls.',
|
|
81
|
+
originator: 'support-primary'
|
|
82
|
+
)
|
|
83
|
+
puts ' ✓ Remembered customer preference'
|
|
84
|
+
|
|
85
|
+
group.remember(
|
|
86
|
+
'Open ticket #789: Customer reported billing discrepancy on invoice dated Nov 15.',
|
|
87
|
+
originator: 'support-primary'
|
|
88
|
+
)
|
|
89
|
+
puts ' ✓ Remembered open ticket'
|
|
90
|
+
|
|
91
|
+
group.remember(
|
|
92
|
+
'Customer has been with us for 5 years and has premium tier subscription.',
|
|
93
|
+
originator: 'support-primary'
|
|
94
|
+
)
|
|
95
|
+
puts ' ✓ Remembered customer status'
|
|
96
|
+
|
|
97
|
+
# Brief pause for async jobs
|
|
98
|
+
sleep 0.3
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Scenario 3: Verify synchronization
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
puts "\n4. Verifying working memory synchronization..."
|
|
104
|
+
|
|
105
|
+
status = group.status
|
|
106
|
+
puts " Working memory nodes: #{status[:working_memory_nodes]}"
|
|
107
|
+
puts " Token utilization: #{(status[:token_utilization] * 100).round(1)}%"
|
|
108
|
+
puts " In sync: #{status[:in_sync] ? '✓ Yes' : '✗ No'}"
|
|
109
|
+
|
|
110
|
+
# Force sync if needed
|
|
111
|
+
unless status[:in_sync]
|
|
112
|
+
result = group.sync_all
|
|
113
|
+
puts " Synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Scenario 4: Simulate primary robot failure and failover
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
puts "\n5. Simulating failover scenario..."
|
|
120
|
+
puts " ⚠ Primary robot 'support-primary' has stopped responding!"
|
|
121
|
+
|
|
122
|
+
# Failover to standby
|
|
123
|
+
group.failover!
|
|
124
|
+
|
|
125
|
+
status = group.status
|
|
126
|
+
puts " Active robots now: #{status[:active].join(', ')}"
|
|
127
|
+
puts " Passive robots now: #{status[:passive].join(', ') || '(none)'}"
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Scenario 5: Verify standby has full context
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
puts "\n6. Verifying standby has full context after failover..."
|
|
133
|
+
|
|
134
|
+
# Use fulltext search (doesn't require embeddings)
|
|
135
|
+
memories = group.recall('customer', limit: 5, strategy: :fulltext, raw: true)
|
|
136
|
+
puts " ✓ Standby recalled #{memories.length} memories about 'customer'"
|
|
137
|
+
memories.each do |memory|
|
|
138
|
+
content = memory['content'] || memory[:content]
|
|
139
|
+
puts " - #{content[0..55]}..."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Scenario 6: Add a new active robot (scaling up)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
puts "\n7. Adding a second active robot (scaling up)..."
|
|
146
|
+
|
|
147
|
+
group.add_active('support-secondary')
|
|
148
|
+
group.sync_robot('support-secondary')
|
|
149
|
+
|
|
150
|
+
status = group.status
|
|
151
|
+
puts " ✓ Now running with #{status[:active].length} active robots"
|
|
152
|
+
puts " Active: #{status[:active].join(', ')}"
|
|
153
|
+
puts " In sync: #{status[:in_sync] ? '✓ Yes' : '✗ No'}"
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Scenario 7: Collaborative memory - both robots can add
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
puts "\n8. Demonstrating collaborative memory..."
|
|
159
|
+
|
|
160
|
+
# This memory is added through the group and synced to all
|
|
161
|
+
group.remember(
|
|
162
|
+
'Customer called again - issue escalated to billing department.',
|
|
163
|
+
originator: 'support-secondary'
|
|
164
|
+
)
|
|
165
|
+
puts ' ✓ Secondary robot added memory, synced to all'
|
|
166
|
+
|
|
167
|
+
status = group.status
|
|
168
|
+
puts " Total shared memories: #{status[:working_memory_nodes]}"
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Scenario 8: Real-time sync via PostgreSQL LISTEN/NOTIFY
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
puts "\n9. Demonstrating real-time sync via PostgreSQL LISTEN/NOTIFY..."
|
|
174
|
+
|
|
175
|
+
# Check initial in-memory state of each robot
|
|
176
|
+
puts ' Initial in-memory working memory state:'
|
|
177
|
+
puts " support-standby: #{group.instance_variable_get(:@active_robots)['support-standby']&.working_memory&.node_count || 0} nodes"
|
|
178
|
+
puts " support-secondary: #{group.instance_variable_get(:@active_robots)['support-secondary']&.working_memory&.node_count || 0} nodes"
|
|
179
|
+
|
|
180
|
+
# Add a memory from secondary robot
|
|
181
|
+
puts "\n Adding memory from support-secondary..."
|
|
182
|
+
group.remember(
|
|
183
|
+
'Resolution: Refund issued for $47.50 - billing error confirmed.',
|
|
184
|
+
originator: 'support-secondary'
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Give the LISTEN/NOTIFY a moment to propagate
|
|
188
|
+
sleep 0.2
|
|
189
|
+
|
|
190
|
+
# Check sync stats
|
|
191
|
+
sync_stats = group.sync_stats
|
|
192
|
+
puts "\n Real-time sync statistics:"
|
|
193
|
+
puts " Nodes synced via NOTIFY: #{sync_stats[:nodes_synced]}"
|
|
194
|
+
puts " Evictions synced: #{sync_stats[:evictions_synced]}"
|
|
195
|
+
puts " Channel notifications received: #{group.channel.notifications_received}"
|
|
196
|
+
puts " Listener active: #{group.channel.listening? ? '✓ Yes' : '✗ No'}"
|
|
197
|
+
|
|
198
|
+
# Verify both robots have the memory in their in-memory cache
|
|
199
|
+
puts "\n In-memory working memory after sync:"
|
|
200
|
+
group.instance_variable_get(:@active_robots).each do |name, htm|
|
|
201
|
+
puts " #{name}: #{htm.working_memory.node_count} nodes"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Summary
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
puts "\n" + '=' * 60
|
|
208
|
+
puts 'Demo Complete!'
|
|
209
|
+
puts "\nRobotGroup enables:"
|
|
210
|
+
puts ' • Shared working memory across multiple robots'
|
|
211
|
+
puts ' • Instant failover with warm standby (passive robots)'
|
|
212
|
+
puts ' • Collaborative context building'
|
|
213
|
+
puts ' • Dynamic scaling (add/remove robots)'
|
|
214
|
+
puts ' • Real-time sync via PostgreSQL LISTEN/NOTIFY'
|
|
215
|
+
puts "\nFinal group status:"
|
|
216
|
+
status = group.status
|
|
217
|
+
status.each { |k, v| puts " #{k}: #{v}" }
|
|
218
|
+
|
|
219
|
+
# Cleanup
|
|
220
|
+
puts "\nCleaning up..."
|
|
221
|
+
group.clear_working_memory
|
|
222
|
+
group.shutdown
|
|
223
|
+
puts '✓ Cleared shared working memory and stopped listener'
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
puts "\n✗ Error: #{e.message}"
|
|
226
|
+
puts e.backtrace.first(5).join("\n")
|
|
227
|
+
group&.shutdown # Ensure we clean up on error too
|
|
228
|
+
exit 1
|
|
229
|
+
end
|