htm 0.0.30 → 0.0.32

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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +2 -3
  3. data/.rubocop.yml +184 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +2 -0
  6. data/Rakefile +93 -12
  7. data/db/migrate/00008_create_node_relationships.rb +54 -0
  8. data/db/migrate/00009_fix_node_relationships_column_types.rb +17 -0
  9. data/db/schema.sql +124 -1
  10. data/docs/api/database.md +35 -57
  11. data/docs/api/embedding-service.md +1 -1
  12. data/docs/api/index.md +26 -15
  13. data/docs/api/working-memory.md +8 -8
  14. data/docs/architecture/index.md +5 -7
  15. data/docs/architecture/overview.md +5 -8
  16. data/docs/assets/images/htm-architecture-overview.svg +1 -1
  17. data/docs/assets/images/htm-context-assembly-flow.svg +2 -2
  18. data/docs/assets/images/htm-layered-architecture.svg +3 -3
  19. data/docs/assets/images/two-tier-memory-architecture.svg +1 -1
  20. data/docs/database/README.md +1 -0
  21. data/docs/database_rake_tasks.md +20 -28
  22. data/docs/development/contributing.md +5 -5
  23. data/docs/development/index.md +4 -7
  24. data/docs/development/schema.md +71 -1
  25. data/docs/development/setup.md +40 -82
  26. data/docs/development/testing.md +1 -1
  27. data/docs/examples/file-loading.md +4 -4
  28. data/docs/examples/mcp-client.md +1 -1
  29. data/docs/getting-started/quick-start.md +4 -4
  30. data/docs/guides/adding-memories.md +14 -1
  31. data/docs/guides/configuration.md +5 -5
  32. data/docs/guides/context-assembly.md +4 -4
  33. data/docs/guides/file-loading.md +12 -12
  34. data/docs/guides/getting-started.md +2 -2
  35. data/docs/guides/long-term-memory.md +7 -27
  36. data/docs/guides/propositions.md +20 -19
  37. data/docs/guides/recalling-memories.md +5 -5
  38. data/docs/guides/tags.md +18 -13
  39. data/docs/multi_framework_support.md +1 -1
  40. data/docs/robots/hive-mind.md +1 -1
  41. data/docs/robots/multi-robot.md +2 -2
  42. data/docs/robots/robot-groups.md +1 -1
  43. data/docs/robots/two-tier-memory.md +72 -94
  44. data/docs/setup_local_database.md +8 -54
  45. data/docs/using_rake_tasks_in_your_app.md +6 -6
  46. data/examples/01_basic_usage.rb +1 -0
  47. data/examples/03_custom_llm_configuration.rb +1 -0
  48. data/examples/04_file_loader_usage.rb +1 -0
  49. data/examples/05_timeframe_demo.rb +1 -0
  50. data/examples/06_example_app/app.rb +1 -0
  51. data/examples/07_cli_app/htm_cli.rb +1 -0
  52. data/examples/09_mcp_client.rb +1 -0
  53. data/examples/10_telemetry/demo.rb +1 -0
  54. data/examples/11_robot_groups/multi_process.rb +1 -0
  55. data/examples/11_robot_groups/same_process.rb +1 -0
  56. data/examples/12_rails_app/.envrc +12 -0
  57. data/examples/12_rails_app/Gemfile +8 -3
  58. data/examples/12_rails_app/Gemfile.lock +94 -89
  59. data/examples/12_rails_app/README.md +70 -19
  60. data/examples/12_rails_app/app/controllers/application_controller.rb +6 -0
  61. data/examples/12_rails_app/app/controllers/chats_controller.rb +305 -0
  62. data/examples/12_rails_app/app/controllers/dashboard_controller.rb +3 -0
  63. data/examples/12_rails_app/app/controllers/files_controller.rb +17 -2
  64. data/examples/12_rails_app/app/controllers/home_controller.rb +8 -0
  65. data/examples/12_rails_app/app/controllers/memories_controller.rb +9 -4
  66. data/examples/12_rails_app/app/controllers/messages_controller.rb +214 -0
  67. data/examples/12_rails_app/app/controllers/robots_controller.rb +11 -1
  68. data/examples/12_rails_app/app/controllers/tags_controller.rb +14 -1
  69. data/examples/12_rails_app/app/javascript/application.js +1 -1
  70. data/examples/12_rails_app/app/models/application_record.rb +5 -0
  71. data/examples/12_rails_app/app/models/chat.rb +36 -0
  72. data/examples/12_rails_app/app/models/message.rb +5 -0
  73. data/examples/12_rails_app/app/models/model.rb +5 -0
  74. data/examples/12_rails_app/app/models/tool_call.rb +5 -0
  75. data/examples/12_rails_app/app/views/chats/index.html.erb +61 -0
  76. data/examples/12_rails_app/app/views/chats/show.html.erb +213 -0
  77. data/examples/12_rails_app/app/views/dashboard/index.html.erb +3 -0
  78. data/examples/12_rails_app/app/views/files/index.html.erb +10 -5
  79. data/examples/12_rails_app/app/views/files/new.html.erb +4 -2
  80. data/examples/12_rails_app/app/views/files/show.html.erb +19 -3
  81. data/examples/12_rails_app/app/views/home/index.html.erb +45 -0
  82. data/examples/12_rails_app/app/views/layouts/application.html.erb +20 -18
  83. data/examples/12_rails_app/app/views/memories/_memory_card.html.erb +1 -1
  84. data/examples/12_rails_app/app/views/memories/deleted.html.erb +3 -1
  85. data/examples/12_rails_app/app/views/memories/edit.html.erb +2 -0
  86. data/examples/12_rails_app/app/views/memories/index.html.erb +2 -0
  87. data/examples/12_rails_app/app/views/memories/new.html.erb +2 -0
  88. data/examples/12_rails_app/app/views/memories/show.html.erb +4 -2
  89. data/examples/12_rails_app/app/views/messages/_message.html.erb +20 -0
  90. data/examples/12_rails_app/app/views/robots/index.html.erb +2 -0
  91. data/examples/12_rails_app/app/views/robots/new.html.erb +2 -0
  92. data/examples/12_rails_app/app/views/robots/show.html.erb +2 -0
  93. data/examples/12_rails_app/app/views/search/index.html.erb +59 -8
  94. data/examples/12_rails_app/app/views/shared/_navbar.html.erb +75 -29
  95. data/examples/12_rails_app/app/views/tags/index.html.erb +2 -0
  96. data/examples/12_rails_app/app/views/tags/show.html.erb +3 -1
  97. data/examples/12_rails_app/config/application.rb +1 -1
  98. data/examples/12_rails_app/config/database.yml +9 -5
  99. data/examples/12_rails_app/config/importmap.rb +1 -1
  100. data/examples/12_rails_app/config/initializers/htm.rb +9 -2
  101. data/examples/12_rails_app/config/initializers/ruby_llm.rb +33 -0
  102. data/examples/12_rails_app/config/routes.rb +39 -23
  103. data/examples/12_rails_app/db/migrate/20250124000001_create_ruby_llm_tables.rb +34 -0
  104. data/examples/12_rails_app/db/migrate/20250124000002_create_models_table.rb +28 -0
  105. data/examples/12_rails_app/db/schema.rb +67 -0
  106. data/examples/examples_helper.rb +25 -0
  107. data/lib/htm/circuit_breaker.rb +5 -6
  108. data/lib/htm/config/builder.rb +12 -12
  109. data/lib/htm/config/database.rb +21 -27
  110. data/lib/htm/config/defaults.yml +25 -13
  111. data/lib/htm/config/validator.rb +12 -18
  112. data/lib/htm/config.rb +93 -173
  113. data/lib/htm/database.rb +193 -199
  114. data/lib/htm/embedding_service.rb +4 -9
  115. data/lib/htm/integrations/sinatra.rb +7 -7
  116. data/lib/htm/job_adapter.rb +14 -21
  117. data/lib/htm/jobs/generate_embedding_job.rb +28 -44
  118. data/lib/htm/jobs/generate_propositions_job.rb +29 -55
  119. data/lib/htm/jobs/generate_relationships_job.rb +137 -0
  120. data/lib/htm/jobs/generate_tags_job.rb +45 -67
  121. data/lib/htm/loaders/markdown_loader.rb +65 -112
  122. data/lib/htm/long_term_memory/fulltext_search.rb +1 -1
  123. data/lib/htm/long_term_memory/hybrid_search.rb +300 -128
  124. data/lib/htm/long_term_memory/node_operations.rb +2 -2
  125. data/lib/htm/long_term_memory/relevance_scorer.rb +100 -68
  126. data/lib/htm/long_term_memory/tag_operations.rb +87 -120
  127. data/lib/htm/long_term_memory/vector_search.rb +1 -1
  128. data/lib/htm/long_term_memory.rb +2 -1
  129. data/lib/htm/mcp/cli.rb +59 -58
  130. data/lib/htm/mcp/server.rb +5 -6
  131. data/lib/htm/mcp/tools.rb +30 -36
  132. data/lib/htm/migration.rb +10 -10
  133. data/lib/htm/models/node.rb +2 -3
  134. data/lib/htm/models/node_relationship.rb +72 -0
  135. data/lib/htm/models/node_tag.rb +2 -2
  136. data/lib/htm/models/robot_node.rb +2 -2
  137. data/lib/htm/models/tag.rb +41 -28
  138. data/lib/htm/observability.rb +45 -51
  139. data/lib/htm/proposition_service.rb +3 -7
  140. data/lib/htm/query_cache.rb +13 -15
  141. data/lib/htm/railtie.rb +1 -2
  142. data/lib/htm/robot_group.rb +9 -9
  143. data/lib/htm/sequel_config.rb +1 -0
  144. data/lib/htm/sql_builder.rb +1 -1
  145. data/lib/htm/tag_service.rb +2 -6
  146. data/lib/htm/timeframe.rb +4 -5
  147. data/lib/htm/timeframe_extractor.rb +42 -83
  148. data/lib/htm/version.rb +1 -1
  149. data/lib/htm/workflows/remember_workflow.rb +112 -115
  150. data/lib/htm/working_memory.rb +21 -26
  151. data/lib/htm.rb +103 -116
  152. data/lib/tasks/db.rake +0 -2
  153. data/lib/tasks/doc.rake +14 -13
  154. data/lib/tasks/files.rake +5 -12
  155. data/lib/tasks/htm.rake +70 -71
  156. data/lib/tasks/jobs.rake +41 -47
  157. data/lib/tasks/tags.rake +3 -8
  158. metadata +28 -106
  159. data/lib/htm/config/section.rb +0 -74
  160. data/lib/htm/loaders/defaults_loader.rb +0 -166
  161. data/lib/htm/loaders/xdg_config_loader.rb +0 -116
