htm 0.0.15 → 0.0.18
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/.architecture/decisions/adrs/001-use-postgresql-timescaledb-storage.md +1 -1
- data/.architecture/decisions/adrs/011-database-side-embedding-generation-with-pgai.md +4 -4
- data/.architecture/decisions/adrs/012-llm-driven-ontology-topic-extraction.md +1 -1
- data/.envrc +12 -24
- data/.irbrc +7 -7
- data/.tbls.yml +2 -2
- data/CHANGELOG.md +138 -0
- data/README.md +97 -1592
- data/Rakefile +8 -3
- data/SETUP.md +12 -12
- data/bin/htm_mcp +27 -0
- data/db/seed_data/README.md +2 -2
- data/db/seeds.rb +2 -2
- data/docs/api/database.md +37 -37
- data/docs/api/htm.md +1 -1
- data/docs/api/yard/HTM/ActiveRecordConfig.md +2 -2
- data/docs/api/yard/HTM/Configuration.md +26 -15
- data/docs/api/yard/HTM/Database.md +7 -8
- data/docs/api/yard/HTM/JobAdapter.md +1 -1
- data/docs/api/yard/HTM/Railtie.md +2 -2
- data/docs/architecture/adrs/001-postgresql-timescaledb.md +1 -1
- data/docs/architecture/adrs/011-pgai-integration.md +4 -4
- data/docs/database_rake_tasks.md +5 -5
- data/docs/development/rake-tasks.md +11 -11
- data/docs/development/setup.md +21 -21
- data/docs/development/testing.md +1 -1
- data/docs/getting-started/installation.md +51 -31
- data/docs/getting-started/quick-start.md +12 -12
- data/docs/guides/getting-started.md +2 -2
- data/docs/guides/long-term-memory.md +1 -1
- data/docs/guides/mcp-server.md +464 -29
- data/docs/guides/robot-groups.md +8 -8
- data/docs/index.md +4 -4
- data/docs/multi_framework_support.md +10 -10
- data/docs/setup_local_database.md +19 -19
- data/docs/using_rake_tasks_in_your_app.md +14 -14
- data/examples/README.md +50 -6
- data/examples/basic_usage.rb +31 -21
- data/examples/cli_app/README.md +8 -8
- data/examples/cli_app/htm_cli.rb +5 -5
- data/examples/config_file_example/README.md +256 -0
- data/examples/config_file_example/config/htm.local.yml +34 -0
- data/examples/config_file_example/custom_config.yml +22 -0
- data/examples/config_file_example/show_config.rb +125 -0
- data/examples/custom_llm_configuration.rb +7 -7
- data/examples/example_app/Rakefile +2 -2
- data/examples/example_app/app.rb +8 -8
- data/examples/file_loader_usage.rb +9 -9
- data/examples/mcp_client.rb +7 -7
- data/examples/rails_app/.gitignore +2 -0
- data/examples/rails_app/Gemfile +22 -0
- data/examples/rails_app/Gemfile.lock +430 -0
- data/examples/rails_app/Procfile.dev +1 -0
- data/examples/rails_app/README.md +98 -0
- data/examples/rails_app/Rakefile +5 -0
- data/examples/rails_app/app/assets/stylesheets/application.css +83 -0
- data/examples/rails_app/app/assets/stylesheets/inter-font.css +6 -0
- data/examples/rails_app/app/controllers/application_controller.rb +19 -0
- data/examples/rails_app/app/controllers/dashboard_controller.rb +27 -0
- data/examples/rails_app/app/controllers/files_controller.rb +205 -0
- data/examples/rails_app/app/controllers/memories_controller.rb +102 -0
- data/examples/rails_app/app/controllers/robots_controller.rb +44 -0
- data/examples/rails_app/app/controllers/search_controller.rb +46 -0
- data/examples/rails_app/app/controllers/tags_controller.rb +30 -0
- data/examples/rails_app/app/javascript/application.js +4 -0
- data/examples/rails_app/app/javascript/controllers/application.js +9 -0
- data/examples/rails_app/app/javascript/controllers/index.js +6 -0
- data/examples/rails_app/app/views/dashboard/index.html.erb +123 -0
- data/examples/rails_app/app/views/files/index.html.erb +108 -0
- data/examples/rails_app/app/views/files/new.html.erb +321 -0
- data/examples/rails_app/app/views/files/show.html.erb +130 -0
- data/examples/rails_app/app/views/layouts/application.html.erb +124 -0
- data/examples/rails_app/app/views/memories/_memory_card.html.erb +51 -0
- data/examples/rails_app/app/views/memories/deleted.html.erb +62 -0
- data/examples/rails_app/app/views/memories/edit.html.erb +35 -0
- data/examples/rails_app/app/views/memories/index.html.erb +81 -0
- data/examples/rails_app/app/views/memories/new.html.erb +71 -0
- data/examples/rails_app/app/views/memories/show.html.erb +126 -0
- data/examples/rails_app/app/views/robots/index.html.erb +106 -0
- data/examples/rails_app/app/views/robots/new.html.erb +36 -0
- data/examples/rails_app/app/views/robots/show.html.erb +79 -0
- data/examples/rails_app/app/views/search/index.html.erb +184 -0
- data/examples/rails_app/app/views/shared/_navbar.html.erb +52 -0
- data/examples/rails_app/app/views/shared/_stat_card.html.erb +52 -0
- data/examples/rails_app/app/views/tags/index.html.erb +131 -0
- data/examples/rails_app/app/views/tags/show.html.erb +67 -0
- data/examples/rails_app/bin/dev +8 -0
- data/examples/rails_app/bin/rails +4 -0
- data/examples/rails_app/bin/rake +4 -0
- data/examples/rails_app/config/application.rb +33 -0
- data/examples/rails_app/config/boot.rb +5 -0
- data/examples/rails_app/config/database.yml +15 -0
- data/examples/rails_app/config/environment.rb +5 -0
- data/examples/rails_app/config/importmap.rb +7 -0
- data/examples/rails_app/config/routes.rb +38 -0
- data/examples/rails_app/config/tailwind.config.js +35 -0
- data/examples/rails_app/config.ru +5 -0
- data/examples/rails_app/log/.keep +0 -0
- data/examples/rails_app/tmp/local_secret.txt +1 -0
- data/examples/robot_groups/multi_process.rb +5 -5
- data/examples/robot_groups/robot_worker.rb +5 -5
- data/examples/robot_groups/same_process.rb +9 -9
- data/examples/sinatra_app/app.rb +1 -1
- data/examples/timeframe_demo.rb +1 -1
- data/lib/htm/active_record_config.rb +12 -28
- data/lib/htm/circuit_breaker.rb +0 -2
- data/lib/htm/config/defaults.yml +246 -0
- data/lib/htm/config.rb +888 -0
- data/lib/htm/database.rb +26 -33
- data/lib/htm/embedding_service.rb +0 -4
- data/lib/htm/integrations/sinatra.rb +3 -7
- data/lib/htm/job_adapter.rb +1 -15
- data/lib/htm/jobs/generate_embedding_job.rb +1 -7
- data/lib/htm/jobs/generate_propositions_job.rb +2 -12
- data/lib/htm/jobs/generate_tags_job.rb +1 -8
- data/lib/htm/loaders/defaults_loader.rb +143 -0
- data/lib/htm/loaders/xdg_config_loader.rb +116 -0
- data/lib/htm/mcp/cli.rb +475 -0
- data/lib/htm/mcp/group_tools.rb +476 -0
- data/lib/htm/mcp/resources.rb +89 -0
- data/lib/htm/mcp/server.rb +98 -0
- data/lib/htm/mcp/tools.rb +488 -0
- data/lib/htm/models/file_source.rb +5 -3
- data/lib/htm/proposition_service.rb +2 -12
- data/lib/htm/railtie.rb +3 -8
- data/lib/htm/tag_service.rb +1 -8
- data/lib/htm/tasks.rb +7 -4
- data/lib/htm/version.rb +1 -1
- data/lib/htm.rb +124 -5
- data/lib/tasks/htm.rake +6 -9
- metadata +81 -6
- data/bin/htm_mcp.rb +0 -621
- data/config/database.yml +0 -74
- data/lib/htm/configuration.rb +0 -766
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fast_mcp'
|
|
4
|
+
|
|
5
|
+
class HTM
|
|
6
|
+
module MCP
|
|
7
|
+
# Session state for the current robot
|
|
8
|
+
# Each MCP client spawns its own server process, so this is naturally isolated
|
|
9
|
+
module Session
|
|
10
|
+
DEFAULT_ROBOT_NAME = "mcp_default"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :logger
|
|
14
|
+
|
|
15
|
+
def htm_instance
|
|
16
|
+
@htm_instance ||= HTM.new(robot_name: DEFAULT_ROBOT_NAME)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_robot(name)
|
|
20
|
+
@robot_name = name
|
|
21
|
+
@htm_instance = HTM.new(robot_name: name)
|
|
22
|
+
logger&.info "Robot set: #{name} (id=#{@htm_instance.robot_id})"
|
|
23
|
+
@htm_instance
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def robot_name
|
|
27
|
+
@robot_name || DEFAULT_ROBOT_NAME
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def robot_initialized?
|
|
31
|
+
@robot_name != nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Tool: Set the robot identity for this session
|
|
37
|
+
class SetRobotTool < FastMcp::Tool
|
|
38
|
+
description "Set the robot identity for this session. Call this first to establish your robot name."
|
|
39
|
+
|
|
40
|
+
arguments do
|
|
41
|
+
required(:name).filled(:string).description("The robot name (will be created if it doesn't exist)")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(name:)
|
|
45
|
+
Session.logger&.info "SetRobotTool called: name=#{name.inspect}"
|
|
46
|
+
|
|
47
|
+
htm = Session.set_robot(name)
|
|
48
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
success: true,
|
|
52
|
+
robot_id: htm.robot_id,
|
|
53
|
+
robot_name: htm.robot_name,
|
|
54
|
+
node_count: robot.node_count,
|
|
55
|
+
message: "Robot '#{name}' is now active for this session"
|
|
56
|
+
}.to_json
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Tool: Get current robot info
|
|
61
|
+
class GetRobotTool < FastMcp::Tool
|
|
62
|
+
description "Get information about the current robot for this session"
|
|
63
|
+
|
|
64
|
+
arguments do
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call
|
|
68
|
+
Session.logger&.info "GetRobotTool called"
|
|
69
|
+
|
|
70
|
+
htm = Session.htm_instance
|
|
71
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
success: true,
|
|
75
|
+
robot_id: htm.robot_id,
|
|
76
|
+
robot_name: htm.robot_name,
|
|
77
|
+
initialized: Session.robot_initialized?,
|
|
78
|
+
memory_summary: robot.memory_summary
|
|
79
|
+
}.to_json
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Tool: Get working memory contents for session restore
|
|
84
|
+
class GetWorkingMemoryTool < FastMcp::Tool
|
|
85
|
+
description "Get all working memory contents for the current robot. Use this to restore a previous session."
|
|
86
|
+
|
|
87
|
+
arguments do
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def call
|
|
91
|
+
htm = Session.htm_instance
|
|
92
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
93
|
+
Session.logger&.info "GetWorkingMemoryTool called for robot=#{htm.robot_name}"
|
|
94
|
+
|
|
95
|
+
# Get all nodes in working memory with their metadata
|
|
96
|
+
# Filter out any robot_nodes where the node has been deleted (node uses default_scope)
|
|
97
|
+
working_memory_nodes = robot.robot_nodes
|
|
98
|
+
.in_working_memory
|
|
99
|
+
.joins(:node) # Inner join excludes deleted nodes
|
|
100
|
+
.includes(node: :tags)
|
|
101
|
+
.order(last_remembered_at: :desc)
|
|
102
|
+
.filter_map do |rn|
|
|
103
|
+
next unless rn.node # Extra safety check
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
id: rn.node.id,
|
|
107
|
+
content: rn.node.content,
|
|
108
|
+
tags: rn.node.tags.map(&:name),
|
|
109
|
+
remember_count: rn.remember_count,
|
|
110
|
+
last_remembered_at: rn.last_remembered_at&.iso8601,
|
|
111
|
+
created_at: rn.node.created_at.iso8601
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
Session.logger&.info "GetWorkingMemoryTool complete: #{working_memory_nodes.length} nodes in working memory"
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
success: true,
|
|
119
|
+
robot_id: htm.robot_id,
|
|
120
|
+
robot_name: htm.robot_name,
|
|
121
|
+
count: working_memory_nodes.length,
|
|
122
|
+
working_memory: working_memory_nodes
|
|
123
|
+
}.to_json
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
Session.logger&.error "GetWorkingMemoryTool error: #{e.message}"
|
|
126
|
+
{ success: false, error: e.message, count: 0, working_memory: [] }.to_json
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Tool: Remember information
|
|
131
|
+
class RememberTool < FastMcp::Tool
|
|
132
|
+
description "Store information in HTM long-term memory with optional tags"
|
|
133
|
+
|
|
134
|
+
arguments do
|
|
135
|
+
required(:content).filled(:string).description("The content to remember")
|
|
136
|
+
optional(:tags).array(:string).description("Optional tags for categorization (e.g., ['database:postgresql', 'config'])")
|
|
137
|
+
optional(:metadata).hash.description("Optional metadata key-value pairs")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def call(content:, tags: [], metadata: {})
|
|
141
|
+
Session.logger&.info "RememberTool called: content=#{content[0..50].inspect}..."
|
|
142
|
+
|
|
143
|
+
htm = Session.htm_instance
|
|
144
|
+
node_id = htm.remember(content, tags: tags, metadata: metadata)
|
|
145
|
+
node = HTM::Models::Node.includes(:tags).find(node_id)
|
|
146
|
+
|
|
147
|
+
Session.logger&.info "Memory stored: node_id=#{node_id}, robot=#{htm.robot_name}, tags=#{node.tags.map(&:name)}"
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
success: true,
|
|
151
|
+
node_id: node_id,
|
|
152
|
+
robot_id: htm.robot_id,
|
|
153
|
+
robot_name: htm.robot_name,
|
|
154
|
+
content: node.content,
|
|
155
|
+
tags: node.tags.map(&:name),
|
|
156
|
+
created_at: node.created_at.iso8601
|
|
157
|
+
}.to_json
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Tool: Recall memories
|
|
162
|
+
class RecallTool < FastMcp::Tool
|
|
163
|
+
description "Search and retrieve memories from HTM using semantic, full-text, or hybrid search"
|
|
164
|
+
|
|
165
|
+
arguments do
|
|
166
|
+
required(:query).filled(:string).description("Search query - can be natural language or keywords")
|
|
167
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 10)")
|
|
168
|
+
optional(:strategy).filled(:string).description("Search strategy: 'vector', 'fulltext', or 'hybrid' (default: 'hybrid')")
|
|
169
|
+
optional(:timeframe).filled(:string).description("Filter by time: 'today', 'this week', 'this month', or ISO8601 date range")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def call(query:, limit: 10, strategy: 'hybrid', timeframe: nil)
|
|
173
|
+
htm = Session.htm_instance
|
|
174
|
+
Session.logger&.info "RecallTool called: query=#{query.inspect}, strategy=#{strategy}, limit=#{limit}, robot=#{htm.robot_name}"
|
|
175
|
+
|
|
176
|
+
recall_opts = {
|
|
177
|
+
limit: limit,
|
|
178
|
+
strategy: strategy.to_sym,
|
|
179
|
+
raw: true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Parse timeframe if provided
|
|
183
|
+
if timeframe
|
|
184
|
+
recall_opts[:timeframe] = parse_timeframe(timeframe)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
memories = htm.recall(query, **recall_opts)
|
|
188
|
+
|
|
189
|
+
results = memories.map do |memory|
|
|
190
|
+
node = HTM::Models::Node.includes(:tags).find(memory['id'])
|
|
191
|
+
{
|
|
192
|
+
id: memory['id'],
|
|
193
|
+
content: memory['content'],
|
|
194
|
+
tags: node.tags.map(&:name),
|
|
195
|
+
created_at: memory['created_at'],
|
|
196
|
+
score: memory['combined_score'] || memory['similarity']
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Session.logger&.info "Recall complete: found #{results.length} memories"
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
success: true,
|
|
204
|
+
query: query,
|
|
205
|
+
strategy: strategy,
|
|
206
|
+
robot_name: htm.robot_name,
|
|
207
|
+
count: results.length,
|
|
208
|
+
results: results
|
|
209
|
+
}.to_json
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def parse_timeframe(timeframe)
|
|
215
|
+
case timeframe.downcase
|
|
216
|
+
when 'today'
|
|
217
|
+
Time.now.beginning_of_day..Time.now
|
|
218
|
+
when 'this week'
|
|
219
|
+
1.week.ago..Time.now
|
|
220
|
+
when 'this month'
|
|
221
|
+
1.month.ago..Time.now
|
|
222
|
+
else
|
|
223
|
+
# Try to parse as ISO8601 range (start..end)
|
|
224
|
+
if timeframe.include?('..')
|
|
225
|
+
parts = timeframe.split('..')
|
|
226
|
+
Time.parse(parts[0])..Time.parse(parts[1])
|
|
227
|
+
else
|
|
228
|
+
# Single date - from that date to now
|
|
229
|
+
Time.parse(timeframe)..Time.now
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
rescue ArgumentError
|
|
233
|
+
nil # Invalid timeframe, skip filtering
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Tool: Forget a memory
|
|
238
|
+
class ForgetTool < FastMcp::Tool
|
|
239
|
+
description "Soft-delete a memory from HTM (can be restored later)"
|
|
240
|
+
|
|
241
|
+
arguments do
|
|
242
|
+
required(:node_id).filled(:integer).description("The ID of the node to forget")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def call(node_id:)
|
|
246
|
+
htm = Session.htm_instance
|
|
247
|
+
Session.logger&.info "ForgetTool called: node_id=#{node_id}, robot=#{htm.robot_name}"
|
|
248
|
+
|
|
249
|
+
htm.forget(node_id)
|
|
250
|
+
|
|
251
|
+
Session.logger&.info "Memory soft-deleted: node_id=#{node_id}"
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
success: true,
|
|
255
|
+
node_id: node_id,
|
|
256
|
+
robot_name: htm.robot_name,
|
|
257
|
+
message: "Memory soft-deleted. Use restore to recover."
|
|
258
|
+
}.to_json
|
|
259
|
+
rescue HTM::NotFoundError, ActiveRecord::RecordNotFound
|
|
260
|
+
Session.logger&.warn "ForgetTool failed: node #{node_id} not found"
|
|
261
|
+
{
|
|
262
|
+
success: false,
|
|
263
|
+
error: "Node #{node_id} not found"
|
|
264
|
+
}.to_json
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Tool: Restore a forgotten memory
|
|
269
|
+
class RestoreTool < FastMcp::Tool
|
|
270
|
+
description "Restore a soft-deleted memory"
|
|
271
|
+
|
|
272
|
+
arguments do
|
|
273
|
+
required(:node_id).filled(:integer).description("The ID of the node to restore")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def call(node_id:)
|
|
277
|
+
htm = Session.htm_instance
|
|
278
|
+
Session.logger&.info "RestoreTool called: node_id=#{node_id}, robot=#{htm.robot_name}"
|
|
279
|
+
|
|
280
|
+
htm.restore(node_id)
|
|
281
|
+
|
|
282
|
+
Session.logger&.info "Memory restored: node_id=#{node_id}"
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
success: true,
|
|
286
|
+
node_id: node_id,
|
|
287
|
+
robot_name: htm.robot_name,
|
|
288
|
+
message: "Memory restored successfully"
|
|
289
|
+
}.to_json
|
|
290
|
+
rescue HTM::NotFoundError, ActiveRecord::RecordNotFound
|
|
291
|
+
Session.logger&.warn "RestoreTool failed: node #{node_id} not found"
|
|
292
|
+
{
|
|
293
|
+
success: false,
|
|
294
|
+
error: "Node #{node_id} not found"
|
|
295
|
+
}.to_json
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Tool: List tags
|
|
300
|
+
class ListTagsTool < FastMcp::Tool
|
|
301
|
+
description "List all tags in HTM, optionally filtered by prefix"
|
|
302
|
+
|
|
303
|
+
arguments do
|
|
304
|
+
optional(:prefix).filled(:string).description("Filter tags by prefix (e.g., 'database' returns 'database:postgresql', etc.)")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def call(prefix: nil)
|
|
308
|
+
Session.logger&.info "ListTagsTool called: prefix=#{prefix.inspect}"
|
|
309
|
+
|
|
310
|
+
tags_query = HTM::Models::Tag.order(:name)
|
|
311
|
+
tags_query = tags_query.where("name LIKE ?", "#{prefix}%") if prefix
|
|
312
|
+
|
|
313
|
+
tags = tags_query.map do |tag|
|
|
314
|
+
{
|
|
315
|
+
name: tag.name,
|
|
316
|
+
node_count: tag.nodes.count
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
Session.logger&.info "ListTagsTool complete: found #{tags.length} tags"
|
|
321
|
+
|
|
322
|
+
{
|
|
323
|
+
success: true,
|
|
324
|
+
prefix: prefix,
|
|
325
|
+
count: tags.length,
|
|
326
|
+
tags: tags
|
|
327
|
+
}.to_json
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Tool: Search tags with fuzzy matching
|
|
332
|
+
class SearchTagsTool < FastMcp::Tool
|
|
333
|
+
description "Search for tags using fuzzy matching (typo-tolerant). Use this when you're unsure of exact tag names."
|
|
334
|
+
|
|
335
|
+
arguments do
|
|
336
|
+
required(:query).filled(:string).description("Search query - can contain typos (e.g., 'postgrsql' finds 'database:postgresql')")
|
|
337
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 20)")
|
|
338
|
+
optional(:min_similarity).filled(:float).description("Minimum similarity threshold 0.0-1.0 (default: 0.3, lower = more fuzzy)")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def call(query:, limit: 20, min_similarity: 0.3)
|
|
342
|
+
Session.logger&.info "SearchTagsTool called: query=#{query.inspect}, limit=#{limit}, min_similarity=#{min_similarity}"
|
|
343
|
+
|
|
344
|
+
htm = Session.htm_instance
|
|
345
|
+
ltm = htm.instance_variable_get(:@long_term_memory)
|
|
346
|
+
|
|
347
|
+
results = ltm.search_tags(query, limit: limit, min_similarity: min_similarity)
|
|
348
|
+
|
|
349
|
+
# Enrich with node counts
|
|
350
|
+
tags = results.map do |result|
|
|
351
|
+
tag = HTM::Models::Tag.find_by(name: result[:name])
|
|
352
|
+
{
|
|
353
|
+
name: result[:name],
|
|
354
|
+
similarity: result[:similarity].round(3),
|
|
355
|
+
node_count: tag&.nodes&.count || 0
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
Session.logger&.info "SearchTagsTool complete: found #{tags.length} tags"
|
|
360
|
+
|
|
361
|
+
{
|
|
362
|
+
success: true,
|
|
363
|
+
query: query,
|
|
364
|
+
min_similarity: min_similarity,
|
|
365
|
+
count: tags.length,
|
|
366
|
+
tags: tags
|
|
367
|
+
}.to_json
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Tool: Find nodes by topic with fuzzy option
|
|
372
|
+
class FindByTopicTool < FastMcp::Tool
|
|
373
|
+
description "Find memory nodes by topic/tag with optional fuzzy matching for typo tolerance"
|
|
374
|
+
|
|
375
|
+
arguments do
|
|
376
|
+
required(:topic).filled(:string).description("Topic or tag to search for (e.g., 'database:postgresql' or 'postgrsql' with fuzzy)")
|
|
377
|
+
optional(:fuzzy).filled(:bool).description("Enable fuzzy matching for typo tolerance (default: false)")
|
|
378
|
+
optional(:exact).filled(:bool).description("Require exact tag match (default: false, uses prefix matching)")
|
|
379
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 20)")
|
|
380
|
+
optional(:min_similarity).filled(:float).description("Minimum similarity for fuzzy mode (default: 0.3)")
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def call(topic:, fuzzy: false, exact: false, limit: 20, min_similarity: 0.3)
|
|
384
|
+
Session.logger&.info "FindByTopicTool called: topic=#{topic.inspect}, fuzzy=#{fuzzy}, exact=#{exact}"
|
|
385
|
+
|
|
386
|
+
htm = Session.htm_instance
|
|
387
|
+
ltm = htm.instance_variable_get(:@long_term_memory)
|
|
388
|
+
|
|
389
|
+
nodes = ltm.nodes_by_topic(
|
|
390
|
+
topic,
|
|
391
|
+
fuzzy: fuzzy,
|
|
392
|
+
exact: exact,
|
|
393
|
+
min_similarity: min_similarity,
|
|
394
|
+
limit: limit
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Enrich with tags
|
|
398
|
+
results = nodes.map do |node_attrs|
|
|
399
|
+
node = HTM::Models::Node.includes(:tags).find_by(id: node_attrs['id'])
|
|
400
|
+
next unless node
|
|
401
|
+
|
|
402
|
+
{
|
|
403
|
+
id: node.id,
|
|
404
|
+
content: node.content[0..200],
|
|
405
|
+
tags: node.tags.map(&:name),
|
|
406
|
+
created_at: node.created_at.iso8601
|
|
407
|
+
}
|
|
408
|
+
end.compact
|
|
409
|
+
|
|
410
|
+
Session.logger&.info "FindByTopicTool complete: found #{results.length} nodes"
|
|
411
|
+
|
|
412
|
+
{
|
|
413
|
+
success: true,
|
|
414
|
+
topic: topic,
|
|
415
|
+
fuzzy: fuzzy,
|
|
416
|
+
exact: exact,
|
|
417
|
+
count: results.length,
|
|
418
|
+
results: results
|
|
419
|
+
}.to_json
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Tool: Get memory statistics
|
|
424
|
+
class StatsTool < FastMcp::Tool
|
|
425
|
+
description "Get statistics about HTM memory usage"
|
|
426
|
+
|
|
427
|
+
arguments do
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def call
|
|
431
|
+
htm = Session.htm_instance
|
|
432
|
+
robot = HTM::Models::Robot.find(htm.robot_id)
|
|
433
|
+
Session.logger&.info "StatsTool called for robot=#{htm.robot_name}"
|
|
434
|
+
|
|
435
|
+
# Note: Node uses default_scope to exclude deleted, so .count returns active nodes
|
|
436
|
+
total_nodes = HTM::Models::Node.count
|
|
437
|
+
deleted_nodes = HTM::Models::Node.deleted.count
|
|
438
|
+
nodes_with_embeddings = HTM::Models::Node.with_embeddings.count
|
|
439
|
+
nodes_with_tags = HTM::Models::Node.joins(:tags).distinct.count
|
|
440
|
+
total_tags = HTM::Models::Tag.count
|
|
441
|
+
total_robots = HTM::Models::Robot.count
|
|
442
|
+
|
|
443
|
+
Session.logger&.info "StatsTool complete: #{total_nodes} active nodes, #{total_tags} tags"
|
|
444
|
+
|
|
445
|
+
{
|
|
446
|
+
success: true,
|
|
447
|
+
current_robot: {
|
|
448
|
+
name: htm.robot_name,
|
|
449
|
+
id: htm.robot_id,
|
|
450
|
+
memory_summary: robot.memory_summary
|
|
451
|
+
},
|
|
452
|
+
statistics: {
|
|
453
|
+
nodes: {
|
|
454
|
+
active: total_nodes,
|
|
455
|
+
deleted: deleted_nodes,
|
|
456
|
+
with_embeddings: nodes_with_embeddings,
|
|
457
|
+
with_tags: nodes_with_tags
|
|
458
|
+
},
|
|
459
|
+
tags: {
|
|
460
|
+
total: total_tags
|
|
461
|
+
},
|
|
462
|
+
robots: {
|
|
463
|
+
total: total_robots
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}.to_json
|
|
467
|
+
rescue StandardError => e
|
|
468
|
+
Session.logger&.error "StatsTool error: #{e.message}"
|
|
469
|
+
{ success: false, error: e.message }.to_json
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# All individual tools for registration
|
|
474
|
+
TOOLS = [
|
|
475
|
+
SetRobotTool,
|
|
476
|
+
GetRobotTool,
|
|
477
|
+
GetWorkingMemoryTool,
|
|
478
|
+
RememberTool,
|
|
479
|
+
RecallTool,
|
|
480
|
+
ForgetTool,
|
|
481
|
+
RestoreTool,
|
|
482
|
+
ListTagsTool,
|
|
483
|
+
SearchTagsTool,
|
|
484
|
+
FindByTopicTool,
|
|
485
|
+
StatsTool
|
|
486
|
+
].freeze
|
|
487
|
+
end
|
|
488
|
+
end
|
|
@@ -41,12 +41,14 @@ class HTM
|
|
|
41
41
|
# - Floating-point rounding errors
|
|
42
42
|
# - Minor timestamp discrepancies across systems
|
|
43
43
|
#
|
|
44
|
-
# @param current_mtime [Time] Current file modification time
|
|
45
|
-
# @return [Boolean] true if file modification time differs by more than DELTA_TIME
|
|
44
|
+
# @param current_mtime [Time, nil] Current file modification time (defaults to reading from filesystem)
|
|
45
|
+
# @return [Boolean] true if file modification time differs by more than DELTA_TIME, or file doesn't exist
|
|
46
46
|
#
|
|
47
|
-
def needs_sync?(current_mtime)
|
|
47
|
+
def needs_sync?(current_mtime = nil)
|
|
48
48
|
return true if mtime.nil?
|
|
49
|
+
return true unless File.exist?(file_path)
|
|
49
50
|
|
|
51
|
+
current_mtime ||= File.mtime(file_path)
|
|
50
52
|
(current_mtime.to_i - mtime.to_i).abs > DELTA_TIME
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -67,8 +67,6 @@ class HTM
|
|
|
67
67
|
# @raise [PropositionError] If extraction fails
|
|
68
68
|
#
|
|
69
69
|
def self.extract(content)
|
|
70
|
-
HTM.logger.debug "PropositionService: Extracting propositions from #{content.length} chars"
|
|
71
|
-
|
|
72
70
|
# Use circuit breaker to protect against cascading failures
|
|
73
71
|
raw_propositions = circuit_breaker.call do
|
|
74
72
|
HTM.configuration.proposition_extractor.call(content)
|
|
@@ -78,11 +76,7 @@ class HTM
|
|
|
78
76
|
parsed_propositions = parse_propositions(raw_propositions)
|
|
79
77
|
|
|
80
78
|
# Validate and filter propositions
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
HTM.logger.debug "PropositionService: Extracted #{valid_propositions.length} valid propositions"
|
|
84
|
-
|
|
85
|
-
valid_propositions
|
|
79
|
+
validate_and_filter_propositions(parsed_propositions)
|
|
86
80
|
|
|
87
81
|
rescue HTM::CircuitBreakerOpenError
|
|
88
82
|
# Re-raise circuit breaker errors without wrapping
|
|
@@ -128,10 +122,7 @@ class HTM
|
|
|
128
122
|
|
|
129
123
|
propositions.each do |proposition|
|
|
130
124
|
# Check minimum length
|
|
131
|
-
if proposition.length < MIN_PROPOSITION_LENGTH
|
|
132
|
-
HTM.logger.debug "PropositionService: Proposition too short, skipping: #{proposition}"
|
|
133
|
-
next
|
|
134
|
-
end
|
|
125
|
+
next if proposition.length < MIN_PROPOSITION_LENGTH
|
|
135
126
|
|
|
136
127
|
# Check maximum length
|
|
137
128
|
if proposition.length > MAX_PROPOSITION_LENGTH
|
|
@@ -141,7 +132,6 @@ class HTM
|
|
|
141
132
|
|
|
142
133
|
# Check for actual content (not just punctuation/whitespace)
|
|
143
134
|
unless proposition.match?(/[a-zA-Z]{3,}/)
|
|
144
|
-
HTM.logger.debug "PropositionService: Proposition lacks content, skipping: #{proposition}"
|
|
145
135
|
next
|
|
146
136
|
end
|
|
147
137
|
|
data/lib/htm/railtie.rb
CHANGED
|
@@ -32,14 +32,13 @@ class HTM
|
|
|
32
32
|
config.logger = Rails.logger
|
|
33
33
|
|
|
34
34
|
# Use ActiveJob for background jobs in Rails
|
|
35
|
-
config.
|
|
35
|
+
config.job.backend = :active_job unless Rails.env.test?
|
|
36
36
|
|
|
37
37
|
# Use inline execution in test environment for synchronous behavior
|
|
38
|
-
config.
|
|
38
|
+
config.job.backend = :inline if Rails.env.test?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
HTM.logger.info "HTM initialized for Rails application"
|
|
42
|
-
HTM.logger.debug "HTM job backend: #{HTM.configuration.job_backend}"
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
# Load Rake tasks
|
|
@@ -63,14 +62,10 @@ class HTM
|
|
|
63
62
|
HTM.logger.info "HTM database connection verified"
|
|
64
63
|
rescue StandardError => e
|
|
65
64
|
HTM.logger.warn "HTM database connection check failed: #{e.message}"
|
|
66
|
-
HTM.logger.warn "Set
|
|
65
|
+
HTM.logger.warn "Set HTM_DATABASE__URL environment variable"
|
|
67
66
|
end
|
|
68
67
|
end
|
|
69
68
|
end
|
|
70
69
|
|
|
71
|
-
# Add generators path
|
|
72
|
-
config.generators do |g|
|
|
73
|
-
g.templates.unshift File.expand_path('../generators/templates', __dir__)
|
|
74
|
-
end
|
|
75
70
|
end
|
|
76
71
|
end
|
data/lib/htm/tag_service.rb
CHANGED
|
@@ -65,9 +65,6 @@ class HTM
|
|
|
65
65
|
# @raise [CircuitBreakerOpenError] If circuit breaker is open
|
|
66
66
|
#
|
|
67
67
|
def self.extract(content, existing_ontology: [])
|
|
68
|
-
HTM.logger.debug "TagService: Extracting tags from #{content.length} chars"
|
|
69
|
-
HTM.logger.debug "TagService: Using ontology with #{existing_ontology.size} existing tags"
|
|
70
|
-
|
|
71
68
|
# Use circuit breaker to protect against cascading failures
|
|
72
69
|
raw_tags = circuit_breaker.call do
|
|
73
70
|
HTM.configuration.tag_extractor.call(content, existing_ontology)
|
|
@@ -77,11 +74,7 @@ class HTM
|
|
|
77
74
|
parsed_tags = parse_tags(raw_tags)
|
|
78
75
|
|
|
79
76
|
# Validate and filter tags
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
HTM.logger.debug "TagService: Extracted #{valid_tags.length} valid tags: #{valid_tags.join(', ')}"
|
|
83
|
-
|
|
84
|
-
valid_tags
|
|
77
|
+
validate_and_filter_tags(parsed_tags)
|
|
85
78
|
|
|
86
79
|
rescue HTM::CircuitBreakerOpenError
|
|
87
80
|
# Re-raise circuit breaker errors without wrapping
|
data/lib/htm/tasks.rb
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
#
|
|
9
9
|
# This will make the following tasks available:
|
|
10
10
|
#
|
|
11
|
-
# Database tasks (all respect RAILS_ENV, default: development):
|
|
11
|
+
# Database tasks (all respect HTM_ENV/RAILS_ENV, default: development):
|
|
12
12
|
# rake htm:db:create # Create database if it doesn't exist
|
|
13
13
|
# rake htm:db:setup # Set up HTM database schema and run migrations
|
|
14
14
|
# rake htm:db:migrate # Run pending database migrations
|
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
# rake htm:db:drop # Drop all HTM tables (destructive!)
|
|
21
21
|
# rake htm:db:reset # Drop and recreate database (destructive!)
|
|
22
22
|
#
|
|
23
|
+
# Environment detection priority: HTM_ENV > RAILS_ENV > RACK_ENV > 'development'
|
|
24
|
+
#
|
|
23
25
|
# Examples:
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
26
|
+
# HTM_ENV=test rake htm:db:create # Create htm_test database
|
|
27
|
+
# HTM_ENV=test rake htm:db:setup # Setup test database with migrations
|
|
28
|
+
# HTM_ENV=test rake htm:db:drop # Drop test database
|
|
29
|
+
# RAILS_ENV=test rake htm:db:create # Also works (for Rails apps)
|
|
27
30
|
#
|
|
28
31
|
# Async job tasks:
|
|
29
32
|
# rake htm:jobs:stats # Show async job statistics
|
data/lib/htm/version.rb
CHANGED