htm 0.0.1 → 0.0.10

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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/.aigcm_msg +1 -0
  3. data/.architecture/reviews/comprehensive-codebase-review.md +577 -0
  4. data/.claude/settings.local.json +92 -0
  5. data/.envrc +1 -0
  6. data/.irbrc +283 -80
  7. data/.tbls.yml +31 -0
  8. data/CHANGELOG.md +314 -16
  9. data/CLAUDE.md +603 -0
  10. data/README.md +76 -5
  11. data/Rakefile +5 -0
  12. data/SETUP.md +132 -101
  13. data/db/migrate/{20250101000001_enable_extensions.rb → 00001_enable_extensions.rb} +0 -1
  14. data/db/migrate/00002_create_robots.rb +11 -0
  15. data/db/migrate/00003_create_file_sources.rb +20 -0
  16. data/db/migrate/00004_create_nodes.rb +65 -0
  17. data/db/migrate/00005_create_tags.rb +13 -0
  18. data/db/migrate/00006_create_node_tags.rb +18 -0
  19. data/db/migrate/00007_create_robot_nodes.rb +26 -0
  20. data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +12 -0
  21. data/db/schema.sql +390 -36
  22. data/docs/api/database.md +19 -232
  23. data/docs/api/embedding-service.md +1 -7
  24. data/docs/api/htm.md +305 -364
  25. data/docs/api/index.md +1 -7
  26. data/docs/api/long-term-memory.md +342 -590
  27. data/docs/api/yard/HTM/ActiveRecordConfig.md +23 -0
  28. data/docs/api/yard/HTM/AuthorizationError.md +11 -0
  29. data/docs/api/yard/HTM/CircuitBreaker.md +92 -0
  30. data/docs/api/yard/HTM/CircuitBreakerOpenError.md +34 -0
  31. data/docs/api/yard/HTM/Configuration.md +175 -0
  32. data/docs/api/yard/HTM/Database.md +99 -0
  33. data/docs/api/yard/HTM/DatabaseError.md +14 -0
  34. data/docs/api/yard/HTM/EmbeddingError.md +18 -0
  35. data/docs/api/yard/HTM/EmbeddingService.md +58 -0
  36. data/docs/api/yard/HTM/Error.md +11 -0
  37. data/docs/api/yard/HTM/JobAdapter.md +39 -0
  38. data/docs/api/yard/HTM/LongTermMemory.md +342 -0
  39. data/docs/api/yard/HTM/NotFoundError.md +17 -0
  40. data/docs/api/yard/HTM/Observability.md +107 -0
  41. data/docs/api/yard/HTM/QueryTimeoutError.md +19 -0
  42. data/docs/api/yard/HTM/Railtie.md +27 -0
  43. data/docs/api/yard/HTM/ResourceExhaustedError.md +13 -0
  44. data/docs/api/yard/HTM/TagError.md +18 -0
  45. data/docs/api/yard/HTM/TagService.md +67 -0
  46. data/docs/api/yard/HTM/Timeframe/Result.md +24 -0
  47. data/docs/api/yard/HTM/Timeframe.md +40 -0
  48. data/docs/api/yard/HTM/TimeframeExtractor/Result.md +24 -0
  49. data/docs/api/yard/HTM/TimeframeExtractor.md +45 -0
  50. data/docs/api/yard/HTM/ValidationError.md +20 -0
  51. data/docs/api/yard/HTM/WorkingMemory.md +131 -0
  52. data/docs/api/yard/HTM.md +80 -0
  53. data/docs/api/yard/index.csv +179 -0
  54. data/docs/api/yard-reference.md +51 -0
  55. data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
  56. data/docs/architecture/adrs/003-ollama-embeddings.md +1 -1
  57. data/docs/architecture/adrs/010-redis-working-memory-rejected.md +2 -27
  58. data/docs/architecture/adrs/index.md +2 -13
  59. data/docs/architecture/hive-mind.md +165 -166
  60. data/docs/architecture/index.md +2 -2
  61. data/docs/architecture/overview.md +5 -171
  62. data/docs/architecture/two-tier-memory.md +1 -35
  63. data/docs/assets/images/adr-010-current-architecture.svg +37 -0
  64. data/docs/assets/images/adr-010-proposed-architecture.svg +48 -0
  65. data/docs/assets/images/adr-dependency-tree.svg +93 -0
  66. data/docs/assets/images/class-hierarchy.svg +55 -0
  67. data/docs/assets/images/exception-hierarchy.svg +45 -0
  68. data/docs/assets/images/htm-architecture-overview.svg +83 -0
  69. data/docs/assets/images/htm-complete-memory-flow.svg +160 -0
  70. data/docs/assets/images/htm-context-assembly-flow.svg +148 -0
  71. data/docs/assets/images/htm-eviction-process.svg +141 -0
  72. data/docs/assets/images/htm-memory-addition-flow.svg +138 -0
  73. data/docs/assets/images/htm-memory-recall-flow.svg +152 -0
  74. data/docs/assets/images/htm-node-states.svg +123 -0
  75. data/docs/assets/images/project-structure.svg +78 -0
  76. data/docs/assets/images/test-directory-structure.svg +38 -0
  77. data/{dbdoc → docs/database}/README.md +127 -125
  78. data/docs/database/public.file_sources.md +42 -0
  79. data/docs/database/public.file_sources.svg +211 -0
  80. data/{dbdoc → docs/database}/public.node_tags.md +7 -8
  81. data/docs/database/public.node_tags.svg +239 -0
  82. data/{dbdoc → docs/database}/public.nodes.md +22 -17
  83. data/docs/database/public.nodes.svg +271 -0
  84. data/docs/database/public.robot_nodes.md +46 -0
  85. data/docs/database/public.robot_nodes.svg +243 -0
  86. data/{dbdoc → docs/database}/public.robots.md +2 -3
  87. data/docs/database/public.robots.svg +161 -0
  88. data/docs/database/public.tags.svg +139 -0
  89. data/{dbdoc → docs/database}/schema.json +941 -630
  90. data/docs/database/schema.svg +282 -0
  91. data/docs/development/index.md +1 -29
  92. data/docs/development/schema.md +134 -309
  93. data/docs/development/testing.md +1 -9
  94. data/docs/getting-started/index.md +47 -0
  95. data/docs/{installation.md → getting-started/installation.md} +2 -2
  96. data/docs/{quick-start.md → getting-started/quick-start.md} +5 -5
  97. data/docs/guides/adding-memories.md +295 -643
  98. data/docs/guides/recalling-memories.md +36 -1
  99. data/docs/guides/search-strategies.md +85 -51
  100. data/docs/images/htm-er-diagram.svg +156 -0
  101. data/docs/index.md +16 -31
  102. data/docs/multi_framework_support.md +4 -4
  103. data/examples/README.md +280 -0
  104. data/examples/basic_usage.rb +18 -16
  105. data/examples/cli_app/htm_cli.rb +146 -8
  106. data/examples/cli_app/temp.log +93 -0
  107. data/examples/custom_llm_configuration.rb +1 -2
  108. data/examples/example_app/app.rb +11 -14
  109. data/examples/file_loader_usage.rb +177 -0
  110. data/examples/robot_groups/lib/robot_group.rb +419 -0
  111. data/examples/robot_groups/lib/working_memory_channel.rb +140 -0
  112. data/examples/robot_groups/multi_process.rb +286 -0
  113. data/examples/robot_groups/robot_worker.rb +136 -0
  114. data/examples/robot_groups/same_process.rb +229 -0
  115. data/examples/sinatra_app/Gemfile +1 -0
  116. data/examples/sinatra_app/Gemfile.lock +166 -0
  117. data/examples/sinatra_app/app.rb +219 -24
  118. data/examples/timeframe_demo.rb +276 -0
  119. data/lib/htm/active_record_config.rb +10 -3
  120. data/lib/htm/circuit_breaker.rb +202 -0
  121. data/lib/htm/configuration.rb +313 -80
  122. data/lib/htm/database.rb +67 -36
  123. data/lib/htm/embedding_service.rb +39 -2
  124. data/lib/htm/errors.rb +131 -11
  125. data/lib/htm/{sinatra.rb → integrations/sinatra.rb} +87 -12
  126. data/lib/htm/job_adapter.rb +10 -3
  127. data/lib/htm/jobs/generate_embedding_job.rb +5 -4
  128. data/lib/htm/jobs/generate_tags_job.rb +4 -0
  129. data/lib/htm/loaders/markdown_loader.rb +263 -0
  130. data/lib/htm/loaders/paragraph_chunker.rb +112 -0
  131. data/lib/htm/long_term_memory.rb +601 -321
  132. data/lib/htm/models/file_source.rb +99 -0
  133. data/lib/htm/models/node.rb +116 -12
  134. data/lib/htm/models/robot.rb +53 -4
  135. data/lib/htm/models/robot_node.rb +51 -0
  136. data/lib/htm/models/tag.rb +302 -0
  137. data/lib/htm/observability.rb +395 -0
  138. data/lib/htm/tag_service.rb +60 -3
  139. data/lib/htm/tasks.rb +29 -0
  140. data/lib/htm/timeframe.rb +194 -0
  141. data/lib/htm/timeframe_extractor.rb +307 -0
  142. data/lib/htm/version.rb +1 -1
  143. data/lib/htm/working_memory.rb +165 -70
  144. data/lib/htm.rb +352 -133
  145. data/lib/tasks/doc.rake +300 -0
  146. data/lib/tasks/files.rake +299 -0
  147. data/lib/tasks/htm.rake +188 -2
  148. data/lib/tasks/jobs.rake +10 -12
  149. data/lib/tasks/tags.rake +194 -0
  150. data/mkdocs.yml +91 -9
  151. data/notes/ARCHITECTURE_REVIEW.md +1167 -0
  152. data/notes/IMPLEMENTATION_SUMMARY.md +606 -0
  153. data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +451 -0
  154. data/notes/next_steps.md +100 -0
  155. data/notes/plan.md +627 -0
  156. data/notes/tag_ontology_enhancement_ideas.md +222 -0
  157. data/notes/timescaledb_removal_summary.md +200 -0
  158. metadata +177 -37
  159. data/db/migrate/20250101000002_create_robots.rb +0 -14
  160. data/db/migrate/20250101000003_create_nodes.rb +0 -42
  161. data/db/migrate/20250101000005_create_tags.rb +0 -38
  162. data/db/migrate/20250101000007_add_node_vector_indexes.rb +0 -30
  163. data/dbdoc/public.node_tags.svg +0 -112
  164. data/dbdoc/public.nodes.svg +0 -118
  165. data/dbdoc/public.robots.svg +0 -90
  166. data/dbdoc/public.tags.svg +0 -60
  167. data/dbdoc/schema.svg +0 -154
  168. data/{dbdoc → docs/database}/public.node_stats.md +0 -0
  169. data/{dbdoc → docs/database}/public.node_stats.svg +0 -0
  170. data/{dbdoc → docs/database}/public.nodes_tags.md +0 -0
  171. data/{dbdoc → docs/database}/public.nodes_tags.svg +0 -0
  172. data/{dbdoc → docs/database}/public.ontology_structure.md +0 -0
  173. data/{dbdoc → docs/database}/public.ontology_structure.svg +0 -0
  174. data/{dbdoc → docs/database}/public.operations_log.md +0 -0
  175. data/{dbdoc → docs/database}/public.operations_log.svg +0 -0
  176. data/{dbdoc → docs/database}/public.relationships.md +0 -0
  177. data/{dbdoc → docs/database}/public.relationships.svg +0 -0
  178. data/{dbdoc → docs/database}/public.robot_activity.md +0 -0
  179. data/{dbdoc → docs/database}/public.robot_activity.svg +0 -0
  180. data/{dbdoc → docs/database}/public.schema_migrations.md +0 -0
  181. data/{dbdoc → docs/database}/public.schema_migrations.svg +0 -0
  182. data/{dbdoc → docs/database}/public.tags.md +3 -3
  183. /data/{dbdoc → docs/database}/public.topic_relationships.md +0 -0
  184. /data/{dbdoc → docs/database}/public.topic_relationships.svg +0 -0
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ module Models
5
+ # FileSource model - tracks loaded source files
6
+ #
7
+ # Represents a file that has been loaded into HTM with its metadata.
8
+ # Each file can have multiple associated nodes (chunks).
9
+ #
10
+ # @example Find source by path
11
+ # source = FileSource.by_path('/path/to/doc.md').first
12
+ # source.chunks # => [Node, Node, ...]
13
+ #
14
+ # @example Check if re-sync needed
15
+ # current_mtime = File.mtime('/path/to/doc.md')
16
+ # source.needs_sync?(current_mtime) # => true/false
17
+ #
18
+ class FileSource < ActiveRecord::Base
19
+ self.table_name = 'file_sources'
20
+
21
+ # Tolerance for mtime comparison to avoid false positives from
22
+ # precision differences between filesystem and database timestamps
23
+ DELTA_TIME = 5 # seconds
24
+
25
+ # Associations
26
+ has_many :nodes, class_name: 'HTM::Models::Node',
27
+ foreign_key: :source_id, dependent: :nullify
28
+
29
+ # Validations
30
+ validates :file_path, presence: true, uniqueness: true
31
+
32
+ # Scopes
33
+ scope :by_path, ->(path) { where(file_path: File.expand_path(path)) }
34
+ scope :stale, -> { where('mtime < last_synced_at') }
35
+ scope :recently_synced, -> { order(last_synced_at: :desc) }
36
+
37
+ # Check if file needs re-sync based on mtime
38
+ #
39
+ # Uses DELTA_TIME tolerance to avoid false positives from:
40
+ # - Nanosecond/microsecond precision differences (filesystem vs PostgreSQL)
41
+ # - Floating-point rounding errors
42
+ # - Minor timestamp discrepancies across systems
43
+ #
44
+ # @param current_mtime [Time] Current file modification time
45
+ # @return [Boolean] true if file modification time differs by more than DELTA_TIME
46
+ #
47
+ def needs_sync?(current_mtime)
48
+ return true if mtime.nil?
49
+
50
+ (current_mtime.to_i - mtime.to_i).abs > DELTA_TIME
51
+ end
52
+
53
+ # Get ordered chunks from this file
54
+ #
55
+ # @return [ActiveRecord::Relation] Nodes ordered by chunk_position
56
+ #
57
+ def chunks
58
+ nodes.order(:chunk_position)
59
+ end
60
+
61
+ # Extract tags from frontmatter
62
+ #
63
+ # @return [Array<String>] Tag names from frontmatter 'tags' field
64
+ #
65
+ def frontmatter_tags
66
+ return [] unless frontmatter.is_a?(Hash)
67
+
68
+ tags = frontmatter['tags'] || frontmatter[:tags] || []
69
+ Array(tags).map(&:to_s)
70
+ end
71
+
72
+ # Get title from frontmatter
73
+ #
74
+ # @return [String, nil] Title from frontmatter
75
+ #
76
+ def title
77
+ return nil unless frontmatter.is_a?(Hash)
78
+ frontmatter['title'] || frontmatter[:title]
79
+ end
80
+
81
+ # Get author from frontmatter
82
+ #
83
+ # @return [String, nil] Author from frontmatter
84
+ #
85
+ def author
86
+ return nil unless frontmatter.is_a?(Hash)
87
+ frontmatter['author'] || frontmatter[:author]
88
+ end
89
+
90
+ # Soft delete all chunks from this file
91
+ #
92
+ # @return [Integer] Number of chunks soft-deleted
93
+ #
94
+ def soft_delete_chunks!
95
+ nodes.update_all(deleted_at: Time.current)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  class HTM
4
6
  module Models