data/lib/htm/mcp/cli.rb CHANGED
@@ -138,51 +138,16 @@ class HTM
138
138
  end
139
139
 
140
140
  def check_database_config!
141
- unless ENV['HTM_DATABASE__URL'] || ENV['HTM_DATABASE__NAME']
142
- warn "Error: Database not configured."
143
- warn "Set HTM_DATABASE__URL or HTM_DATABASE__NAME environment variable."
144
- warn "Run 'htm_mcp help' for details."
145
- exit 1
146
- end
141
+ return if ENV['HTM_DATABASE__URL'] || ENV['HTM_DATABASE__NAME']
142
+ warn "Error: Database not configured."
143
+ warn "Set HTM_DATABASE__URL or HTM_DATABASE__NAME environment variable."
144
+ warn "Run 'htm_mcp help' for details."
145
+ exit 1
147
146
  end
148
147
 
149
148
  def print_error_suggestion(error_message)
150
- msg = error_message.to_s.downcase
151
-
152
149
  warn ""
153
- if msg.include?("does not exist")
154
- warn "Suggestion: The database does not exist. Create it with:"
155
- warn " createdb #{extract_dbname(ENV['HTM_DATABASE__URL'] || ENV['HTM_DATABASE__NAME'])}"
156
- warn "Then initialize the schema with:"
157
- warn " htm_mcp setup"
158
- elsif msg.include?("password authentication failed") || msg.include?("no password supplied")
159
- warn "Suggestion: Check your database credentials."
160
- warn "Verify HTM_DATABASE__URL has correct username and password:"
161
- warn " postgresql://USER:PASSWORD@localhost:5432/DATABASE"
162
- elsif msg.include?("connection refused") || msg.include?("could not connect")
163
- warn "Suggestion: PostgreSQL server is not running or not accepting connections."
164
- warn "Start PostgreSQL with:"
165
- warn " brew services start postgresql@17 # macOS with Homebrew"
166
- warn " sudo systemctl start postgresql # Linux"
167
- elsif msg.include?("role") && msg.include?("does not exist")
168
- warn "Suggestion: The database user does not exist. Create it with:"
169
- warn " createuser -s YOUR_USERNAME"
170
- elsif msg.include?("permission denied")
171
- warn "Suggestion: The user lacks permission to access this database."
172
- warn "Grant access or use a different user with appropriate privileges."
173
- elsif msg.include?("timeout") || msg.include?("timed out")
174
- warn "Suggestion: Connection timed out. Check:"
175
- warn " - PostgreSQL is running"
176
- warn " - Firewall allows connections on port 5432"
177
- warn " - Host address is correct"
178
- elsif msg.include?("extension") && msg.include?("vector")
179
- warn "Suggestion: pgvector extension is not installed. Install it with:"
180
- warn " brew install pgvector # macOS"
181
- warn "Then enable it in your database:"
182
- warn " psql -d DATABASE -c 'CREATE EXTENSION vector;'"
183
- else
184
- warn "Suggestion: Run 'htm_mcp help' for configuration details."
185
- end
150
+ suggestion_lines_for(error_message.to_s.downcase).each { |line| warn line }
186
151
  end
