htm 0.0.10 → 0.0.14
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/.dictate.toml +46 -0
- data/.envrc +2 -0
- data/CHANGELOG.md +86 -3
- data/README.md +86 -7
- data/Rakefile +14 -2
- data/bin/htm_mcp.rb +621 -0
- data/config/database.yml +20 -13
- data/db/migrate/00010_add_soft_delete_to_associations.rb +29 -0
- data/db/migrate/00011_add_performance_indexes.rb +21 -0
- data/db/migrate/00012_add_tags_trigram_index.rb +18 -0
- data/db/migrate/00013_enable_lz4_compression.rb +43 -0
- data/db/schema.sql +49 -92
- data/docs/api/index.md +1 -1
- data/docs/api/yard/HTM.md +2 -4
- data/docs/architecture/index.md +1 -1
- data/docs/development/index.md +1 -1
- data/docs/getting-started/index.md +1 -1
- data/docs/guides/index.md +1 -1
- data/docs/images/telemetry-architecture.svg +153 -0
- data/docs/telemetry.md +391 -0
- data/examples/README.md +171 -1
- data/examples/cli_app/README.md +1 -1
- data/examples/cli_app/htm_cli.rb +1 -1
- data/examples/mcp_client.rb +529 -0
- data/examples/sinatra_app/app.rb +1 -1
- data/examples/telemetry/README.md +147 -0
- data/examples/telemetry/SETUP_README.md +169 -0
- data/examples/telemetry/demo.rb +498 -0
- data/examples/telemetry/grafana/dashboards/htm-metrics.json +457 -0
- data/lib/htm/configuration.rb +261 -70
- data/lib/htm/database.rb +46 -22
- data/lib/htm/embedding_service.rb +24 -14
- data/lib/htm/errors.rb +15 -1
- data/lib/htm/jobs/generate_embedding_job.rb +19 -0
- data/lib/htm/jobs/generate_propositions_job.rb +103 -0
- data/lib/htm/jobs/generate_tags_job.rb +24 -0
- data/lib/htm/loaders/markdown_chunker.rb +79 -0
- data/lib/htm/loaders/markdown_loader.rb +41 -15
- data/lib/htm/long_term_memory/fulltext_search.rb +138 -0
- data/lib/htm/long_term_memory/hybrid_search.rb +324 -0
- data/lib/htm/long_term_memory/node_operations.rb +209 -0
- data/lib/htm/long_term_memory/relevance_scorer.rb +355 -0
- data/lib/htm/long_term_memory/robot_operations.rb +34 -0
- data/lib/htm/long_term_memory/tag_operations.rb +428 -0
- data/lib/htm/long_term_memory/vector_search.rb +109 -0
- data/lib/htm/long_term_memory.rb +51 -1153
- data/lib/htm/models/node.rb +35 -2
- data/lib/htm/models/node_tag.rb +31 -0
- data/lib/htm/models/robot_node.rb +31 -0
- data/lib/htm/models/tag.rb +44 -0
- data/lib/htm/proposition_service.rb +169 -0
- data/lib/htm/query_cache.rb +214 -0
- data/lib/htm/sql_builder.rb +178 -0
- data/lib/htm/tag_service.rb +16 -6
- data/lib/htm/tasks.rb +8 -2
- data/lib/htm/telemetry.rb +224 -0
- data/lib/htm/version.rb +1 -1
- data/lib/htm.rb +64 -3
- data/lib/tasks/doc.rake +1 -1
- data/lib/tasks/htm.rake +259 -13
- data/mkdocs.yml +96 -96
- metadata +75 -18
- data/.aigcm_msg +0 -1
- data/.claude/settings.local.json +0 -92
- data/CLAUDE.md +0 -603
- data/examples/cli_app/temp.log +0 -93
- data/lib/htm/loaders/paragraph_chunker.rb +0 -112
- data/notes/ARCHITECTURE_REVIEW.md +0 -1167
- data/notes/IMPLEMENTATION_SUMMARY.md +0 -606
- data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +0 -451
- data/notes/next_steps.md +0 -100
- data/notes/plan.md +0 -627
- data/notes/tag_ontology_enhancement_ideas.md +0 -222
- data/notes/timescaledb_removal_summary.md +0 -200
data/bin/htm_mcp.rb
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
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: Search tags with fuzzy matching
|
|
399
|
+
class SearchTagsTool < FastMcp::Tool
|
|
400
|
+
description "Search for tags using fuzzy matching (typo-tolerant). Use this when you're unsure of exact tag names."
|
|
401
|
+
|
|
402
|
+
arguments do
|
|
403
|
+
required(:query).filled(:string).description("Search query - can contain typos (e.g., 'postgrsql' finds 'database:postgresql')")
|
|
404
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 20)")
|
|
405
|
+
optional(:min_similarity).filled(:float).description("Minimum similarity threshold 0.0-1.0 (default: 0.3, lower = more fuzzy)")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def call(query:, limit: 20, min_similarity: 0.3)
|
|
409
|
+
MCP_STDERR_LOG.info "SearchTagsTool called: query=#{query.inspect}, limit=#{limit}, min_similarity=#{min_similarity}"
|
|
410
|
+
|
|
411
|
+
htm = MCPSession.htm_instance
|
|
412
|
+
ltm = htm.instance_variable_get(:@long_term_memory)
|
|
413
|
+
|
|
414
|
+
results = ltm.search_tags(query, limit: limit, min_similarity: min_similarity)
|
|
415
|
+
|
|
416
|
+
# Enrich with node counts
|
|
417
|
+
tags = results.map do |result|
|
|
418
|
+
tag = HTM::Models::Tag.find_by(name: result[:name])
|
|
419
|
+
{
|
|
420
|
+
name: result[:name],
|
|
421
|
+
similarity: result[:similarity].round(3),
|
|
422
|
+
node_count: tag&.nodes&.count || 0
|
|
423
|
+
}
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
MCP_STDERR_LOG.info "SearchTagsTool complete: found #{tags.length} tags"
|
|
427
|
+
|
|
428
|
+
{
|
|
429
|
+
success: true,
|
|
430
|
+
query: query,
|
|
431
|
+
min_similarity: min_similarity,
|
|
432
|
+
count: tags.length,
|
|
433
|
+
tags: tags
|
|
434
|
+
}.to_json
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Tool: Find nodes by topic with fuzzy option
|
|
439
|
+
class FindByTopicTool < FastMcp::Tool
|
|
440
|
+
description "Find memory nodes by topic/tag with optional fuzzy matching for typo tolerance"
|
|
441
|
+
|
|
442
|
+
arguments do
|
|
443
|
+
required(:topic).filled(:string).description("Topic or tag to search for (e.g., 'database:postgresql' or 'postgrsql' with fuzzy)")
|
|
444
|
+
optional(:fuzzy).filled(:bool).description("Enable fuzzy matching for typo tolerance (default: false)")
|
|
445
|
+
optional(:exact).filled(:bool).description("Require exact tag match (default: false, uses prefix matching)")
|
|
446
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 20)")
|
|
447
|
+
optional(:min_similarity).filled(:float).description("Minimum similarity for fuzzy mode (default: 0.3)")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def call(topic:, fuzzy: false, exact: false, limit: 20, min_similarity: 0.3)
|
|
451
|
+
MCP_STDERR_LOG.info "FindByTopicTool called: topic=#{topic.inspect}, fuzzy=#{fuzzy}, exact=#{exact}"
|
|
452
|
+
|
|
453
|
+
htm = MCPSession.htm_instance
|
|
454
|
+
ltm = htm.instance_variable_get(:@long_term_memory)
|
|
455
|
+
|
|
456
|
+
nodes = ltm.nodes_by_topic(
|
|
457
|
+
topic,
|
|
458
|
+
fuzzy: fuzzy,
|
|
459
|
+
exact: exact,
|
|
460
|
+
min_similarity: min_similarity,
|
|
461
|
+
limit: limit
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Enrich with tags
|
|
465
|
+
results = nodes.map do |node_attrs|
|
|
466
|
+
node = HTM::Models::Node.includes(:tags).find_by(id: node_attrs['id'])
|
|
467
|
+
next unless node
|
|
468
|
+
|
|
469
|
+
{
|
|
470
|
+
id: node.id,
|
|
471
|
+
content: node.content[0..200],
|
|
472
|
+
tags: node.tags.map(&:name),
|
|
473
|
+
created_at: node.created_at.iso8601
|
|
474
|
+
}
|
|
475
|
+
end.compact
|
|
476
|
+
|
|
477
|
+
MCP_STDERR_LOG.info "FindByTopicTool complete: found #{results.length} nodes"
|
|
478
|
+
|
|
479
|
+
{
|
|
480
|
+
success: true,
|
|
481
|
+
topic: topic,
|
|
482
|
+
fuzzy: fuzzy,
|
|
483
|
+
exact: exact,
|
|
484
|
+
count: results.length,
|
|
485
|
+
results: results
|
|
486
|
+
}.to_json
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Tool: Get memory statistics
|
|
491
|
+
class StatsTool < FastMcp::Tool
|
|
492
|
+
description "Get statistics about HTM memory usage"
|
|
493
|
+
|
|
494
|
+
arguments do
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def call
|
|
498
|
+
htm = MCPSession.htm_instance
|
|
499
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
500
|
+
MCP_STDERR_LOG.info "StatsTool called for robot=#{htm.robot_name}"
|
|
501
|
+
|
|
502
|
+
# Note: Node uses default_scope to exclude deleted, so .count returns active nodes
|
|
503
|
+
total_nodes = HTM::Models::Node.count
|
|
504
|
+
deleted_nodes = HTM::Models::Node.deleted.count
|
|
505
|
+
nodes_with_embeddings = HTM::Models::Node.with_embeddings.count
|
|
506
|
+
nodes_with_tags = HTM::Models::Node.joins(:tags).distinct.count
|
|
507
|
+
total_tags = HTM::Models::Tag.count
|
|
508
|
+
total_robots = HTM::Models::Robot.count
|
|
509
|
+
|
|
510
|
+
MCP_STDERR_LOG.info "StatsTool complete: #{total_nodes} active nodes, #{total_tags} tags"
|
|
511
|
+
|
|
512
|
+
{
|
|
513
|
+
success: true,
|
|
514
|
+
current_robot: {
|
|
515
|
+
name: htm.robot_name,
|
|
516
|
+
id: htm.robot_id,
|
|
517
|
+
memory_summary: robot.memory_summary
|
|
518
|
+
},
|
|
519
|
+
statistics: {
|
|
520
|
+
nodes: {
|
|
521
|
+
active: total_nodes,
|
|
522
|
+
deleted: deleted_nodes,
|
|
523
|
+
with_embeddings: nodes_with_embeddings,
|
|
524
|
+
with_tags: nodes_with_tags
|
|
525
|
+
},
|
|
526
|
+
tags: {
|
|
527
|
+
total: total_tags
|
|
528
|
+
},
|
|
529
|
+
robots: {
|
|
530
|
+
total: total_robots
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}.to_json
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
MCP_STDERR_LOG.error "StatsTool error: #{e.message}"
|
|
536
|
+
{ success: false, error: e.message }.to_json
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Resource: Memory Statistics
|
|
541
|
+
class MemoryStatsResource < FastMcp::Resource
|
|
542
|
+
uri "htm://statistics"
|
|
543
|
+
resource_name "HTM Memory Statistics"
|
|
544
|
+
mime_type "application/json"
|
|
545
|
+
|
|
546
|
+
def content
|
|
547
|
+
htm = MCPSession.htm_instance
|
|
548
|
+
{
|
|
549
|
+
total_nodes: HTM::Models::Node.active.count,
|
|
550
|
+
total_tags: HTM::Models::Tag.count,
|
|
551
|
+
total_robots: HTM::Models::Robot.count,
|
|
552
|
+
current_robot: htm.robot_name,
|
|
553
|
+
robot_id: htm.robot_id,
|
|
554
|
+
robot_initialized: MCPSession.robot_initialized?,
|
|
555
|
+
embedding_provider: HTM.configuration.embedding_provider,
|
|
556
|
+
embedding_model: HTM.configuration.embedding_model
|
|
557
|
+
}.to_json
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Resource: Tag Hierarchy
|
|
562
|
+
class TagHierarchyResource < FastMcp::Resource
|
|
563
|
+
uri "htm://tags/hierarchy"
|
|
564
|
+
resource_name "HTM Tag Hierarchy"
|
|
565
|
+
mime_type "text/plain"
|
|
566
|
+
|
|
567
|
+
def content
|
|
568
|
+
HTM::Models::Tag.all.tree_string
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Resource: Recent Memories
|
|
573
|
+
class RecentMemoriesResource < FastMcp::Resource
|
|
574
|
+
uri "htm://memories/recent"
|
|
575
|
+
resource_name "Recent HTM Memories"
|
|
576
|
+
mime_type "application/json"
|
|
577
|
+
|
|
578
|
+
def content
|
|
579
|
+
recent = HTM::Models::Node.active
|
|
580
|
+
.includes(:tags)
|
|
581
|
+
.order(created_at: :desc)
|
|
582
|
+
.limit(20)
|
|
583
|
+
.map do |node|
|
|
584
|
+
{
|
|
585
|
+
id: node.id,
|
|
586
|
+
content: node.content[0..200],
|
|
587
|
+
tags: node.tags.map(&:name),
|
|
588
|
+
created_at: node.created_at.iso8601
|
|
589
|
+
}
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
{ recent_memories: recent }.to_json
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Create and configure the MCP server
|
|
597
|
+
server = FastMcp::Server.new(
|
|
598
|
+
name: 'htm-memory-server',
|
|
599
|
+
version: HTM::VERSION
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Register tools
|
|
603
|
+
server.register_tool(SetRobotTool) # Call first to set robot identity
|
|
604
|
+
server.register_tool(GetRobotTool) # Get current robot info
|
|
605
|
+
server.register_tool(GetWorkingMemoryTool) # Get working memory for session restore
|
|
606
|
+
server.register_tool(RememberTool)
|
|
607
|
+
server.register_tool(RecallTool)
|
|
608
|
+
server.register_tool(ForgetTool)
|
|
609
|
+
server.register_tool(RestoreTool)
|
|
610
|
+
server.register_tool(ListTagsTool)
|
|
611
|
+
server.register_tool(SearchTagsTool) # Fuzzy tag search with typo tolerance
|
|
612
|
+
server.register_tool(FindByTopicTool) # Find nodes by topic with fuzzy option
|
|
613
|
+
server.register_tool(StatsTool)
|
|
614
|
+
|
|
615
|
+
# Register resources
|
|
616
|
+
server.register_resource(MemoryStatsResource)
|
|
617
|
+
server.register_resource(TagHierarchyResource)
|
|
618
|
+
server.register_resource(RecentMemoriesResource)
|
|
619
|
+
|
|
620
|
+
# Start the server (STDIO transport by default)
|
|
621
|
+
server.start
|
data/config/database.yml
CHANGED
|
@@ -2,36 +2,46 @@
|
|
|
2
2
|
# Uses ERB to read from environment variables
|
|
3
3
|
#
|
|
4
4
|
# Priority:
|
|
5
|
-
# 1. HTM_DBURL - Full connection URL (preferred)
|
|
5
|
+
# 1. HTM_DBURL - Full connection URL (preferred for development/production)
|
|
6
6
|
# 2. Individual HTM_DB* variables - Host, name, user, password, port
|
|
7
7
|
# 3. Defaults for development/test
|
|
8
8
|
#
|
|
9
9
|
# Example HTM_DBURL format:
|
|
10
10
|
# postgresql://user:password@host:port/database?sslmode=require
|
|
11
|
+
#
|
|
12
|
+
# Test database:
|
|
13
|
+
# Tests always use htm_test database (Rails convention).
|
|
14
|
+
# Set RAILS_ENV=test or RACK_ENV=test to use the test configuration.
|
|
11
15
|
|
|
12
16
|
<%
|
|
13
17
|
require 'uri'
|
|
14
|
-
|
|
18
|
+
|
|
19
|
+
# Determine current environment
|
|
20
|
+
current_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
|
|
21
|
+
|
|
15
22
|
# Parse connection from HTM_DBURL or use individual variables
|
|
16
23
|
if ENV['HTM_DBURL']
|
|
17
24
|
uri = URI.parse(ENV['HTM_DBURL'])
|
|
18
25
|
params = URI.decode_www_form(uri.query || '').to_h
|
|
19
|
-
|
|
26
|
+
base_database = uri.path[1..-1]
|
|
27
|
+
|
|
28
|
+
# Extract base name (remove _development, _test, _production suffixes if present)
|
|
29
|
+
base_name = base_database.sub(/_(development|test|production)$/, '')
|
|
30
|
+
|
|
20
31
|
db_config = {
|
|
21
32
|
'host' => uri.host,
|
|
22
33
|
'port' => uri.port || 5432,
|
|
23
|
-
'
|
|
34
|
+
'base_name' => base_name,
|
|
24
35
|
'username' => uri.user,
|
|
25
36
|
'password' => uri.password,
|
|
26
37
|
'sslmode' => params['sslmode'] || 'prefer'
|
|
27
38
|
}
|
|
28
39
|
else
|
|
29
|
-
env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
|
|
30
40
|
db_config = {
|
|
31
41
|
'host' => ENV.fetch('HTM_DBHOST', 'localhost'),
|
|
32
42
|
'port' => ENV.fetch('HTM_DBPORT', 5432).to_i,
|
|
33
|
-
'
|
|
34
|
-
'username' => ENV.fetch('HTM_DBUSER', '
|
|
43
|
+
'base_name' => ENV.fetch('HTM_DBNAME', 'htm').sub(/_(development|test|production)$/, ''),
|
|
44
|
+
'username' => ENV.fetch('HTM_DBUSER', ENV['USER']),
|
|
35
45
|
'password' => ENV.fetch('HTM_DBPASS', ''),
|
|
36
46
|
'sslmode' => ENV.fetch('HTM_SSLMODE', 'prefer')
|
|
37
47
|
}
|
|
@@ -53,15 +63,12 @@ default: &default
|
|
|
53
63
|
|
|
54
64
|
development:
|
|
55
65
|
<<: *default
|
|
56
|
-
database: <%= db_config['
|
|
66
|
+
database: <%= db_config['base_name'] %>_development
|
|
57
67
|
|
|
58
68
|
test:
|
|
59
69
|
<<: *default
|
|
60
|
-
database: <%= db_config['
|
|
70
|
+
database: <%= db_config['base_name'] %>_test
|
|
61
71
|
|
|
62
72
|
production:
|
|
63
73
|
<<: *default
|
|
64
|
-
database: <%= db_config['
|
|
65
|
-
<% unless ENV['HTM_DBURL'] %>
|
|
66
|
-
# WARNING: Production should use HTM_DBURL with SSL
|
|
67
|
-
<% end %>
|
|
74
|
+
database: <%= db_config['base_name'] %>_production
|