htm 0.0.30 → 0.0.32

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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +2 -3
  3. data/.rubocop.yml +184 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +2 -0
  6. data/Rakefile +93 -12
  7. data/db/migrate/00008_create_node_relationships.rb +54 -0
  8. data/db/migrate/00009_fix_node_relationships_column_types.rb +17 -0
  9. data/db/schema.sql +124 -1
  10. data/docs/api/database.md +35 -57
  11. data/docs/api/embedding-service.md +1 -1
  12. data/docs/api/index.md +26 -15
  13. data/docs/api/working-memory.md +8 -8
  14. data/docs/architecture/index.md +5 -7
  15. data/docs/architecture/overview.md +5 -8
  16. data/docs/assets/images/htm-architecture-overview.svg +1 -1
  17. data/docs/assets/images/htm-context-assembly-flow.svg +2 -2
  18. data/docs/assets/images/htm-layered-architecture.svg +3 -3
  19. data/docs/assets/images/two-tier-memory-architecture.svg +1 -1
  20. data/docs/database/README.md +1 -0
  21. data/docs/database_rake_tasks.md +20 -28
  22. data/docs/development/contributing.md +5 -5
  23. data/docs/development/index.md +4 -7
  24. data/docs/development/schema.md +71 -1
  25. data/docs/development/setup.md +40 -82
  26. data/docs/development/testing.md +1 -1
  27. data/docs/examples/file-loading.md +4 -4
  28. data/docs/examples/mcp-client.md +1 -1
  29. data/docs/getting-started/quick-start.md +4 -4
  30. data/docs/guides/adding-memories.md +14 -1
  31. data/docs/guides/configuration.md +5 -5
  32. data/docs/guides/context-assembly.md +4 -4
  33. data/docs/guides/file-loading.md +12 -12
  34. data/docs/guides/getting-started.md +2 -2
  35. data/docs/guides/long-term-memory.md +7 -27
  36. data/docs/guides/propositions.md +20 -19
  37. data/docs/guides/recalling-memories.md +5 -5
  38. data/docs/guides/tags.md +18 -13
  39. data/docs/multi_framework_support.md +1 -1
  40. data/docs/robots/hive-mind.md +1 -1
  41. data/docs/robots/multi-robot.md +2 -2
  42. data/docs/robots/robot-groups.md +1 -1
  43. data/docs/robots/two-tier-memory.md +72 -94
  44. data/docs/setup_local_database.md +8 -54
  45. data/docs/using_rake_tasks_in_your_app.md +6 -6
  46. data/examples/01_basic_usage.rb +1 -0
  47. data/examples/03_custom_llm_configuration.rb +1 -0
  48. data/examples/04_file_loader_usage.rb +1 -0
  49. data/examples/05_timeframe_demo.rb +1 -0
  50. data/examples/06_example_app/app.rb +1 -0
  51. data/examples/07_cli_app/htm_cli.rb +1 -0
  52. data/examples/09_mcp_client.rb +1 -0
  53. data/examples/10_telemetry/demo.rb +1 -0
  54. data/examples/11_robot_groups/multi_process.rb +1 -0
  55. data/examples/11_robot_groups/same_process.rb +1 -0
  56. data/examples/12_rails_app/.envrc +12 -0
  57. data/examples/12_rails_app/Gemfile +8 -3
  58. data/examples/12_rails_app/Gemfile.lock +94 -89
  59. data/examples/12_rails_app/README.md +70 -19
  60. data/examples/12_rails_app/app/controllers/application_controller.rb +6 -0
  61. data/examples/12_rails_app/app/controllers/chats_controller.rb +305 -0
  62. data/examples/12_rails_app/app/controllers/dashboard_controller.rb +3 -0
  63. data/examples/12_rails_app/app/controllers/files_controller.rb +17 -2
  64. data/examples/12_rails_app/app/controllers/home_controller.rb +8 -0
  65. data/examples/12_rails_app/app/controllers/memories_controller.rb +9 -4
  66. data/examples/12_rails_app/app/controllers/messages_controller.rb +214 -0
  67. data/examples/12_rails_app/app/controllers/robots_controller.rb +11 -1
  68. data/examples/12_rails_app/app/controllers/tags_controller.rb +14 -1
  69. data/examples/12_rails_app/app/javascript/application.js +1 -1
  70. data/examples/12_rails_app/app/models/application_record.rb +5 -0
  71. data/examples/12_rails_app/app/models/chat.rb +36 -0
  72. data/examples/12_rails_app/app/models/message.rb +5 -0
  73. data/examples/12_rails_app/app/models/model.rb +5 -0
  74. data/examples/12_rails_app/app/models/tool_call.rb +5 -0
  75. data/examples/12_rails_app/app/views/chats/index.html.erb +61 -0
  76. data/examples/12_rails_app/app/views/chats/show.html.erb +213 -0
  77. data/examples/12_rails_app/app/views/dashboard/index.html.erb +3 -0
  78. data/examples/12_rails_app/app/views/files/index.html.erb +10 -5
  79. data/examples/12_rails_app/app/views/files/new.html.erb +4 -2
  80. data/examples/12_rails_app/app/views/files/show.html.erb +19 -3
  81. data/examples/12_rails_app/app/views/home/index.html.erb +45 -0
  82. data/examples/12_rails_app/app/views/layouts/application.html.erb +20 -18
  83. data/examples/12_rails_app/app/views/memories/_memory_card.html.erb +1 -1
  84. data/examples/12_rails_app/app/views/memories/deleted.html.erb +3 -1
  85. data/examples/12_rails_app/app/views/memories/edit.html.erb +2 -0
  86. data/examples/12_rails_app/app/views/memories/index.html.erb +2 -0
  87. data/examples/12_rails_app/app/views/memories/new.html.erb +2 -0
  88. data/examples/12_rails_app/app/views/memories/show.html.erb +4 -2
  89. data/examples/12_rails_app/app/views/messages/_message.html.erb +20 -0
  90. data/examples/12_rails_app/app/views/robots/index.html.erb +2 -0
  91. data/examples/12_rails_app/app/views/robots/new.html.erb +2 -0
  92. data/examples/12_rails_app/app/views/robots/show.html.erb +2 -0
  93. data/examples/12_rails_app/app/views/search/index.html.erb +59 -8
  94. data/examples/12_rails_app/app/views/shared/_navbar.html.erb +75 -29
  95. data/examples/12_rails_app/app/views/tags/index.html.erb +2 -0
  96. data/examples/12_rails_app/app/views/tags/show.html.erb +3 -1
  97. data/examples/12_rails_app/config/application.rb +1 -1
  98. data/examples/12_rails_app/config/database.yml +9 -5
  99. data/examples/12_rails_app/config/importmap.rb +1 -1
  100. data/examples/12_rails_app/config/initializers/htm.rb +9 -2
  101. data/examples/12_rails_app/config/initializers/ruby_llm.rb +33 -0
  102. data/examples/12_rails_app/config/routes.rb +39 -23
  103. data/examples/12_rails_app/db/migrate/20250124000001_create_ruby_llm_tables.rb +34 -0
  104. data/examples/12_rails_app/db/migrate/20250124000002_create_models_table.rb +28 -0
  105. data/examples/12_rails_app/db/schema.rb +67 -0
  106. data/examples/examples_helper.rb +25 -0
  107. data/lib/htm/circuit_breaker.rb +5 -6
  108. data/lib/htm/config/builder.rb +12 -12
  109. data/lib/htm/config/database.rb +21 -27
  110. data/lib/htm/config/defaults.yml +25 -13
  111. data/lib/htm/config/validator.rb +12 -18
  112. data/lib/htm/config.rb +93 -173
  113. data/lib/htm/database.rb +193 -199
  114. data/lib/htm/embedding_service.rb +4 -9
  115. data/lib/htm/integrations/sinatra.rb +7 -7
  116. data/lib/htm/job_adapter.rb +14 -21
  117. data/lib/htm/jobs/generate_embedding_job.rb +28 -44
  118. data/lib/htm/jobs/generate_propositions_job.rb +29 -55
  119. data/lib/htm/jobs/generate_relationships_job.rb +137 -0
  120. data/lib/htm/jobs/generate_tags_job.rb +45 -67
  121. data/lib/htm/loaders/markdown_loader.rb +65 -112
  122. data/lib/htm/long_term_memory/fulltext_search.rb +1 -1
  123. data/lib/htm/long_term_memory/hybrid_search.rb +300 -128
  124. data/lib/htm/long_term_memory/node_operations.rb +2 -2
  125. data/lib/htm/long_term_memory/relevance_scorer.rb +100 -68
  126. data/lib/htm/long_term_memory/tag_operations.rb +87 -120
  127. data/lib/htm/long_term_memory/vector_search.rb +1 -1
  128. data/lib/htm/long_term_memory.rb +2 -1
  129. data/lib/htm/mcp/cli.rb +59 -58
  130. data/lib/htm/mcp/server.rb +5 -6
  131. data/lib/htm/mcp/tools.rb +30 -36
  132. data/lib/htm/migration.rb +10 -10
  133. data/lib/htm/models/node.rb +2 -3
  134. data/lib/htm/models/node_relationship.rb +72 -0
  135. data/lib/htm/models/node_tag.rb +2 -2
  136. data/lib/htm/models/robot_node.rb +2 -2
  137. data/lib/htm/models/tag.rb +41 -28
  138. data/lib/htm/observability.rb +45 -51
  139. data/lib/htm/proposition_service.rb +3 -7
  140. data/lib/htm/query_cache.rb +13 -15
  141. data/lib/htm/railtie.rb +1 -2
  142. data/lib/htm/robot_group.rb +9 -9
  143. data/lib/htm/sequel_config.rb +1 -0
  144. data/lib/htm/sql_builder.rb +1 -1
  145. data/lib/htm/tag_service.rb +2 -6
  146. data/lib/htm/timeframe.rb +4 -5
  147. data/lib/htm/timeframe_extractor.rb +42 -83
  148. data/lib/htm/version.rb +1 -1
  149. data/lib/htm/workflows/remember_workflow.rb +112 -115
  150. data/lib/htm/working_memory.rb +21 -26
  151. data/lib/htm.rb +103 -116
  152. data/lib/tasks/db.rake +0 -2
  153. data/lib/tasks/doc.rake +14 -13
  154. data/lib/tasks/files.rake +5 -12
  155. data/lib/tasks/htm.rake +70 -71
  156. data/lib/tasks/jobs.rake +41 -47
  157. data/lib/tasks/tags.rake +3 -8
  158. metadata +28 -106
  159. data/lib/htm/config/section.rb +0 -74
  160. data/lib/htm/loaders/defaults_loader.rb +0 -166
  161. data/lib/htm/loaders/xdg_config_loader.rb +0 -116
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ class ChatsController < ApplicationController
7
+ def index
8
+ @chats = Chat.order(updated_at: :desc).limit(20)
9
+ @current_chat = Chat.find_by(id: session[:chat_id])
10
+ end
11
+
12
+ def show
13
+ @chat = Chat.find(params[:id])
14
+ @messages = @chat.messages.order(:created_at)
15
+ @available_providers = detect_available_providers
16
+ current_provider = @chat.model&.provider || 'ollama'
17
+ @available_models = fetch_models_for_provider(current_provider)
18
+ @htm_settings = htm_settings
19
+ session[:chat_id] = @chat.id
20
+ end
21
+
22
+ def create
23
+ @chat = Chat.new
24
+ @chat.assume_model_exists = true
25
+
26
+ # Use remembered model for default provider, or fall back to env default
27
+ default_provider = 'ollama'
28
+ remembered_model = provider_model_memory[default_provider]
29
+ default_model = remembered_model || ENV.fetch('CHAT_MODEL', 'gemma3:latest')
30
+
31
+ @chat.model = default_model
32
+ @chat.provider = default_provider
33
+ @chat.save!
34
+
35
+ # Remember this model for the provider
36
+ remember_model_for_provider(default_provider, default_model)
37
+
38
+ session[:chat_id] = @chat.id
39
+ redirect_to chat_path(@chat)
40
+ end
41
+
42
+ def update
43
+ @chat = Chat.find(params[:id])
44
+
45
+ # Handle provider change
46
+ if params[:provider].present?
47
+ new_provider = params[:provider]
48
+ models = fetch_models_for_provider(new_provider)
49
+
50
+ # Restore last-used model for this provider, or use first available
51
+ remembered_model = provider_model_memory[new_provider]
52
+ selected_model = if remembered_model && models.include?(remembered_model)
53
+ remembered_model
54
+ else
55
+ models.first || "#{new_provider}-default"
56
+ end
57
+
58
+ model = Model.find_or_create_by!(model_id: selected_model, provider: new_provider) do |m|
59
+ m.name = selected_model
60
+ end
61
+ @chat.update_column(:model_id, model.id)
62
+
63
+ # Remember this model for the provider
64
+ remember_model_for_provider(new_provider, selected_model)
65
+
66
+ # Handle model change
67
+ elsif params[:model_id].present?
68
+ current_provider = @chat.model&.provider || 'ollama'
69
+ model = Model.find_or_create_by!(model_id: params[:model_id], provider: current_provider) do |m|
70
+ m.name = params[:model_id]
71
+ end
72
+ @chat.update_column(:model_id, model.id)
73
+
74
+ # Remember this model for the provider
75
+ remember_model_for_provider(current_provider, params[:model_id])
76
+ end
77
+
78
+ redirect_to chat_path(@chat)
79
+ end
80
+
81
+ # API endpoint to get models for a provider (used by JavaScript)
82
+ def models
83
+ provider = params[:provider] || 'ollama'
84
+ models = fetch_models_for_provider(provider)
85
+ render json: models
86
+ end
87
+
88
+ def destroy
89
+ Chat.find(params[:id]).destroy
90
+ session.delete(:chat_id) if session[:chat_id] == params[:id].to_i
91
+ redirect_to app_root_path, notice: 'Chat deleted'
92
+ end
93
+
94
+ def update_htm_settings
95
+ @chat = Chat.find(params[:id])
96
+
97
+ # Update HTM settings in session
98
+ limit_param = params[:htm_limit]
99
+ limit_value = limit_param == 'all' ? 'all' : (limit_param || 5).to_i
100
+
101
+ session[:htm_settings] = {
102
+ strategy: params[:htm_strategy] || 'hybrid',
103
+ limit: limit_value,
104
+ propositions: params[:htm_propositions] == '1',
105
+ tags_only: params[:htm_tags_only] == '1',
106
+ tag_filter: params[:htm_tag_filter].presence
107
+ }
108
+
109
+ Rails.logger.info "Updated HTM settings: #{session[:htm_settings].inspect}"
110
+ redirect_to chat_path(@chat)
111
+ end
112
+
113
+ private
114
+
115
+ def detect_available_providers
116
+ providers = []
117
+
118
+ # Use RubyLLM's provider registry to dynamically detect available providers
119
+ RubyLLM.providers.each do |provider_class|
120
+ provider_id = provider_class.name.split('::').last.downcase
121
+ display_name = provider_display_name(provider_id)
122
+
123
+ if provider_class.local?
124
+ # Local providers: check if service is reachable
125
+ providers << { id: provider_id, name: "#{display_name} (Local)" } if local_provider_available?(provider_id)
126
+ else
127
+ # Cloud providers: check if required API key env var is set
128
+ providers << { id: provider_id, name: display_name } if cloud_provider_configured?(provider_class)
129
+ end
130
+ end
131
+
132
+ # Add custom local providers not in RubyLLM registry (use OpenAI-compatible API)
133
+ providers << { id: 'lmstudio', name: 'LM Studio (Local)' } if lmstudio_available?
134
+
135
+ providers
136
+ end
137
+
138
+ def provider_display_name(provider_id)
139
+ {
140
+ 'openai' => 'OpenAI',
141
+ 'anthropic' => 'Anthropic',
142
+ 'gemini' => 'Google Gemini',
143
+ 'deepseek' => 'DeepSeek',
144
+ 'openrouter' => 'OpenRouter',
145
+ 'ollama' => 'Ollama',
146
+ 'gpustack' => 'GPUStack',
147
+ 'bedrock' => 'AWS Bedrock',
148
+ 'vertexai' => 'Google Vertex AI',
149
+ 'mistral' => 'Mistral',
150
+ 'perplexity' => 'Perplexity',
151
+ 'xai' => 'xAI',
152
+ 'lmstudio' => 'LM Studio'
153
+ }[provider_id] || provider_id.titleize
154
+ end
155
+
156
+ def cloud_provider_configured?(provider_class)
157
+ # Check if all required configuration keys have corresponding env vars set
158
+ provider_class.configuration_requirements.all? do |config_key|
159
+ # Convert config key like :anthropic_api_key to env var ANTHROPIC_API_KEY
160
+ env_var = config_key.to_s.upcase
161
+ ENV[env_var].present?
162
+ end
163
+ end
164
+
165
+ def local_provider_available?(provider_id)
166
+ case provider_id
167
+ when 'ollama'
168
+ ollama_available?
169
+ when 'gpustack'
170
+ gpustack_available?
171
+ else
172
+ false
173
+ end
174
+ end
175
+
176
+ def ollama_available?
177
+ ollama_url = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
178
+ uri = URI("#{ollama_url}/api/tags")
179
+ response = Net::HTTP.get_response(uri)
180
+ response.is_a?(Net::HTTPSuccess)
181
+ rescue StandardError
182
+ false
183
+ end
184
+
185
+ def gpustack_available?
186
+ gpustack_url = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:8080')
187
+ uri = URI("#{gpustack_url}/v1/models")
188
+ response = Net::HTTP.get_response(uri)
189
+ response.is_a?(Net::HTTPSuccess)
190
+ rescue StandardError
191
+ false
192
+ end
193
+
194
+ def lmstudio_available?
195
+ lmstudio_url = ENV.fetch('LMSTUDIO_URL', 'http://localhost:1234')
196
+ uri = URI("#{lmstudio_url}/v1/models")
197
+ response = Net::HTTP.get_response(uri)
198
+ response.is_a?(Net::HTTPSuccess)
199
+ rescue StandardError
200
+ false
201
+ end
202
+
203
+ def fetch_models_for_provider(provider)
204
+ case provider
205
+ when 'ollama'
206
+ fetch_ollama_models
207
+ when 'gpustack'
208
+ fetch_gpustack_models
209
+ when 'lmstudio'
210
+ fetch_lmstudio_models
211
+ else
212
+ fetch_models_from_registry(provider)
213
+ end
214
+ end
215
+
216
+ # Use RubyLLM's model registry for cloud providers
217
+ def fetch_models_from_registry(provider)
218
+ # Get chat models for this provider from RubyLLM registry
219
+ models = RubyLLM.models.by_provider(provider).chat_models
220
+ model_ids = models.map(&:id).sort
221
+
222
+ if model_ids.empty?
223
+ Rails.logger.warn "No models found in RubyLLM registry for provider: #{provider}"
224
+ # Return fallback models for common providers
225
+ fallback_models_for(provider)
226
+ else
227
+ model_ids
228
+ end
229
+ rescue StandardError => e
230
+ Rails.logger.warn "Failed to fetch models from registry for #{provider}: #{e.message}"
231
+ fallback_models_for(provider)
232
+ end
233
+
234
+ # Fallback models when registry is empty (e.g., not refreshed)
235
+ def fallback_models_for(provider)
236
+ case provider
237
+ when 'openai'
238
+ %w[gpt-4o gpt-4o-mini gpt-4-turbo gpt-4 gpt-3.5-turbo]
239
+ when 'anthropic'
240
+ %w[claude-sonnet-4-20250514 claude-3-5-sonnet-20241022 claude-3-5-haiku-20241022 claude-3-opus-20240229]
241
+ when 'gemini'
242
+ %w[gemini-2.0-flash gemini-1.5-pro gemini-1.5-flash]
243
+ when 'deepseek'
244
+ %w[deepseek-chat deepseek-coder]
245
+ when 'openrouter'
246
+ %w[openai/gpt-4o anthropic/claude-3.5-sonnet meta-llama/llama-3-70b-instruct]
247
+ else
248
+ []
249
+ end
250
+ end
251
+
252
+ def fetch_ollama_models
253
+ ollama_url = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
254
+ response = Net::HTTP.get(URI("#{ollama_url}/api/tags"))
255
+ data = JSON.parse(response)
256
+ data['models'].map { |m| m['name'] }.sort
257
+ rescue StandardError => e
258
+ Rails.logger.warn "Failed to fetch Ollama models: #{e.message}"
259
+ []
260
+ end
261
+
262
+ def fetch_gpustack_models
263
+ gpustack_url = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:8080')
264
+ response = Net::HTTP.get(URI("#{gpustack_url}/v1/models"))
265
+ data = JSON.parse(response)
266
+ data['data'].map { |m| m['id'] }.sort
267
+ rescue StandardError => e
268
+ Rails.logger.warn "Failed to fetch GPUStack models: #{e.message}"
269
+ []
270
+ end
271
+
272
+ def fetch_lmstudio_models
273
+ lmstudio_url = ENV.fetch('LMSTUDIO_URL', 'http://localhost:1234')
274
+ response = Net::HTTP.get(URI("#{lmstudio_url}/v1/models"))
275
+ data = JSON.parse(response)
276
+ data['data'].map { |m| m['id'] }.sort
277
+ rescue StandardError => e
278
+ Rails.logger.warn "Failed to fetch LM Studio models: #{e.message}"
279
+ []
280
+ end
281
+
282
+ # Per-provider model memory stored in session
283
+ def provider_model_memory
284
+ session[:provider_models] ||= {}
285
+ end
286
+
287
+ def remember_model_for_provider(provider, model_id)
288
+ session[:provider_models] ||= {}
289
+ session[:provider_models][provider] = model_id
290
+ Rails.logger.info "Remembered model '#{model_id}' for provider '#{provider}'"
291
+ end
292
+
293
+ # HTM context enhancement settings stored in session
294
+ def htm_settings
295
+ defaults = {
296
+ strategy: 'hybrid',
297
+ limit: 5,
298
+ propositions: false,
299
+ tags_only: false,
300
+ tag_filter: nil
301
+ }
302
+ stored = session[:htm_settings] || {}
303
+ defaults.merge(stored.symbolize_keys)
304
+ end
305
+ end
@@ -4,6 +4,9 @@ require 'ostruct'
4
4
 