187
152
 
188
153
  def extract_dbname(url_or_name)
@@ -190,7 +155,7 @@ class HTM
190
155
 
191
156
  # Extract database name from URL like postgresql://user@host:port/dbname
192
157
  if url_or_name =~ %r{/([^/?]+)(?:\?|$)}
193
- $1
158
+ ::Regexp.last_match(1)
194
159
  else
195
160
  "htm_development"
196
161
  end
@@ -231,7 +196,7 @@ class HTM
231
196
  pending = check_migration_status
232
197
  puts
233
198
 
234
- if pending > 0
199
+ if pending.positive?
235
200
  warn "Warning: #{pending} pending migration(s) detected."
236
201
  warn " Run 'htm_mcp setup' to apply pending migrations."
237
202
  puts
@@ -255,7 +220,8 @@ class HTM
255
220
  version: File.basename(file).split('_').first,
256
221
  name: File.basename(file, '.rb')
257
222
  }
258
- end.sort_by { |m| m[:version] }
223
+ end
224
+ available_migrations = available_migrations.sort_by { |m| m[:version] }
259
225
 
260
226
  # Ensure Sequel connection for migration check
261
227
  HTM::SequelConfig.establish_connection!
@@ -308,12 +274,9 @@ class HTM
308
274
 
309
275
  begin