5
7
  # Node model - represents a memory node (conversation message)
6
8
  #
9
+ # Nodes are globally unique by content (via content_hash) and can be
10
+ # linked to multiple robots through the robot_nodes join table.
11
+ #
7
12
  # Nearest Neighbor Search (via neighbor gem):
8
13
  # # Find 5 nearest neighbors by cosine distance
9
14
  # neighbors = Node.nearest_neighbors(:embedding, query_vector, distance: "cosine").limit(5)
@@ -18,30 +23,73 @@ class HTM
18
23
  class Node < ActiveRecord::Base
19
24
  self.table_name = 'nodes'
20
25
 
21
- # Associations
22
- belongs_to :robot, class_name: 'HTM::Models::Robot', foreign_key: 'robot_id', primary_key: 'id'
26
+ # Associations - Many-to-many with robots via robot_nodes
27
+ has_many :robot_nodes, class_name: 'HTM::Models::RobotNode', dependent: :destroy
28
+ has_many :robots, through: :robot_nodes, class_name: 'HTM::Models::Robot'
23
29
  has_many :node_tags, class_name: 'HTM::Models::NodeTag', dependent: :destroy
24
30
  has_many :tags, through: :node_tags, class_name: 'HTM::Models::Tag'
25
31
 
32
+ # Optional source file association (for nodes loaded from files)
33
+ belongs_to :file_source, class_name: 'HTM::Models::FileSource',
34
+ foreign_key: :source_id, optional: true
35
+
26
36
  # Neighbor - vector similarity search
