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.
- checksums.yaml +4 -4
- data/.aigcm_msg +1 -0
- data/.architecture/reviews/comprehensive-codebase-review.md +577 -0
- data/.claude/settings.local.json +95 -0
- data/.irbrc +283 -80
- data/.tbls.yml +2 -1
- data/CHANGELOG.md +327 -26
- data/CLAUDE.md +603 -0
- data/README.md +83 -12
- data/Rakefile +5 -0
- data/bin/htm_mcp.rb +527 -0
- data/db/migrate/{20250101000001_enable_extensions.rb → 00001_enable_extensions.rb} +0 -1
- data/db/migrate/00002_create_robots.rb +11 -0
- data/db/migrate/00003_create_file_sources.rb +20 -0
- data/db/migrate/00004_create_nodes.rb +65 -0
- data/db/migrate/00005_create_tags.rb +13 -0
- data/db/migrate/00006_create_node_tags.rb +18 -0
- data/db/migrate/00007_create_robot_nodes.rb +26 -0
- data/db/migrate/00009_add_working_memory_to_robot_nodes.rb +12 -0
- data/db/schema.sql +172 -1
- data/docs/api/database.md +1 -2
- data/docs/api/htm.md +197 -2
- data/docs/api/yard/HTM/ActiveRecordConfig.md +23 -0
- data/docs/api/yard/HTM/AuthorizationError.md +11 -0
- data/docs/api/yard/HTM/CircuitBreaker.md +92 -0
- data/docs/api/yard/HTM/CircuitBreakerOpenError.md +34 -0
- data/docs/api/yard/HTM/Configuration.md +175 -0
- data/docs/api/yard/HTM/Database.md +99 -0
- data/docs/api/yard/HTM/DatabaseError.md +14 -0
- data/docs/api/yard/HTM/EmbeddingError.md +18 -0
- data/docs/api/yard/HTM/EmbeddingService.md +58 -0
- data/docs/api/yard/HTM/Error.md +11 -0
- data/docs/api/yard/HTM/JobAdapter.md +39 -0
- data/docs/api/yard/HTM/LongTermMemory.md +342 -0
- data/docs/api/yard/HTM/NotFoundError.md +17 -0
- data/docs/api/yard/HTM/Observability.md +107 -0
- data/docs/api/yard/HTM/QueryTimeoutError.md +19 -0
- data/docs/api/yard/HTM/Railtie.md +27 -0
- data/docs/api/yard/HTM/ResourceExhaustedError.md +13 -0
- data/docs/api/yard/HTM/TagError.md +18 -0
- data/docs/api/yard/HTM/TagService.md +67 -0
- data/docs/api/yard/HTM/Timeframe/Result.md +24 -0
- data/docs/api/yard/HTM/Timeframe.md +40 -0
- data/docs/api/yard/HTM/TimeframeExtractor/Result.md +24 -0
- data/docs/api/yard/HTM/TimeframeExtractor.md +45 -0
- data/docs/api/yard/HTM/ValidationError.md +20 -0
- data/docs/api/yard/HTM/WorkingMemory.md +131 -0
- data/docs/api/yard/HTM.md +80 -0
- data/docs/api/yard/index.csv +179 -0
- data/docs/api/yard-reference.md +51 -0
- data/docs/database/README.md +128 -128
- data/docs/database/public.file_sources.md +42 -0
- data/docs/database/public.file_sources.svg +211 -0
- data/docs/database/public.node_tags.md +4 -4
- data/docs/database/public.node_tags.svg +212 -79
- data/docs/database/public.nodes.md +22 -12
- data/docs/database/public.nodes.svg +246 -127
- data/docs/database/public.robot_nodes.md +11 -9
- data/docs/database/public.robot_nodes.svg +220 -98
- data/docs/database/public.robots.md +2 -2
- data/docs/database/public.robots.svg +136 -81
- data/docs/database/public.tags.md +3 -3
- data/docs/database/public.tags.svg +118 -39
- data/docs/database/schema.json +850 -771
- data/docs/database/schema.svg +256 -197
- data/docs/development/schema.md +67 -2
- data/docs/guides/adding-memories.md +93 -7
- data/docs/guides/recalling-memories.md +36 -1
- data/examples/README.md +405 -0
- data/examples/cli_app/htm_cli.rb +65 -5
- data/examples/cli_app/temp.log +93 -0
- data/examples/file_loader_usage.rb +177 -0
- data/examples/mcp_client.rb +529 -0
- data/examples/robot_groups/lib/robot_group.rb +419 -0
- data/examples/robot_groups/lib/working_memory_channel.rb +140 -0
- data/examples/robot_groups/multi_process.rb +286 -0
- data/examples/robot_groups/robot_worker.rb +136 -0
- data/examples/robot_groups/same_process.rb +229 -0
- data/examples/timeframe_demo.rb +276 -0
- data/lib/htm/active_record_config.rb +1 -1
- data/lib/htm/circuit_breaker.rb +202 -0
- data/lib/htm/configuration.rb +59 -13
- data/lib/htm/database.rb +67 -36
- data/lib/htm/embedding_service.rb +39 -2
- data/lib/htm/errors.rb +131 -11
- data/lib/htm/jobs/generate_embedding_job.rb +5 -4
- data/lib/htm/jobs/generate_tags_job.rb +4 -0
- data/lib/htm/loaders/markdown_loader.rb +263 -0
- data/lib/htm/loaders/paragraph_chunker.rb +112 -0
- data/lib/htm/long_term_memory.rb +460 -343
- data/lib/htm/models/file_source.rb +99 -0
- data/lib/htm/models/node.rb +80 -5
- data/lib/htm/models/robot.rb +24 -1
- data/lib/htm/models/robot_node.rb +1 -0
- data/lib/htm/models/tag.rb +254 -4
- data/lib/htm/observability.rb +395 -0
- data/lib/htm/tag_service.rb +60 -3
- data/lib/htm/tasks.rb +26 -1
- data/lib/htm/timeframe.rb +194 -0
- data/lib/htm/timeframe_extractor.rb +307 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm/working_memory.rb +165 -70
- data/lib/htm.rb +328 -130
- data/lib/tasks/doc.rake +300 -0
- data/lib/tasks/files.rake +299 -0
- data/lib/tasks/htm.rake +158 -3
- data/lib/tasks/jobs.rake +3 -9
- data/lib/tasks/tags.rake +166 -6
- data/mkdocs.yml +36 -1
- data/notes/ARCHITECTURE_REVIEW.md +1167 -0
- data/notes/IMPLEMENTATION_SUMMARY.md +606 -0
- data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +451 -0
- data/notes/next_steps.md +100 -0
- data/notes/plan.md +627 -0
- data/notes/tag_ontology_enhancement_ideas.md +222 -0
- data/notes/timescaledb_removal_summary.md +200 -0
- metadata +158 -17
- data/db/migrate/20250101000002_create_robots.rb +0 -14
- data/db/migrate/20250101000003_create_nodes.rb +0 -42
- data/db/migrate/20250101000005_create_tags.rb +0 -38
- data/db/migrate/20250101000007_add_node_vector_indexes.rb +0 -30
- data/db/migrate/20250125000001_add_content_hash_to_nodes.rb +0 -14
- data/db/migrate/20250125000002_create_robot_nodes.rb +0 -35
- data/db/migrate/20250125000003_remove_source_and_robot_id_from_nodes.rb +0 -28
- data/db/migrate/20250126000001_create_working_memories.rb +0 -19
- data/db/migrate/20250126000002_remove_unused_columns.rb +0 -12
- data/docs/database/public.working_memories.md +0 -40
- data/docs/database/public.working_memories.svg +0 -112
- 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
|