htm 0.0.1 → 0.0.2

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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.tbls.yml +30 -0
  4. data/CHANGELOG.md +30 -0
  5. data/SETUP.md +132 -101
  6. data/db/migrate/20250125000001_add_content_hash_to_nodes.rb +14 -0
  7. data/db/migrate/20250125000002_create_robot_nodes.rb +35 -0
  8. data/db/migrate/20250125000003_remove_source_and_robot_id_from_nodes.rb +28 -0
  9. data/db/migrate/20250126000001_create_working_memories.rb +19 -0
  10. data/db/migrate/20250126000002_remove_unused_columns.rb +12 -0
  11. data/db/schema.sql +226 -43
  12. data/docs/api/database.md +20 -232
  13. data/docs/api/embedding-service.md +1 -7
  14. data/docs/api/htm.md +195 -449
  15. data/docs/api/index.md +1 -7
  16. data/docs/api/long-term-memory.md +342 -590
  17. data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
  18. data/docs/architecture/adrs/003-ollama-embeddings.md +1 -1
  19. data/docs/architecture/adrs/010-redis-working-memory-rejected.md +2 -27
  20. data/docs/architecture/adrs/index.md +2 -13
  21. data/docs/architecture/hive-mind.md +165 -166
  22. data/docs/architecture/index.md +2 -2
  23. data/docs/architecture/overview.md +5 -171
  24. data/docs/architecture/two-tier-memory.md +1 -35
  25. data/docs/assets/images/adr-010-current-architecture.svg +37 -0
  26. data/docs/assets/images/adr-010-proposed-architecture.svg +48 -0
  27. data/docs/assets/images/adr-dependency-tree.svg +93 -0
  28. data/docs/assets/images/class-hierarchy.svg +55 -0
  29. data/docs/assets/images/exception-hierarchy.svg +45 -0
  30. data/docs/assets/images/htm-architecture-overview.svg +83 -0
  31. data/docs/assets/images/htm-complete-memory-flow.svg +160 -0
  32. data/docs/assets/images/htm-context-assembly-flow.svg +148 -0
  33. data/docs/assets/images/htm-eviction-process.svg +141 -0
  34. data/docs/assets/images/htm-memory-addition-flow.svg +138 -0
  35. data/docs/assets/images/htm-memory-recall-flow.svg +152 -0
  36. data/docs/assets/images/htm-node-states.svg +123 -0
  37. data/docs/assets/images/project-structure.svg +78 -0
  38. data/docs/assets/images/test-directory-structure.svg +38 -0
  39. data/{dbdoc → docs/database}/README.md +5 -3
  40. data/{dbdoc → docs/database}/public.node_tags.md +4 -5
  41. data/docs/database/public.node_tags.svg +106 -0
  42. data/{dbdoc → docs/database}/public.nodes.md +3 -8
  43. data/docs/database/public.nodes.svg +152 -0
  44. data/docs/database/public.robot_nodes.md +44 -0
  45. data/docs/database/public.robot_nodes.svg +121 -0
  46. data/{dbdoc → docs/database}/public.robots.md +1 -2
  47. data/docs/database/public.robots.svg +106 -0
  48. data/docs/database/public.working_memories.md +40 -0
  49. data/docs/database/public.working_memories.svg +112 -0
  50. data/{dbdoc → docs/database}/schema.json +342 -110
  51. data/docs/database/schema.svg +223 -0
  52. data/docs/development/index.md +1 -29
  53. data/docs/development/schema.md +84 -324
  54. data/docs/development/testing.md +1 -9
  55. data/docs/getting-started/index.md +47 -0
  56. data/docs/{installation.md → getting-started/installation.md} +2 -2
  57. data/docs/{quick-start.md → getting-started/quick-start.md} +5 -5
  58. data/docs/guides/adding-memories.md +221 -655
  59. data/docs/guides/search-strategies.md +85 -51
  60. data/docs/images/htm-er-diagram.svg +156 -0
  61. data/docs/index.md +16 -31
  62. data/docs/multi_framework_support.md +4 -4
  63. data/examples/basic_usage.rb +18 -16
  64. data/examples/cli_app/htm_cli.rb +86 -8
  65. data/examples/custom_llm_configuration.rb +1 -2
  66. data/examples/example_app/app.rb +11 -14
  67. data/examples/sinatra_app/Gemfile +1 -0
  68. data/examples/sinatra_app/Gemfile.lock +166 -0
  69. data/examples/sinatra_app/app.rb +219 -24
  70. data/lib/htm/active_record_config.rb +10 -3
  71. data/lib/htm/configuration.rb +265 -78
  72. data/lib/htm/{sinatra.rb → integrations/sinatra.rb} +87 -12
  73. data/lib/htm/job_adapter.rb +10 -3
  74. data/lib/htm/long_term_memory.rb +220 -57
  75. data/lib/htm/models/node.rb +36 -7
  76. data/lib/htm/models/robot.rb +30 -4
  77. data/lib/htm/models/robot_node.rb +50 -0
  78. data/lib/htm/models/tag.rb +52 -0
  79. data/lib/htm/models/working_memory_entry.rb +88 -0
  80. data/lib/htm/tasks.rb +4 -0
  81. data/lib/htm/version.rb +1 -1
  82. data/lib/htm.rb +34 -13
  83. data/lib/tasks/htm.rake +32 -1
  84. data/lib/tasks/jobs.rake +7 -3
  85. data/lib/tasks/tags.rake +34 -0
  86. data/mkdocs.yml +56 -9
  87. metadata +61 -31
  88. data/dbdoc/public.node_tags.svg +0 -112
  89. data/dbdoc/public.nodes.svg +0 -118
  90. data/dbdoc/public.robots.svg +0 -90
  91. data/dbdoc/schema.svg +0 -154
  92. /data/{dbdoc → docs/database}/public.node_stats.md +0 -0
  93. /data/{dbdoc → docs/database}/public.node_stats.svg +0 -0
  94. /data/{dbdoc → docs/database}/public.nodes_tags.md +0 -0
  95. /data/{dbdoc → docs/database}/public.nodes_tags.svg +0 -0
  96. /data/{dbdoc → docs/database}/public.ontology_structure.md +0 -0
  97. /data/{dbdoc → docs/database}/public.ontology_structure.svg +0 -0
  98. /data/{dbdoc → docs/database}/public.operations_log.md +0 -0
  99. /data/{dbdoc → docs/database}/public.operations_log.svg +0 -0
  100. /data/{dbdoc → docs/database}/public.relationships.md +0 -0
  101. /data/{dbdoc → docs/database}/public.relationships.svg +0 -0
  102. /data/{dbdoc → docs/database}/public.robot_activity.md +0 -0
  103. /data/{dbdoc → docs/database}/public.robot_activity.svg +0 -0
  104. /data/{dbdoc → docs/database}/public.schema_migrations.md +0 -0
  105. /data/{dbdoc → docs/database}/public.schema_migrations.svg +0 -0
  106. /data/{dbdoc → docs/database}/public.tags.md +0 -0
  107. /data/{dbdoc → docs/database}/public.tags.svg +0 -0
  108. /data/{dbdoc → docs/database}/public.topic_relationships.md +0 -0
  109. /data/{dbdoc → docs/database}/public.topic_relationships.svg +0 -0
