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.
- checksums.yaml +4 -4
- data/.aigcm_msg +1 -0
- data/.architecture/reviews/comprehensive-codebase-review.md +577 -0
- data/.claude/settings.local.json +92 -0
- data/.envrc +1 -0
- data/.irbrc +283 -80
- data/.tbls.yml +31 -0
- data/CHANGELOG.md +314 -16
- data/CLAUDE.md +603 -0
- data/README.md +76 -5
- data/Rakefile +5 -0
- data/SETUP.md +132 -101
- 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 +390 -36
- data/docs/api/database.md +19 -232
- data/docs/api/embedding-service.md +1 -7
- data/docs/api/htm.md +305 -364
- data/docs/api/index.md +1 -7
- data/docs/api/long-term-memory.md +342 -590
- 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/architecture/adrs/001-postgresql-timescaledb.md +1 -1
- data/docs/architecture/adrs/003-ollama-embeddings.md +1 -1
- data/docs/architecture/adrs/010-redis-working-memory-rejected.md +2 -27
- data/docs/architecture/adrs/index.md +2 -13
- data/docs/architecture/hive-mind.md +165 -166
- data/docs/architecture/index.md +2 -2
- data/docs/architecture/overview.md +5 -171
- data/docs/architecture/two-tier-memory.md +1 -35
- data/docs/assets/images/adr-010-current-architecture.svg +37 -0
- data/docs/assets/images/adr-010-proposed-architecture.svg +48 -0
- data/docs/assets/images/adr-dependency-tree.svg +93 -0
- data/docs/assets/images/class-hierarchy.svg +55 -0
- data/docs/assets/images/exception-hierarchy.svg +45 -0
- data/docs/assets/images/htm-architecture-overview.svg +83 -0
- data/docs/assets/images/htm-complete-memory-flow.svg +160 -0
- data/docs/assets/images/htm-context-assembly-flow.svg +148 -0
- data/docs/assets/images/htm-eviction-process.svg +141 -0
- data/docs/assets/images/htm-memory-addition-flow.svg +138 -0
- data/docs/assets/images/htm-memory-recall-flow.svg +152 -0
- data/docs/assets/images/htm-node-states.svg +123 -0
- data/docs/assets/images/project-structure.svg +78 -0
- data/docs/assets/images/test-directory-structure.svg +38 -0
- data/{dbdoc → docs/database}/README.md +127 -125
- data/docs/database/public.file_sources.md +42 -0
- data/docs/database/public.file_sources.svg +211 -0
- data/{dbdoc → docs/database}/public.node_tags.md +7 -8
- data/docs/database/public.node_tags.svg +239 -0
- data/{dbdoc → docs/database}/public.nodes.md +22 -17
- data/docs/database/public.nodes.svg +271 -0
- data/docs/database/public.robot_nodes.md +46 -0
- data/docs/database/public.robot_nodes.svg +243 -0
- data/{dbdoc → docs/database}/public.robots.md +2 -3
- data/docs/database/public.robots.svg +161 -0
- data/docs/database/public.tags.svg +139 -0
- data/{dbdoc → docs/database}/schema.json +941 -630
- data/docs/database/schema.svg +282 -0
- data/docs/development/index.md +1 -29
- data/docs/development/schema.md +134 -309
- data/docs/development/testing.md +1 -9
- data/docs/getting-started/index.md +47 -0
- data/docs/{installation.md → getting-started/installation.md} +2 -2
- data/docs/{quick-start.md → getting-started/quick-start.md} +5 -5
- data/docs/guides/adding-memories.md +295 -643
- data/docs/guides/recalling-memories.md +36 -1
- data/docs/guides/search-strategies.md +85 -51
- data/docs/images/htm-er-diagram.svg +156 -0
- data/docs/index.md +16 -31
- data/docs/multi_framework_support.md +4 -4
- data/examples/README.md +280 -0
- data/examples/basic_usage.rb +18 -16
- data/examples/cli_app/htm_cli.rb +146 -8
- data/examples/cli_app/temp.log +93 -0
- data/examples/custom_llm_configuration.rb +1 -2
- data/examples/example_app/app.rb +11 -14
- data/examples/file_loader_usage.rb +177 -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/sinatra_app/Gemfile +1 -0
- data/examples/sinatra_app/Gemfile.lock +166 -0
- data/examples/sinatra_app/app.rb +219 -24
- data/examples/timeframe_demo.rb +276 -0
- data/lib/htm/active_record_config.rb +10 -3
- data/lib/htm/circuit_breaker.rb +202 -0
- data/lib/htm/configuration.rb +313 -80
- 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/{sinatra.rb → integrations/sinatra.rb} +87 -12
- data/lib/htm/job_adapter.rb +10 -3
- 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 +601 -321
- data/lib/htm/models/file_source.rb +99 -0
- data/lib/htm/models/node.rb +116 -12
- data/lib/htm/models/robot.rb +53 -4
- data/lib/htm/models/robot_node.rb +51 -0
- data/lib/htm/models/tag.rb +302 -0
- data/lib/htm/observability.rb +395 -0
- data/lib/htm/tag_service.rb +60 -3
- data/lib/htm/tasks.rb +29 -0
- 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 +352 -133
- data/lib/tasks/doc.rake +300 -0
- data/lib/tasks/files.rake +299 -0
- data/lib/tasks/htm.rake +188 -2
- data/lib/tasks/jobs.rake +10 -12
- data/lib/tasks/tags.rake +194 -0
- data/mkdocs.yml +91 -9
- 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 +177 -37
- 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/dbdoc/public.node_tags.svg +0 -112
- data/dbdoc/public.nodes.svg +0 -118
- data/dbdoc/public.robots.svg +0 -90
- data/dbdoc/public.tags.svg +0 -60
- data/dbdoc/schema.svg +0 -154
- data/{dbdoc → docs/database}/public.node_stats.md +0 -0
- data/{dbdoc → docs/database}/public.node_stats.svg +0 -0
- data/{dbdoc → docs/database}/public.nodes_tags.md +0 -0
- data/{dbdoc → docs/database}/public.nodes_tags.svg +0 -0
- data/{dbdoc → docs/database}/public.ontology_structure.md +0 -0
- data/{dbdoc → docs/database}/public.ontology_structure.svg +0 -0
- data/{dbdoc → docs/database}/public.operations_log.md +0 -0
- data/{dbdoc → docs/database}/public.operations_log.svg +0 -0
- data/{dbdoc → docs/database}/public.relationships.md +0 -0
- data/{dbdoc → docs/database}/public.relationships.svg +0 -0
- data/{dbdoc → docs/database}/public.robot_activity.md +0 -0
- data/{dbdoc → docs/database}/public.robot_activity.svg +0 -0
- data/{dbdoc → docs/database}/public.schema_migrations.md +0 -0
- data/{dbdoc → docs/database}/public.schema_migrations.svg +0 -0
- data/{dbdoc → docs/database}/public.tags.md +3 -3
- /data/{dbdoc → docs/database}/public.topic_relationships.md +0 -0
- /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
|
data/lib/htm/models/node.rb
CHANGED
|
@@ -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
|
-
|
|
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 :
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
#
|
|
70
|
-
vector_str = "[#{query_embedding.join(',')}]"
|
|
71
|
-
|
|
72
|
-
|
|
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
|
data/lib/htm/models/robot.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
has_many :
|
|
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:
|
|
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
|