htm 0.0.18 → 0.0.30

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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +12 -0
  4. data/Rakefile +104 -18
  5. data/db/migrate/00001_enable_extensions.rb +9 -5
  6. data/db/migrate/00002_create_robots.rb +18 -6
  7. data/db/migrate/00003_create_file_sources.rb +30 -17
  8. data/db/migrate/00004_create_nodes.rb +60 -48
  9. data/db/migrate/00005_create_tags.rb +24 -12
  10. data/db/migrate/00006_create_node_tags.rb +28 -13
  11. data/db/migrate/00007_create_robot_nodes.rb +40 -26
  12. data/db/schema.sql +17 -1
  13. data/db/seeds.rb +34 -34
  14. data/docs/api/embedding-service.md +140 -110
  15. data/docs/api/yard/HTM/ActiveRecordConfig.md +6 -0
  16. data/docs/api/yard/HTM/Config.md +173 -0
  17. data/docs/api/yard/HTM/ConfigSection.md +28 -0
  18. data/docs/api/yard/HTM/Database.md +1 -1
  19. data/docs/api/yard/HTM/Railtie.md +2 -2
  20. data/docs/api/yard/HTM.md +0 -57
  21. data/docs/api/yard/index.csv +76 -61
  22. data/docs/api/yard-reference.md +2 -1
  23. data/docs/architecture/adrs/003-ollama-embeddings.md +45 -36
  24. data/docs/architecture/adrs/004-hive-mind.md +1 -1
  25. data/docs/architecture/adrs/008-robot-identification.md +1 -1
  26. data/docs/architecture/index.md +11 -9
  27. data/docs/architecture/overview.md +11 -7
  28. data/docs/assets/images/balanced-strategy-decay.svg +41 -0
  29. data/docs/assets/images/class-hierarchy.svg +1 -1
  30. data/docs/assets/images/eviction-priority.svg +43 -0
  31. data/docs/assets/images/exception-hierarchy.svg +2 -2
  32. data/docs/assets/images/hive-mind-shared-memory.svg +52 -0
  33. data/docs/assets/images/htm-architecture-overview.svg +3 -3
  34. data/docs/assets/images/htm-core-components.svg +4 -4
  35. data/docs/assets/images/htm-layered-architecture.svg +1 -1
  36. data/docs/assets/images/htm-memory-addition-flow.svg +2 -2
  37. data/docs/assets/images/htm-memory-recall-flow.svg +2 -2
  38. data/docs/assets/images/memory-topology.svg +53 -0
  39. data/docs/assets/images/two-tier-memory-architecture.svg +55 -0
  40. data/docs/database/naming-convention.md +244 -0
  41. data/docs/database_rake_tasks.md +31 -0
  42. data/docs/development/rake-tasks.md +80 -35
  43. data/docs/development/setup.md +76 -44
  44. data/docs/examples/basic-usage.md +133 -0
  45. data/docs/examples/config-files.md +170 -0
  46. data/docs/examples/file-loading.md +208 -0
  47. data/docs/examples/index.md +116 -0
  48. data/docs/examples/llm-configuration.md +168 -0
  49. data/docs/examples/mcp-client.md +172 -0
  50. data/docs/examples/rails-integration.md +173 -0
  51. data/docs/examples/robot-groups.md +210 -0
  52. data/docs/examples/sinatra-integration.md +218 -0
  53. data/docs/examples/standalone-app.md +216 -0
  54. data/docs/examples/telemetry.md +224 -0
  55. data/docs/examples/timeframes.md +143 -0
  56. data/docs/getting-started/installation.md +97 -40
  57. data/docs/getting-started/quick-start.md +28 -11
  58. data/docs/guides/configuration.md +515 -0
  59. data/docs/guides/file-loading.md +322 -0
  60. data/docs/guides/getting-started.md +40 -9
  61. data/docs/guides/index.md +3 -3
  62. data/docs/guides/mcp-server.md +100 -13
  63. data/docs/guides/propositions.md +264 -0
  64. data/docs/guides/recalling-memories.md +4 -4
  65. data/docs/guides/search-strategies.md +3 -3
  66. data/docs/guides/tags.md +318 -0
  67. data/docs/guides/telemetry.md +229 -0
  68. data/docs/index.md +8 -16
  69. data/docs/{architecture → robots}/hive-mind.md +8 -111
  70. data/docs/robots/index.md +73 -0
  71. data/docs/{guides → robots}/multi-robot.md +3 -3
  72. data/docs/{guides → robots}/robot-groups.md +8 -7
  73. data/docs/{architecture → robots}/two-tier-memory.md +13 -149
  74. data/docs/robots/why-robots.md +85 -0
  75. data/examples/.envrc +6 -0
  76. data/examples/.gitignore +2 -0
  77. data/examples/00_create_examples_db.rb +94 -0
  78. data/examples/{basic_usage.rb → 01_basic_usage.rb} +12 -16
  79. data/examples/{custom_llm_configuration.rb → 03_custom_llm_configuration.rb} +13 -3
  80. data/examples/{file_loader_usage.rb → 04_file_loader_usage.rb} +11 -14
  81. data/examples/{timeframe_demo.rb → 05_timeframe_demo.rb} +10 -3
  82. data/examples/{example_app → 06_example_app}/app.rb +15 -15
  83. data/examples/{cli_app → 07_cli_app}/htm_cli.rb +15 -22
  84. data/examples/08_sinatra_app/Gemfile.lock +241 -0
  85. data/examples/{sinatra_app → 08_sinatra_app}/app.rb +19 -18
  86. data/examples/{mcp_client.rb → 09_mcp_client.rb} +5 -8
  87. data/examples/{telemetry → 10_telemetry}/SETUP_README.md +1 -1
  88. data/examples/{telemetry → 10_telemetry}/demo.rb +14 -10
  89. data/examples/11_robot_groups/README.md +335 -0
  90. data/examples/{robot_groups → 11_robot_groups/lib}/robot_worker.rb +17 -3
  91. data/examples/{robot_groups → 11_robot_groups}/multi_process.rb +9 -9
  92. data/examples/{robot_groups → 11_robot_groups}/same_process.rb +9 -12
  93. data/examples/{rails_app → 12_rails_app}/Gemfile +3 -0
  94. data/examples/{rails_app → 12_rails_app}/Gemfile.lock +87 -58
  95. data/examples/{rails_app → 12_rails_app}/app/controllers/dashboard_controller.rb +10 -6
  96. data/examples/{rails_app → 12_rails_app}/app/controllers/files_controller.rb +5 -5
  97. data/examples/{rails_app → 12_rails_app}/app/controllers/memories_controller.rb +11 -7
  98. data/examples/{rails_app → 12_rails_app}/app/controllers/robots_controller.rb +8 -8
  99. data/examples/12_rails_app/app/controllers/tags_controller.rb +36 -0
  100. data/examples/{rails_app → 12_rails_app}/app/views/dashboard/index.html.erb +2 -2
  101. data/examples/{rails_app → 12_rails_app}/app/views/files/new.html.erb +5 -2
  102. data/examples/{rails_app → 12_rails_app}/app/views/memories/_memory_card.html.erb +3 -3
  103. data/examples/{rails_app → 12_rails_app}/app/views/memories/deleted.html.erb +3 -3
  104. data/examples/{rails_app → 12_rails_app}/app/views/memories/edit.html.erb +3 -3
  105. data/examples/{rails_app → 12_rails_app}/app/views/memories/show.html.erb +4 -4
  106. data/examples/{rails_app → 12_rails_app}/app/views/robots/index.html.erb +2 -2
  107. data/examples/{rails_app → 12_rails_app}/app/views/robots/show.html.erb +4 -4
  108. data/examples/{rails_app → 12_rails_app}/app/views/search/index.html.erb +1 -1
  109. data/examples/{rails_app → 12_rails_app}/app/views/tags/index.html.erb +2 -2
  110. data/examples/{rails_app → 12_rails_app}/app/views/tags/show.html.erb +1 -1
  111. data/examples/12_rails_app/config/initializers/htm.rb +7 -0
  112. data/examples/12_rails_app/config/initializers/rack.rb +5 -0
  113. data/examples/README.md +230 -211
  114. data/examples/examples_helper.rb +138 -0
  115. data/lib/htm/config/builder.rb +167 -0
  116. data/lib/htm/config/database.rb +317 -0
  117. data/lib/htm/config/defaults.yml +41 -13
  118. data/lib/htm/config/section.rb +74 -0
  119. data/lib/htm/config/validator.rb +83 -0
  120. data/lib/htm/config.rb +65 -361
  121. data/lib/htm/database.rb +85 -127
  122. data/lib/htm/errors.rb +14 -0
  123. data/lib/htm/integrations/sinatra.rb +13 -44
  124. data/lib/htm/job_adapter.rb +75 -1
  125. data/lib/htm/jobs/generate_embedding_job.rb +3 -4
  126. data/lib/htm/jobs/generate_propositions_job.rb +4 -5
  127. data/lib/htm/jobs/generate_tags_job.rb +16 -15
  128. data/lib/htm/loaders/defaults_loader.rb +23 -0
  129. data/lib/htm/loaders/markdown_loader.rb +17 -15
  130. data/lib/htm/loaders/xdg_config_loader.rb +9 -9
  131. data/lib/htm/long_term_memory/fulltext_search.rb +14 -14
  132. data/lib/htm/long_term_memory/hybrid_search.rb +396 -229
  133. data/lib/htm/long_term_memory/node_operations.rb +24 -23
  134. data/lib/htm/long_term_memory/relevance_scorer.rb +23 -20
  135. data/lib/htm/long_term_memory/robot_operations.rb +4 -4
  136. data/lib/htm/long_term_memory/tag_operations.rb +91 -77
  137. data/lib/htm/long_term_memory/vector_search.rb +4 -5
  138. data/lib/htm/long_term_memory.rb +13 -13
  139. data/lib/htm/mcp/cli.rb +115 -8
  140. data/lib/htm/mcp/resources.rb +4 -3
  141. data/lib/htm/mcp/server.rb +5 -4
  142. data/lib/htm/mcp/tools.rb +37 -28
  143. data/lib/htm/migration.rb +72 -0
  144. data/lib/htm/models/file_source.rb +52 -31
  145. data/lib/htm/models/node.rb +224 -108
  146. data/lib/htm/models/node_tag.rb +49 -28
  147. data/lib/htm/models/robot.rb +38 -27
  148. data/lib/htm/models/robot_node.rb +63 -35
  149. data/lib/htm/models/tag.rb +126 -123
  150. data/lib/htm/observability.rb +45 -41
  151. data/lib/htm/proposition_service.rb +76 -7
  152. data/lib/htm/railtie.rb +2 -2
  153. data/lib/htm/robot_group.rb +30 -18
  154. data/lib/htm/sequel_config.rb +215 -0
  155. data/lib/htm/sql_builder.rb +14 -16
  156. data/lib/htm/tag_service.rb +78 -0
  157. data/lib/htm/tasks.rb +3 -0
  158. data/lib/htm/version.rb +1 -1
  159. data/lib/htm/workflows/remember_workflow.rb +213 -0
  160. data/lib/htm.rb +27 -22
  161. data/lib/tasks/db.rake +0 -2
  162. data/lib/tasks/doc.rake +2 -2
  163. data/lib/tasks/files.rake +11 -18
  164. data/lib/tasks/htm.rake +190 -62
  165. data/lib/tasks/jobs.rake +179 -54
  166. data/lib/tasks/tags.rake +8 -13
  167. data/mkdocs.yml +33 -8
  168. data/scripts/backfill_parent_tags.rb +376 -0
  169. data/scripts/normalize_plural_tags.rb +335 -0
  170. metadata +168 -86
  171. data/docs/api/yard/HTM/Configuration.md +0 -240
  172. data/docs/telemetry.md +0 -391
  173. data/examples/rails_app/app/controllers/tags_controller.rb +0 -30
  174. data/examples/sinatra_app/Gemfile.lock +0 -166
  175. data/lib/htm/active_record_config.rb +0 -104
  176. /data/examples/{config_file_example → 02_config_file_example}/README.md +0 -0
  177. /data/examples/{config_file_example → 02_config_file_example}/config/htm.local.yml +0 -0
  178. /data/examples/{config_file_example → 02_config_file_example}/custom_config.yml +0 -0
  179. /data/examples/{config_file_example → 02_config_file_example}/show_config.rb +0 -0
  180. /data/examples/{example_app → 06_example_app}/Rakefile +0 -0
  181. /data/examples/{cli_app → 07_cli_app}/README.md +0 -0
  182. /data/examples/{sinatra_app → 08_sinatra_app}/Gemfile +0 -0
  183. /data/examples/{telemetry → 10_telemetry}/README.md +0 -0
  184. /data/examples/{telemetry → 10_telemetry}/grafana/dashboards/htm-metrics.json +0 -0
  185. /data/examples/{rails_app → 12_rails_app}/.gitignore +0 -0
  186. /data/examples/{rails_app → 12_rails_app}/Procfile.dev +0 -0
  187. /data/examples/{rails_app → 12_rails_app}/README.md +0 -0
  188. /data/examples/{rails_app → 12_rails_app}/Rakefile +0 -0
  189. /data/examples/{rails_app → 12_rails_app}/app/assets/stylesheets/application.css +0 -0
  190. /data/examples/{rails_app → 12_rails_app}/app/assets/stylesheets/inter-font.css +0 -0
  191. /data/examples/{rails_app → 12_rails_app}/app/controllers/application_controller.rb +0 -0
  192. /data/examples/{rails_app → 12_rails_app}/app/controllers/search_controller.rb +0 -0
  193. /data/examples/{rails_app → 12_rails_app}/app/javascript/application.js +0 -0
  194. /data/examples/{rails_app → 12_rails_app}/app/javascript/controllers/application.js +0 -0
  195. /data/examples/{rails_app → 12_rails_app}/app/javascript/controllers/index.js +0 -0
  196. /data/examples/{rails_app → 12_rails_app}/app/views/files/index.html.erb +0 -0
  197. /data/examples/{rails_app → 12_rails_app}/app/views/files/show.html.erb +0 -0
  198. /data/examples/{rails_app → 12_rails_app}/app/views/layouts/application.html.erb +0 -0
  199. /data/examples/{rails_app → 12_rails_app}/app/views/memories/index.html.erb +0 -0
  200. /data/examples/{rails_app → 12_rails_app}/app/views/memories/new.html.erb +0 -0
  201. /data/examples/{rails_app → 12_rails_app}/app/views/robots/new.html.erb +0 -0
  202. /data/examples/{rails_app → 12_rails_app}/app/views/shared/_navbar.html.erb +0 -0
  203. /data/examples/{rails_app → 12_rails_app}/app/views/shared/_stat_card.html.erb +0 -0
  204. /data/examples/{rails_app → 12_rails_app}/bin/dev +0 -0
  205. /data/examples/{rails_app → 12_rails_app}/bin/rails +0 -0
  206. /data/examples/{rails_app → 12_rails_app}/bin/rake +0 -0
  207. /data/examples/{rails_app → 12_rails_app}/config/application.rb +0 -0
  208. /data/examples/{rails_app → 12_rails_app}/config/boot.rb +0 -0
  209. /data/examples/{rails_app → 12_rails_app}/config/database.yml +0 -0
  210. /data/examples/{rails_app → 12_rails_app}/config/environment.rb +0 -0
  211. /data/examples/{rails_app → 12_rails_app}/config/importmap.rb +0 -0
  212. /data/examples/{rails_app → 12_rails_app}/config/routes.rb +0 -0
  213. /data/examples/{rails_app → 12_rails_app}/config/tailwind.config.js +0 -0
  214. /data/examples/{rails_app → 12_rails_app}/config.ru +0 -0
  215. /data/examples/{rails_app → 12_rails_app}/log/.keep +0 -0
  216. /data/examples/{rails_app → 12_rails_app}/tmp/local_secret.txt +0 -0