@@ -6,10 +6,44 @@ require 'logger'
6
6
  class HTM
7
7
  # HTM Configuration
8
8
  #
9
- # Applications using HTM should configure LLM access by providing two methods:
10
- # 1. embedding_generator - Converts text to vector embeddings
11
- # 2. tag_extractor - Extracts hierarchical tags from text
12
- # 3. logger - Logger instance for HTM operations
9
+ # HTM uses RubyLLM for multi-provider LLM support. Supported providers:
10
+ # - :openai (OpenAI API)
11
+ # - :anthropic (Anthropic Claude)
12
+ # - :gemini (Google Gemini)
13
+ # - :azure (Azure OpenAI)
14
+ # - :ollama (Local Ollama - default)
15
+ # - :huggingface (HuggingFace Inference API)
16
+ # - :openrouter (OpenRouter)
17
+ # - :bedrock (AWS Bedrock)
18
+ # - :deepseek (DeepSeek)
19
+ #
20
+ # @example Configure with OpenAI
21
+ # HTM.configure do |config|
22
+ # config.embedding_provider = :openai
23
+ # config.embedding_model = 'text-embedding-3-small'
24
+ # config.tag_provider = :openai
25
+ # config.tag_model = 'gpt-4o-mini'
26
+ # config.openai_api_key = ENV['OPENAI_API_KEY']
27
+ # end
28
+ #
29
+ # @example Configure with Ollama (default)
30
+ # HTM.configure do |config|
31
+ # config.embedding_provider = :ollama
32
+ # config.embedding_model = 'nomic-embed-text'
33
+ # config.tag_provider = :ollama
34
+ # config.tag_model = 'llama3'
35
+ # config.ollama_url = 'http://localhost:11434'
36
+ # end
37
+ #
38
+ # @example Configure with Anthropic for tags, OpenAI for embeddings
39
+ # HTM.configure do |config|
40
+ # config.embedding_provider = :openai
41
+ # config.embedding_model = 'text-embedding-3-small'
42
+ # config.openai_api_key = ENV['OPENAI_API_KEY']
43
+ # config.tag_provider = :anthropic
44
+ # config.tag_model = 'claude-3-haiku-20240307'
45
+ # config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
46
+ # end
13
47
  #
