htm 0.0.10 → 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/.claude/settings.local.json +4 -1
- data/CHANGELOG.md +34 -1
- data/README.md +7 -7
- data/bin/htm_mcp.rb +527 -0
- data/examples/README.md +125 -0
- data/examples/mcp_client.rb +529 -0
- data/lib/htm/version.rb +1 -1
- metadata +34 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6e41109b86fd5c51c8f96cec8a11ea9455035da54d72ae461b3b2f0a7ab7a8d
|
|
4
|
+
data.tar.gz: 1fe15917933aefa827a12c6e92406711ab95449097cb78bdab4c0f6510f595c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c02e326797a6986e1a5b987bec948f09c0ffc7cb45045898e3fc6faf5c57c9bf3ca4d399324e88528f994c7e3e1989bb71f462aae8adbb270b7c99654aa135d3
|
|
7
|
+
data.tar.gz: 4ee90ea84f36ab2d85c72cae4f5a1382e9a174d354f2d976d73b8915cb5dc8df68f6ae799ec082076293ce9b386500b94b4fe12d291903c0804351f5fd027151
|
data/.claude/settings.local.json
CHANGED
|
@@ -84,7 +84,10 @@
|
|
|
84
84
|
"WebFetch(domain:www.barndominiumlife.com)",
|
|
85
85
|
"Bash(git rm:*)",
|
|
86
86
|
"Bash(HTM_DBURL=\"postgresql://dewayne@localhost:5432/htm_development\" psql -c \"DROP TABLE IF EXISTS working_memories CASCADE;\" htm_development)",
|
|
87
|
-
"Bash(git checkout:*)"
|
|
87
|
+
"Bash(git checkout:*)",
|
|
88
|
+
"WebFetch(domain:rubygems.org)",
|
|
89
|
+
"WebFetch(domain:www.rubyllm-mcp.com)",
|
|
90
|
+
"Bash(gem contents:*)"
|
|
88
91
|
],
|
|
89
92
|
"deny": [],
|
|
90
93
|
"ask": []
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.11] - 2025-12-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **MCP Server and Client** - Model Context Protocol integration for AI assistants
|
|
14
|
+
- `bin/htm_mcp.rb` - FastMCP-based server exposing HTM tools:
|
|
15
|
+
- `SetRobotTool` - Set robot identity for the session (per-client isolation)
|
|
16
|
+
- `GetRobotTool` - Get current robot information
|
|
17
|
+
- `GetWorkingMemoryTool` - Retrieve working memory for session restore
|
|
18
|
+
- `RememberTool` - Store information with tags and metadata
|
|
19
|
+
- `RecallTool` - Search memories with vector/fulltext/hybrid strategies
|
|
20
|
+
- `ForgetTool` / `RestoreTool` - Soft delete and restore memories
|
|
21
|
+
- `ListTagsTool` - List tags with optional prefix filtering
|
|
22
|
+
- `StatsTool` - Memory usage statistics
|
|
23
|
+
- Resources: `htm://statistics`, `htm://tags/hierarchy`, `htm://memories/recent`
|
|
24
|
+
- STDERR logging to avoid corrupting MCP JSON-RPC protocol
|
|
25
|
+
- Session-based robot identity via `MCPSession` module
|
|
26
|
+
- `examples/mcp_client.rb` - Interactive chat client using ruby_llm-mcp:
|
|
27
|
+
- Prompts for robot name on startup (or uses `HTM_ROBOT_NAME` env var)
|
|
28
|
+
- Session restore: offers to restore working memory from previous session
|
|
29
|
+
- Interactive chat loop with Ollama LLM (gpt-oss model)
|
|
30
|
+
- Tool call logging for visibility
|
|
31
|
+
- Slash commands: `/tools`, `/resources`, `/stats`, `/tags`, `/clear`, `/help`, `/exit`
|
|
32
|
+
- **Session restore feature** - MCP client can restore previous session context
|
|
33
|
+
- `GetWorkingMemoryTool` returns all nodes in working memory for a robot
|
|
34
|
+
- Client prompts user to restore previous session on startup
|
|
35
|
+
- Working memory injected into chat context for continuity
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **GetWorkingMemoryTool** now uses `joins(:node)` to exclude soft-deleted nodes
|
|
39
|
+
- **StatsTool** fixed scope error (`Node.active` → `Node.count` with default_scope)
|
|
40
|
+
- **MCP tool response parsing** - Extract `.text` from `RubyLLM::MCP::Content` objects
|
|
41
|
+
|
|
10
42
|
## [0.0.10] - 2025-12-02
|
|
11
43
|
|
|
12
44
|
### Added
|
|
@@ -436,7 +468,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
436
468
|
- Working memory size is user-configurable
|
|
437
469
|
- See ADRs for detailed architectural decisions and rationale
|
|
438
470
|
|
|
439
|
-
[Unreleased]: https://github.com/madbomber/htm/compare/v0.0.
|
|
471
|
+
[Unreleased]: https://github.com/madbomber/htm/compare/v0.0.12...HEAD
|
|
472
|
+
[0.0.12]: https://github.com/madbomber/htm/compare/v0.0.10...v0.0.12
|
|
440
473
|
[0.0.10]: https://github.com/madbomber/htm/compare/v0.0.9...v0.0.10
|
|
441
474
|
[0.0.9]: https://github.com/madbomber/htm/compare/v0.0.8...v0.0.9
|
|
442
475
|
[0.0.8]: https://github.com/madbomber/htm/compare/v0.0.7...v0.0.8
|
data/README.md
CHANGED
|
@@ -1400,13 +1400,13 @@ This separation allows you to provide any LLM implementation while HTM handles r
|
|
|
1400
1400
|
## Roadmap
|
|
1401
1401
|
|
|
1402
1402
|
- [x] Phase 1: Foundation (basic two-tier memory)
|
|
1403
|
-
- [
|
|
1404
|
-
- [
|
|
1405
|
-
- [
|
|
1406
|
-
- [
|
|
1407
|
-
- [
|
|
1408
|
-
- [
|
|
1409
|
-
- [
|
|
1403
|
+
- [x] Phase 2: RAG retrieval (semantic search)
|
|
1404
|
+
- [x] Phase 3: Relationships & tags
|
|
1405
|
+
- [x] Phase 4: Working memory management
|
|
1406
|
+
- [x] Phase 5: Hive mind features
|
|
1407
|
+
- [x] Phase 6: Operations & observability
|
|
1408
|
+
- [x] Phase 7: Advanced features
|
|
1409
|
+
- [x] Phase 8: Production-ready gem
|
|
1410
1410
|
|
|
1411
1411
|
|
|
1412
1412
|
## Contributing
|
data/bin/htm_mcp.rb
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
# htm/bin/htm_mcp.rb
|
|
4
|
+
|
|
5
|
+
# MCP Server Example for HTM
|
|
6
|
+
#
|
|
7
|
+
# This example demonstrates using FastMCP to expose HTM's memory
|
|
8
|
+
# capabilities as an MCP (Model Context Protocol) server that can
|
|
9
|
+
# be used by AI assistants like Claude Desktop.
|
|
10
|
+
#
|
|
11
|
+
# Prerequisites:
|
|
12
|
+
# 1. Install fast-mcp gem: gem install fast-mcp
|
|
13
|
+
# 2. Set HTM_DBURL environment variable
|
|
14
|
+
# 3. Initialize database schema: rake db_setup
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# ruby examples/mcp_server.rb
|
|
18
|
+
#
|
|
19
|
+
# The server uses STDIO transport by default, making it compatible
|
|
20
|
+
# with Claude Desktop and other MCP clients.
|
|
21
|
+
#
|
|
22
|
+
# Claude Desktop configuration (~/.config/claude/claude_desktop_config.json):
|
|
23
|
+
# {
|
|
24
|
+
# "mcpServers": {
|
|
25
|
+
# "htm-memory": {
|
|
26
|
+
# "command": "ruby",
|
|
27
|
+
# "args": ["/path/to/htm/examples/mcp_server.rb"],
|
|
28
|
+
# "env": {
|
|
29
|
+
# "HTM_DBURL": "postgresql://postgres@localhost:5432/htm_development"
|
|
30
|
+
# }
|
|
31
|
+
# }
|
|
32
|
+
# }
|
|
33
|
+
# }
|
|
34
|
+
|
|
35
|
+
require_relative '../lib/htm'
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
require 'fast_mcp'
|
|
39
|
+
rescue LoadError
|
|
40
|
+
warn "Error: fast-mcp gem not found."
|
|
41
|
+
warn "Install it with: gem install fast-mcp"
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check environment
|
|
46
|
+
unless ENV['HTM_DBURL']
|
|
47
|
+
warn "Error: HTM_DBURL not set."
|
|
48
|
+
warn " export HTM_DBURL=\"postgresql://postgres@localhost:5432/htm_development\""
|
|
49
|
+
exit 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# IMPORTANT: MCP uses STDIO for JSON-RPC communication.
|
|
53
|
+
# ALL logging must go to STDERR to avoid corrupting the protocol.
|
|
54
|
+
# Create a logger that writes to STDERR for MCP server diagnostics.
|
|
55
|
+
MCP_STDERR_LOG = Logger.new($stderr)
|
|
56
|
+
MCP_STDERR_LOG.level = Logger::INFO
|
|
57
|
+
MCP_STDERR_LOG.formatter = proc do |severity, datetime, _progname, msg|
|
|
58
|
+
"[MCP #{severity}] #{datetime.strftime('%H:%M:%S')} #{msg}\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Silent logger for RubyLLM/HTM internals (prevents STDOUT corruption)
|
|
62
|
+
mcp_logger = Logger.new(IO::NULL)
|
|
63
|
+
|
|
64
|
+
# Configure RubyLLM to not log to STDOUT (corrupts MCP protocol)
|
|
65
|
+
require 'ruby_llm'
|
|
66
|
+
RubyLLM.configure do |config|
|
|
67
|
+
config.logger = mcp_logger
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Configure HTM
|
|
71
|
+
HTM.configure do |config|
|
|
72
|
+
config.job_backend = :inline # Synchronous for MCP responses
|
|
73
|
+
config.logger = mcp_logger # Silent logging for MCP
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Session state for the current robot
|
|
77
|
+
# Each MCP client spawns its own server process, so this is naturally isolated
|
|
78
|
+
module MCPSession
|
|
79
|
+
DEFAULT_ROBOT_NAME = "mcp_default"
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
def htm_instance
|
|
83
|
+
@htm_instance ||= HTM.new(robot_name: DEFAULT_ROBOT_NAME)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def set_robot(name)
|
|
87
|
+
@robot_name = name
|
|
88
|
+
@htm_instance = HTM.new(robot_name: name)
|
|
89
|
+
MCP_STDERR_LOG.info "Robot set: #{name} (id=#{@htm_instance.robot_id})"
|
|
90
|
+
@htm_instance
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def robot_name
|
|
94
|
+
@robot_name || DEFAULT_ROBOT_NAME
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def robot_initialized?
|
|
98
|
+
@robot_name != nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Tool: Set the robot identity for this session
|
|
104
|
+
class SetRobotTool < FastMcp::Tool
|
|
105
|
+
description "Set the robot identity for this session. Call this first to establish your robot name."
|
|
106
|
+
|
|
107
|
+
arguments do
|
|
108
|
+
required(:name).filled(:string).description("The robot name (will be created if it doesn't exist)")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def call(name:)
|
|
112
|
+
MCP_STDERR_LOG.info "SetRobotTool called: name=#{name.inspect}"
|
|
113
|
+
|
|
114
|
+
htm = MCPSession.set_robot(name)
|
|
115
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
success: true,
|
|
119
|
+
robot_id: htm.robot_id,
|
|
120
|
+
robot_name: htm.robot_name,
|
|
121
|
+
node_count: robot.node_count,
|
|
122
|
+
message: "Robot '#{name}' is now active for this session"
|
|
123
|
+
}.to_json
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Tool: Get current robot info
|
|
128
|
+
class GetRobotTool < FastMcp::Tool
|
|
129
|
+
description "Get information about the current robot for this session"
|
|
130
|
+
|
|
131
|
+
arguments do
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def call
|
|
135
|
+
MCP_STDERR_LOG.info "GetRobotTool called"
|
|
136
|
+
|
|
137
|
+
htm = MCPSession.htm_instance
|
|
138
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
success: true,
|
|
142
|
+
robot_id: htm.robot_id,
|
|
143
|
+
robot_name: htm.robot_name,
|
|
144
|
+
initialized: MCPSession.robot_initialized?,
|
|
145
|
+
memory_summary: robot.memory_summary
|
|
146
|
+
}.to_json
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Tool: Get working memory contents for session restore
|
|
151
|
+
class GetWorkingMemoryTool < FastMcp::Tool
|
|
152
|
+
description "Get all working memory contents for the current robot. Use this to restore a previous session."
|
|
153
|
+
|
|
154
|
+
arguments do
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def call
|
|
158
|
+
htm = MCPSession.htm_instance
|
|
159
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
160
|
+
MCP_STDERR_LOG.info "GetWorkingMemoryTool called for robot=#{htm.robot_name}"
|
|
161
|
+
|
|
162
|
+
# Get all nodes in working memory with their metadata
|
|
163
|
+
# Filter out any robot_nodes where the node has been deleted (node uses default_scope)
|
|
164
|
+
working_memory_nodes = robot.robot_nodes
|
|
165
|
+
.in_working_memory
|
|
166
|
+
.joins(:node) # Inner join excludes deleted nodes
|
|
167
|
+
.includes(node: :tags)
|
|
168
|
+
.order(last_remembered_at: :desc)
|
|
169
|
+
.filter_map do |rn|
|
|
170
|
+
next unless rn.node # Extra safety check
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
id: rn.node.id,
|
|
174
|
+
content: rn.node.content,
|
|
175
|
+
tags: rn.node.tags.map(&:name),
|
|
176
|
+
remember_count: rn.remember_count,
|
|
177
|
+
last_remembered_at: rn.last_remembered_at&.iso8601,
|
|
178
|
+
created_at: rn.node.created_at.iso8601
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
MCP_STDERR_LOG.info "GetWorkingMemoryTool complete: #{working_memory_nodes.length} nodes in working memory"
|
|
183
|
+
|
|
184
|
+
{
|
|
185
|
+
success: true,
|
|
186
|
+
robot_id: htm.robot_id,
|
|
187
|
+
robot_name: htm.robot_name,
|
|
188
|
+
count: working_memory_nodes.length,
|
|
189
|
+
working_memory: working_memory_nodes
|
|
190
|
+
}.to_json
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
MCP_STDERR_LOG.error "GetWorkingMemoryTool error: #{e.message}"
|
|
193
|
+
{ success: false, error: e.message, count: 0, working_memory: [] }.to_json
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Tool: Remember information
|
|
198
|
+
class RememberTool < FastMcp::Tool
|
|
199
|
+
description "Store information in HTM long-term memory with optional tags"
|
|
200
|
+
|
|
201
|
+
arguments do
|
|
202
|
+
required(:content).filled(:string).description("The content to remember")
|
|
203
|
+
optional(:tags).array(:string).description("Optional tags for categorization (e.g., ['database:postgresql', 'config'])")
|
|
204
|
+
optional(:metadata).hash.description("Optional metadata key-value pairs")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def call(content:, tags: [], metadata: {})
|
|
208
|
+
MCP_STDERR_LOG.info "RememberTool called: content=#{content[0..50].inspect}..."
|
|
209
|
+
|
|
210
|
+
htm = MCPSession.htm_instance
|
|
211
|
+
node_id = htm.remember(content, tags: tags, metadata: metadata)
|
|
212
|
+
node = HTM::Models::Node.includes(:tags).find(node_id)
|
|
213
|
+
|
|
214
|
+
MCP_STDERR_LOG.info "Memory stored: node_id=#{node_id}, robot=#{htm.robot_name}, tags=#{node.tags.map(&:name)}"
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
success: true,
|
|
218
|
+
node_id: node_id,
|
|
219
|
+
robot_id: htm.robot_id,
|
|
220
|
+
robot_name: htm.robot_name,
|
|
221
|
+
content: node.content,
|
|
222
|
+
tags: node.tags.map(&:name),
|
|
223
|
+
created_at: node.created_at.iso8601
|
|
224
|
+
}.to_json
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Tool: Recall memories
|
|
229
|
+
class RecallTool < FastMcp::Tool
|
|
230
|
+
description "Search and retrieve memories from HTM using semantic, full-text, or hybrid search"
|
|
231
|
+
|
|
232
|
+
arguments do
|
|
233
|
+
required(:query).filled(:string).description("Search query - can be natural language or keywords")
|
|
234
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 10)")
|
|
235
|
+
optional(:strategy).filled(:string).description("Search strategy: 'vector', 'fulltext', or 'hybrid' (default: 'hybrid')")
|
|
236
|
+
optional(:timeframe).filled(:string).description("Filter by time: 'today', 'this week', 'this month', or ISO8601 date range")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def call(query:, limit: 10, strategy: 'hybrid', timeframe: nil)
|
|
240
|
+
htm = MCPSession.htm_instance
|
|
241
|
+
MCP_STDERR_LOG.info "RecallTool called: query=#{query.inspect}, strategy=#{strategy}, limit=#{limit}, robot=#{htm.robot_name}"
|
|
242
|
+
|
|
243
|
+
recall_opts = {
|
|
244
|
+
limit: limit,
|
|
245
|
+
strategy: strategy.to_sym,
|
|
246
|
+
raw: true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Parse timeframe if provided
|
|
250
|
+
if timeframe
|
|
251
|
+
recall_opts[:timeframe] = parse_timeframe(timeframe)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
memories = htm.recall(query, **recall_opts)
|
|
255
|
+
|
|
256
|
+
results = memories.map do |memory|
|
|
257
|
+
node = HTM::Models::Node.includes(:tags).find(memory['id'])
|
|
258
|
+
{
|
|
259
|
+
id: memory['id'],
|
|
260
|
+
content: memory['content'],
|
|
261
|
+
tags: node.tags.map(&:name),
|
|
262
|
+
created_at: memory['created_at'],
|
|
263
|
+
score: memory['combined_score'] || memory['similarity']
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
MCP_STDERR_LOG.info "Recall complete: found #{results.length} memories"
|
|
268
|
+
|
|
269
|
+
{
|
|
270
|
+
success: true,
|
|
271
|
+
query: query,
|
|
272
|
+
strategy: strategy,
|
|
273
|
+
robot_name: htm.robot_name,
|
|
274
|
+
count: results.length,
|
|
275
|
+
results: results
|
|
276
|
+
}.to_json
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def parse_timeframe(timeframe)
|
|
282
|
+
case timeframe.downcase
|
|
283
|
+
when 'today'
|
|
284
|
+
Time.now.beginning_of_day..Time.now
|
|
285
|
+
when 'this week'
|
|
286
|
+
1.week.ago..Time.now
|
|
287
|
+
when 'this month'
|
|
288
|
+
1.month.ago..Time.now
|
|
289
|
+
else
|
|
290
|
+
# Try to parse as ISO8601 range (start..end)
|
|
291
|
+
if timeframe.include?('..')
|
|
292
|
+
parts = timeframe.split('..')
|
|
293
|
+
Time.parse(parts[0])..Time.parse(parts[1])
|
|
294
|
+
else
|
|
295
|
+
# Single date - from that date to now
|
|
296
|
+
Time.parse(timeframe)..Time.now
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
rescue ArgumentError
|
|
300
|
+
nil # Invalid timeframe, skip filtering
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Tool: Forget a memory
|
|
305
|
+
class ForgetTool < FastMcp::Tool
|
|
306
|
+
description "Soft-delete a memory from HTM (can be restored later)"
|
|
307
|
+
|
|
308
|
+
arguments do
|
|
309
|
+
required(:node_id).filled(:integer).description("The ID of the node to forget")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def call(node_id:)
|
|
313
|
+
htm = MCPSession.htm_instance
|
|
314
|
+
MCP_STDERR_LOG.info "ForgetTool called: node_id=#{node_id}, robot=#{htm.robot_name}"
|
|
315
|
+
|
|
316
|
+
htm.forget(node_id)
|
|
317
|
+
|
|
318
|
+
MCP_STDERR_LOG.info "Memory soft-deleted: node_id=#{node_id}"
|
|
319
|
+
|
|
320
|
+
{
|
|
321
|
+
success: true,
|
|
322
|
+
node_id: node_id,
|
|
323
|
+
robot_name: htm.robot_name,
|
|
324
|
+
message: "Memory soft-deleted. Use restore to recover."
|
|
325
|
+
}.to_json
|
|
326
|
+
rescue ActiveRecord::RecordNotFound
|
|
327
|
+
MCP_STDERR_LOG.warn "ForgetTool failed: node #{node_id} not found"
|
|
328
|
+
{
|
|
329
|
+
success: false,
|
|
330
|
+
error: "Node #{node_id} not found"
|
|
331
|
+
}.to_json
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Tool: Restore a forgotten memory
|
|
336
|
+
class RestoreTool < FastMcp::Tool
|
|
337
|
+
description "Restore a soft-deleted memory"
|
|
338
|
+
|
|
339
|
+
arguments do
|
|
340
|
+
required(:node_id).filled(:integer).description("The ID of the node to restore")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def call(node_id:)
|
|
344
|
+
htm = MCPSession.htm_instance
|
|
345
|
+
MCP_STDERR_LOG.info "RestoreTool called: node_id=#{node_id}, robot=#{htm.robot_name}"
|
|
346
|
+
|
|
347
|
+
htm.restore(node_id)
|
|
348
|
+
|
|
349
|
+
MCP_STDERR_LOG.info "Memory restored: node_id=#{node_id}"
|
|
350
|
+
|
|
351
|
+
{
|
|
352
|
+
success: true,
|
|
353
|
+
node_id: node_id,
|
|
354
|
+
robot_name: htm.robot_name,
|
|
355
|
+
message: "Memory restored successfully"
|
|
356
|
+
}.to_json
|
|
357
|
+
rescue ActiveRecord::RecordNotFound
|
|
358
|
+
MCP_STDERR_LOG.warn "RestoreTool failed: node #{node_id} not found"
|
|
359
|
+
{
|
|
360
|
+
success: false,
|
|
361
|
+
error: "Node #{node_id} not found"
|
|
362
|
+
}.to_json
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Tool: List tags
|
|
367
|
+
class ListTagsTool < FastMcp::Tool
|
|
368
|
+
description "List all tags in HTM, optionally filtered by prefix"
|
|
369
|
+
|
|
370
|
+
arguments do
|
|
371
|
+
optional(:prefix).filled(:string).description("Filter tags by prefix (e.g., 'database' returns 'database:postgresql', etc.)")
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def call(prefix: nil)
|
|
375
|
+
MCP_STDERR_LOG.info "ListTagsTool called: prefix=#{prefix.inspect}"
|
|
376
|
+
|
|
377
|
+
tags_query = HTM::Models::Tag.order(:name)
|
|
378
|
+
tags_query = tags_query.where("name LIKE ?", "#{prefix}%") if prefix
|
|
379
|
+
|
|
380
|
+
tags = tags_query.map do |tag|
|
|
381
|
+
{
|
|
382
|
+
name: tag.name,
|
|
383
|
+
node_count: tag.nodes.count
|
|
384
|
+
}
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
MCP_STDERR_LOG.info "ListTagsTool complete: found #{tags.length} tags"
|
|
388
|
+
|
|
389
|
+
{
|
|
390
|
+
success: true,
|
|
391
|
+
prefix: prefix,
|
|
392
|
+
count: tags.length,
|
|
393
|
+
tags: tags
|
|
394
|
+
}.to_json
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Tool: Get memory statistics
|
|
399
|
+
class StatsTool < FastMcp::Tool
|
|
400
|
+
description "Get statistics about HTM memory usage"
|
|
401
|
+
|
|
402
|
+
arguments do
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def call
|
|
406
|
+
htm = MCPSession.htm_instance
|
|
407
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
408
|
+
MCP_STDERR_LOG.info "StatsTool called for robot=#{htm.robot_name}"
|
|
409
|
+
|
|
410
|
+
# Note: Node uses default_scope to exclude deleted, so .count returns active nodes
|
|
411
|
+
total_nodes = HTM::Models::Node.count
|
|
412
|
+
deleted_nodes = HTM::Models::Node.deleted.count
|
|
413
|
+
nodes_with_embeddings = HTM::Models::Node.with_embeddings.count
|
|
414
|
+
nodes_with_tags = HTM::Models::Node.joins(:tags).distinct.count
|
|
415
|
+
total_tags = HTM::Models::Tag.count
|
|
416
|
+
total_robots = HTM::Models::Robot.count
|
|
417
|
+
|
|
418
|
+
MCP_STDERR_LOG.info "StatsTool complete: #{total_nodes} active nodes, #{total_tags} tags"
|
|
419
|
+
|
|
420
|
+
{
|
|
421
|
+
success: true,
|
|
422
|
+
current_robot: {
|
|
423
|
+
name: htm.robot_name,
|
|
424
|
+
id: htm.robot_id,
|
|
425
|
+
memory_summary: robot.memory_summary
|
|
426
|
+
},
|
|
427
|
+
statistics: {
|
|
428
|
+
nodes: {
|
|
429
|
+
active: total_nodes,
|
|
430
|
+
deleted: deleted_nodes,
|
|
431
|
+
with_embeddings: nodes_with_embeddings,
|
|
432
|
+
with_tags: nodes_with_tags
|
|
433
|
+
},
|
|
434
|
+
tags: {
|
|
435
|
+
total: total_tags
|
|
436
|
+
},
|
|
437
|
+
robots: {
|
|
438
|
+
total: total_robots
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}.to_json
|
|
442
|
+
rescue StandardError => e
|
|
443
|
+
MCP_STDERR_LOG.error "StatsTool error: #{e.message}"
|
|
444
|
+
{ success: false, error: e.message }.to_json
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Resource: Memory Statistics
|
|
449
|
+
class MemoryStatsResource < FastMcp::Resource
|
|
450
|
+
uri "htm://statistics"
|
|
451
|
+
resource_name "HTM Memory Statistics"
|
|
452
|
+
mime_type "application/json"
|
|
453
|
+
|
|
454
|
+
def content
|
|
455
|
+
htm = MCPSession.htm_instance
|
|
456
|
+
{
|
|
457
|
+
total_nodes: HTM::Models::Node.active.count,
|
|
458
|
+
total_tags: HTM::Models::Tag.count,
|
|
459
|
+
total_robots: HTM::Models::Robot.count,
|
|
460
|
+
current_robot: htm.robot_name,
|
|
461
|
+
robot_id: htm.robot_id,
|
|
462
|
+
robot_initialized: MCPSession.robot_initialized?,
|
|
463
|
+
embedding_provider: HTM.configuration.embedding_provider,
|
|
464
|
+
embedding_model: HTM.configuration.embedding_model
|
|
465
|
+
}.to_json
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Resource: Tag Hierarchy
|
|
470
|
+
class TagHierarchyResource < FastMcp::Resource
|
|
471
|
+
uri "htm://tags/hierarchy"
|
|
472
|
+
resource_name "HTM Tag Hierarchy"
|
|
473
|
+
mime_type "text/plain"
|
|
474
|
+
|
|
475
|
+
def content
|
|
476
|
+
HTM::Models::Tag.all.tree_string
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Resource: Recent Memories
|
|
481
|
+
class RecentMemoriesResource < FastMcp::Resource
|
|
482
|
+
uri "htm://memories/recent"
|
|
483
|
+
resource_name "Recent HTM Memories"
|
|
484
|
+
mime_type "application/json"
|
|
485
|
+
|
|
486
|
+
def content
|
|
487
|
+
recent = HTM::Models::Node.active
|
|
488
|
+
.includes(:tags)
|
|
489
|
+
.order(created_at: :desc)
|
|
490
|
+
.limit(20)
|
|
491
|
+
.map do |node|
|
|
492
|
+
{
|
|
493
|
+
id: node.id,
|
|
494
|
+
content: node.content[0..200],
|
|
495
|
+
tags: node.tags.map(&:name),
|
|
496
|
+
created_at: node.created_at.iso8601
|
|
497
|
+
}
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
{ recent_memories: recent }.to_json
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Create and configure the MCP server
|
|
505
|
+
server = FastMcp::Server.new(
|
|
506
|
+
name: 'htm-memory-server',
|
|
507
|
+
version: HTM::VERSION
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Register tools
|
|
511
|
+
server.register_tool(SetRobotTool) # Call first to set robot identity
|
|
512
|
+
server.register_tool(GetRobotTool) # Get current robot info
|
|
513
|
+
server.register_tool(GetWorkingMemoryTool) # Get working memory for session restore
|
|
514
|
+
server.register_tool(RememberTool)
|
|
515
|
+
server.register_tool(RecallTool)
|
|
516
|
+
server.register_tool(ForgetTool)
|
|
517
|
+
server.register_tool(RestoreTool)
|
|
518
|
+
server.register_tool(ListTagsTool)
|
|
519
|
+
server.register_tool(StatsTool)
|
|
520
|
+
|
|
521
|
+
# Register resources
|
|
522
|
+
server.register_resource(MemoryStatsResource)
|
|
523
|
+
server.register_resource(TagHierarchyResource)
|
|
524
|
+
server.register_resource(RecentMemoriesResource)
|
|
525
|
+
|
|
526
|
+
# Start the server (STDIO transport by default)
|
|
527
|
+
server.start
|
data/examples/README.md
CHANGED
|
@@ -107,6 +107,127 @@ ruby examples/timeframe_demo.rb
|
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
110
|
+
### mcp_server.rb & mcp_client.rb
|
|
111
|
+
|
|
112
|
+
**Model Context Protocol (MCP) integration for AI assistants.**
|
|
113
|
+
|
|
114
|
+
A pair of examples demonstrating how to expose HTM as an MCP server and connect to it from a chat client. This enables AI assistants like Claude Desktop to use HTM's memory capabilities.
|
|
115
|
+
|
|
116
|
+
#### mcp_server.rb
|
|
117
|
+
|
|
118
|
+
An MCP server that exposes HTM's memory operations as tools:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
ruby examples/mcp_server.rb
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Tools exposed:**
|
|
125
|
+
- `SetRobotTool` - Set the robot identity for this session (call first)
|
|
126
|
+
- `GetRobotTool` - Get current robot information
|
|
127
|
+
- `GetWorkingMemoryTool` - Get working memory contents for session restore
|
|
128
|
+
- `RememberTool` - Store information with optional tags and metadata
|
|
129
|
+
- `RecallTool` - Search memories using vector, fulltext, or hybrid strategies
|
|
130
|
+
- `ForgetTool` - Soft-delete a memory (recoverable)
|
|
131
|
+
- `RestoreTool` - Restore a soft-deleted memory
|
|
132
|
+
- `ListTagsTool` - List tags with optional prefix filtering
|
|
133
|
+
- `StatsTool` - Get memory usage statistics
|
|
134
|
+
|
|
135
|
+
**Resources exposed:**
|
|
136
|
+
- `htm://statistics` - Memory statistics as JSON
|
|
137
|
+
- `htm://tags/hierarchy` - Tag hierarchy as text tree
|
|
138
|
+
- `htm://memories/recent` - Last 20 memories
|
|
139
|
+
|
|
140
|
+
**Claude Desktop configuration** (`~/.config/claude/claude_desktop_config.json`):
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"mcpServers": {
|
|
144
|
+
"htm-memory": {
|
|
145
|
+
"command": "ruby",
|
|
146
|
+
"args": ["/path/to/htm/examples/mcp_server.rb"],
|
|
147
|
+
"env": {
|
|
148
|
+
"HTM_DBURL": "postgresql://user@localhost:5432/htm_development"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### mcp_client.rb
|
|
156
|
+
|
|
157
|
+
An interactive chat client that connects to the MCP server via STDIO and uses a local Ollama model (gpt-oss) for conversation:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
ruby examples/mcp_client.rb
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Features:**
|
|
164
|
+
- Prompts for robot name on startup (or uses `HTM_ROBOT_NAME` env var)
|
|
165
|
+
- Calls `SetRobotTool` to establish robot identity with the server
|
|
166
|
+
- Offers to restore previous session from working memory
|
|
167
|
+
- Connects to `mcp_server.rb` automatically via STDIO transport
|
|
168
|
+
- Interactive chat loop with tool calling
|
|
169
|
+
- LLM decides when to remember/recall information
|
|
170
|
+
- Logs tool calls and results for visibility
|
|
171
|
+
|
|
172
|
+
**Commands:**
|
|
173
|
+
- `/tools` - List available MCP tools
|
|
174
|
+
- `/resources` - List available MCP resources
|
|
175
|
+
- `/clear` - Clear chat history
|
|
176
|
+
- `/help` - Show help
|
|
177
|
+
- `/exit` - Quit
|
|
178
|
+
|
|
179
|
+
**Example startup and conversation:**
|
|
180
|
+
```
|
|
181
|
+
$ ruby examples/mcp_client.rb
|
|
182
|
+
Connecting to HTM MCP server...
|
|
183
|
+
[✓] Connected to HTM MCP server
|
|
184
|
+
[✓] Found 9 tools:
|
|
185
|
+
- SetRobotTool: Set the robot identity for this session...
|
|
186
|
+
- GetRobotTool: Get information about the current robot...
|
|
187
|
+
- GetWorkingMemoryTool: Get all working memory contents...
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
Enter your robot name (or press Enter for default): alice-assistant
|
|
191
|
+
[✓] Robot name: alice-assistant
|
|
192
|
+
Setting robot identity on MCP server...
|
|
193
|
+
[✓] Robot identity set: alice-assistant (id=5, nodes=12)
|
|
194
|
+
|
|
195
|
+
Found 3 memories in working memory from previous session.
|
|
196
|
+
Restore previous session? (y/N): y
|
|
197
|
+
[✓] Will restore 3 memories after chat setup
|
|
198
|
+
|
|
199
|
+
Initializing chat with gpt-oss:latest...
|
|
200
|
+
[✓] Chat initialized with tools attached
|
|
201
|
+
Restoring 3 memories to chat context...
|
|
202
|
+
[✓] Restored 3 memories to chat context
|
|
203
|
+
|
|
204
|
+
======================================================================
|
|
205
|
+
HTM MCP Client - AI Chat with Memory Tools
|
|
206
|
+
======================================================================
|
|
207
|
+
|
|
208
|
+
Robot: alice-assistant
|
|
209
|
+
Model: gpt-oss:latest (via Ollama)
|
|
210
|
+
...
|
|
211
|
+
|
|
212
|
+
you> What's the API rate limit?
|
|
213
|
+
|
|
214
|
+
assistant> The API rate limit is 1000 requests per minute.
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Additional dependencies:**
|
|
218
|
+
```bash
|
|
219
|
+
gem install fast-mcp ruby_llm-mcp
|
|
220
|
+
ollama pull gpt-oss # Or your preferred model
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Environment Variables:**
|
|
224
|
+
- `HTM_DBURL` - PostgreSQL connection (required)
|
|
225
|
+
- `OLLAMA_URL` - Ollama server URL (default: http://localhost:11434)
|
|
226
|
+
- `OLLAMA_MODEL` - Model to use (default: gpt-oss:latest)
|
|
227
|
+
- `HTM_ROBOT_NAME` - Robot name (optional, prompts if not set)
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
110
231
|
## Application Examples
|
|
111
232
|
|
|
112
233
|
### example_app/
|
|
@@ -245,6 +366,8 @@ examples/
|
|
|
245
366
|
├── custom_llm_configuration.rb # LLM integration patterns
|
|
246
367
|
├── file_loader_usage.rb # Document loading
|
|
247
368
|
├── timeframe_demo.rb # Time-based filtering
|
|
369
|
+
├── mcp_server.rb # MCP server exposing HTM tools
|
|
370
|
+
├── mcp_client.rb # MCP client with chat interface
|
|
248
371
|
├── example_app/
|
|
249
372
|
│ ├── app.rb # Full-featured demo app
|
|
250
373
|
│ └── Rakefile
|
|
@@ -274,6 +397,8 @@ examples/
|
|
|
274
397
|
| Custom LLM integration | `custom_llm_configuration.rb` |
|
|
275
398
|
| Loading documents/files | `file_loader_usage.rb` |
|
|
276
399
|
| Time-based queries | `timeframe_demo.rb` |
|
|
400
|
+
| MCP server for AI assistants | `mcp_server.rb` |
|
|
401
|
+
| MCP client with chat interface | `mcp_client.rb` |
|
|
277
402
|
| Web application | `sinatra_app/` |
|
|
278
403
|
| CLI tool | `cli_app/` |
|
|
279
404
|
| Multi-robot coordination | `robot_groups/` |
|
|
@@ -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
|
data/lib/htm/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: htm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dewayne VanHoozer
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -121,6 +121,20 @@ dependencies:
|
|
|
121
121
|
- - ">="
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
123
|
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: fast-mcp
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
124
138
|
- !ruby/object:Gem::Dependency
|
|
125
139
|
name: rake
|
|
126
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -177,6 +191,20 @@ dependencies:
|
|
|
177
191
|
- - ">="
|
|
178
192
|
- !ruby/object:Gem::Version
|
|
179
193
|
version: '0'
|
|
194
|
+
- !ruby/object:Gem::Dependency
|
|
195
|
+
name: ruby_llm-mcp
|
|
196
|
+
requirement: !ruby/object:Gem::Requirement
|
|
197
|
+
requirements:
|
|
198
|
+
- - ">="
|
|
199
|
+
- !ruby/object:Gem::Version
|
|
200
|
+
version: '0'
|
|
201
|
+
type: :development
|
|
202
|
+
prerelease: false
|
|
203
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
204
|
+
requirements:
|
|
205
|
+
- - ">="
|
|
206
|
+
- !ruby/object:Gem::Version
|
|
207
|
+
version: '0'
|
|
180
208
|
- !ruby/object:Gem::Dependency
|
|
181
209
|
name: yard
|
|
182
210
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -213,7 +241,8 @@ description: |
|
|
|
213
241
|
(Retrieval-Augmented Generation) techniques.
|
|
214
242
|
email:
|
|
215
243
|
- dvanhoozer@gmail.com
|
|
216
|
-
executables:
|
|
244
|
+
executables:
|
|
245
|
+
- htm_mcp.rb
|
|
217
246
|
extensions: []
|
|
218
247
|
extra_rdoc_files: []
|
|
219
248
|
files:
|
|
@@ -249,6 +278,7 @@ files:
|
|
|
249
278
|
- README.md
|
|
250
279
|
- Rakefile
|
|
251
280
|
- SETUP.md
|
|
281
|
+
- bin/htm_mcp.rb
|
|
252
282
|
- config/database.yml
|
|
253
283
|
- db/migrate/00001_enable_extensions.rb
|
|
254
284
|
- db/migrate/00002_create_robots.rb
|
|
@@ -402,6 +432,7 @@ files:
|
|
|
402
432
|
- examples/example_app/Rakefile
|
|
403
433
|
- examples/example_app/app.rb
|
|
404
434
|
- examples/file_loader_usage.rb
|
|
435
|
+
- examples/mcp_client.rb
|
|
405
436
|
- examples/robot_groups/lib/robot_group.rb
|
|
406
437
|
- examples/robot_groups/lib/working_memory_channel.rb
|
|
407
438
|
- examples/robot_groups/multi_process.rb
|