@@ -7,48 +7,67 @@ class HTM
7
7
  # This model represents the relationship between a robot and a node,
8
8
  # tracking when and how many times a robot has "remembered" a piece of content.
9
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'
10
+ class RobotNode < Sequel::Model(:robot_nodes)
11
+ # Associations
12
+ many_to_one :robot, class: 'HTM::Models::Robot', key: :robot_id
13
+ many_to_one :node, class: 'HTM::Models::Node', key: :node_id
14
+
15
+ # Plugins
16
+ plugin :validation_helpers
17
+ plugin :timestamps, update_on_create: true
18
+
19
+ # Validations
20
+ def validate
21
+ super
22
+ validates_presence [:robot_id, :node_id]
23
+ validates_unique [:robot_id, :node_id], message: 'already linked to this node'
24
+ end
25
+
26
+ # Dataset methods (scopes)
27
+ dataset_module do
28
+ def active
29
+ where(deleted_at: nil)
30
+ end
31
+
32
+ def recent
33
+ order(Sequel.desc(:last_remembered_at))
34
+ end
24
35
 
25
- belongs_to :robot, class_name: 'HTM::Models::Robot'
26
- belongs_to :node, class_name: 'HTM::Models::Node'
36
+ def by_robot(robot_id)
37
+ where(robot_id: robot_id)
38
+ end
27
39
 
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' }
40
+ def by_node(node_id)
41
+ where(node_id: node_id)
42
+ end
31
43
 