14
48
  # @example Configure with custom methods
15
49
  # HTM.configure do |config|
@@ -19,39 +53,72 @@ class HTM
19
53
  # config.tag_extractor = ->(text, ontology) {
20
54
  # MyApp::LLMService.extract_tags(text, ontology) # Returns Array<String>
21
55
  # }
22
- # config.logger = Rails.logger # Use Rails logger
23
- # end
24
- #
25
- # @example Use defaults with custom timeouts
26
- # HTM.configure do |config|
27
- # config.embedding_timeout = 60 # 1 minute for faster models
28
- # config.tag_timeout = 300 # 5 minutes for larger models
29
- # config.connection_timeout = 10 # 10 seconds connection timeout
30
- # config.reset_to_defaults # Apply default implementations with new timeouts
56
+ # config.logger = Rails.logger
31
57
  # end
32
58
  #
33
- # @example Use defaults
34
- # HTM.configure # Uses default implementations
35
- #
36
59
  class Configuration
37
60
  attr_accessor :embedding_generator, :tag_extractor, :token_counter
38
61
  attr_accessor :embedding_model, :embedding_provider, :embedding_dimensions
39
62
  attr_accessor :tag_model, :tag_provider
40
- attr_accessor :ollama_url
41
63
  attr_accessor :embedding_timeout, :tag_timeout, :connection_timeout
42
64
  attr_accessor :logger
43
65
  attr_accessor :job_backend
44
66
 
67
+ # Provider-specific API keys and endpoints
68
+ attr_accessor :openai_api_key, :openai_organization, :openai_project
69
+ attr_accessor :anthropic_api_key
70
+ attr_accessor :gemini_api_key
71
+ attr_accessor :azure_api_key, :azure_endpoint, :azure_api_version
72
+ attr_accessor :ollama_url
73
+ attr_accessor :huggingface_api_key
74
+ attr_accessor :openrouter_api_key
75
+ attr_accessor :bedrock_access_key, :bedrock_secret_key, :bedrock_region
76
+ attr_accessor :deepseek_api_key
77
+
78
+ # Supported providers
79
+ SUPPORTED_PROVIDERS = %i[
80
+ openai anthropic gemini azure ollama
81
+ huggingface openrouter bedrock deepseek
82
+ ].freeze
83
+
84
+ # Default embedding dimensions by provider/model
85
+ DEFAULT_DIMENSIONS = {
86
+ openai: 1536, # text-embedding-3-small
87
+ anthropic: 1024, # voyage embeddings
88
+ gemini: 768, # text-embedding-004
89
+ azure: 1536, # same as OpenAI
90
+ ollama: 768, # nomic-embed-text
91
+ huggingface: 768, # varies by model
92
+ openrouter: 1536, # varies by model
93
+ bedrock: 1536, # titan-embed-text
94
+ deepseek: 1536 # varies by model
95
+ }.freeze
96
+
45
97
  def initialize
46
- # Default configuration
98
+ # Default configuration - Ollama for local development
47
99
  @embedding_provider = :ollama
48
- @embedding_model = 'nomic-embed-text'
100
+ @embedding_model = 'nomic-embed-text:latest' # Include tag for Ollama models
49
101
  @embedding_dimensions = 768
50
102
 
51
103
  @tag_provider = :ollama