5
5
  class DashboardController < ApplicationController
6
6
  def index
7
+ # Show current HTM database connection
8
+ @htm_database_url = HTM.db.opts[:orig_uri] || "#{HTM.db.opts[:adapter]}://#{HTM.db.opts[:user]}@#{HTM.db.opts[:host]}:#{HTM.db.opts[:port]}/#{HTM.db.opts[:database]}"
9
+
7
10
  # Note: HTM::Models::Node has a default_scope that excludes deleted nodes
8
11
  # so we don't need to call .active explicitly
9
12
  @stats = {
@@ -7,7 +7,12 @@ class FilesController < ApplicationController
7
7
 
8
8
  def show
9
9
  @file_source = HTM::Models::FileSource[params[:id]]
10
- @chunks = @file_source.chunks.order(:id)
10
+ unless @file_source
11
+ flash[:alert] = 'File source not found'
12
+ redirect_to files_path
13
+ return
14
+ end
15
+ @chunks = @file_source.nodes_dataset.order(:chunk_position).all
11
16
  end
12
17
 
13
18
  def new
@@ -128,6 +133,11 @@ class FilesController < ApplicationController
128
133
 
129
134
  def sync
130
135
  @file_source = HTM::Models::FileSource[params[:id]]
136
+ unless @file_source
137
+ flash[:alert] = 'File source not found'
138
+ redirect_to files_path
139
+ return
140
+ end
131
141
 
132
142
  begin
133
143
  result = htm.load_file(@file_source.file_path, force: true)
@@ -136,11 +146,16 @@ class FilesController < ApplicationController
136
146
  flash[:alert] = "Error syncing file: #{e.message}"
137
147
  end
138
148
 
139
- redirect_to file_path(@file_source)
149
+ redirect_to file_path(@file_source.id)
140
150
  end
141
151
 
142
152
  def destroy
143
153
  @file_source = HTM::Models::FileSource[params[:id]]
154
+ unless @file_source
155
+ flash[:alert] = 'File source not found'
156
+ redirect_to files_path
157
+ return
158
+ end
144
159
 
145
160
  begin
146
161
  htm.unload_file(@file_source.file_path)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HomeController < ApplicationController
4
+ def index
5
+ @chat_count = Chat.count rescue 0
6
+ @memory_count = HTM::Models::Node.count rescue 0
7
+ end
8
+ end
@@ -17,7 +17,8 @@ class MemoriesController < ApplicationController
17
17
  end
18
18
 
19
19
  if params[:search].present?
20
- @memories = @memories.where(Sequel.ilike(:content, "%#{params[:search]}%"))
20
+ search_pattern = "%#{Sequel.like_escape(params[:search])}%"
21
+ @memories = @memories.where(Sequel.ilike(:content, search_pattern))
21
22
  end
22
23
 
23
24
  # Simple pagination without Kaminari
@@ -67,13 +68,13 @@ class MemoriesController < ApplicationController
67
68
 
68
69
  if content.blank?
69
70
  flash[:alert] = 'Content is required'
70
- redirect_to edit_memory_path(@memory)
71
+ redirect_to edit_memory_path(@memory.id)
71
72
  return
72
73
  end
73
74
 
74
75
  @memory.update(content: content)
75
76
  flash[:notice] = 'Memory updated successfully'
76
- redirect_to memory_path(@memory)
77
+ redirect_to memory_path(@memory.id)
77
78
  end
78
79
 
79
80
  def destroy
@@ -91,7 +92,7 @@ class MemoriesController < ApplicationController
91
92
  def restore
92
93
  htm.restore(@memory.id)
93
94
  flash[:notice] = 'Memory restored successfully.'
94
- redirect_to memory_path(@memory)
95
+ redirect_to memory_path(@memory.id)
95
96
  end
96
97
 
97
98
  def deleted
@@ -102,5 +103,9 @@ class MemoriesController < ApplicationController
102
103
 
103
104
  def set_memory
104
105
  @memory = HTM::Models::Node.with_deleted[params[:id].to_i]
106
+ return if @memory
107
+
108
+ flash[:alert] = 'Memory not found'
109
+ redirect_to memories_path
105
110
  end
106
111
  end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MessagesController < ApplicationController
4
+ def create
5
+ @chat = Chat.find(params[:chat_id])
6
+ user_content = params[:content]
7
+ provider = @chat.model&.provider || 'ollama'
8
+
9
+ # Configure provider once at request start (clean slate)
10
+ configure_provider_cleanly(provider)
11
+
12
+ # Retrieve context from HTM (RAG)
13
+ context_results = fetch_htm_context(user_content)
14
+ context_text = format_context(context_results)
15
+ Rails.logger.info "Built context (#{context_text.length} chars): #{context_text[0..200]}..."
16
+
17
+ # Build system prompt with HTM context
18
+ system_prompt = build_system_prompt(context_text)
19
+
20
+ # Ensure chat has a valid Model record
21
+ ensure_model_record(@chat)
22
+
23
+ # Use acts_as_chat - the chat object handles its own LLM configuration
24
+ @chat.assume_model_exists = true
25
+ @chat.with_instructions(system_prompt, replace: true)
26
+
27
+ begin
28
+ @chat.ask(user_content)
29
+ rescue RubyLLM::Error => e
30
+ handle_llm_error(@chat, user_content, e)
31
+ end
32
+
33
+ redirect_to chat_path(@chat)
34
+ end
35
+
36
+ private
37
+
38
+ def fetch_htm_context(query)
39
+ settings = htm_settings
40
+
41
+ # If HTM is disabled, return empty context
42
+ if settings[:strategy] == 'off'
43
+ Rails.logger.info 'HTM context disabled (strategy: off)'
44
+ return []
45
+ end
46
+
47
+ # Build recall options from settings
48
+ recall_options = {
49
+ strategy: settings[:strategy].to_sym
50
+ }
51
+
52
+ # Handle limit - 'all' means no limit (use a large number)
53
+ if settings[:limit] == 'all'
54
+ recall_options[:limit] = 10_000
55
+ else
56
+ recall_options[:limit] = settings[:limit].to_i
57
+ end
58
+
59
+ # Add tag filter if specified
60
+ recall_options[:tags] = settings[:tag_filter] if settings[:tag_filter].present?
61
+
62
+ # Add metadata filter for propositions if enabled
63
+ if settings[:propositions]
64
+ # Include both regular nodes and proposition nodes
65
+ Rails.logger.info 'Including proposition nodes in HTM context'
66
+ elsif settings[:tags_only]
67
+ # Only search by tags, not content
68
+ Rails.logger.info 'Using tags-only search mode'
69
+ end
70
+
71
+ results = htm.recall(query, **recall_options)
72
+ limit_display = settings[:limit] == 'all' ? 'all' : settings[:limit]
73
+ Rails.logger.info "HTM recall (#{settings[:strategy]}, limit: #{limit_display}) returned #{results.size} results"
74
+ results
75
+ rescue StandardError => e
76
+ Rails.logger.warn "HTM recall failed: #{e.class}: #{e.message}"
77
+ []
78
+ end
79
+
80
+ def htm_settings
81
+ defaults = {
82
+ strategy: 'hybrid',
83
+ limit: 5,
84
+ propositions: false,
85
+ tags_only: false,
86
+ tag_filter: nil
87
+ }
88
+ stored = session[:htm_settings] || {}
89
+ defaults.merge(stored.symbolize_keys)
90
+ end
91
+
92
+ def format_context(results)
93
+ return 'No relevant context found.' if results.empty?
94
+
95
+ results.map.with_index do |r, i|
96
+ content = case r
97
+ when String then r
98
+ when Hash then r[:content] || r['content']
99
+ else r.respond_to?(:content) ? r.content : r.to_s
100
+ end
101
+ "[#{i + 1}] #{content&.truncate(500)}"
102
+ end.join("\n\n")
103
+ end
104
+
105
+ def build_system_prompt(context)
106
+ settings = htm_settings
107
+
108
+ if settings[:strategy] == 'off'
109
+ <<~PROMPT
110
+ You are a helpful assistant.
111
+ Answer questions based on your training knowledge.
112
+ PROMPT
113
+ else
114
+ <<~PROMPT
115
+ You are a helpful assistant with access to a knowledge base.
116
+
117
+ Use the following context from the knowledge base to answer questions.
118
+ If the context doesn't contain relevant information, acknowledge that
119
+ and provide your best answer based on your training.
120
+
121
+ === Knowledge Base Context ===
122
+ #{context}
123
+ === End Context ===
124
+ PROMPT
125
+ end
126
+ end
127
+
128
+ def ensure_model_record(chat)
129
+ stored_value = chat[:model_id]
130
+
131
+ # Already a valid Model record ID
132
+ if stored_value.to_s =~ /^\d+$/
133
+ return if Model.exists?(id: stored_value)
134
+ end
135
+
136
+ # Create Model record from string name
137
+ model_name = stored_value.presence || ENV.fetch('CHAT_MODEL', 'gemma3:latest')
138
+ model = Model.find_or_create_by!(model_id: model_name, provider: 'ollama') do |m|
139
+ m.name = model_name
140
+ end
141
+
142
+ chat.update_column(:model_id, model.id)
143
+ chat.reload
144
+ end
145
+
146
+ def handle_llm_error(chat, user_content, error)
147
+ chat.messages.create!(role: 'user', content: user_content)
148
+ chat.messages.create!(
149
+ role: 'assistant',
150
+ content: "**Error from #{chat.model&.provider || 'provider'}:** #{error.message}"
151
+ )
152
+ Rails.logger.error "LLM Error: #{error.class}: #{error.message}"
153
+ Rails.logger.error error.backtrace.first(5).join("\n")
154
+ end
155
+
156
+ # Configure provider with a clean slate - no residual config from previous requests
157
+ def configure_provider_cleanly(provider)
158
+ Rails.logger.info "=== Configuring provider: #{provider} ==="
159
+
160
+ # Reset OpenAI config to prevent cross-contamination between providers
161
+ # (LM Studio uses openai_api_base which affects other providers)
162
+ reset_openai_config unless provider == 'lmstudio'
163
+
164
+ case provider
165
+ when 'ollama'
166
+ configure_ollama
167
+ when 'lmstudio'
168
+ configure_lmstudio
169
+ when 'gpustack'
170
+ configure_gpustack
171
+ else
172
+ configure_cloud_provider(provider)
173
+ end
174
+ end
175
+
176
+ def reset_openai_config
177
+ RubyLLM.config.openai_api_base = nil
178
+ RubyLLM.config.openai_api_key = ENV['OPENAI_API_KEY']
179
+ end
180
+
181
+ def configure_ollama
182
+ base_url = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
183
+ base_url = "#{base_url}/v1" unless base_url.end_with?('/v1')
184
+ RubyLLM.config.ollama_api_base = base_url
185
+ Rails.logger.info " Ollama API: #{base_url}"
186
+ end
187
+
188
+ def configure_lmstudio
189
+ base_url = ENV.fetch('LMSTUDIO_URL', 'http://localhost:1234')
190
+ base_url = "#{base_url}/v1" unless base_url.end_with?('/v1')
191
+ RubyLLM.config.openai_api_base = base_url
192
+ RubyLLM.config.openai_api_key = 'lm-studio'
193
+ Rails.logger.info " LM Studio API: #{base_url}"
194
+ end
195
+
196
+ def configure_gpustack
197
+ base_url = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:8080')
198
+ RubyLLM.config.gpustack_api_base = base_url
199
+ Rails.logger.info " GPUStack API: #{base_url}"
200
+ end
201
+
202
+ def configure_cloud_provider(provider)
203
+ provider_class = RubyLLM.providers.find { |p| p.name.split('::').last.downcase == provider }
204
+ return unless provider_class
205
+
206
+ provider_class.configuration_requirements.each do |config_key|
207
+ env_var = config_key.to_s.upcase
208
+ if ENV[env_var].present?
209
+ RubyLLM.config.send("#{config_key}=", ENV[env_var])
210
+ Rails.logger.info " Set #{config_key} from #{env_var}"
211
+ end
212
+ end
213
+ end
214
+ end
@@ -7,6 +7,11 @@ class RobotsController < ApplicationController
7
7
 
8
8
  def show
9
9
  @robot = HTM::Models::Robot[params[:id]]
10
+ unless @robot
11
+ flash[:alert] = 'Robot not found'
12
+ redirect_to robots_path
13
+ return
14
+ end
10
15
  # Default scope already excludes deleted nodes, so no .active needed
11
16
  @memory_count = @robot.nodes_dataset.count
12
17
  @recent_memories = @robot.nodes_dataset.order(Sequel.desc(:created_at)).limit(10).all
@@ -37,8 +42,13 @@ class RobotsController < ApplicationController
37
42
 
38
43
  def switch
39
44
  robot = HTM::Models::Robot[params[:id]]
45
+ unless robot
46
+ flash[:alert] = 'Robot not found'
47
+ redirect_to robots_path
48
+ return
49
+ end
40
50
  self.current_robot_name = robot.name
41
51
  flash[:notice] = "Switched to robot '#{robot.name}'"
42
- redirect_to root_path
52
+ redirect_to htm_root_path
43
53
  end
44
54
  end
@@ -31,6 +31,19 @@ class TagsController < ApplicationController
31
31
 
32
32
  def show
33
33
  @tag = HTM::Models::Tag[params[:id]]
34
- @memories = @tag.nodes_dataset.active.eager(:tags).order(Sequel.desc(:created_at)).all
34
+ unless @tag
35
+ flash[:alert] = 'Tag not found'
36
+ redirect_to tags_path
37
+ return
38
+ end
39
+ # Note: Node has a default scope that excludes deleted nodes, so we don't need .active
40
+ # We need to filter through node_tags that aren't deleted and join properly
41
+ @memories = HTM::Models::Node
42
+ .join(:node_tags, node_id: Sequel[:nodes][:id])
43
+ .where(Sequel[:node_tags][:tag_id] => @tag.id)
44
+ .where(Sequel[:node_tags][:deleted_at] => nil)
45
+ .eager(:tags)
46
+ .order(Sequel.desc(Sequel[:nodes][:created_at]))
47
+ .all
35
48
  end
36
49
  end
@@ -1,4 +1,4 @@
1
1
  // Configure your import map in config/importmap.rb
2
+ // Turbo removed - causes issues with CDN-based setup, using vanilla JS instead
2
3
 
3
- import "@hotwired/turbo-rails"
4
4
  import "controllers"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ primary_abstract_class
5
+ end