32
- # Scopes
33
- # Soft delete - by default, only show non-deleted entries
34
- default_scope { where(deleted_at: nil) }
44
+ def frequently_remembered
45
+ where { remember_count > 1 }.order(Sequel.desc(:remember_count))
46
+ end
35
47
 
36
- scope :recent, -> { order(last_remembered_at: :desc) }
37
- scope :by_robot, ->(robot_id) { where(robot_id: robot_id) }
38
- scope :by_node, ->(node_id) { where(node_id: node_id) }
39
- scope :frequently_remembered, -> { where('remember_count > 1').order(remember_count: :desc) }
40
- scope :in_working_memory, -> { where(working_memory: true) }
48
+ def in_working_memory
49
+ where(working_memory: true)
50
+ end
41
51
 
42
- # Soft delete scopes
43
- scope :deleted, -> { unscoped.where.not(deleted_at: nil) }
44
- scope :with_deleted, -> { unscoped }
52
+ def deleted
53
+ exclude(deleted_at: nil)
54
+ end
55
+
56
+ def with_deleted
57
+ unfiltered
58
+ end
59
+ end
60
+
61
+ # Apply default scope for active records
62
+ set_dataset(dataset.where(Sequel[:robot_nodes][:deleted_at] => nil))
45
63
 
46
64
  # Soft delete - mark as deleted without removing from database