52
- @tag_model = 'llama3'
53
-
54
- @ollama_url = ENV['OLLAMA_URL'] || 'http://localhost:11434'
104
+ @tag_model = 'gemma3:latest' # Include tag for Ollama models
105
+
106
+ # Provider credentials from environment variables
107
+ @openai_api_key = ENV['OPENAI_API_KEY']
108
+ @openai_organization = ENV['OPENAI_ORGANIZATION']
109
+ @openai_project = ENV['OPENAI_PROJECT']
110
+ @anthropic_api_key = ENV['ANTHROPIC_API_KEY']
111
+ @gemini_api_key = ENV['GEMINI_API_KEY']
112
+ @azure_api_key = ENV['AZURE_OPENAI_API_KEY']
113
+ @azure_endpoint = ENV['AZURE_OPENAI_ENDPOINT']
114
+ @azure_api_version = ENV['AZURE_OPENAI_API_VERSION'] || '2024-02-01'
115
+ @ollama_url = ENV['OLLAMA_API_BASE'] || ENV['OLLAMA_URL'] || 'http://localhost:11434'
116
+ @huggingface_api_key = ENV['HUGGINGFACE_API_KEY']
117
+ @openrouter_api_key = ENV['OPENROUTER_API_KEY']
118
+ @bedrock_access_key = ENV['AWS_ACCESS_KEY_ID']
119
+ @bedrock_secret_key = ENV['AWS_SECRET_ACCESS_KEY']
120
+ @bedrock_region = ENV['AWS_REGION'] || 'us-east-1'
121
+ @deepseek_api_key = ENV['DEEPSEEK_API_KEY']
55
122
 
56
123
  # Timeout settings (in seconds) - apply to all LLM providers
57
124
  @embedding_timeout = 120 # 2 minutes for embedding generation
@@ -96,6 +163,76 @@ class HTM
96
163
  unless [:active_job, :sidekiq, :inline, :thread].include?(@job_backend)
97
164
  raise HTM::ValidationError, "job_backend must be one of: :active_job, :sidekiq, :inline, :thread (got #{@job_backend.inspect})"
98
165
  end
166
+
167
+ # Validate provider if specified
168
+ if @embedding_provider && !SUPPORTED_PROVIDERS.include?(@embedding_provider)
169
+ raise HTM::ValidationError, "embedding_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@embedding_provider.inspect})"
170
+ end
171
+
172
+ if @tag_provider && !SUPPORTED_PROVIDERS.include?(@tag_provider)
173
+ raise HTM::ValidationError, "tag_provider must be one of: #{SUPPORTED_PROVIDERS.join(', ')} (got #{@tag_provider.inspect})"
174
+ end
175
+ end
176
+
177
+ # Normalize Ollama model name to include tag if missing
178
+ #
179
+ # Ollama models require a tag (e.g., :latest, :7b, :13b). If the user
180
+ # specifies a model without a tag, we append :latest by default.
181
+ #
182
+ # @param model_name [String] Original model name
183
+ # @return [String] Normalized model name with tag
184
+ #
185
+ def normalize_ollama_model(model_name)
186
+ return model_name if model_name.nil? || model_name.empty?
187
+ return model_name if model_name.include?(':')
188
+
189
+ "#{model_name}:latest"
190
+ end
191
+
192
+ # Configure RubyLLM with the appropriate provider credentials
193
+ #
194
+ # @param provider [Symbol] The provider to configure (:openai, :anthropic, etc.)
195
+ #
196
+ def configure_ruby_llm(provider = nil)
197
+ require 'ruby_llm' unless defined?(RubyLLM)
198
+
199
+ provider ||= @embedding_provider
200
+
201
+ RubyLLM.configure do |config|
202
+ case provider
203
+ when :openai
204
+ config.openai_api_key = @openai_api_key if @openai_api_key
205
+ config.openai_organization = @openai_organization if @openai_organization && config.respond_to?(:openai_organization=)
206
+ config.openai_project = @openai_project if @openai_project && config.respond_to?(:openai_project=)
207
+ when :anthropic
208
+ config.anthropic_api_key = @anthropic_api_key if @anthropic_api_key
209
+ when :gemini
210
+ config.gemini_api_key = @gemini_api_key if @gemini_api_key
211
+ when :azure
212
+ config.azure_api_key = @azure_api_key if @azure_api_key && config.respond_to?(:azure_api_key=)
213
+ config.azure_endpoint = @azure_endpoint if @azure_endpoint && config.respond_to?(:azure_endpoint=)
214
+ config.azure_api_version = @azure_api_version if @azure_api_version && config.respond_to?(:azure_api_version=)
215
+ when :ollama
216
+ # Ollama exposes OpenAI-compatible API at /v1
217
+ # Ensure URL has /v1 suffix (add if missing, don't duplicate if present)
218
+ ollama_api_base = if @ollama_url.end_with?('/v1') || @ollama_url.end_with?('/v1/')
219
+ @ollama_url.sub(%r{/+$}, '') # Just remove trailing slashes
220
+ else
221
+ "#{@ollama_url.sub(%r{/+$}, '')}/v1"
222
+ end
223
+ config.ollama_api_base = ollama_api_base
224
+ when :huggingface
225
+ config.huggingface_api_key = @huggingface_api_key if @huggingface_api_key && config.respond_to?(:huggingface_api_key=)
226
+ when :openrouter
227
+ config.openrouter_api_key = @openrouter_api_key if @openrouter_api_key && config.respond_to?(:openrouter_api_key=)
228
+ when :bedrock
229
+ config.bedrock_api_key = @bedrock_access_key if @bedrock_access_key && config.respond_to?(:bedrock_api_key=)
230
+ config.bedrock_secret_key = @bedrock_secret_key if @bedrock_secret_key && config.respond_to?(:bedrock_secret_key=)
231
+ config.bedrock_region = @bedrock_region if @bedrock_region && config.respond_to?(:bedrock_region=)
232
+ when :deepseek
233
+ config.deepseek_api_key = @deepseek_api_key if @deepseek_api_key && config.respond_to?(:deepseek_api_key=)
234
+ end
235
+ end
99
236
  end