310
276
  require 'yaml'
311
- config_data = YAML.safe_load(
312
- File.read(path),
313
- permitted_classes: [Symbol],
277
+ config_data = YAML.safe_load_file(path, permitted_classes: [Symbol],
314
278
  symbolize_names: true,
315
- aliases: true
316
- ) || {}
279
+ aliases: true) || {}
317
280
 
318
281
  # Determine which section to use based on environment
319
282
  env = HTM::Config.env.to_sym
@@ -425,7 +388,7 @@ class HTM
425
388
  args = args.dup
426
389
 
427
390
  # Handle -c / --config option first (can be combined with other commands)
428
- config_loaded = handle_config_option(args)
391
+ handle_config_option(args)
429
392
 
430
393
  # Process remaining command
431
394
  case args[0]&.downcase
@@ -448,12 +411,12 @@ class HTM
448
411
  # 'stdio' is accepted for compatibility with MCP clients that pass it as an argument
449
412
  return false
450
413
  when /^-/
451
- $stderr.puts "Unknown option: #{args[0]}"
452
- $stderr.puts "Run 'htm_mcp help' for usage."
414
+ warn "Unknown option: #{args[0]}"
415
+ warn "Run 'htm_mcp help' for usage."
453
416
  exit 1
454
417
  else
455
- $stderr.puts "Unknown command: #{args[0]}"
456
- $stderr.puts "Run 'htm_mcp help' for usage."
418
+ warn "Unknown command: #{args[0]}"
419
+ warn "Run 'htm_mcp help' for usage."
457
420
  exit 1
458
421
  end
459
422
  true
@@ -494,7 +457,7 @@ class HTM
494
457
  if args.empty? || args.first == '--tasks' || args.first == '-T'
495
458
  # Check for optional pattern after -T/--tasks
496
459
  pattern = nil
497
- if args.first == '--tasks' || args.first == '-T'
460
+ if ['--tasks', '-T'].include?(args.first)
498
461
  pattern = args[1] # May be nil if no pattern provided
499
462
  end
500
463
  list_rake_tasks(pattern: pattern)
@@ -532,7 +495,7 @@ class HTM
532
495
 
533
496
  # Load all HTM task files
534
497
  tasks_dir = File.expand_path('../../tasks', __dir__)
535
- Dir.glob(File.join(tasks_dir, '*.rake')).sort.each do |rake_file|
498
+ Dir.glob(File.join(tasks_dir, '*.rake')).each do |rake_file|
536
499
  load rake_file
537
500
  end
538
501
  end
@@ -542,8 +505,8 @@ class HTM
542
505
 
543
506
  # Collect tasks with descriptions, sorted by name
544
507
  tasks = Rake.application.tasks
545
- .select { |t| t.comment && t.name.start_with?('htm:') }
546
- .sort_by(&:name)
508
+ .select { |t| t.comment && t.name.start_with?('htm:') }
509
+ .sort_by(&:name)
547
510
 
548
511
  # Filter by pattern if provided (matches task name)
549
512
  if pattern
@@ -577,6 +540,44 @@ class HTM
577
540
  puts "Run with: htm_mcp rake <task_name>"
578
541
  puts "Example: htm_mcp rake htm:db:stats"
579
542
  end