47
65
  #
48
66
  # @return [Boolean] true if soft deleted successfully
49
67
  #
50
68
  def soft_delete!
51
- update!(deleted_at: Time.current)
69
+ update(deleted_at: Time.now)
70
+ true
52
71
  end
53
72
 
54
73
  # Restore a soft-deleted entry
@@ -56,7 +75,8 @@ class HTM
56
75
  # @return [Boolean] true if restored successfully
57
76
  #
58
77
  def restore!
59
- update!(deleted_at: nil)
78
+ update(deleted_at: nil)
79
+ true
60
80
  end
61
81
 
62
82
  # Check if entry is soft-deleted
@@ -64,7 +84,15 @@ class HTM
64
84
  # @return [Boolean] true if deleted_at is set
65
85
  #
66
86
  def deleted?
67
- deleted_at.present?
87
+ !deleted_at.nil?
88
+ end
89
+
90
+ # Check if this node is in working memory
91
+ #
92
+ # @return [Boolean] true if working_memory is set
93
+ #
94
+ def working_memory?
95
+ !!working_memory
68
96
  end
69
97
 
70
98
  # Record that a robot remembered this content again
@@ -73,8 +101,8 @@ class HTM
73
101
  #
74
102
  def record_remember!
75
103
  self.remember_count += 1