100
237
 
101
238
  private
@@ -153,50 +290,93 @@ class HTM
153
290
  end
154
291
  end
155
292
 
156
- # Default embedding generator using Ollama HTTP API
293
+ # Default embedding generator using RubyLLM
157
294
  #
158
295
  # @return [Proc] Callable that takes text and returns embedding vector
159
296
  #
160
297
  def default_embedding_generator
161
298
  lambda do |text|
162
- require 'net/http'
163
- require 'json'
299
+ require 'ruby_llm' unless defined?(RubyLLM)
164
300
 
165
- case @embedding_provider
166
- when :ollama
167
- uri = URI("#{@ollama_url}/api/embeddings")
168
- request = Net::HTTP::Post.new(uri)
169
- request['Content-Type'] = 'application/json'
170
- request.body = { model: @embedding_model, prompt: text }.to_json
171
-
172
- response = Net::HTTP.start(uri.hostname, uri.port,
173
- read_timeout: @embedding_timeout,
174
- open_timeout: @connection_timeout) do |http|
175
- http.request(request)
176
- end
301
+ # Configure RubyLLM for the embedding provider
302
+ configure_ruby_llm(@embedding_provider)
177
303
 
178
- data = JSON.parse(response.body)
179
- embedding = data['embedding']
304
+ # Refresh models for Ollama to discover local models
305
+ if @embedding_provider == :ollama && !@ollama_models_refreshed
306
+ RubyLLM.models.refresh!
307
+ @ollama_models_refreshed = true
308
+ end
180
309
 
181
- unless embedding.is_a?(Array)
182
- raise HTM::EmbeddingError, "Invalid embedding response format"
183
- end
310
+ # Normalize Ollama model name (ensure it has a tag like :latest)
311
+ model = @embedding_provider == :ollama ? normalize_ollama_model(@embedding_model) : @embedding_model
184
312
 
185
- embedding
186
- else
187
- raise HTM::EmbeddingError, "Unsupported embedding provider: #{@embedding_provider}. Only :ollama is currently supported."
313
+ # Generate embedding using RubyLLM
314
+ response = RubyLLM.embed(text, model: model)
315
+
316
+ # Extract embedding vector from response
317
+ embedding = extract_embedding_from_response(response)
318
+
319
+ unless embedding.is_a?(Array) && embedding.all? { |v| v.is_a?(Numeric) }
320
+ raise HTM::EmbeddingError, "Invalid embedding response format from #{@embedding_provider}"
188
321
  end
322
+
323
+ embedding
189
324
  end
190
325
  end
191
326
 