543
+
544
+ private
545
+
546
+ def suggestion_lines_for(msg)
547
+ if msg.include?("does not exist") && !msg.include?("role")
548
+ dbname = extract_dbname(ENV['HTM_DATABASE__URL'] || ENV.fetch('HTM_DATABASE__NAME', nil))
549
+ ["Suggestion: The database does not exist. Create it with:",
550
+ " createdb #{dbname}",
551
+ "Then initialize the schema with:", " htm_mcp setup"]
552
+ elsif msg.include?("password authentication failed") || msg.include?("no password supplied")
553
+ ["Suggestion: Check your database credentials.",
554
+ "Verify HTM_DATABASE__URL has correct username and password:",
555
+ " postgresql://USER:PASSWORD@localhost:5432/DATABASE"]
556
+ elsif msg.include?("connection refused") || msg.include?("could not connect")
557
+ ["Suggestion: PostgreSQL server is not running or not accepting connections.",
558
+ "Start PostgreSQL with:",
559
+ " brew services start postgresql@17 # macOS with Homebrew",
560
+ " sudo systemctl start postgresql # Linux"]
561
+ elsif msg.include?("role") && msg.include?("does not exist")
562
+ ["Suggestion: The database user does not exist. Create it with:",
563
+ " createuser -s YOUR_USERNAME"]
564
+ elsif msg.include?("permission denied")
565
+ ["Suggestion: The user lacks permission to access this database.",
566
+ "Grant access or use a different user with appropriate privileges."]
567
+ elsif msg.include?("timeout") || msg.include?("timed out")
568
+ ["Suggestion: Connection timed out. Check:",
569
+ " - PostgreSQL is running",
570
+ " - Firewall allows connections on port 5432",
571
+ " - Host address is correct"]
572
+ elsif msg.include?("extension") && msg.include?("vector")
573
+ ["Suggestion: pgvector extension is not installed. Install it with:",
574
+ " brew install pgvector # macOS",
575
+ "Then enable it in your database:",
576
+ " psql -d DATABASE -c 'CREATE EXTENSION vector;'"]
577
+ else
578
+ ["Suggestion: Run 'htm_mcp help' for configuration details."]
579
+ end
580
+ end
580
581
  end
581
582
  end
582
583
  end
@@ -28,12 +28,11 @@ class HTM
28
28
  end
29
29
 
30
30
  def check_database_config!
31
- unless ENV['HTM_DATABASE__URL'] || ENV['HTM_DATABASE__NAME']
32
- warn "Error: Database not configured."
33
- warn "Set HTM_DATABASE__URL or HTM_DATABASE__NAME environment variable."
34
- warn "Run 'htm_mcp help' for details."
35
- exit 1
36
- end
31
+ return if ENV['HTM_DATABASE__URL'] || ENV['HTM_DATABASE__NAME']
32
+ warn "Error: Database not configured."
33
+ warn "Set HTM_DATABASE__URL or HTM_DATABASE__NAME environment variable."
34
+ warn "Run 'htm_mcp help' for details."
35
+ exit 1
37
36
  end
38
37
 
39
38
  def verify_database_connection!
data/lib/htm/mcp/tools.rb CHANGED
@@ -88,44 +88,38 @@ class HTM
88
88
  end
89
89
 
90
90
  def call
91
- htm = Session.htm_instance
91
+ htm = Session.htm_instance
92
92
  robot = HTM::Models::Robot[htm.robot_id]
93
93
  Session.logger&.info "GetWorkingMemoryTool called for robot=#{htm.robot_name}"
94
94
 
95
- # Get all nodes in working memory with their metadata
96
- # Filter out any robot_nodes where the node has been deleted
97
- working_memory_nodes = robot.robot_nodes_dataset
98
- .in_working_memory
99
- .eager(node: :tags)
100
- .order(Sequel.desc(:last_remembered_at))
101
- .all
102
- .filter_map do |rn|
103
- node = rn.node
104
- next unless node # Exclude if node is nil (was deleted)
95
+ nodes = robot.robot_nodes_dataset
96
+ .in_working_memory
97
+ .eager(node: :tags)
98
+ .order(Sequel.desc(:last_remembered_at))
99
+ .all
100
+ .filter_map { |rn| format_working_memory_entry(rn) }
105
101
 
106
- {
107
- id: node.id,
108
- content: node.content,
109
- tags: node.tags.map(&:name),
110
- remember_count: rn.remember_count,
111
- last_remembered_at: rn.last_remembered_at&.iso8601,
112
- created_at: node.created_at.iso8601
113
- }
114
- end
115
-
116
- Session.logger&.info "GetWorkingMemoryTool complete: #{working_memory_nodes.length} nodes in working memory"
117
-
118
- {
119
- success: true,
120
- robot_id: htm.robot_id,
121
- robot_name: htm.robot_name,
122
- count: working_memory_nodes.length,
123
- working_memory: working_memory_nodes
124
- }.to_json
102
+ Session.logger&.info "GetWorkingMemoryTool complete: #{nodes.length} nodes in working memory"
103
+ { success: true, robot_id: htm.robot_id, robot_name: htm.robot_name, count: nodes.length, working_memory: nodes }.to_json
125
104
  rescue StandardError => e
126
105
  Session.logger&.error "GetWorkingMemoryTool error: #{e.message}"
127
106
  { success: false, error: e.message, count: 0, working_memory: [] }.to_json