27
37
  has_neighbors :embedding
28
38
 
29
39
  # Validations
30
40
  validates :content, presence: true
31
- validates :robot_id, presence: true
32
- validates :embedding_dimension, numericality: { greater_than: 0, less_than_or_equal_to: 2000 }, allow_nil: true
41
+ validates :content_hash, presence: true, uniqueness: true
33
42
 
34
43
  # Callbacks
44
+ before_validation :set_content_hash, if: -> { content_hash.blank? && content.present? }
35
45
  before_create :set_defaults
36
46
  before_save :update_timestamps
37
47
 
38
48
  # Scopes
39
- scope :by_robot, ->(robot_id) { where(robot_id: robot_id) }
40
- scope :by_source, ->(source) { where(source: source) }
41
- scope :in_working_memory, -> { where(in_working_memory: true) }
49
+ # Soft delete - by default, only show non-deleted nodes
50
+ default_scope { where(deleted_at: nil) }
51
+
52
+ scope :by_robot, ->(robot_id) { joins(:robot_nodes).where(robot_nodes: { robot_id: robot_id }) }
42
53
  scope :recent, -> { order(created_at: :desc) }
43
54
  scope :in_timeframe, ->(start_time, end_time) { where(created_at: start_time..end_time) }
44
55
  scope :with_embeddings, -> { where.not(embedding: nil) }