192
- # Default tag extractor using Ollama HTTP API
327
+ # Extract embedding vector from RubyLLM response
328
+ #
329
+ # @param response [Object] RubyLLM embed response
330
+ # @return [Array<Float>] Embedding vector
331
+ #
332
+ def extract_embedding_from_response(response)
333
+ return nil unless response
334
+
335
+ # Handle different response formats from RubyLLM
336
+ case response
337
+ when Array
338
+ # Direct array response
339
+ response
340
+ when ->(r) { r.respond_to?(:vectors) }
341
+ # RubyLLM::Embedding object with vectors method
342
+ vectors = response.vectors
343
+ vectors.is_a?(Array) && vectors.first.is_a?(Array) ? vectors.first : vectors
344
+ when ->(r) { r.respond_to?(:to_a) }
345
+ # Can be converted to array
346
+ response.to_a
347
+ when ->(r) { r.respond_to?(:embedding) }
348
+ # Has embedding attribute
349
+ response.embedding
350
+ else
351
+ # Try to extract vectors from instance variables
352
+ if response.respond_to?(:instance_variable_get)
353
+ vectors = response.instance_variable_get(:@vectors)
354
+ return vectors.first if vectors.is_a?(Array) && vectors.first.is_a?(Array)
355
+ return vectors if vectors.is_a?(Array)
356
+ end
357
+ raise HTM::EmbeddingError, "Cannot extract embedding from response: #{response.class}"
358
+ end
359
+ end
360
+
361
+ # Default tag extractor using RubyLLM chat
193
362
  #
194
363
  # @return [Proc] Callable that takes text and ontology, returns array of tags
195
364
  #
196
365
  def default_tag_extractor
197
366
  lambda do |text, existing_ontology = []|
198
- require 'net/http'
199
- require 'json'
367
+ require 'ruby_llm' unless defined?(RubyLLM)
368
+
369
+ # Configure RubyLLM for the tag provider
370
+ configure_ruby_llm(@tag_provider)
371
+
372
+ # Refresh models for Ollama to discover local models
373
+ if @tag_provider == :ollama && !@ollama_models_refreshed
374
+ RubyLLM.models.refresh!
375
+ @ollama_models_refreshed = true
376
+ end
377
+
378
+ # Normalize Ollama model name (ensure it has a tag like :latest)
379
+ model = @tag_provider == :ollama ? normalize_ollama_model(@tag_model) : @tag_model
200
380
 
201
381
  # Build prompt
202
382
  ontology_context = if existing_ontology.any?
@@ -225,41 +405,48 @@ class HTM
225
405
  Return ONLY the topic tags, one per line, no explanations.
226
406
  PROMPT
227
407
 
228
- case @tag_provider
229
- when :ollama
230
- uri = URI("#{@ollama_url}/api/generate")
231
- request = Net::HTTP::Post.new(uri)
232
- request['Content-Type'] = 'application/json'
233
- request.body = {
234
- model: @tag_model,
235
- prompt: prompt,
236
- system: 'You are a precise topic extraction system. Output only topic tags in hierarchical format: root:subtopic:detail',
237
- stream: false,
238
- options: { temperature: 0 }
239
- }.to_json
240
-
241
- response = Net::HTTP.start(uri.hostname, uri.port,
242
- read_timeout: @tag_timeout,
243
- open_timeout: @connection_timeout) do |http|
244
- http.request(request)
245
- end
408
+ system_prompt = 'You are a precise topic extraction system. Output only topic tags in hierarchical format: root:subtopic:detail'
246
409
 
247
- data = JSON.parse(response.body)
248
- response_text = data['response']
410
+ # Use RubyLLM chat for tag extraction
411
+ chat = RubyLLM.chat(model: model)
412
+ chat.with_instructions(system_prompt)
413
+ response = chat.ask(prompt)
249
414
 
250
- # Parse and validate tags
251
- tags = response_text.to_s.split("\n").map(&:strip).reject(&:empty?)
415
+ # Extract text from response
416
+ response_text = extract_text_from_response(response)
252
417
 
253
- # Validate format: lowercase alphanumeric + hyphens + colons
254
- valid_tags = tags.select do |tag|
255
- tag =~ /^[a-z0-9\-]+(:[a-z0-9\-]+)*$/
256
- end
418
+ # Parse and validate tags
419
+ tags = response_text.to_s.split("\n").map(&:strip).reject(&:empty?)
257
420
 