128
107
  end
108
+
109
+ private
110
+
111
+ def format_working_memory_entry(rn)
112
+ node = rn.node
113
+ return nil unless node
114
+ {
115
+ id: node.id,
116
+ content: node.content,
117
+ tags: node.tags.map(&:name),
118
+ remember_count: rn.remember_count,
119
+ last_remembered_at: rn.last_remembered_at&.iso8601,
120
+ created_at: node.created_at.iso8601
121
+ }
122
+ end
129
123
  end
130
124
 
131
125
  # Tool: Remember information
@@ -221,10 +215,10 @@ class HTM
221
215
  Time.new(now.year, now.month, now.day)..now
222
216
  when 'this week'
223
217
  # 7 days ago
224
- (now - 7 * 24 * 60 * 60)..now
218
+ (now - (7 * 24 * 60 * 60))..now
225
219
  when 'this month'
226
220
  # 30 days ago
227
- (now - 30 * 24 * 60 * 60)..now
221
+ (now - (30 * 24 * 60 * 60))..now
228
222
  else
229
223
  # Try to parse as ISO8601 range (start..end)
230
224
  if timeframe.include?('..')
@@ -438,14 +432,14 @@ class HTM
438
432
  robot = HTM::Models::Robot[htm.robot_id]
439
433
  Session.logger&.info "StatsTool called for robot=#{htm.robot_name}"
440
434
 
441
- # Note: Node uses set_dataset to exclude deleted, so .count returns active nodes
435
+ # NOTE: Node uses set_dataset to exclude deleted, so .count returns active nodes
442
436
  total_nodes = HTM::Models::Node.count
443
437
  deleted_nodes = HTM::Models::Node.deleted.count
444
438
  nodes_with_embeddings = HTM::Models::Node.with_embeddings.count
445
439
  nodes_with_tags = HTM::Models::Node
446
- .join(:node_tags, node_id: :id)
447
- .distinct
448
- .count
440
+ .join(:node_tags, node_id: :id)
441
+ .distinct
442
+ .count
449
443
  total_tags = HTM::Models::Tag.count
450
444
  total_robots = HTM::Models::Robot.count
451
445
 
data/lib/htm/migration.rb CHANGED
@@ -41,24 +41,24 @@ class HTM
41
41
  private
42
42
 
43
43
  # Delegate common methods to db
44
- def create_table(name, **options, &block)
45
- db.create_table(name, **options, &block)
44
+ def create_table(name, **, &)
45
+ db.create_table(name, **, &)
46
46
  end
47
47
 
48
- def drop_table(name, **options)
49
- db.drop_table(name, **options)
48
+ def drop_table(name, **)
49
+ db.drop_table(name, **)
50
50
  end
51
51
 
52
- def alter_table(name, &block)
53
- db.alter_table(name, &block)
52
+ def alter_table(name, &)
53
+ db.alter_table(name, &)
54
54
  end
55
55
 
56
- def add_index(table, columns, **options)
57
- db.add_index(table, columns, **options)
56
+ def add_index(table, columns, **)
57
+ db.add_index(table, columns, **)
58
58
  end
59
59
 
60
- def drop_index(table, columns, **options)
61
- db.drop_index(table, columns, **options)
60
+ def drop_index(table, columns, **)
61
+ db.drop_index(table, columns, **)
62
62
  end
63
63
 
64
64
  def run(sql)
@@ -43,7 +43,7 @@ class HTM
43
43
  # Validations
44
44
  def validate
45
45
  super
46
- validates_presence [:content, :content_hash]
46
+ validates_presence %i[content content_hash]
47
47
  validates_unique :content_hash
48
48
  end
49
49
 
@@ -110,7 +110,6 @@ class HTM
110
110
 
111
111
  # Select distance operator based on metric
112
112
  operator = case distance.to_s
113
- when "cosine" then "<=>"
114
113
  when "euclidean", "l2" then "<->"
115
114
  when "inner_product" then "<#>"
116
115
  else "<=>"
@@ -356,7 +355,7 @@ class HTM
356
355
  def to_hash
357
356
  values.transform_keys(&:to_s)
358
357
  end
359
- alias_method :attributes, :to_hash
358
+ alias attributes to_hash
360
359
  end
361
360
  end