76
- self.last_remembered_at = Time.current
77
- save!
104
+ self.last_remembered_at = Time.now
105
+ save
78
106
  self
79
107
  end
80
108
  end
@@ -4,79 +4,117 @@ class HTM
4
4
  module Models
5
5
  # Tag model - represents unique tag names
6
6
  # Tags have a many-to-many relationship with nodes through node_tags
7
- class Tag < ActiveRecord::Base
8
- self.table_name = 'tags'
9
-
7
+ class Tag < Sequel::Model(:tags)
10
8
  # Associations
11
- has_many :node_tags, class_name: 'HTM::Models::NodeTag', dependent: :destroy
12
- has_many :nodes, through: :node_tags, class_name: 'HTM::Models::Node'
9
+ one_to_many :node_tags, class: 'HTM::Models::NodeTag', key: :tag_id
10
+ many_to_many :nodes, class: 'HTM::Models::Node',
11
+ join_table: :node_tags, left_key: :tag_id, right_key: :node_id
12
+
13
+ # Plugins
14
+ plugin :validation_helpers
15
+ plugin :timestamps, update_on_create: true
16
+
17
+ # Tag name format regex
18
+ TAG_FORMAT = /\A[a-z0-9\-]+(:[a-z0-9\-]+)*\z/
13
19
 
14
20
  # Validations
15
- validates :name, presence: true
16
- validates :name, format: {
17
- with: /\A[a-z0-9\-]+(:[a-z0-9\-]+)*\z/,
18
- message: "must be lowercase with hyphens, using colons for hierarchy (e.g., 'database:postgresql:performance')"
19
- }
20
- validates :name, uniqueness: { message: "already exists" }
21
-
22
- # Callbacks
23
- before_create :set_created_at
24
-
25
- # Scopes
26
- # Soft delete - by default, only show non-deleted tags
27
- default_scope { where(deleted_at: nil) }
28
-
29
- scope :by_name, ->(name) { where(name: name) }
30
- scope :with_prefix, ->(prefix) { where("name LIKE ?", "#{prefix}%") }
31
- scope :hierarchical, -> { where("name LIKE '%:%'") }
32
- scope :root_level, -> { where("name NOT LIKE '%:%'") }
33
-
34
- # Soft delete scopes
35
- scope :deleted, -> { unscoped.where.not(deleted_at: nil) }
36
- scope :with_deleted, -> { unscoped }
37
-
38
- # Orphaned tags - tags with no active (non-deleted) node associations
39
- scope :orphaned, -> {
40
- where(
41
- "NOT EXISTS (
42
- SELECT 1 FROM node_tags
43
- JOIN nodes ON nodes.id = node_tags.node_id
44
- WHERE node_tags.tag_id = tags.id
45
- AND node_tags.deleted_at IS NULL
46
- AND nodes.deleted_at IS NULL
47
- )"
48
- )
49
- }
21
+ def validate
22
+ super
23
+ validates_presence :name
24
+ validates_format TAG_FORMAT, :name,
25
+ message: "must be lowercase with hyphens, using colons for hierarchy (e.g., 'database:postgresql:performance')"
26
+ validates_unique :name, message: "already exists"
27
+ end
28
+
29
+ # Dataset methods (scopes)
30
+ dataset_module do
31
+ # Soft delete - by default, only show non-deleted tags
32
+ def active
33
+ where(deleted_at: nil)
34
+ end
35
+
36
+ def by_name(name)
37
+ where(name: name)
38
+ end
39
+
40
+ def with_prefix(prefix)
41
+ where(Sequel.like(:name, "#{prefix}%"))
42
+ end
43
+
44
+ def hierarchical
45
+ where(Sequel.like(:name, '%:%'))
46
+ end
47
+
48
+ def root_level
49
+ exclude(Sequel.like(:name, '%:%'))
50
+ end
51
+
52
+ def deleted
53
+ exclude(deleted_at: nil)
54
+ end
55
+
56
+ def with_deleted
57
+ unfiltered
58
+ end
59
+
60
+ # Orphaned tags - tags with no active (non-deleted) node associations
61
+ def orphaned
62
+ where(
63
+ Sequel.lit(
64
+ "NOT EXISTS (
65
+ SELECT 1 FROM node_tags
66
+ JOIN nodes ON nodes.id = node_tags.node_id
67
+ WHERE node_tags.tag_id = tags.id
68
+ AND node_tags.deleted_at IS NULL
69
+ AND nodes.deleted_at IS NULL
70
+ )"
71
+ )
72
+ )
73
+ end
74
+ end
75
+
76
+ # Apply default scope for active records
77
+ set_dataset(dataset.where(Sequel[:tags][:deleted_at] => nil))
78
+
79
+ # Hooks
80
+ def before_create
81
+ self.created_at ||= Time.now
82
+ super
83
+ end
50
84
 
