htm 0.0.2 → 0.0.11

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.aigcm_msg +1 -0
  3. data/.architecture/reviews/comprehensive-codebase-review.md +577 -0
  4. data/.claude/settings.local.json +95 -0
  5. data/.irbrc +283 -80
  6. data/.tbls.yml +2 -1
  7. data/CHANGELOG.md +327 -26
  8. data/CLAUDE.md +603 -0
  9. data/README.md +83 -12
  10. data/Rakefile +5 -0
  11. data/bin/htm_mcp.rb +527 -0
  12. data/db/migrate/{20250101000001_enable_extensions.rb → 00001_enable_extensions.rb} +0 -1
  13. data/db/migrate/00002_create_robots.rb +11 -0
  14. data/db/migrate/00003_create_file_sources.rb +20 -0
  15. data/db/migrate/00004_create_nodes.rb +65 -0
  16. data/db/migrate/00005_create_tags.rb +13 -0
  17. data/db/migrate/00006_create_node_tags.rb +18 -0
  18. data/db/migrate/00007_create_robot_nodes.rb +26 -0
  19. data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +12 -0
  20. data/db/schema.sql +172 -1
  21. data/docs/api/database.md +1 -2
  22. data/docs/api/htm.md +197 -2
  23. data/docs/api/yard/HTM/ActiveRecordConfig.md +23 -0
  24. data/docs/api/yard/HTM/AuthorizationError.md +11 -0
  25. data/docs/api/yard/HTM/CircuitBreaker.md +92 -0
  26. data/docs/api/yard/HTM/CircuitBreakerOpenError.md +34 -0
  27. data/docs/api/yard/HTM/Configuration.md +175 -0
  28. data/docs/api/yard/HTM/Database.md +99 -0
  29. data/docs/api/yard/HTM/DatabaseError.md +14 -0
  30. data/docs/api/yard/HTM/EmbeddingError.md +18 -0
  31. data/docs/api/yard/HTM/EmbeddingService.md +58 -0
  32. data/docs/api/yard/HTM/Error.md +11 -0
  33. data/docs/api/yard/HTM/JobAdapter.md +39 -0
  34. data/docs/api/yard/HTM/LongTermMemory.md +342 -0
  35. data/docs/api/yard/HTM/NotFoundError.md +17 -0
  36. data/docs/api/yard/HTM/Observability.md +107 -0
  37. data/docs/api/yard/HTM/QueryTimeoutError.md +19 -0
  38. data/docs/api/yard/HTM/Railtie.md +27 -0
  39. data/docs/api/yard/HTM/ResourceExhaustedError.md +13 -0
  40. data/docs/api/yard/HTM/TagError.md +18 -0
  41. data/docs/api/yard/HTM/TagService.md +67 -0
  42. data/docs/api/yard/HTM/Timeframe/Result.md +24 -0
  43. data/docs/api/yard/HTM/Timeframe.md +40 -0
  44. data/docs/api/yard/HTM/TimeframeExtractor/Result.md +24 -0
  45. data/docs/api/yard/HTM/TimeframeExtractor.md +45 -0
  46. data/docs/api/yard/HTM/ValidationError.md +20 -0
  47. data/docs/api/yard/HTM/WorkingMemory.md +131 -0
  48. data/docs/api/yard/HTM.md +80 -0
  49. data/docs/api/yard/index.csv +179 -0
  50. data/docs/api/yard-reference.md +51 -0
  51. data/docs/database/README.md +128 -128
  52. data/docs/database/public.file_sources.md +42 -0
  53. data/docs/database/public.file_sources.svg +211 -0
  54. data/docs/database/public.node_tags.md +4 -4
  55. data/docs/database/public.node_tags.svg +212 -79
  56. data/docs/database/public.nodes.md +22 -12
  57. data/docs/database/public.nodes.svg +246 -127
  58. data/docs/database/public.robot_nodes.md +11 -9
  59. data/docs/database/public.robot_nodes.svg +220 -98
  60. data/docs/database/public.robots.md +2 -2
  61. data/docs/database/public.robots.svg +136 -81
  62. data/docs/database/public.tags.md +3 -3
  63. data/docs/database/public.tags.svg +118 -39
  64. data/docs/database/schema.json +850 -771
  65. data/docs/database/schema.svg +256 -197
  66. data/docs/development/schema.md +67 -2
  67. data/docs/guides/adding-memories.md +93 -7
  68. data/docs/guides/recalling-memories.md +36 -1
  69. data/examples/README.md +405 -0
  70. data/examples/cli_app/htm_cli.rb +65 -5
  71. data/examples/cli_app/temp.log +93 -0
  72. data/examples/file_loader_usage.rb +177 -0
  73. data/examples/mcp_client.rb +529 -0
  74. data/examples/robot_groups/lib/robot_group.rb +419 -0
  75. data/examples/robot_groups/lib/working_memory_channel.rb +140 -0
  76. data/examples/robot_groups/multi_process.rb +286 -0
  77. data/examples/robot_groups/robot_worker.rb +136 -0
  78. data/examples/robot_groups/same_process.rb +229 -0
  79. data/examples/timeframe_demo.rb +276 -0
  80. data/lib/htm/active_record_config.rb +1 -1
  81. data/lib/htm/circuit_breaker.rb +202 -0
  82. data/lib/htm/configuration.rb +59 -13
  83. data/lib/htm/database.rb +67 -36
  84. data/lib/htm/embedding_service.rb +39 -2
  85. data/lib/htm/errors.rb +131 -11
  86. data/lib/htm/jobs/generate_embedding_job.rb +5 -4
  87. data/lib/htm/jobs/generate_tags_job.rb +4 -0
  88. data/lib/htm/loaders/markdown_loader.rb +263 -0
  89. data/lib/htm/loaders/paragraph_chunker.rb +112 -0
  90. data/lib/htm/long_term_memory.rb +460 -343
  91. data/lib/htm/models/file_source.rb +99 -0
  92. data/lib/htm/models/node.rb +80 -5
  93. data/lib/htm/models/robot.rb +24 -1
  94. data/lib/htm/models/robot_node.rb +1 -0
  95. data/lib/htm/models/tag.rb +254 -4
  96. data/lib/htm/observability.rb +395 -0
  97. data/lib/htm/tag_service.rb +60 -3
  98. data/lib/htm/tasks.rb +26 -1
  99. data/lib/htm/timeframe.rb +194 -0
  100. data/lib/htm/timeframe_extractor.rb +307 -0
  101. data/lib/htm/version.rb +1 -1
  102. data/lib/htm/working_memory.rb +165 -70
  103. data/lib/htm.rb +328 -130
  104. data/lib/tasks/doc.rake +300 -0
  105. data/lib/tasks/files.rake +299 -0
  106. data/lib/tasks/htm.rake +158 -3
  107. data/lib/tasks/jobs.rake +3 -9
  108. data/lib/tasks/tags.rake +166 -6
  109. data/mkdocs.yml +36 -1
  110. data/notes/ARCHITECTURE_REVIEW.md +1167 -0
  111. data/notes/IMPLEMENTATION_SUMMARY.md +606 -0
  112. data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +451 -0
  113. data/notes/next_steps.md +100 -0
  114. data/notes/plan.md +627 -0
  115. data/notes/tag_ontology_enhancement_ideas.md +222 -0
  116. data/notes/timescaledb_removal_summary.md +200 -0
  117. metadata +158 -17
  118. data/db/migrate/20250101000002_create_robots.rb +0 -14
  119. data/db/migrate/20250101000003_create_nodes.rb +0 -42
  120. data/db/migrate/20250101000005_create_tags.rb +0 -38
  121. data/db/migrate/20250101000007_add_node_vector_indexes.rb +0 -30
  122. data/db/migrate/20250125000001_add_content_hash_to_nodes.rb +0 -14
  123. data/db/migrate/20250125000002_create_robot_nodes.rb +0 -35
  124. data/db/migrate/20250125000003_remove_source_and_robot_id_from_nodes.rb +0 -28
  125. data/db/migrate/20250126000001_create_working_memories.rb +0 -19
  126. data/db/migrate/20250126000002_remove_unused_columns.rb +0 -12
  127. data/docs/database/public.working_memories.md +0 -40
  128. data/docs/database/public.working_memories.svg +0 -112
  129. data/lib/htm/models/working_memory_entry.rb +0 -88
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # File loader example for HTM
5
+ #
6
+ # Demonstrates loading markdown files into long-term memory with:
7
+ # - Single file loading with frontmatter extraction
8
+ # - Directory loading with glob patterns
9
+ # - Querying nodes from loaded files
10
+ # - Unloading files
11
+ #
12
+ # Prerequisites:
13
+ # 1. Set HTM_DBURL environment variable (see SETUP.md)
14
+ # 2. Initialize database schema: rake db_setup
15
+ # 3. Install dependencies: bundle install
16
+
17
+ require_relative '../lib/htm'
18
+ require 'tempfile'
19
+ require 'fileutils'
20
+
21
+ puts "HTM File Loader Example"
22
+ puts "=" * 60
23
+
24
+ # Check environment
25
+ unless ENV['HTM_DBURL']
26
+ puts "ERROR: HTM_DBURL not set. Please set it:"
27
+ puts " export HTM_DBURL=\"postgresql://postgres@localhost:5432/htm_development\""
28
+ puts "See SETUP.md for details."
29
+ exit 1
30
+ end
31
+
32
+ begin
33
+ # Configure HTM globally (uses Ollama by default)
34
+ puts "\n1. Configuring HTM with Ollama provider..."
35
+ HTM.configure do |config|
36
+ config.embedding_provider = :ollama
37
+ config.embedding_model = 'nomic-embed-text:latest'
38
+ config.embedding_dimensions = 768
39
+ config.tag_provider = :ollama
40
+ config.tag_model = 'gemma3:latest'
41
+ config.reset_to_defaults
42
+ end
43
+ puts " Configured with Ollama provider"
44
+
45
+ # Initialize HTM
46
+ puts "\n2. Initializing HTM..."
47
+ htm = HTM.new(
48
+ robot_name: "FileLoaderDemo",
49
+ working_memory_size: 128_000
50
+ )
51
+ puts " Robot: #{htm.robot_name} (ID: #{htm.robot_id})"
52
+
53
+ # Create a temporary directory with sample markdown files
54
+ puts "\n3. Creating sample markdown files..."
55
+ temp_dir = Dir.mktmpdir('htm_file_loader_demo')
56
+
57
+ # Sample file with frontmatter
58
+ doc1_content = <<~MD
59
+ ---
60
+ title: PostgreSQL Guide
61
+ author: HTM Team
62
+ tags:
63
+ - database
64
+ - postgresql
65
+ ---
66
+
67
+ PostgreSQL is a powerful open-source relational database.
68
+
69
+ It supports advanced features like:
70
+ - JSON/JSONB data types
71
+ - Full-text search
72
+ - Vector similarity search via pgvector
73
+
74
+ PostgreSQL is ideal for applications requiring complex queries.
75
+ MD
76
+
77
+ # Sample file without frontmatter
78
+ doc2_content = <<~MD
79
+ Ruby is a dynamic programming language.
80
+
81
+ Key features include:
82
+ - Everything is an object
83
+ - Blocks and iterators
84
+ - Metaprogramming capabilities
85
+
86
+ Ruby on Rails made Ruby popular for web development.
87
+ MD
88
+
89
+ doc1_path = File.join(temp_dir, 'postgresql_guide.md')
90
+ doc2_path = File.join(temp_dir, 'ruby_intro.md')
91
+ File.write(doc1_path, doc1_content)
92
+ File.write(doc2_path, doc2_content)
93
+ puts " Created: #{doc1_path}"
94
+ puts " Created: #{doc2_path}"
95
+
96
+ # Load a single file
97
+ puts "\n4. Loading single file with frontmatter..."
98
+ result = htm.load_file(doc1_path)
99
+ puts " File: postgresql_guide.md"
100
+ puts " Source ID: #{result[:file_source_id]}"
101
+ puts " Chunks created: #{result[:chunks_created]}"
102
+ puts " Skipped: #{result[:skipped]}"
103
+
104
+ # Access the file source to show frontmatter
105
+ source = HTM::Models::FileSource.find(result[:file_source_id])
106
+ puts " Frontmatter title: #{source.title}"
107
+ puts " Frontmatter author: #{source.author}"
108
+ puts " Frontmatter tags: #{source.frontmatter_tags.join(', ')}"
109
+
110
+ # Load a directory
111
+ puts "\n5. Loading directory..."
112
+ results = htm.load_directory(temp_dir, pattern: '*.md')
113
+ puts " Directory: #{temp_dir}"
114
+ puts " Files processed: #{results.size}"
115
+ results.each do |r|
116
+ status = r[:skipped] ? 'skipped' : "#{r[:chunks_created]} chunks"
117
+ puts " - #{File.basename(r[:file_path])}: #{status}"
118
+ end
119
+
120
+ # Query nodes from a specific file
121
+ puts "\n6. Querying nodes from loaded file..."
122
+ nodes = htm.nodes_from_file(doc1_path)
123
+ puts " Nodes from postgresql_guide.md: #{nodes.size}"
124
+ nodes.each_with_index do |node, idx|
125
+ preview = node.content[0..50].gsub("\n", " ")
126
+ puts " [#{idx}] #{preview}..."
127
+ end
128
+
129
+ # Demonstrate re-sync behavior (file unchanged)
130
+ puts "\n7. Re-loading unchanged file (should skip)..."
131
+ result = htm.load_file(doc1_path)
132
+ puts " Skipped: #{result[:skipped]}"
133
+ puts " (File unchanged, no sync needed)"
134
+
135
+ # Force reload
136
+ puts "\n8. Force reloading file..."
137
+ result = htm.load_file(doc1_path, force: true)
138
+ puts " Skipped: #{result[:skipped]}"
139
+ puts " Chunks updated: #{result[:chunks_updated]}"
140
+
141
+ # Unload a file
142
+ puts "\n9. Unloading file..."
143
+ count = htm.unload_file(doc2_path)
144
+ puts " Unloaded: ruby_intro.md"
145
+ puts " Chunks soft-deleted: #{count}"
146
+
147
+ # Verify unload
148
+ nodes = htm.nodes_from_file(doc2_path)
149
+ puts " Nodes remaining: #{nodes.size} (should be 0)"
150
+
151
+ # Cleanup
152
+ puts "\n10. Cleaning up..."
153
+ htm.unload_file(doc1_path)
154
+ FileUtils.rm_rf(temp_dir)
155
+ puts " Removed temporary files"
156
+
157
+ puts "\n" + "=" * 60
158
+ puts "Example completed successfully!"
159
+ puts "\nFile loading API methods:"
160
+ puts " - htm.load_file(path, force: false)"
161
+ puts " - htm.load_directory(path, pattern: '**/*.md', force: false)"
162
+ puts " - htm.nodes_from_file(path)"
163
+ puts " - htm.unload_file(path)"
164
+ puts "\nRake tasks:"
165
+ puts " - rake htm:files:load[path]"
166
+ puts " - rake htm:files:load_dir[path,pattern]"
167
+ puts " - rake htm:files:list"
168
+ puts " - rake htm:files:info[path]"
169
+ puts " - rake htm:files:unload[path]"
170
+ puts " - rake htm:files:sync"
171
+ puts " - rake htm:files:stats"
172
+
173
+ rescue => e
174
+ puts "\nError: #{e.message}"
175
+ puts e.backtrace.first(5).join("\n")
176
+ exit 1
177
+ end
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # MCP Client Example for HTM
5
+ #
6
+ # This example demonstrates using ruby_llm-mcp to connect to the
7
+ # HTM MCP server and interact with it through a chat interface
8
+ # using a local Ollama model.
9
+ #
10
+ # Prerequisites:
11
+ # 1. Install gems: gem install ruby_llm-mcp
12
+ # 2. Have Ollama running with gpt-oss model: ollama pull gpt-oss
13
+ # 3. Set HTM_DBURL environment variable
14
+ # 4. The htm_mcp.rb must be available (this client will launch it)
15
+ #
16
+ # Usage:
17
+ # ruby examples/mcp_client.rb
18
+ #
19
+ # The client connects to the HTM MCP server via STDIO transport
20
+ # and provides an interactive chat loop where you can:
21
+ # - Ask the LLM to remember information
22
+ # - Query memories using natural language
23
+ # - List tags and statistics
24
+ # - All through conversational AI with tool calling
25
+
26
+ require 'ruby_llm'
27
+ require 'ruby_llm/mcp'
28
+
29
+ # Configuration
30
+ OLLAMA_MODEL = ENV.fetch('OLLAMA_MODEL', 'gpt-oss:latest')
31
+ OLLAMA_URL = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
32
+ MCP_SERVER_PATH = File.expand_path('../bin/htm_mcp.rb', __dir__)
33
+ ENV_ROBOT_NAME = ENV['HTM_ROBOT_NAME'] # nil if not set, allows prompting
34
+
35
+ class HTMMcpClient
36
+ attr_reader :robot_name
37
+
38
+ def initialize
39
+ validate_environment
40
+ setup_ruby_llm
41
+ setup_mcp_client
42
+ prompt_for_robot_name
43
+ set_robot_identity
44
+ check_working_memory
45
+ setup_chat
46
+ restore_session_if_requested
47
+ end
48
+
49
+
50
+ def run
51
+ print_banner
52
+ chat_loop
53
+ ensure
54
+ cleanup
55
+ end
56
+
57
+ private
58
+
59
+ def validate_environment
60
+ unless ENV['HTM_DBURL']
61
+ warn 'Error: HTM_DBURL not set.'
62
+ warn ' export HTM_DBURL="postgresql://postgres@localhost:5432/htm_development"'
63
+ exit 1
64
+ end
65
+
66
+ unless File.exist?(MCP_SERVER_PATH)
67
+ warn "Error: MCP server not found at #{MCP_SERVER_PATH}"
68
+ warn 'Please ensure mcp_server.rb exists in the examples directory.'
69
+ exit 1
70
+ end
71
+
72
+ # Check Ollama is running
73
+ require 'net/http'
74
+ begin
75
+ uri = URI(OLLAMA_URL)
76
+ Net::HTTP.get_response(uri)
77
+ rescue StandardError => e
78
+ warn "Error: Cannot connect to Ollama at #{OLLAMA_URL}"
79
+ warn " #{e.message}"
80
+ warn 'Please ensure Ollama is running: ollama serve'
81
+ exit 1
82
+ end
83
+ end
84
+
85
+
86
+ def setup_ruby_llm
87
+ # Configure RubyLLM for Ollama
88
+ RubyLLM.configure do |config|
89
+ ollama_api_base = OLLAMA_URL.end_with?('/v1') ? OLLAMA_URL : "#{OLLAMA_URL}/v1"
90
+ config.ollama_api_base = ollama_api_base
91
+ end
92
+ end
93
+
94
+
95
+ def setup_mcp_client
96
+ puts 'Connecting to HTM MCP server...'
97
+
98
+ @mcp_client = RubyLLM::MCP.client(
99
+ name: 'htm-memory',
100
+ transport_type: :stdio,
101
+ request_timeout: 60_000, # 60 seconds (in ms) for Ollama embedding generation
102
+ config: {
103
+ command: RbConfig.ruby,
104
+ args: [MCP_SERVER_PATH],
105
+ env: {
106
+ 'HTM_DBURL' => ENV['HTM_DBURL'],
107
+ 'OLLAMA_URL' => OLLAMA_URL
108
+ }
109
+ }
110
+ )
111
+
112
+ # Wait for server to be ready
113
+ sleep 0.5
114
+
115
+ if @mcp_client.alive?
116
+ puts '[✓] Connected to HTM MCP server'
117
+ else
118
+ warn '[✗] Failed to connect to HTM MCP server'
119
+ exit 1
120
+ end
121
+
122
+ # List available tools
123
+ @tools = @mcp_client.tools
124
+ puts "[✓] Found #{@tools.length} tools:"
125
+ @tools.each do |tool|
126
+ puts " - #{tool.name}: #{tool.description[0..60]}..."
127
+ end
128
+ puts
129
+ end
130
+
131
+
132
+ def prompt_for_robot_name
133
+ if ENV_ROBOT_NAME
134
+ @robot_name = ENV_ROBOT_NAME
135
+ puts "[✓] Using robot name from HTM_ROBOT_NAME: #{@robot_name}"
136
+ return
137
+ end
138
+
139
+ puts
140
+ print 'Enter your robot name (or press Enter for "Assistant"): '
141
+ input = gets&.chomp
142
+
143
+ @robot_name = if input.nil? || input.empty?
144
+ 'Assistant'
145
+ else
146
+ input
147
+ end
148
+
149
+ puts "[✓] Robot name: #{@robot_name}"
150
+ end
151
+
152
+
153
+ def set_robot_identity
154
+ puts 'Setting robot identity on MCP server...'
155
+
156
+ # Find the SetRobotTool
157
+ set_robot_tool = @tools.find { |t| t.name == 'SetRobotTool' }
158
+
159
+ unless set_robot_tool
160
+ warn '[⚠] SetRobotTool not found - using default robot identity'
161
+ return
162
+ end
163
+
164
+ # Call SetRobotTool directly via MCP
165
+ result = set_robot_tool.call(name: @robot_name)
166
+
167
+ # Parse the result
168
+ begin
169
+ response = JSON.parse(result.is_a?(String) ? result : result.to_s)
170
+ if response['success']
171
+ puts "[✓] Robot identity set: #{response['robot_name']} (id=#{response['robot_id']}, nodes=#{response['node_count']})"
172
+ else
173
+ warn "[⚠] Failed to set robot identity: #{response['error']}"
174
+ end
175
+ rescue JSON::ParserError => e
176
+ warn "[⚠] Could not parse SetRobotTool response: #{e.message}"
177
+ end
178
+ puts
179
+ end
180
+
181
+
182
+ def check_working_memory
183
+ # Check if there's working memory to restore
184
+ get_wm_tool = @tools.find { |t| t.name == 'GetWorkingMemoryTool' }
185
+ @working_memory_to_restore = nil
186
+ return unless get_wm_tool
187
+
188
+ result = get_wm_tool.call({})
189
+
190
+ # Extract JSON from MCP Content object
191
+ json_str = result.respond_to?(:text) ? result.text : result.to_s
192
+ response = JSON.parse(json_str)
193
+
194
+ # Debug: show what we got
195
+ puts "[Debug] Working memory check: success=#{response['success']}, count=#{response['count']}"
196
+
197
+ return unless response['success'] && response['count'].to_i > 0
198
+
199
+ puts "Found #{response['count']} memories in working memory from previous session."
200
+ print 'Restore previous session? (y/N): '
201
+ input = gets&.chomp&.downcase
202
+
203
+ if %w[y yes].include?(input)
204
+ @working_memory_to_restore = response['working_memory']
205
+ puts "[✓] Will restore #{@working_memory_to_restore.length} memories after chat setup"
206
+ else
207
+ puts '[✓] Starting fresh session'
208
+ end
209
+ puts
210
+ rescue JSON::ParserError => e
211
+ warn "[⚠] Could not parse working memory response: #{e.message}"
212
+ warn "[⚠] Raw: #{json_str.inspect[0..200]}" if defined?(json_str)
213
+ rescue StandardError => e
214
+ warn "[⚠] Error checking working memory: #{e.class} - #{e.message}"
215
+ end
216
+
217
+
218
+ def restore_session_if_requested
219
+ return unless @working_memory_to_restore
220
+
221
+ puts "Restoring #{@working_memory_to_restore.length} memories to chat context..."
222
+
223
+ # Build a system context from the working memory
224
+ context_parts = @working_memory_to_restore.map do |mem|
225
+ tags_str = mem['tags'].empty? ? '' : " [#{mem['tags'].join(', ')}]"
226
+ "- #{mem['content']}#{tags_str}"
227
+ end
228
+
229
+ context_message = <<~CONTEXT
230
+ The following information was remembered from your previous session with this user:
231
+
232
+ #{context_parts.join("\n")}
233
+
234
+ Use this context to inform your responses, but don't explicitly mention that you're restoring a session unless asked.
235
+ CONTEXT
236
+
237
+ # Add the context as a system message to prime the chat
238
+ @chat.add_message(role: :user, content: "Previous session context: #{context_message}")
239
+ @chat.add_message(role: :assistant,
240
+ content: "I've restored context from our previous session. How can I help you today?")
241
+
242
+ puts "[✓] Restored #{@working_memory_to_restore.length} memories to chat context"
243
+ puts
244
+ end
245
+
246
+
247
+ def setup_chat
248
+ puts "Initializing chat with #{OLLAMA_MODEL}..."
249
+
250
+ @chat = RubyLLM.chat(
251
+ model: OLLAMA_MODEL,
252
+ provider: :ollama,
253
+ assume_model_exists: true
254
+ )
255
+
256
+ # Attach MCP tools to the chat
257
+ @chat.with_tools(*@tools)
258
+
259
+ # Set up tool call logging
260
+ setup_tool_callbacks
261
+
262
+ puts '[✓] Chat initialized with tools attached'
263
+ end
264
+
265
+
266
+ def print_banner
267
+ puts
268
+ puts '=' * 70
269
+ puts 'HTM MCP Client - AI Chat with Memory Tools'
270
+ puts '=' * 70
271
+ puts
272
+ puts "Robot: #{@robot_name}"
273
+ puts "Model: #{OLLAMA_MODEL} (via Ollama)"
274
+ puts "MCP Server: #{MCP_SERVER_PATH}"
275
+ puts
276
+ puts 'Available tools:'
277
+ @tools.each do |tool|
278
+ puts " • #{tool.name}"
279
+ end
280
+ puts
281
+ puts 'Example queries:'
282
+ puts ' "Remember that the API rate limit is 1000 requests per minute"'
283
+ puts ' "What do you know about databases?"'
284
+ puts ' "Show me the memory statistics"'
285
+ puts ' "List all tags"'
286
+ puts ' "Forget node 123"'
287
+ puts
288
+ puts 'Commands:'
289
+ puts ' /tools - List available MCP tools'
290
+ puts ' /resources - List available MCP resources'
291
+ puts ' /stats - Show memory statistics'
292
+ puts ' /tags - List all tags'
293
+ puts ' /clear - Clear chat history'
294
+ puts ' /help - Show this help'
295
+ puts ' /exit - Quit'
296
+ puts
297
+ puts '=' * 70
298
+ puts
299
+ end
300
+
301
+
302
+ def chat_loop
303
+ loop do
304
+ print 'you> '
305
+ input = gets&.chomp
306
+ break if input.nil?
307
+
308
+ next if input.empty?
309
+
310
+ # Handle commands
311
+ case input
312
+ when '/exit', '/quit', '/q'
313
+ break
314
+ when '/tools'
315
+ show_tools
316
+ next
317
+ when '/resources'
318
+ show_resources
319
+ next
320
+ when '/stats'
321
+ show_stats
322
+ next
323
+ when '/tags'
324
+ show_tags
325
+ next
326
+ when '/clear'
327
+ clear_chat
328
+ next
329
+ when '/help'
330
+ print_banner
331
+ next
332
+ end
333
+
334
+ # Send to LLM with tools
335
+ begin
336
+ print "\n#{@robot_name}> "
337
+ response = @chat.ask(input)
338
+ puts response.content
339
+ puts
340
+ rescue StandardError => e
341
+ puts "\n[✗] Error: #{e.message}"
342
+ puts " #{e.class}: #{e.backtrace.first}"
343
+ puts
344
+ end
345
+ end
346
+
347
+ puts "\nGoodbye!"
348
+ end
349
+
350
+
351
+ def show_tools
352
+ puts "\nAvailable MCP Tools:"
353
+ puts '-' * 50
354
+ @tools.each do |tool|
355
+ puts
356
+ puts "#{tool.name}"
357
+ puts " #{tool.description}"
358
+ end
359
+ puts
360
+ end
361
+
362
+
363
+ def show_resources
364
+ puts "\nAvailable MCP Resources:"
365
+ puts '-' * 50
366
+ begin
367
+ resources = @mcp_client.resources
368
+ if resources.empty?
369
+ puts ' (no resources available)'
370
+ else
371
+ resources.each do |resource|
372
+ puts
373
+ puts "#{resource.uri}"
374
+ puts " #{resource.name}" if resource.respond_to?(:name)
375
+ end
376
+ end
377
+ rescue StandardError => e
378
+ puts " Error fetching resources: #{e.message}"
379
+ end
380
+ puts
381
+ end
382
+
383
+
384
+ def show_stats
385
+ puts "\nMemory Statistics:"
386
+ puts '-' * 50
387
+
388
+ stats_tool = @tools.find { |t| t.name == 'StatsTool' }
389
+ unless stats_tool
390
+ puts ' StatsTool not available'
391
+ puts
392
+ return
393
+ end
394
+
395
+ result = stats_tool.call({})
396
+
397
+ # Handle different response types from MCP
398
+ # RubyLLM::MCP::Content objects have a .text attribute
399
+ json_str = case result
400
+ when String then result
401
+ when Hash then result.to_json
402
+ else
403
+ # Try to extract text from MCP Content objects
404
+ if result.respond_to?(:text)
405
+ result.text
406
+ elsif result.respond_to?(:content)
407
+ result.content.is_a?(String) ? result.content : result.content.to_s
408
+ else
409
+ result.to_s
410
+ end
411
+ end
412
+
413
+ response = JSON.parse(json_str)
414
+
415
+ if response['success']
416
+ robot = response['current_robot']
417
+ stats = response['statistics']
418
+
419
+ puts
420
+ puts "Current Robot: #{robot['name']} (id=#{robot['id']})"
421
+ puts " Working memory: #{robot['memory_summary']['in_working_memory']} nodes"
422
+ puts " Total nodes: #{robot['memory_summary']['total_nodes']}"
423
+ puts " With embeddings: #{robot['memory_summary']['with_embeddings']}"
424
+ puts
425
+ puts 'Global Statistics:'
426
+ puts " Active nodes: #{stats['nodes']['active']}"
427
+ puts " Deleted nodes: #{stats['nodes']['deleted']}"
428
+ puts " Total tags: #{stats['tags']['total']}"
429
+ puts " Total robots: #{stats['robots']['total']}"
430
+ else
431
+ puts " Error: #{response['error']}"
432
+ end
433
+ puts
434
+ rescue JSON::ParserError => e
435
+ puts " Error parsing response: #{e.message}"
436
+ puts " Raw response: #{result.inspect[0..200]}"
437
+ puts
438
+ rescue StandardError => e
439
+ puts " Error: #{e.message}"
440
+ puts
441
+ end
442
+
443
+
444
+ def show_tags
445
+ puts "\nTags:"
446
+ puts '-' * 50
447
+
448
+ tags_tool = @tools.find { |t| t.name == 'ListTagsTool' }
449
+ unless tags_tool
450
+ puts ' ListTagsTool not available'
451
+ puts
452
+ return
453
+ end
454
+
455
+ result = tags_tool.call({})
456
+
457
+ # Extract JSON from MCP Content object
458
+ json_str = result.respond_to?(:text) ? result.text : result.to_s
459
+ response = JSON.parse(json_str)
460
+
461
+ if response['success']
462
+ tags = response['tags']
463
+ if tags.empty?
464
+ puts ' (no tags found)'
465
+ else
466
+ tags.each do |tag|
467
+ puts " #{tag['name']} (#{tag['node_count']} nodes)"
468
+ end
469
+ end
470
+ else
471
+ puts " Error: #{response['error']}"
472
+ end
473
+ puts
474
+ rescue JSON::ParserError => e
475
+ puts " Error parsing response: #{e.message}"
476
+ puts
477
+ rescue StandardError => e
478
+ puts " Error: #{e.message}"
479
+ puts
480
+ end
481
+
482
+
483
+ def clear_chat
484
+ @chat = RubyLLM.chat(
485
+ model: OLLAMA_MODEL,
486
+ provider: :ollama,
487
+ assume_model_exists: true
488
+ )
489
+ @chat.with_tools(*@tools)
490
+ setup_tool_callbacks
491
+
492
+ puts '[✓] Chat history cleared'
493
+ puts
494
+ end
495
+
496
+
497
+ def setup_tool_callbacks
498
+ @chat.on_tool_call do |tool_call|
499
+ puts "\n[Tool Call] #{tool_call.name}"
500
+ puts " Arguments: #{tool_call.arguments.inspect}"
501
+ end
502
+
503
+ @chat.on_tool_result do |tool_call, result|
504
+ # tool_call may be a Content object, so safely get the name
505
+ tool_name = tool_call.respond_to?(:name) ? tool_call.name : tool_call.class.name.split('::').last
506
+ puts "[Tool Result] #{tool_name}"
507
+ display_result = result.to_s
508
+ display_result = display_result[0..200] + '...' if display_result.length > 200
509
+ puts " Result: #{display_result}"
510
+ puts
511
+ end
512
+ end
513
+
514
+
515
+ def cleanup
516
+ @mcp_client&.close if @mcp_client.respond_to?(:close)
517
+ end
518
+ end
519
+
520
+ # Main entry point
521
+ begin
522
+ require 'ruby_llm/mcp'
523
+ rescue LoadError
524
+ warn 'Error: ruby_llm-mcp gem not found.'
525
+ warn 'Install it with: gem install ruby_llm-mcp'
526
+ exit 1
527
+ end
528
+
529
+ HTMMcpClient.new.run