362
361
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ module Models
5
+ # NodeRelationship model - weighted directed edge between two nodes
6
+ #
7
+ # Edges are stored in both directions so the CTE traversal only needs
8
+ # WHERE source_id IN (seeds) rather than an OR across both columns.
9
+ #
10
+ # Weights are Jaccard similarity scores for tag_cooccurrence edges:
11
+ # weight = |tags(A) ∩ tags(B)| / |tags(A) ∪ tags(B)|
12
+ #
13
+ class NodeRelationship < Sequel::Model(:node_relationships)
14
+ REL_TYPES = %w[related_to supports contradicts derived_from].freeze
15
+ ORIGINS = %w[tag_cooccurrence tag_hierarchy explicit].freeze
16
+
17
+ # Associations
18
+ many_to_one :source_node, class: 'HTM::Models::Node', key: :source_id
19
+ many_to_one :target_node, class: 'HTM::Models::Node', key: :target_id
20
+
21
+ # Plugins
22
+ plugin :validation_helpers
23
+ plugin :timestamps, update_on_create: true
24
+
25
+ # Validations
26
+ def validate
27
+ super
28
+ validates_presence %i[source_id target_id rel_type origin weight]
29
+ validates_includes REL_TYPES, :rel_type, allow_missing: true
30
+ validates_includes ORIGINS, :origin, allow_missing: true
31
+ validates_unique %i[source_id target_id rel_type], message: 'relationship already exists'
32
+ errors.add(:source_id, 'cannot relate a node to itself') if source_id && source_id == target_id
33
+ errors.add(:weight, 'must be between 0.0 and 1.0') if weight && !weight.between?(0.0, 1.0)
34
+ end
35
+
36
+ # Dataset methods (scopes)
37
+ dataset_module do
38
+ def by_origin(origin)
39
+ where(origin: origin.to_s)
40
+ end
41
+
42
+ def by_rel_type(rel_type)
43
+ where(rel_type: rel_type.to_s)
44
+ end
45
+
46
+ def above_weight(min_weight)
47
+ where { weight >= min_weight }
48
+ end
49
+
50
+ def neighbors_of(node_id)
51
+ where(source_id: node_id).order(Sequel.desc(:weight))
52
+ end
53
+
54
+ def between_nodes(source_id, target_id)
55
+ where(source_id: source_id, target_id: target_id)
56
+ end
57
+ end
58
+
59
+ # Hooks
60
+ def before_create
61
+ self.created_at ||= Time.now
62
+ self.updated_at ||= Time.now
63
+ super
64
+ end
65
+
66
+ def before_save
67
+ self.updated_at = Time.now if changed_columns.any?
68
+ super
69
+ end
70
+ end
71
+ end
72
+ end
@@ -15,8 +15,8 @@ class HTM
15
15
  # Validations
16
16
  def validate
17
17
  super
18
- validates_presence [:node_id, :tag_id]
19
- validates_unique [:node_id, :tag_id], message: "already associated with this node"
18
+ validates_presence %i[node_id tag_id]
19
+ validates_unique %i[node_id tag_id], message: "already associated with this node"
20
20
  end
21
21
 
22
22
  # Dataset methods (scopes)
@@ -19,8 +19,8 @@ class HTM
19
19
  # Validations
20
20
  def validate
21
21
  super
22
- validates_presence [:robot_id, :node_id]
23
- validates_unique [:robot_id, :node_id], message: 'already linked to this node'
22
+ validates_presence %i[robot_id node_id]
23
+ validates_unique %i[robot_id node_id], message: 'already linked to this node'
24
24
  end
25
25
 
26
26
  # Dataset methods (scopes)
@@ -15,14 +15,14 @@ class HTM
15
15
  plugin :timestamps, update_on_create: true
16
16
 
17
17
  # Tag name format regex
18
- TAG_FORMAT = /\A[a-z0-9\-]+(:[a-z0-9\-]+)*\z/
18
+ TAG_FORMAT = /\A[a-z0-9-]+(:[a-z0-9-]+)*\z/
19
19
 
20
20
  # Validations
21
21
  def validate
22
22
  super
23
23
  validates_presence :name
24
24
  validates_format TAG_FORMAT, :name,
25
- message: "must be lowercase with hyphens, using colons for hierarchy (e.g., 'database:postgresql:performance')"
25
+ message: "must be lowercase with hyphens, using colons for hierarchy (e.g., 'database:postgresql:performance')"
26
26
  validates_unique :name, message: "already exists"
27
27
  end
28
28
 
@@ -233,8 +233,7 @@ class HTM
233
233
 
234
234
  line_prefix = is_last_array.map { |was_last| was_last ? ' ' : '| ' }.join
235
235
 
236
- branch = is_last ? '+-- ' : '+-- '
237
- result += "#{line_prefix}#{branch}#{key}\n"
236
+ result += "#{line_prefix}+-- #{key}\n"
238
237
 
239
238
  children = node[key]
240
239
  unless children.empty?
@@ -313,44 +312,58 @@ class HTM
313
312
 
314
313
  # Generate SVG tree visualization (internal helper)
315
314
  def self.generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
316
- colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#6366F1']
317
-
315
+ colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#6366F1']
318
316
  svg_lines = []
319
317
  svg_lines << %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height + 40}">)
320
318
  svg_lines << ' <rect width="100%" height="100%" fill="transparent"/>'