56
+ scope :from_source, ->(source_id) { where(source_id: source_id).order(:chunk_position) }
57
+
58
+ # Soft delete scopes
59
+ scope :deleted, -> { unscoped.where.not(deleted_at: nil) }
60
+ scope :with_deleted, -> { unscoped }
61
+ scope :deleted_before, ->(time) { deleted.where('deleted_at < ?', time) }
62
+
63
+ # Class methods
64
+
65
+ # Permanently delete all soft-deleted nodes older than the specified time
66
+ #
67
+ # @param older_than [Time, ActiveSupport::Duration] Delete nodes soft-deleted before this time
68
+ # Can be a Time object or a duration like 30.days.ago
69
+ # @return [Integer] Number of nodes permanently deleted
70
+ #
71
+ def self.purge_deleted(older_than:)
72
+ deleted_before(older_than).destroy_all.count
73
+ end
74
+
75
+ # Find a node by content hash, or return nil
76
+ #
77
+ # @param content [String] The content to search for
78
+ # @return [Node, nil] The existing node or nil
79
+ #
80
+ def self.find_by_content(content)
81
+ hash = generate_content_hash(content)
82
+ find_by(content_hash: hash)
83
+ end
84
+
85
+ # Generate SHA-256 hash for content
86
+ #
87
+ # @param content [String] Content to hash
88
+ # @return [String] 64-character hex hash
89
+ #
90
+ def self.generate_content_hash(content)
91
+ Digest::SHA256.hexdigest(content.to_s)
92
+ end
45
93
 