258
- # Limit depth to 5 levels (4 colons maximum)
259
- valid_tags.select { |tag| tag.count(':') < 5 }
260
- else
261
- raise HTM::TagError, "Unsupported tag provider: #{@tag_provider}. Only :ollama is currently supported."
421
+ # Validate format: lowercase alphanumeric + hyphens + colons
422
+ valid_tags = tags.select do |tag|
423
+ tag =~ /^[a-z0-9\-]+(:[a-z0-9\-]+)*$/
262
424
  end
425
+
426
+ # Limit depth to 5 levels (4 colons maximum)
427
+ valid_tags.select { |tag| tag.count(':') < 5 }
428
+ end
429
+ end
430
+
431
+ # Extract text content from RubyLLM chat response
432
+ #
433
+ # @param response [Object] RubyLLM chat response
434
+ # @return [String] Response text
435
+ #
436
+ def extract_text_from_response(response)
437
+ return '' unless response
438
+
439
+ case response
440
+ when String
441
+ response
442
+ when ->(r) { r.respond_to?(:content) }
443
+ response.content.to_s
444
+ when ->(r) { r.respond_to?(:text) }
445
+ response.text.to_s
446
+ when ->(r) { r.respond_to?(:to_s) }
447
+ response.to_s
448
+ else
449
+ ''
263
450
  end
264
451
  end
265
452
  end
@@ -1,5 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # HTM Sinatra Integration
4
+ #
5
+ # Optional integration for using HTM with Sinatra web applications.
6
+ # This file is NOT loaded automatically - require it explicitly:
7
+ #
8
+ # require 'htm'
9
+ # require 'htm/integrations/sinatra'
10
+ #
11
+ # Provides:
12
+ # - HTM::Sinatra::Helpers - Request helpers (init_htm, htm, remember, recall)
13
+ # - HTM::Sinatra::Middleware - Connection pool management
14
+ # - Sinatra::Base.register_htm - One-line setup
15
+ #
16
+
3
17
  require 'sinatra/base'
4
18
 
5
19
  class HTM
@@ -8,20 +22,22 @@ class HTM
8
22
  # Provides convenient helper methods for using HTM in Sinatra applications.
9
23
  #
10
24
  # @example Basic usage
25
+ # require 'htm/integrations/sinatra'
26
+ #
11
27
  # class MyApp < Sinatra::Base
12
- # helpers HTM::Sinatra::Helpers
28
+ # register_htm
13
29
  #
14
30
  # before do
15
31
  # init_htm(robot_name: session[:user_id] || 'guest')
16
32
  # end
17
33
  #
18
34
  # post '/remember' do
19
- # node_id = htm.remember(params[:content], source: 'user')
35
+ # node_id = remember(params[:content])
20
36
  # json status: 'ok', node_id: node_id
21
37
  # end
22
38
  #
23
39
  # get '/recall' do
24
- # memories = htm.recall(params[:topic], limit: 10)
40
+ # memories = recall(params[:topic], limit: 10)
25
41
  # json memories: memories
26
42
  # end
27
43
  # end
@@ -53,11 +69,11 @@ class HTM
53
69
  # Remember information (convenience method)
54
70
  #
55
71
  # @param content [String] Content to remember
56
- # @param source [String] Source identifier (default: 'user')
72
+ # @param tags [Array<String>] Optional tags to assign
57
73
  # @return [Integer] Node ID
58
74
  #
59
- def remember(content, source: 'user')
60
- htm.remember(content, source: source)
75
+ def remember(content, tags: [])
76
+ htm.remember(content, tags: tags)
61
77
  end
62
78
 
63
79
  # Recall memories (convenience method)
@@ -91,16 +107,18 @@ class HTM
91
107
  # end
92
108
  #
93
109
  class Middleware
110
+ # Class-level storage for connection configuration (shared across threads)
111
+ @@db_config = nil
112
+ @@config_mutex = Mutex.new
113
+
94
114
  def initialize(app, options = {})
95
115
  @app = app
96
116
  @options = options
97
117
  end
98
118
 
99
119
  def call(env)
100
- # Establish connection if needed
101
- unless HTM::ActiveRecordConfig.connected?
102
- HTM::ActiveRecordConfig.establish_connection!
103
- end
120
+ # Ensure connection is available in this thread
121
+ ensure_thread_connection!
104
122
 