321
-
322
- svg_lines << %Q( <text x="#{width / 2}" y="25" text-anchor="middle" fill="#F3F4F6" font-family="system-ui, sans-serif" font-size="16" font-weight="bold">#{title}</text>)
319
+ svg_lines << svg_title_element(width, title)
323
320
 
324
321
  positions.each do |path, pos|
325
322
  parent_path = path.include?(':') ? path.split(':')[0..-2].join(':') : nil
326
323
  next unless parent_path && positions[parent_path]
324
+ svg_lines.concat svg_node_elements(path, pos, positions[parent_path], colors, padding, node_width, node_height)
325
+ end
327
326
 
328
- parent_pos = positions[parent_path]
329
- x1 = padding + (parent_pos[:x] * (node_width + 40)) + node_width
330
- y1 = 40 + padding + (parent_pos[:y] * (node_height + 20)) + (node_height / 2)
331
- x2 = padding + (pos[:x] * (node_width + 40))
332
- y2 = 40 + padding + (pos[:y] * (node_height + 20)) + (node_height / 2)
327
+ svg_lines << '</svg>'
328
+ svg_lines.join("\n")
329
+ end
333
330
 
334
- mid_x = (x1 + x2) / 2
335
- svg_lines << %Q( <path d="M#{x1},#{y1} C#{mid_x},#{y1} #{mid_x},#{y2} #{x2},#{y2}" stroke="#4B5563" stroke-width="2" fill="none"/>)
336
- end
331
+ def self.svg_title_element(width, title)
332
+ %( <text x="#{width / 2}" y="25" text-anchor="middle" fill="#F3F4F6" font-family="system-ui, sans-serif" font-size="16" font-weight="bold">#{title}</text>) # rubocop:disable Layout/LineLength
333
+ end
337
334
 
338
- positions.each do |path, pos|
339
- depth = path.count(':')
340
- color = colors[depth % colors.size]
335
+ def self.svg_node_elements(path, pos, parent_pos, colors, padding, node_width, node_height)
336
+ c = svg_node_coords(pos, parent_pos, padding, node_width, node_height)
337
+ color = colors[path.count(':') % colors.size]
338
+ [
339
+ svg_edge_path(c[:x1], c[:y1], c[:x2], c[:y2]),
340
+ svg_node_rect(c[:x], c[:y], node_width, node_height, color),
341
+ svg_node_label(c[:x], c[:y], node_width, node_height, pos[:label])
342
+ ]
343
+ end
341
344
 
342
- x = padding + (pos[:x] * (node_width + 40))
343
- y = 40 + padding + (pos[:y] * (node_height + 20))
345
+ def self.svg_node_coords(pos, parent_pos, padding, node_width, node_height)
346
+ x2 = padding + (pos[:x] * (node_width + 40))
347
+ y2 = 40 + padding + (pos[:y] * (node_height + 20)) + (node_height / 2)
348
+ {
349
+ x1: padding + (parent_pos[:x] * (node_width + 40)) + node_width,
350
+ y1: 40 + padding + (parent_pos[:y] * (node_height + 20)) + (node_height / 2),
351
+ x2: x2, y2: y2,
352
+ x: x2, y: 40 + padding + (pos[:y] * (node_height + 20))
353
+ }
354
+ end
344
355
 
345
- svg_lines << %Q( <rect x="#{x}" y="#{y}" width="#{node_width}" height="#{node_height}" rx="6" fill="#{color}" opacity="0.9"/>)
356
+ def self.svg_edge_path(x1, y1, x2, y2)
357
+ mid_x = (x1 + x2) / 2
358
+ %( <path d="M#{x1},#{y1} C#{mid_x},#{y1} #{mid_x},#{y2} #{x2},#{y2}" stroke="#4B5563" stroke-width="2" fill="none"/>)
359
+ end
346
360
 
347
- text_x = x + (node_width / 2)
348
- text_y = y + (node_height / 2) + 4
349
- svg_lines << %Q( <text x="#{text_x}" y="#{text_y}" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, sans-serif" font-size="11" font-weight="500">#{pos[:label]}</text>)
350
- end
361
+ def self.svg_node_rect(x, y, node_width, node_height, color)
362
+ %( <rect x="#{x}" y="#{y}" width="#{node_width}" height="#{node_height}" rx="6" fill="#{color}" opacity="0.9"/>)
363
+ end
351
364
 
352
- svg_lines << '</svg>'
353
- svg_lines.join("\n")
365
+ def self.svg_node_label(x, y, node_width, node_height, label)
366
+ %( <text x="#{x + (node_width / 2)}" y="#{y + (node_height / 2) + 4}" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, sans-serif" font-size="11" font-weight="500">#{label}</text>) # rubocop:disable Layout/LineLength
354
367
  end
355
368
 
356
369
  # Instance methods