51
85
  # Class methods
52
86
 
87
+ # Check if a tag exists with the given conditions
88
+ #
89
+ # @param conditions [Hash] Conditions to check
90
+ # @return [Boolean] true if a matching tag exists
91
+ #
92
+ def self.exists?(conditions = {})
93
+ where(conditions).any?
94
+ end
95
+
53
96
  # Find tags with a given prefix (hierarchical query)
54
97
  #
55
98
  # @param prefix [String] Tag prefix to match (e.g., "database" matches "database:postgresql")
56
- # @return [ActiveRecord::Relation] Tags matching the prefix
57
- #
58
- # @example Find all database-related tags
59
- # Tag.find_by_topic_prefix("database")
60
- # # => [#<Tag name: "database:postgresql">, #<Tag name: "database:mysql">]
99
+ # @return [Sequel::Dataset] Tags matching the prefix
61
100
  #
62
101
  def self.find_by_topic_prefix(prefix)
63
- where("name LIKE ?", "#{prefix}%")
102
+ dataset.with_prefix(prefix)
64
103
  end
65
104
 
66
105
  # Get the most frequently used tags
67
106
  #
68
107
  # @param limit [Integer] Maximum number of tags to return (default: 10)
69
- # @return [ActiveRecord::Relation] Tags with usage_count attribute
70
- #
71
- # @example Get top 5 most used tags
72
- # Tag.popular_tags(5).each { |t| puts "#{t.name}: #{t.usage_count}" }
108
+ # @return [Array<Tag>] Tags with usage_count attribute
73
109
  #
74
110
  def self.popular_tags(limit = 10)
75
- joins(:node_tags)
76
- .select('tags.*, COUNT(node_tags.id) as usage_count')
77
- .group('tags.id')
78
- .order('usage_count DESC')
111
+ dataset
112
+ .select_append { count(node_tags[:id]).as(usage_count) }
113
+ .join(:node_tags, tag_id: :id)
114
+ .group(:id)
115
+ .order(Sequel.desc(:usage_count))
79
116
  .limit(limit)
117
+ .all
80
118
  end
81
119
 
82
120
  # Find or create a tag by name
@@ -85,25 +123,40 @@ class HTM
85
123
  # @return [Tag] The found or created tag
86
124
  #
87
125
  def self.find_or_create_by_name(name)
88
- find_or_create_by(name: name)
126
+ find_or_create(name: name)
89
127
  end
90
128
 
91
- # Returns a nested hash tree structure from the current scope
129
+ # Expand a hierarchical tag name into all ancestor paths
92
130
  #
93
- # @return [Hash] Nested hash representing the tag hierarchy
131
+ # @param tag_name [String] Hierarchical tag (e.g., "a:b:c:d")
132
+ # @return [Array<String>] All paths from root to leaf
94
133
  #
95
- # @example Get all tags as a tree
96
- # Tag.all.tree
97
- # # => { "database" => { "postgresql" => {}, "mysql" => {} }, "ai" => { "llm" => {} } }
134
+ def self.expand_hierarchy(tag_name)
135
+ return [] if tag_name.nil? || tag_name.empty?
136
+
137
+ levels = tag_name.split(':')
138
+ (1..levels.size).map { |i| levels[0, i].join(':') }
139
+ end
140
+
141
+ # Find or create a tag and all its ancestor tags
98
142
  #