105
123
  # Process request
106
124
  status, headers, body = @app.call(env)
@@ -108,8 +126,55 @@ class HTM
108
126
  # Return response
109
127
  [status, headers, body]
110
128
  ensure
111
- # Return connections to pool
112
- ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord)
129
+ # Return connections to pool after request completes
130
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
131
+ ActiveRecord::Base.connection_handler.clear_active_connections!
132
+ end
133
+ end
134
+
135
+ # Store the connection config at startup (called from register_htm)
136
+ def self.store_config!
137
+ @@config_mutex.synchronize do
138
+ return if @@db_config
139
+
140
+ @@db_config = HTM::ActiveRecordConfig.load_database_config
141
+ HTM.logger.debug "HTM database config stored for thread-safe access"
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def ensure_thread_connection!
148
+ # Check if connection pool exists and has an active connection
149
+ pool_exists = begin
150
+ ActiveRecord::Base.connection_pool
151
+ true
152
+ rescue ActiveRecord::ConnectionNotDefined
153
+ false
154
+ end
155
+
156
+ if pool_exists
157
+ return if ActiveRecord::Base.connection_pool.active_connection?
158
+ end
159
+
160
+ # Re-establish connection using stored config
161
+ if @@db_config
162
+ ActiveRecord::Base.establish_connection(@@db_config)
163
+ HTM.logger.debug "HTM database connection established for request thread"
164
+ else
165
+ raise "HTM database config not stored - call register_htm at app startup"
166
+ end
167
+ rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished => e
168
+ # Pool doesn't exist, establish connection
169
+ if @@db_config
170
+ ActiveRecord::Base.establish_connection(@@db_config)
171
+ HTM.logger.debug "HTM database connection established for request thread"
172
+ else
173
+ raise "HTM database config not stored - call register_htm at app startup"
174
+ end
175
+ rescue StandardError => e
176
+ HTM.logger.error "Failed to ensure thread connection: #{e.class} - #{e.message}"
177
+ raise
113
178
  end
114
179
  end
115
180
  end
@@ -150,6 +215,16 @@ module ::Sinatra
150
215
  end
151
216
  end
152
217
 
218
+ # Store database config for thread-safe access and establish initial connection
219
+ begin
220
+ HTM::Sinatra::Middleware.store_config!
221
+ HTM::ActiveRecordConfig.establish_connection!
222
+ HTM.logger.info "HTM database connection established"
223
+ rescue StandardError => e
224
+ HTM.logger.error "Failed to establish HTM database connection: #{e.message}"
225
+ raise
226
+ end
227
+
153
228
  HTM.logger.info "HTM registered with Sinatra application"
154
229
  HTM.logger.debug "HTM job backend: #{HTM.configuration.job_backend}"
155
230
  end
@@ -72,7 +72,10 @@ class HTM
72
72
 
73
73
  # Convert job class to Sidekiq worker if needed
74
74
  sidekiq_class = to_sidekiq_worker(job_class)
75
- sidekiq_class.perform_async(**params)
75
+
76
+ # Sidekiq 7.x requires native JSON types - convert symbol keys to strings
77
+ json_params = params.transform_keys(&:to_s)
78
+ sidekiq_class.perform_async(json_params)
76
79
 
77
80
  HTM.logger.debug "Enqueued #{job_class.name} via Sidekiq with params: #{params.inspect}"
78
81
  end
@@ -135,12 +138,16 @@ class HTM
135
138
  return job_class if job_class.included_modules.include?(Sidekiq::Worker)
136
139
 
137
140
  # Create wrapper Sidekiq worker
141
+ # Note: Sidekiq 7.x requires JSON-compatible args, so we accept a hash
142
+ # and convert string keys back to symbols for the underlying job
138
143
  Class.new do
139
144
  include Sidekiq::Worker
140
145
  sidekiq_options queue: :htm, retry: 3
141
146
 
142
- define_method(:perform) do |**params|
143
- job_class.perform(**params)
147
+ define_method(:perform) do |params|
148
+ # Convert string keys back to symbols for the job class
149
+ symbolized_params = params.transform_keys(&:to_sym)
150
+ job_class.perform(**symbolized_params)
144
151
  end
145
152
 
146
153
  # Set descriptive name