htm 0.0.17 → 0.0.18
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/.architecture/decisions/adrs/001-use-postgresql-timescaledb-storage.md +1 -1
- data/.architecture/decisions/adrs/011-database-side-embedding-generation-with-pgai.md +4 -4
- data/.architecture/decisions/adrs/012-llm-driven-ontology-topic-extraction.md +1 -1
- data/.envrc +12 -25
- data/.irbrc +7 -7
- data/.tbls.yml +2 -2
- data/CHANGELOG.md +71 -0
- data/README.md +1 -1
- data/Rakefile +8 -3
- data/SETUP.md +12 -12
- data/bin/htm_mcp +0 -4
- data/db/seed_data/README.md +2 -2
- data/db/seeds.rb +2 -2
- data/docs/api/database.md +37 -37
- data/docs/api/htm.md +1 -1
- data/docs/api/yard/HTM/ActiveRecordConfig.md +2 -2
- data/docs/api/yard/HTM/Configuration.md +26 -15
- data/docs/api/yard/HTM/Database.md +7 -8
- data/docs/api/yard/HTM/JobAdapter.md +1 -1
- data/docs/api/yard/HTM/Railtie.md +2 -2
- data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
- data/docs/architecture/adrs/011-pgai-integration.md +4 -4
- data/docs/database_rake_tasks.md +5 -5
- data/docs/development/rake-tasks.md +11 -11
- data/docs/development/setup.md +21 -21
- data/docs/development/testing.md +1 -1
- data/docs/getting-started/installation.md +20 -20
- data/docs/getting-started/quick-start.md +12 -12
- data/docs/guides/getting-started.md +2 -2
- data/docs/guides/long-term-memory.md +1 -1
- data/docs/guides/mcp-server.md +17 -17
- data/docs/guides/robot-groups.md +8 -8
- data/docs/index.md +4 -4
- data/docs/multi_framework_support.md +8 -8
- data/docs/setup_local_database.md +19 -19
- data/docs/using_rake_tasks_in_your_app.md +14 -14
- data/examples/README.md +50 -6
- data/examples/basic_usage.rb +31 -21
- data/examples/cli_app/README.md +8 -8
- data/examples/cli_app/htm_cli.rb +5 -5
- data/examples/config_file_example/README.md +256 -0
- data/examples/config_file_example/config/htm.local.yml +34 -0
- data/examples/config_file_example/custom_config.yml +22 -0
- data/examples/config_file_example/show_config.rb +125 -0
- data/examples/custom_llm_configuration.rb +7 -7
- data/examples/example_app/Rakefile +2 -2
- data/examples/example_app/app.rb +8 -8
- data/examples/file_loader_usage.rb +9 -9
- data/examples/mcp_client.rb +5 -5
- data/examples/rails_app/Gemfile.lock +48 -56
- data/examples/rails_app/README.md +1 -1
- data/examples/robot_groups/multi_process.rb +5 -5
- data/examples/robot_groups/robot_worker.rb +5 -5
- data/examples/robot_groups/same_process.rb +9 -9
- data/examples/sinatra_app/app.rb +1 -1
- data/examples/timeframe_demo.rb +1 -1
- data/lib/htm/active_record_config.rb +12 -25
- data/lib/htm/circuit_breaker.rb +0 -2
- data/lib/htm/config/defaults.yml +246 -0
- data/lib/htm/config.rb +888 -0
- data/lib/htm/database.rb +23 -27
- data/lib/htm/embedding_service.rb +0 -4
- data/lib/htm/integrations/sinatra.rb +3 -7
- data/lib/htm/job_adapter.rb +1 -15
- data/lib/htm/jobs/generate_embedding_job.rb +1 -7
- data/lib/htm/jobs/generate_propositions_job.rb +2 -12
- data/lib/htm/jobs/generate_tags_job.rb +1 -8
- data/lib/htm/loaders/defaults_loader.rb +143 -0
- data/lib/htm/loaders/xdg_config_loader.rb +116 -0
- data/lib/htm/mcp/cli.rb +200 -58
- data/lib/htm/mcp/server.rb +3 -3
- data/lib/htm/proposition_service.rb +2 -12
- data/lib/htm/railtie.rb +3 -4
- data/lib/htm/tag_service.rb +1 -8
- data/lib/htm/version.rb +1 -1
- data/lib/htm.rb +124 -5
- metadata +24 -4
- data/config/database.yml +0 -77
- data/lib/htm/configuration.rb +0 -799
data/lib/htm/database.rb
CHANGED
|
@@ -11,7 +11,7 @@ class HTM
|
|
|
11
11
|
class << self
|
|
12
12
|
# Set up the HTM database schema
|
|
13
13
|
#
|
|
14
|
-
# @param db_url [String] Database connection URL (uses ENV['
|
|
14
|
+
# @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
|
|
15
15
|
# @param run_migrations [Boolean] Whether to run migrations (default: true)
|
|
16
16
|
# @param dump_schema [Boolean] Whether to dump schema to db/schema.sql after setup (default: false)
|
|
17
17
|
# @return [void]
|
|
@@ -40,7 +40,7 @@ class HTM
|
|
|
40
40
|
|
|
41
41
|
# Run pending database migrations
|
|
42
42
|
#
|
|
43
|
-
# @param db_url [String] Database connection URL (uses ENV['
|
|
43
|
+
# @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
|
|
44
44
|
# @return [void]
|
|
45
45
|
#
|
|
46
46
|
def migrate(db_url = nil)
|
|
@@ -57,7 +57,7 @@ class HTM
|
|
|
57
57
|
|
|
58
58
|
# Show migration status
|
|
59
59
|
#
|
|
60
|
-
# @param db_url [String] Database connection URL (uses ENV['
|
|
60
|
+
# @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
|
|
61
61
|
# @return [void]
|
|
62
62
|
#
|
|
63
63
|
def migration_status(db_url = nil)
|
|
@@ -158,7 +158,7 @@ class HTM
|
|
|
158
158
|
# All seeding logic is contained in db/seeds.rb and reads data
|
|
159
159
|
# from markdown files in db/seed_data/ directory.
|
|
160
160
|
#
|
|
161
|
-
# @param db_url [String] Database connection URL (uses ENV['
|
|
161
|
+
# @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
|
|
162
162
|
# @return [void]
|
|
163
163
|
#
|
|
164
164
|
def seed(db_url = nil)
|
|
@@ -292,7 +292,7 @@ class HTM
|
|
|
292
292
|
# - Index information
|
|
293
293
|
# - Relationship diagrams
|
|
294
294
|
#
|
|
295
|
-
# @param db_url [String] Database connection URL (uses ENV['
|
|
295
|
+
# @param db_url [String] Database connection URL (uses ENV['HTM_DATABASE__URL'] if not provided)
|
|
296
296
|
# @return [void]
|
|
297
297
|
#
|
|
298
298
|
def generate_docs(db_url = nil)
|
|
@@ -319,8 +319,8 @@ class HTM
|
|
|
319
319
|
end
|
|
320
320
|
|
|
321
321
|
# Get database URL
|
|
322
|
-
dsn = db_url || ENV['
|
|
323
|
-
raise "Database configuration not found. Set
|
|
322
|
+
dsn = db_url || ENV['HTM_DATABASE__URL']
|
|
323
|
+
raise "Database configuration not found. Set HTM_DATABASE__URL environment variable." unless dsn
|
|
324
324
|
|
|
325
325
|
# Ensure sslmode is set for local development (tbls requires it)
|
|
326
326
|
unless dsn.include?('sslmode=')
|
|
@@ -460,30 +460,29 @@ class HTM
|
|
|
460
460
|
# @return [Hash, nil] Connection configuration hash
|
|
461
461
|
#
|
|
462
462
|
def parse_connection_params
|
|
463
|
-
return nil unless ENV['
|
|
463
|
+
return nil unless ENV['HTM_DATABASE__NAME']
|
|
464
464
|
|
|
465
465
|
{
|
|
466
|
-
host: ENV['
|
|
467
|
-
port: (ENV['
|
|
468
|
-
dbname: ENV['
|
|
469
|
-
user: ENV['
|
|
470
|
-
password: ENV['
|
|
471
|
-
sslmode: ENV['
|
|
466
|
+
host: ENV['HTM_DATABASE__HOST'] || 'localhost',
|
|
467
|
+
port: (ENV['HTM_DATABASE__PORT'] || 5432).to_i,
|
|
468
|
+
dbname: ENV['HTM_DATABASE__NAME'],
|
|
469
|
+
user: ENV['HTM_DATABASE__USER'],
|
|
470
|
+
password: ENV['HTM_DATABASE__PASSWORD'],
|
|
471
|
+
sslmode: ENV['HTM_DATABASE__SSLMODE'] || 'prefer'
|
|
472
472
|
}
|
|
473
473
|
end
|
|
474
474
|
|
|
475
|
-
# Get default database configuration
|
|
475
|
+
# Get default database configuration
|
|
476
476
|
#
|
|
477
|
-
# Uses
|
|
478
|
-
# and respects RAILS_ENV for environment-specific database selection.
|
|
477
|
+
# Uses HTM::Config for database settings.
|
|
479
478
|
#
|
|
480
479
|
# @return [Hash, nil] Connection configuration hash with PG-style keys
|
|
481
480
|
#
|
|
482
481
|
def default_config
|
|
483
|
-
|
|
482
|
+
htm_config = HTM.config
|
|
484
483
|
|
|
485
|
-
|
|
486
|
-
ar_config =
|
|
484
|
+
if htm_config.database_configured?
|
|
485
|
+
ar_config = htm_config.database_config
|
|
487
486
|
|
|
488
487
|
# Convert ActiveRecord config keys to PG-style keys
|
|
489
488
|
{
|
|
@@ -494,13 +493,10 @@ class HTM
|
|
|
494
493
|
password: ar_config[:password],
|
|
495
494
|
sslmode: ar_config[:sslmode] || 'prefer'
|
|
496
495
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
elsif ENV['HTM_DBNAME']
|
|
502
|
-
parse_connection_params
|
|
503
|
-
end
|
|
496
|
+
elsif ENV['HTM_DATABASE__URL']
|
|
497
|
+
parse_connection_url(ENV['HTM_DATABASE__URL'])
|
|
498
|
+
elsif ENV['HTM_DATABASE__NAME']
|
|
499
|
+
parse_connection_params
|
|
504
500
|
end
|
|
505
501
|
end
|
|
506
502
|
|
|
@@ -68,8 +68,6 @@ class HTM
|
|
|
68
68
|
# @raise [CircuitBreakerOpenError] If circuit breaker is open
|
|
69
69
|
#
|
|
70
70
|
def self.generate(text)
|
|
71
|
-
HTM.logger.debug "EmbeddingService: Generating embedding for #{text.length} chars"
|
|
72
|
-
|
|
73
71
|
# Use circuit breaker to protect against cascading failures
|
|
74
72
|
raw_embedding = circuit_breaker.call do
|
|
75
73
|
HTM.configuration.embedding_generator.call(text)
|
|
@@ -95,8 +93,6 @@ class HTM
|
|
|
95
93
|
# Format for database storage
|
|
96
94
|
storage_string = format_for_storage(storage_embedding)
|
|
97
95
|
|
|
98
|
-
HTM.logger.debug "EmbeddingService: Generated #{actual_dimension}D embedding (padded to #{max_dim})"
|
|
99
|
-
|
|
100
96
|
{
|
|
101
97
|
embedding: raw_embedding,
|
|
102
98
|
dimension: actual_dimension,
|
|
@@ -138,7 +138,6 @@ class HTM
|
|
|
138
138
|
return if @@db_config
|
|
139
139
|
|
|
140
140
|
@@db_config = HTM::ActiveRecordConfig.load_database_config
|
|
141
|
-
HTM.logger.debug "HTM database config stored for thread-safe access"
|
|
142
141
|
end
|
|
143
142
|
end
|
|
144
143
|
|
|
@@ -160,15 +159,13 @@ class HTM
|
|
|
160
159
|
# Re-establish connection using stored config
|
|
161
160
|
if @@db_config
|
|
162
161
|
ActiveRecord::Base.establish_connection(@@db_config)
|
|
163
|
-
HTM.logger.debug "HTM database connection established for request thread"
|
|
164
162
|
else
|
|
165
163
|
raise "HTM database config not stored - call register_htm at app startup"
|
|
166
164
|
end
|
|
167
|
-
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
|
|
165
|
+
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
|
|
168
166
|
# Pool doesn't exist, establish connection
|
|
169
167
|
if @@db_config
|
|
170
168
|
ActiveRecord::Base.establish_connection(@@db_config)
|
|
171
|
-
HTM.logger.debug "HTM database connection established for request thread"
|
|
172
169
|
else
|
|
173
170
|
raise "HTM database config not stored - call register_htm at app startup"
|
|
174
171
|
end
|
|
@@ -209,9 +206,9 @@ module ::Sinatra
|
|
|
209
206
|
|
|
210
207
|
# Use Sidekiq if available, otherwise thread-based
|
|
211
208
|
if defined?(::Sidekiq)
|
|
212
|
-
config.
|
|
209
|
+
config.job.backend = :sidekiq
|
|
213
210
|
else
|
|
214
|
-
config.
|
|
211
|
+
config.job.backend = :thread
|
|
215
212
|
end
|
|
216
213
|
end
|
|
217
214
|
|
|
@@ -226,7 +223,6 @@ module ::Sinatra
|
|
|
226
223
|
end
|
|
227
224
|
|
|
228
225
|
HTM.logger.info "HTM registered with Sinatra application"
|
|
229
|
-
HTM.logger.debug "HTM job backend: #{HTM.configuration.job_backend}"
|
|
230
226
|
end
|
|
231
227
|
end
|
|
232
228
|
end
|
data/lib/htm/job_adapter.rb
CHANGED
|
@@ -14,7 +14,7 @@ class HTM
|
|
|
14
14
|
#
|
|
15
15
|
# @example Configure job backend
|
|
16
16
|
# HTM.configure do |config|
|
|
17
|
-
# config.
|
|
17
|
+
# config.job.backend = :active_job
|
|
18
18
|
# end
|
|
19
19
|
#
|
|
20
20
|
# @example Enqueue a job
|
|
@@ -60,8 +60,6 @@ class HTM
|
|
|
60
60
|
# Convert job class to ActiveJob if needed
|
|
61
61
|
active_job_class = to_active_job_class(job_class)
|
|
62
62
|
active_job_class.perform_later(**params)
|
|
63
|
-
|
|
64
|
-
HTM.logger.debug "Enqueued #{job_class.name} via ActiveJob with params: #{params.inspect}"
|
|
65
63
|
end
|
|
66
64
|
|
|
67
65
|
# Enqueue job using Sidekiq directly
|
|
@@ -76,38 +74,26 @@ class HTM
|
|
|
76
74
|
# Sidekiq 7.x requires native JSON types - convert symbol keys to strings
|
|
77
75
|
json_params = params.transform_keys(&:to_s)
|
|
78
76
|
sidekiq_class.perform_async(json_params)
|
|
79
|
-
|
|
80
|
-
HTM.logger.debug "Enqueued #{job_class.name} via Sidekiq with params: #{params.inspect}"
|
|
81
77
|
end
|
|
82
78
|
|
|
83
79
|
# Execute job inline (synchronously)
|
|
84
80
|
def enqueue_inline(job_class, **params)
|
|
85
|
-
HTM.logger.debug "Executing #{job_class.name} inline with params: #{params.inspect}"
|
|
86
|
-
|
|
87
81
|
begin
|
|
88
82
|
job_class.perform(**params)
|
|
89
|
-
HTM.logger.debug "Completed #{job_class.name} inline execution"
|
|
90
83
|
rescue StandardError => e
|
|
91
84
|
HTM.logger.error "Inline job #{job_class.name} failed: #{e.class.name} - #{e.message}"
|
|
92
|
-
HTM.logger.debug e.backtrace.first(5).join("\n")
|
|
93
85
|
end
|
|
94
86
|
end
|
|
95
87
|
|
|
96
88
|
# Execute job in background thread (legacy)
|
|
97
89
|
def enqueue_thread(job_class, **params)
|
|
98
90
|
Thread.new do
|
|
99
|
-
HTM.logger.debug "Executing #{job_class.name} in thread with params: #{params.inspect}"
|
|
100
|
-
|
|
101
91
|
begin
|
|
102
92
|
job_class.perform(**params)
|
|
103
|
-
HTM.logger.debug "Completed #{job_class.name} thread execution"
|
|
104
93
|
rescue StandardError => e
|
|
105
94
|
HTM.logger.error "Thread job #{job_class.name} failed: #{e.class.name} - #{e.message}"
|
|
106
|
-
HTM.logger.debug e.backtrace.first(5).join("\n")
|
|
107
95
|
end
|
|
108
96
|
end
|
|
109
|
-
|
|
110
|
-
HTM.logger.debug "Started thread for #{job_class.name}"
|
|
111
97
|
rescue StandardError => e
|
|
112
98
|
HTM.logger.error "Failed to start thread for #{job_class.name}: #{e.message}"
|
|
113
99
|
end
|
|
@@ -31,17 +31,12 @@ class HTM
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Skip if already has embedding
|
|
34
|
-
if node.embedding.present?
|
|
35
|
-
HTM.logger.debug "GenerateEmbeddingJob: Node #{node_id} already has embedding, skipping"
|
|
36
|
-
return
|
|
37
|
-
end
|
|
34
|
+
return if node.embedding.present?
|
|
38
35
|
|
|
39
36
|
provider = HTM.configuration.embedding_provider.to_s
|
|
40
37
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
38
|
|
|
42
39
|
begin
|
|
43
|
-
HTM.logger.debug "GenerateEmbeddingJob: Generating embedding for node #{node_id}"
|
|
44
|
-
|
|
45
40
|
# Generate and process embedding using EmbeddingService
|
|
46
41
|
result = HTM::EmbeddingService.generate(node.content)
|
|
47
42
|
|
|
@@ -77,7 +72,6 @@ class HTM
|
|
|
77
72
|
|
|
78
73
|
# Log unexpected errors
|
|
79
74
|
HTM.logger.error "GenerateEmbeddingJob: Unexpected error for node #{node_id}: #{e.class.name} - #{e.message}"
|
|
80
|
-
HTM.logger.debug e.backtrace.first(5).join("\n")
|
|
81
75
|
end
|
|
82
76
|
end
|
|
83
77
|
end
|
|
@@ -33,21 +33,12 @@ class HTM
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# Skip if this node is already a proposition (prevent recursion)
|
|
36
|
-
if node.metadata&.dig('is_proposition')
|
|
37
|
-
HTM.logger.debug "GeneratePropositionsJob: Node #{node_id} is a proposition, skipping"
|
|
38
|
-
return
|
|
39
|
-
end
|
|
36
|
+
return if node.metadata&.dig('is_proposition')
|
|
40
37
|
|
|
41
38
|
begin
|
|
42
|
-
HTM.logger.debug "GeneratePropositionsJob: Extracting propositions for node #{node_id}"
|
|
43
|
-
|
|
44
39
|
# Extract propositions using PropositionService
|
|
45
40
|
propositions = HTM::PropositionService.extract(node.content)
|
|
46
|
-
|
|
47
|
-
if propositions.empty?
|
|
48
|
-
HTM.logger.debug "GeneratePropositionsJob: No propositions extracted for node #{node_id}"
|
|
49
|
-
return
|
|
50
|
-
end
|
|
41
|
+
return if propositions.empty?
|
|
51
42
|
|
|
52
43
|
HTM.logger.info "GeneratePropositionsJob: Extracted #{propositions.length} propositions for node #{node_id}"
|
|
53
44
|
|
|
@@ -95,7 +86,6 @@ class HTM
|
|
|
95
86
|
rescue StandardError => e
|
|
96
87
|
# Log unexpected errors
|
|
97
88
|
HTM.logger.error "GeneratePropositionsJob: Unexpected error for node #{node_id}: #{e.class.name} - #{e.message}"
|
|
98
|
-
HTM.logger.debug e.backtrace.first(5).join("\n")
|
|
99
89
|
end
|
|
100
90
|
end
|
|
101
91
|
end
|
|
@@ -37,8 +37,6 @@ class HTM
|
|
|
37
37
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
38
|
|
|
39
39
|
begin
|
|
40
|
-
HTM.logger.debug "GenerateTagsJob: Extracting tags for node #{node_id}"
|
|
41
|
-
|
|
42
40
|
# Get existing ontology for context (sample of recent tags)
|
|
43
41
|
existing_ontology = HTM::Models::Tag
|
|
44
42
|
.order(created_at: :desc)
|
|
@@ -47,11 +45,7 @@ class HTM
|
|
|
47
45
|
|
|
48
46
|
# Extract and validate tags using TagService
|
|
49
47
|
tag_names = HTM::TagService.extract(node.content, existing_ontology: existing_ontology)
|
|
50
|
-
|
|
51
|
-
if tag_names.empty?
|
|
52
|
-
HTM.logger.debug "GenerateTagsJob: No tags extracted for node #{node_id}"
|
|
53
|
-
return
|
|
54
|
-
end
|
|
48
|
+
return if tag_names.empty?
|
|
55
49
|
|
|
56
50
|
# Create or find tags and associate with node
|
|
57
51
|
tag_names.each do |tag_name|
|
|
@@ -102,7 +96,6 @@ class HTM
|
|
|
102
96
|
|
|
103
97
|
# Log unexpected errors
|
|
104
98
|
HTM.logger.error "GenerateTagsJob: Unexpected error for node #{node_id}: #{e.class.name} - #{e.message}"
|
|
105
|
-
HTM.logger.debug e.backtrace.first(5).join("\n")
|
|
106
99
|
end
|
|
107
100
|
end
|
|
108
101
|
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'anyway_config'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
class HTM
|
|
7
|
+
module Loaders
|
|
8
|
+
# Bundled Defaults Loader for Anyway Config
|
|
9
|
+
#
|
|
10
|
+
# Loads default configuration values from a YAML file bundled with the gem.
|
|
11
|
+
# This ensures defaults are always available regardless of where HTM is installed.
|
|
12
|
+
#
|
|
13
|
+
# The defaults.yml file has this structure:
|
|
14
|
+
# defaults: # Base values for all environments
|
|
15
|
+
# database:
|
|
16
|
+
# host: localhost
|
|
17
|
+
# port: 5432
|
|
18
|
+
# development: # Overrides for development
|
|
19
|
+
# database:
|
|
20
|
+
# name: htm_development
|
|
21
|
+
# test: # Overrides for test
|
|
22
|
+
# database:
|
|
23
|
+
# name: htm_test
|
|
24
|
+
# production: # Overrides for production
|
|
25
|
+
# database:
|
|
26
|
+
# sslmode: require
|
|
27
|
+
#
|
|
28
|
+
# This loader deep-merges `defaults` with the current environment's overrides.
|
|
29
|
+
#
|
|
30
|
+
# This loader runs at LOWEST priority (before XDG), so all other sources
|
|
31
|
+
# can override these bundled defaults:
|
|
32
|
+
# 1. Bundled defaults (this loader)
|
|
33
|
+
# 2. XDG user config (~/.config/htm/htm.yml)
|
|
34
|
+
# 3. Project config (./config/htm.yml)
|
|
35
|
+
# 4. Local overrides (./config/htm.local.yml)
|
|
36
|
+
# 5. Environment variables (HTM_*)
|
|
37
|
+
# 6. Programmatic (configure block)
|
|
38
|
+
#
|
|
39
|
+
class DefaultsLoader < Anyway::Loaders::Base
|
|
40
|
+
DEFAULTS_PATH = File.expand_path('../config/defaults.yml', __dir__).freeze
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Returns the path to the bundled defaults file
|
|
44
|
+
#
|
|
45
|
+
# @return [String] path to defaults.yml
|
|
46
|
+
def defaults_path
|
|
47
|
+
DEFAULTS_PATH
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if defaults file exists
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def defaults_exist?
|
|
54
|
+
File.exist?(DEFAULTS_PATH)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Load and parse the raw YAML content
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash] parsed YAML with symbolized keys
|
|
60
|
+
def load_raw_yaml
|
|
61
|
+
return {} unless defaults_exist?
|
|
62
|
+
|
|
63
|
+
content = File.read(defaults_path)
|
|
64
|
+
YAML.safe_load(
|
|
65
|
+
content,
|
|
66
|
+
permitted_classes: [Symbol],
|
|
67
|
+
symbolize_names: true,
|
|
68
|
+
aliases: true
|
|
69
|
+
) || {}
|
|
70
|
+
rescue Psych::SyntaxError => e
|
|
71
|
+
warn "HTM: Failed to parse bundled defaults #{defaults_path}: #{e.message}"
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Extract the schema (attribute names) from the defaults section
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash] the defaults section containing all attribute definitions
|
|
78
|
+
def schema
|
|
79
|
+
raw = load_raw_yaml
|
|
80
|
+
raw[:defaults] || {}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def call(name:, **_options)
|
|
85
|
+
return {} unless self.class.defaults_exist?
|
|
86
|
+
|
|
87
|
+
trace!(:bundled_defaults, path: self.class.defaults_path) do
|
|
88
|
+
load_and_merge_for_environment
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Load defaults and deep merge with environment-specific overrides
|
|
95
|
+
#
|
|
96
|
+
# @return [Hash] merged configuration for current environment
|
|
97
|
+
def load_and_merge_for_environment
|
|
98
|
+
raw = self.class.load_raw_yaml
|
|
99
|
+
return {} if raw.empty?
|
|
100
|
+
|
|
101
|
+
# Start with the defaults section
|
|
102
|
+
defaults = raw[:defaults] || {}
|
|
103
|
+
|
|
104
|
+
# Deep merge with environment-specific overrides
|
|
105
|
+
env = current_environment
|
|
106
|
+
env_overrides = raw[env.to_sym] || {}
|
|
107
|
+
|
|
108
|
+
deep_merge(defaults, env_overrides)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Deep merge two hashes, with overlay taking precedence
|
|
112
|
+
#
|
|
113
|
+
# @param base [Hash] base configuration
|
|
114
|
+
# @param overlay [Hash] overlay configuration (takes precedence)
|
|
115
|
+
# @return [Hash] merged result
|
|
116
|
+
def deep_merge(base, overlay)
|
|
117
|
+
base.merge(overlay) do |_key, old_val, new_val|
|
|
118
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
119
|
+
deep_merge(old_val, new_val)
|
|
120
|
+
else
|
|
121
|
+
new_val
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Determine the current environment
|
|
127
|
+
#
|
|
128
|
+
# Priority: HTM_ENV > RAILS_ENV > RACK_ENV > 'development'
|
|
129
|
+
#
|
|
130
|
+
# @return [String] current environment name
|
|
131
|
+
def current_environment
|
|
132
|
+
ENV['HTM_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Register the defaults loader at LOWEST priority (before :yml loader)
|
|
139
|
+
# This ensures bundled defaults are overridden by all other sources:
|
|
140
|
+
# - XDG user config (registered after this, also before :yml)
|
|
141
|
+
# - Project config (:yml loader)
|
|
142
|
+
# - Environment variables (:env loader)
|
|
143
|
+
Anyway.loaders.insert_before :yml, :bundled_defaults, HTM::Loaders::DefaultsLoader
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'anyway_config'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
class HTM
|
|
7
|
+
module Loaders
|
|
8
|
+
# XDG Base Directory Specification loader for Anyway Config
|
|
9
|
+
#
|
|
10
|
+
# Loads configuration from XDG-compliant paths:
|
|
11
|
+
# 1. $XDG_CONFIG_HOME/htm/htm.yml (if XDG_CONFIG_HOME is set)
|
|
12
|
+
# 2. ~/.config/htm/htm.yml (XDG default fallback)
|
|
13
|
+
#
|
|
14
|
+
# On macOS, also checks:
|
|
15
|
+
# 3. ~/Library/Application Support/htm/htm.yml
|
|
16
|
+
#
|
|
17
|
+
# This loader runs BEFORE the project-local config loader,
|
|
18
|
+
# so project configs take precedence over user-global configs.
|
|
19
|
+
#
|
|
20
|
+
# @example XDG config file location
|
|
21
|
+
# ~/.config/htm/htm.yml
|
|
22
|
+
#
|
|
23
|
+
# @example Custom XDG_CONFIG_HOME
|
|
24
|
+
# export XDG_CONFIG_HOME=/my/config
|
|
25
|
+
# # Looks for /my/config/htm/htm.yml
|
|
26
|
+
#
|
|
27
|
+
class XdgConfigLoader < Anyway::Loaders::Base
|
|
28
|
+
class << self
|
|
29
|
+
# Returns all XDG config paths to check, in order of priority (lowest first)
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String>] list of potential config file paths
|
|
32
|
+
def config_paths
|
|
33
|
+
paths = []
|
|
34
|
+
|
|
35
|
+
# macOS Application Support (lowest priority for XDG loader)
|
|
36
|
+
if macos?
|
|
37
|
+
macos_path = File.expand_path('~/Library/Application Support/htm')
|
|
38
|
+
paths << macos_path if Dir.exist?(File.dirname(macos_path))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# XDG default: ~/.config/htm
|
|
42
|
+
xdg_default = File.expand_path('~/.config/htm')
|
|
43
|
+
paths << xdg_default
|
|
44
|
+
|
|
45
|
+
# XDG_CONFIG_HOME override (highest priority for XDG loader)
|
|
46
|
+
if ENV['XDG_CONFIG_HOME'] && !ENV['XDG_CONFIG_HOME'].empty?
|
|
47
|
+
xdg_home = File.join(ENV['XDG_CONFIG_HOME'], 'htm')
|
|
48
|
+
paths << xdg_home unless xdg_home == xdg_default
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
paths
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Find the first existing config file
|
|
55
|
+
#
|
|
56
|
+
# @param name [String] config name (e.g., 'htm')
|
|
57
|
+
# @return [String, nil] path to config file or nil if not found
|
|
58
|
+
def find_config_file(name)
|
|
59
|
+
config_paths.reverse_each do |dir|
|
|
60
|
+
file = File.join(dir, "#{name}.yml")
|
|
61
|
+
return file if File.exist?(file)
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def macos?
|
|
69
|
+
RUBY_PLATFORM.include?('darwin')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call(name:, **_options)
|
|
74
|
+
config_file = self.class.find_config_file(name)
|
|
75
|
+
return {} unless config_file
|
|
76
|
+
|
|
77
|
+
trace!(:xdg, path: config_file) do
|
|
78
|
+
load_yaml(config_file, name)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def load_yaml(path, name)
|
|
85
|
+
return {} unless File.exist?(path)
|
|
86
|
+
|
|
87
|
+
content = File.read(path)
|
|
88
|
+
parsed = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true, aliases: true) || {}
|
|
89
|
+
|
|
90
|
+
# Support environment-specific configs
|
|
91
|
+
env = Anyway::Settings.current_environment ||
|
|
92
|
+
ENV['HTM_ENV'] ||
|
|
93
|
+
ENV['RAILS_ENV'] ||
|
|
94
|
+
ENV['RACK_ENV'] ||
|
|
95
|
+
'development'
|
|
96
|
+
|
|
97
|
+
# Check for environment key first, fall back to root level
|
|
98
|
+
if parsed.key?(env.to_sym)
|
|
99
|
+
parsed[env.to_sym] || {}
|
|
100
|
+
elsif parsed.key?(env.to_s)
|
|
101
|
+
parsed[env.to_s] || {}
|
|
102
|
+
else
|
|
103
|
+
# No environment key, treat as flat config
|
|
104
|
+
parsed
|
|
105
|
+
end
|
|
106
|
+
rescue Psych::SyntaxError => e
|
|
107
|
+
warn "HTM: Failed to parse XDG config #{path}: #{e.message}"
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Register the XDG loader with Anyway Config
|
|
115
|
+
# Insert before :yml so project-local config takes precedence
|
|
116
|
+
Anyway.loaders.insert_before :yml, :xdg, HTM::Loaders::XdgConfigLoader
|