46
94
  # Instance methods
47
95
 
@@ -65,19 +113,43 @@ class HTM
65
113
  query_embedding = other.is_a?(Node) ? other.embedding : other
66
114
  return nil unless embedding.present? && query_embedding.present?
67
115
 
116
+ # Validate embedding is an array of finite numeric values
117
+ unless query_embedding.is_a?(Array) && query_embedding.all? { |v| v.is_a?(Numeric) && v.finite? }
118
+ return nil
119
+ end
120
+
68
121
  # Calculate cosine similarity: 1 - (embedding <=> query_embedding)
69
- # Format the array as a PostgreSQL vector literal: '[0.1,0.2,0.3]'
70
- vector_str = "[#{query_embedding.join(',')}]"
71
- result = self.class.connection.select_value(
72
- "SELECT 1 - (embedding <=> '#{vector_str}'::vector) FROM nodes WHERE id = #{id}"
122
+ # Safely format the array as a PostgreSQL vector literal
123
+ vector_str = "[#{query_embedding.map { |v| v.to_f }.join(',')}]"
124
+ conn = self.class.connection
125
+ quoted_vector = conn.quote(vector_str)
126
+ quoted_id = conn.quote(id)
127
+
128
+ result = conn.select_value(
129
+ "SELECT 1 - (embedding <=> #{quoted_vector}::vector) FROM nodes WHERE id = #{quoted_id}"
73
130
  )
74
131
  result&.to_f
75
132
  end
76
133
 
134
+ # Get all tag names associated with this node
135
+ #
136
+ # @return [Array<String>] Array of hierarchical tag names (e.g., ["database:postgresql", "ai:llm"])
137
+ #
77
138
  def tag_names
78
139
  tags.pluck(:name)
79
140
  end
80
141
 
142
+ # Add tags to this node (creates tags if they don't exist)
143
+ #
144
+ # @param tag_names [Array<String>, String] Tag name(s) to add
145
+ # @return [void]
146
+ #
147
+ # @example Add a single tag
148
+ # node.add_tags("database:postgresql")
149
+ #
150
+ # @example Add multiple tags
151
+ # node.add_tags(["database:postgresql", "ai:embeddings"])
152
+ #
81
153
  def add_tags(tag_names)
82
154
  Array(tag_names).each do |tag_name|
83
155
  tag = HTM::Models::Tag.find_or_create_by(name: tag_name)
@@ -85,6 +157,11 @@ class HTM
85
157
  end
86
158
  end
87
159
 
160
+ # Remove a tag from this node
161
+ #
162
+ # @param tag_name [String] Tag name to remove
163
+ # @return [void]
164
+ #
88
165
  def remove_tag(tag_name)
89
166
  tag = HTM::Models::Tag.find_by(name: tag_name)
90
167
  return unless tag
@@ -92,10 +169,37 @@ class HTM
92
169
  node_tags.where(tag_id: tag.id).destroy_all
93
170
  end
94
171
 
172
+ # Soft delete - mark node as deleted without removing from database
173
+ #
174
+ # @return [Boolean] true if soft deleted successfully
175
+ #
176
+ def soft_delete!
177
+ update!(deleted_at: Time.current)
178
+ end
179
+
180
+ # Restore a soft-deleted node
181
+ #
182
+ # @return [Boolean] true if restored successfully
183
+ #
184
+ def restore!
185
+ update!(deleted_at: nil)
186
+ end
187
+
188
+ # Check if node is soft-deleted
189
+ #
190
+ # @return [Boolean] true if deleted_at is set
191
+ #
192
+ def deleted?
193
+ deleted_at.present?
194
+ end
195
+
95
196
  private
96
197
 
198
+ def set_content_hash
199
+ self.content_hash = self.class.generate_content_hash(content)
200
+ end
201
+
97
202
  def set_defaults
98
- self.in_working_memory ||= false
99
203
  self.created_at ||= Time.current
100
204
  self.updated_at ||= Time.current
101
205
  self.last_accessed ||= Time.current
@@ -3,12 +3,18 @@
3
3
  class HTM
4
4
  module Models
5
5
  # Robot model - represents an LLM agent using the HTM system
6
+ #
7
+ # Robots can share memories through the many-to-many relationship with nodes.
8
+ # When a robot is deleted, only the robot_nodes links are removed; shared
9
+ # nodes remain in the database for other robots.
10
+ #
6
11
  class Robot < ActiveRecord::Base
7
12
  self.table_name = 'robots'
8
13
 
9
- # Associations
10
- has_many :nodes, class_name: 'HTM::Models::Node', dependent: :destroy
11
- has_many :operation_logs, class_name: 'HTM::Models::OperationLog', dependent: :destroy
14
+ # Associations - Many-to-many with nodes via robot_nodes
15
+ # dependent: :destroy removes links only, NOT the shared nodes
16
+ has_many :robot_nodes, class_name: 'HTM::Models::RobotNode', dependent: :destroy
17
+ has_many :nodes, through: :robot_nodes, class_name: 'HTM::Models::Node'
12
18
 
13
19
  # Validations
14
20
  validates :name, presence: true
@@ -21,23 +27,66 @@ class HTM
21
27
  scope :by_name, ->(name) { where(name: name) }
22
28
 
23
29
  # Class methods
30
+
31
+ # Find or create a robot by name
32
+ #
33
+ # @param robot_name [String] Name of the robot
34
+ # @return [Robot] The found or created robot
35
+ #
24
36
  def self.find_or_create_by_name(robot_name)
25
37
  find_or_create_by(name: robot_name)
26
38
  end
27
39
 
28
40
  # Instance methods
41
+
42
+ # Get the total number of nodes associated with this robot
43
+ #
44
+ # @return [Integer] Number of nodes
45
+ #
29
46
  def node_count
30
47
  nodes.count
31
48
  end
32
49
 
50
+ # Get the most recent nodes for this robot
51
+ #
52
+ # @param limit [Integer] Maximum number of nodes to return (default: 10)
53
+ # @return [ActiveRecord::Relation] Recent nodes ordered by created_at desc
54
+ #
33
55
  def recent_nodes(limit = 10)
34
56
  nodes.recent.limit(limit)
35
57
  end
36
58
 
59
+ # Get nodes with their remember metadata for this robot
60
+ #
61
+ # @param limit [Integer] Max nodes to return
62
+ # @return [Array<Hash>] Nodes with remember_count, first/last_remembered_at
63
+ #
64
+ def nodes_with_metadata(limit = 10)
65
+ robot_nodes
66
+ .includes(:node)
67
+ .order(last_remembered_at: :desc)
68
+ .limit(limit)
69
+ .map do |rn|
70
+ {
71
+ node: rn.node,
72
+ remember_count: rn.remember_count,
73
+ first_remembered_at: rn.first_remembered_at,
74
+ last_remembered_at: rn.last_remembered_at
75
+ }
76
+ end
77
+ end
78
+
79
+ # Get a summary of this robot's memory state
80
+ #
81
+ # @return [Hash] Summary including:
82
+ # - :total_nodes [Integer] Total nodes associated with this robot
83
+ # - :in_working_memory [Integer] Nodes currently in working memory
84
+ # - :with_embeddings [Integer] Nodes that have embeddings generated
85
+ #
37
86
  def memory_summary
38
87
  {
39
88
  total_nodes: nodes.count,
40
- in_working_memory: nodes.in_working_memory.count,
89
+ in_working_memory: robot_nodes.in_working_memory.count,
41
90
  with_embeddings: nodes.with_embeddings.count
42
91
  }
43
92
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ module Models
5
+ # RobotNode Join Model - Links robots to nodes (many-to-many)
6
+ #
7
+ # This model represents the relationship between a robot and a node,
8
+ # tracking when and how many times a robot has "remembered" a piece of content.
9
+ #
10
+ # @example Find all robots that remember a node
11
+ # node.robots
12
+ #
13
+ # @example Find all nodes a robot remembers
14
+ # robot.nodes
15
+ #
16
+ # @example Track remember activity
17
+ # link = RobotNode.find_by(robot: robot, node: node)
18
+ # link.remember_count # => 3
19
+ # link.first_remembered_at
20
+ # link.last_remembered_at
21
+ #
22
+ class RobotNode < ActiveRecord::Base
23
+ self.table_name = 'robot_nodes'
24
+
25
+ belongs_to :robot, class_name: 'HTM::Models::Robot'
26
+ belongs_to :node, class_name: 'HTM::Models::Node'
27
+
28
+ validates :robot_id, presence: true
29
+ validates :node_id, presence: true
30
+ validates :robot_id, uniqueness: { scope: :node_id, message: 'already linked to this node' }
31
+
32
+ # Scopes
33
+ scope :recent, -> { order(last_remembered_at: :desc) }
34
+ scope :by_robot, ->(robot_id) { where(robot_id: robot_id) }
35
+ scope :by_node, ->(node_id) { where(node_id: node_id) }
36
+ scope :frequently_remembered, -> { where('remember_count > 1').order(remember_count: :desc) }
37
+ scope :in_working_memory, -> { where(working_memory: true) }
38
+
39
+ # Record that a robot remembered this content again
40
+ #
41
+ # @return [RobotNode] Updated record
42
+ #
43
+ def record_remember!
44
+ self.remember_count += 1
45
+ self.last_remembered_at = Time.current
46
+ save!
47
+ self
48
+ end
49
+ end
50
+ end
51
+ end