99
- # @example Get filtered tags as a tree
100
- # Tag.with_prefix("database").tree
101
- # # => { "database" => { "postgresql" => {} } }
143
+ # @param name [String] Hierarchical tag name (e.g., "database:postgresql:extensions")
144
+ # @return [Array<Tag>] All created/found tags from root to leaf
145
+ #
146
+ def self.find_or_create_with_ancestors(name)
147
+ expand_hierarchy(name).map do |tag_name|
148
+ find_or_create(name: tag_name)
149
+ end
150
+ end
151
+
152
+ # Returns a nested hash tree structure from the current scope
153
+ #
154
+ # @return [Hash] Nested hash representing the tag hierarchy
102
155
  #
103
156
  def self.tree
104
157
  tree = {}
105
158
 
106
- all.order(:name).pluck(:name).each do |tag_name|
159
+ order(:name).select_map(:name).each do |tag_name|
107
160
  parts = tag_name.split(':')
108
161
  current = tree
109
162
 
@@ -118,24 +171,13 @@ class HTM
118
171
 
119
172
  # Returns a formatted string representation of the tag tree
120
173
  #
121
- # Uses directory-style formatting with ├── and └── characters
122
- #
123
174
  # @return [String] Formatted tree string
124
175
  #
125
- # @example Display all tags as a tree
126
- # puts Tag.all.tree_string
127
- # # ├── ai
128
- # # │ └── llm
129
- # # └── database
130
- # # └── postgresql
131
- #
132
176
  def self.tree_string
133
177
  format_tree_branch(tree)
134
178
  end
135
179
 
136
180
  # Returns a Mermaid flowchart representation of the tag tree
137
- # Example: puts Tag.all.tree_mermaid
138
- # Example: Tag.all.tree_mermaid(direction: 'LR') # Left to right
139
181
  #
140
182
  # @param direction [String] Flow direction: 'TD' (top-down), 'LR' (left-right), 'BT', 'RL'
141
183
  # @return [String] Mermaid flowchart syntax
@@ -148,15 +190,12 @@ class HTM
148
190
  node_id = 0
149
191
  node_ids = {}
150
192
 
151
- # Generate Mermaid nodes and connections
152
193
  generate_mermaid_nodes(tree_data, nil, lines, node_ids, node_id)
153
194
 
154
195
  lines.join("\n")
155
196
  end
156
197
 
157
198
  # Returns an SVG representation of the tag tree
158
- # Uses dark theme with transparent background
159
- # Example: File.write('tags.svg', Tag.all.tree_svg)
160
199
  #
161
200
  # @param title [String] Optional title for the SVG
162
201
  # @return [String] SVG markup
@@ -165,28 +204,22 @@ class HTM
165
204
  tree_data = tree
166
205
  return empty_tree_svg(title) if tree_data.empty?
167
206
 
168
- # Calculate dimensions based on tree structure
169
207
  stats = calculate_tree_stats(tree_data)
170
- node_count = stats[:total_nodes]
171
208
  max_depth = stats[:max_depth]
172
209
 
173
- # Layout constants
174
210
  node_width = 140
175
211
  node_height = 30
176
212
  h_spacing = 180
177
213
  v_spacing = 50
178
214
  padding = 40
179
215
 
180
- # Calculate positions for all nodes
181
216
  positions = {}
182
- y_offset = [0] # Use array to allow mutation in closure
217
+ y_offset = [0]
183
218
  calculate_node_positions(tree_data, 0, positions, y_offset, h_spacing, v_spacing)
184
219
 
185
- # Calculate SVG dimensions
186
220
  width = (max_depth * h_spacing) + node_width + (padding * 2)
187
221
  height = (y_offset[0] * v_spacing) + node_height + (padding * 2)
188
222
 
189
- # Generate SVG
190
223
  generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
191
224
  end
192
225
 
@@ -198,14 +231,11 @@ class HTM
198
231
  sorted_keys.each_with_index do |key, index|
199
232
  is_last = (index == sorted_keys.size - 1)
200
233
 
201
- # Build prefix from parent branches
202
- line_prefix = is_last_array.map { |was_last| was_last ? ' ' : '│ ' }.join
234
+ line_prefix = is_last_array.map { |was_last| was_last ? ' ' : '| ' }.join
203
235
 
204
- # Add branch character and key
205
- branch = is_last ? '└── ' : '├── '
236
+ branch = is_last ? '+-- ' : '+-- '
206
237
  result += "#{line_prefix}#{branch}#{key}\n"
207
238
 
208
- # Recurse into children
209
239
  children = node[key]
210
240
  unless children.empty?
211
241
  result += format_tree_branch(children, is_last_array + [is_last])
@@ -220,20 +250,16 @@ class HTM
220
250
  node.keys.sort.each do |key|
221
251
  current_path = parent_path ? "#{parent_path}:#{key}" : key
222
252
 
223
- # Create unique node ID
224
253
  node_id = "n#{counter}"
225
254
  node_ids[current_path] = node_id
226
255
  counter += 1
227
256
 
228
- # Add node definition with styling
229
257
  lines << " #{node_id}[\"#{key}\"]"
230
258
 
231
- # Add connection from parent
232
259
  if parent_path && node_ids[parent_path]
233
260
  lines << " #{node_ids[parent_path]} --> #{node_id}"
234
261
  end
235
262
 
236
- # Recurse into children
237
263
  children = node[key]
238
264
  counter = generate_mermaid_nodes(children, current_path, lines, node_ids, counter) unless children.empty?
239
265
  end
@@ -287,17 +313,14 @@ class HTM
287
313
 
288
314
  # Generate SVG tree visualization (internal helper)
289
315
  def self.generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
290
- # Color palette for different depths (dark theme)
291
316
  colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#6366F1']
292
317
 
293
318
  svg_lines = []
294
319
  svg_lines << %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height + 40}">)
295
320
  svg_lines << ' <rect width="100%" height="100%" fill="transparent"/>'
296
321
 
297
- # Title
298
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>)
299
323
 
300
- # Draw connections first (so they appear behind nodes)
301
324
  positions.each do |path, pos|
302
325
  parent_path = path.include?(':') ? path.split(':')[0..-2].join(':') : nil
303
326
  next unless parent_path && positions[parent_path]
@@ -308,12 +331,10 @@ class HTM
308
331
  x2 = padding + (pos[:x] * (node_width + 40))
309
332
  y2 = 40 + padding + (pos[:y] * (node_height + 20)) + (node_height / 2)
310
333
 
311
- # Curved connection line
312
334
  mid_x = (x1 + x2) / 2
313
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"/>)
314
336
  end
315
337
 
316
- # Draw nodes
317
338
  positions.each do |path, pos|
318
339
  depth = path.count(':')
319
340
  color = colors[depth % colors.size]
@@ -321,10 +342,8 @@ class HTM
321
342
  x = padding + (pos[:x] * (node_width + 40))
322
343
  y = 40 + padding + (pos[:y] * (node_height + 20))
323
344
 
324
- # Node rectangle with rounded corners
325
345
  svg_lines << %Q( <rect x="#{x}" y="#{y}" width="#{node_width}" height="#{node_height}" rx="6" fill="#{color}" opacity="0.9"/>)
326
346
 
327
- # Node label
328
347
  text_x = x + (node_width / 2)
329
348
  text_y = y + (node_height / 2) + 4
330
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>)
@@ -340,10 +359,6 @@ class HTM
340
359
  #
341
360
  # @return [String] The first segment of the hierarchical tag
342
361
  #
343
- # @example
344
- # tag = Tag.find_by(name: "database:postgresql:extensions")
345
- # tag.root_topic # => "database"
346
- #
347
362
  def root_topic
348
363
  name.split(':').first
349
364
  end
@@ -352,10 +367,6 @@ class HTM
352
367
  #
353
368
  # @return [Array<String>] Array of topic segments
354
369
  #
355
- # @example
356
- # tag = Tag.find_by(name: "database:postgresql:extensions")
357
- # tag.topic_levels # => ["database", "postgresql", "extensions"]
358
- #
359
370
  def topic_levels
360
371
  name.split(':')
361
372
  end
@@ -364,10 +375,6 @@ class HTM
364
375
  #
365
376
  # @return [Integer] Number of hierarchy levels
366
377
  #
367
- # @example
368
- # Tag.find_by(name: "database").depth # => 1
369
- # Tag.find_by(name: "database:postgresql").depth # => 2
370
- #
371
378
  def depth
372
379
  topic_levels.length
373
380
  end
@@ -385,7 +392,7 @@ class HTM
385
392
  # @return [Integer] Count of nodes with this tag
386
393
  #
387
394
  def usage_count
388
- node_tags.count
395
+ node_tags_dataset.count
389
396
  end
390
397
 
391
398
  # Soft delete - mark tag as deleted without removing from database
@@ -393,7 +400,8 @@ class HTM
393
400
  # @return [Boolean] true if soft deleted successfully
394
401
  #
395
402
  def soft_delete!
396
- update!(deleted_at: Time.current)
403
+ update(deleted_at: Time.now)
404
+ true
397
405
  end
398
406
 
399
407
  # Restore a soft-deleted tag
@@ -401,7 +409,8 @@ class HTM
401
409
  # @return [Boolean] true if restored successfully
402
410
  #
403
411
  def restore!
404
- update!(deleted_at: nil)
412
+ update(deleted_at: nil)
413
+ true
405
414
  end
406
415
 
407
416
  # Check if tag is soft-deleted
@@ -409,13 +418,7 @@ class HTM
409
418
  # @return [Boolean] true if deleted_at is set
410
419
  #
411
420
  def deleted?
412
- deleted_at.present?
413
- end
414
-
415
- private
416
-
417
- def set_created_at
418
- self.created_at ||= Time.current
421
+ !deleted_at.nil?
419
422
  end
420
423
  end
421
424
  end