htm 0.0.15 → 0.0.17
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/.envrc +1 -0
- data/CHANGELOG.md +67 -0
- data/README.md +97 -1592
- data/bin/htm_mcp +31 -0
- data/config/database.yml +7 -4
- data/docs/getting-started/installation.md +31 -11
- data/docs/guides/mcp-server.md +456 -21
- data/docs/multi_framework_support.md +2 -2
- data/examples/mcp_client.rb +2 -2
- data/examples/rails_app/.gitignore +2 -0
- data/examples/rails_app/Gemfile +22 -0
- data/examples/rails_app/Gemfile.lock +438 -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/lib/htm/active_record_config.rb +2 -5
- data/lib/htm/configuration.rb +35 -2
- data/lib/htm/database.rb +3 -6
- data/lib/htm/mcp/cli.rb +333 -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/railtie.rb +0 -4
- data/lib/htm/tasks.rb +7 -4
- data/lib/htm/version.rb +1 -1
- data/lib/tasks/htm.rake +6 -9
- metadata +59 -4
- data/bin/htm_mcp.rb +0 -621
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fast_mcp'
|
|
4
|
+
|
|
5
|
+
class HTM
|
|
6
|
+
module MCP
|
|
7
|
+
# Session state for robot groups
|
|
8
|
+
module GroupSession
|
|
9
|
+
class << self
|
|
10
|
+
def groups
|
|
11
|
+
@groups ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get_group(name)
|
|
15
|
+
groups[name]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_group(name, group)
|
|
19
|
+
groups[name] = group
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remove_group(name)
|
|
23
|
+
group = groups.delete(name)
|
|
24
|
+
group&.shutdown
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def group_names
|
|
28
|
+
groups.keys
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Tool: Create a new robot group
|
|
34
|
+
class CreateGroupTool < FastMcp::Tool
|
|
35
|
+
description "Create a new robot group for coordinating multiple robots with shared working memory"
|
|
36
|
+
|
|
37
|
+
arguments do
|
|
38
|
+
required(:name).filled(:string).description("Unique name for the robot group")
|
|
39
|
+
optional(:max_tokens).filled(:integer).description("Maximum token budget for shared working memory (default: 4000)")
|
|
40
|
+
optional(:join_as).filled(:string).description("Role to join as: 'active' or 'passive' (default: 'active')")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(name:, max_tokens: 4000, join_as: 'active')
|
|
44
|
+
Session.logger&.info "CreateGroupTool called: name=#{name.inspect}, max_tokens=#{max_tokens}"
|
|
45
|
+
|
|
46
|
+
if GroupSession.get_group(name)
|
|
47
|
+
return { success: false, error: "Group '#{name}' already exists in this session" }.to_json
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get current robot name
|
|
51
|
+
robot_name = Session.robot_name
|
|
52
|
+
|
|
53
|
+
# Create the group with current robot as initial member
|
|
54
|
+
active = join_as == 'active' ? [robot_name] : []
|
|
55
|
+
passive = join_as == 'passive' ? [robot_name] : []
|
|
56
|
+
|
|
57
|
+
group = HTM::RobotGroup.new(
|
|
58
|
+
name: name,
|
|
59
|
+
active: active,
|
|
60
|
+
passive: passive,
|
|
61
|
+
max_tokens: max_tokens
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
GroupSession.set_group(name, group)
|
|
65
|
+
|
|
66
|
+
Session.logger&.info "Group created: #{name}, robot=#{robot_name} joined as #{join_as}"
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
success: true,
|
|
70
|
+
group_name: name,
|
|
71
|
+
max_tokens: max_tokens,
|
|
72
|
+
robot_name: robot_name,
|
|
73
|
+
role: join_as,
|
|
74
|
+
message: "Group '#{name}' created. Robot '#{robot_name}' joined as #{join_as}."
|
|
75
|
+
}.to_json
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Session.logger&.error "CreateGroupTool error: #{e.message}"
|
|
78
|
+
{ success: false, error: e.message }.to_json
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Tool: List all robot groups
|
|
83
|
+
class ListGroupsTool < FastMcp::Tool
|
|
84
|
+
description "List all robot groups in this session with their status"
|
|
85
|
+
|
|
86
|
+
arguments do
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def call
|
|
90
|
+
Session.logger&.info "ListGroupsTool called"
|
|
91
|
+
|
|
92
|
+
groups = GroupSession.group_names.map do |name|
|
|
93
|
+
group = GroupSession.get_group(name)
|
|
94
|
+
status = group.status
|
|
95
|
+
{
|
|
96
|
+
name: name,
|
|
97
|
+
active_robots: status[:active],
|
|
98
|
+
passive_robots: status[:passive],
|
|
99
|
+
total_members: status[:total_members],
|
|
100
|
+
in_sync: status[:in_sync],
|
|
101
|
+
token_utilization: status[:token_utilization]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Session.logger&.info "ListGroupsTool complete: #{groups.length} groups"
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
success: true,
|
|
109
|
+
count: groups.length,
|
|
110
|
+
groups: groups
|
|
111
|
+
}.to_json
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Tool: Get detailed group status
|
|
116
|
+
class GetGroupStatusTool < FastMcp::Tool
|
|
117
|
+
description "Get detailed status of a specific robot group"
|
|
118
|
+
|
|
119
|
+
arguments do
|
|
120
|
+
required(:name).filled(:string).description("Name of the robot group")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def call(name:)
|
|
124
|
+
Session.logger&.info "GetGroupStatusTool called: name=#{name.inspect}"
|
|
125
|
+
|
|
126
|
+
group = GroupSession.get_group(name)
|
|
127
|
+
unless group
|
|
128
|
+
return { success: false, error: "Group '#{name}' not found in this session" }.to_json
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
status = group.status
|
|
132
|
+
sync_stats = group.sync_stats
|
|
133
|
+
|
|
134
|
+
Session.logger&.info "GetGroupStatusTool complete: #{name}"
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
success: true,
|
|
138
|
+
group_name: name,
|
|
139
|
+
status: status,
|
|
140
|
+
sync_stats: sync_stats
|
|
141
|
+
}.to_json
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Tool: Join current robot to an existing group
|
|
146
|
+
class JoinGroupTool < FastMcp::Tool
|
|
147
|
+
description "Join the current robot to an existing robot group"
|
|
148
|
+
|
|
149
|
+
arguments do
|
|
150
|
+
required(:name).filled(:string).description("Name of the robot group to join")
|
|
151
|
+
optional(:role).filled(:string).description("Role to join as: 'active' or 'passive' (default: 'active')")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def call(name:, role: 'active')
|
|
155
|
+
Session.logger&.info "JoinGroupTool called: name=#{name.inspect}, role=#{role}"
|
|
156
|
+
|
|
157
|
+
group = GroupSession.get_group(name)
|
|
158
|
+
unless group
|
|
159
|
+
return { success: false, error: "Group '#{name}' not found in this session" }.to_json
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
robot_name = Session.robot_name
|
|
163
|
+
|
|
164
|
+
if group.member?(robot_name)
|
|
165
|
+
return { success: false, error: "Robot '#{robot_name}' is already a member of group '#{name}'" }.to_json
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
robot_id = if role == 'passive'
|
|
169
|
+
group.add_passive(robot_name)
|
|
170
|
+
else
|
|
171
|
+
group.add_active(robot_name)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Session.logger&.info "Robot #{robot_name} joined group #{name} as #{role}"
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
success: true,
|
|
178
|
+
group_name: name,
|
|
179
|
+
robot_name: robot_name,
|
|
180
|
+
robot_id: robot_id,
|
|
181
|
+
role: role,
|
|
182
|
+
message: "Robot '#{robot_name}' joined group '#{name}' as #{role}"
|
|
183
|
+
}.to_json
|
|
184
|
+
rescue ArgumentError => e
|
|
185
|
+
{ success: false, error: e.message }.to_json
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Tool: Leave a robot group
|
|
190
|
+
class LeaveGroupTool < FastMcp::Tool
|
|
191
|
+
description "Remove the current robot from a robot group"
|
|
192
|
+
|
|
193
|
+
arguments do
|
|
194
|
+
required(:name).filled(:string).description("Name of the robot group to leave")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def call(name:)
|
|
198
|
+
Session.logger&.info "LeaveGroupTool called: name=#{name.inspect}"
|
|
199
|
+
|
|
200
|
+
group = GroupSession.get_group(name)
|
|
201
|
+
unless group
|
|
202
|
+
return { success: false, error: "Group '#{name}' not found in this session" }.to_json
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
robot_name = Session.robot_name
|
|
206
|
+
|
|
207
|
+
unless group.member?(robot_name)
|
|
208
|
+
return { success: false, error: "Robot '#{robot_name}' is not a member of group '#{name}'" }.to_json
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
group.remove(robot_name)
|
|
212
|
+
|
|
213
|
+
Session.logger&.info "Robot #{robot_name} left group #{name}"
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
success: true,
|
|
217
|
+
group_name: name,
|
|
218
|
+
robot_name: robot_name,
|
|
219
|
+
message: "Robot '#{robot_name}' left group '#{name}'"
|
|
220
|
+
}.to_json
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Tool: Remember via group (syncs to all members)
|
|
225
|
+
class GroupRememberTool < FastMcp::Tool
|
|
226
|
+
description "Store information in a robot group's shared working memory (syncs to all members)"
|
|
227
|
+
|
|
228
|
+
arguments do
|
|
229
|
+
required(:group_name).filled(:string).description("Name of the robot group")
|
|
230
|
+
required(:content).filled(:string).description("The content to remember")
|
|
231
|
+
optional(:tags).array(:string).description("Optional tags for categorization")
|
|
232
|
+
optional(:metadata).hash.description("Optional metadata key-value pairs")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def call(group_name:, content:, tags: [], metadata: {})
|
|
236
|
+
Session.logger&.info "GroupRememberTool called: group=#{group_name.inspect}, content=#{content[0..50].inspect}..."
|
|
237
|
+
|
|
238
|
+
group = GroupSession.get_group(group_name)
|
|
239
|
+
unless group
|
|
240
|
+
return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
robot_name = Session.robot_name
|
|
244
|
+
node_id = group.remember(content, originator: robot_name, tags: tags, metadata: metadata)
|
|
245
|
+
|
|
246
|
+
Session.logger&.info "Group memory stored: node_id=#{node_id}, group=#{group_name}"
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
success: true,
|
|
250
|
+
node_id: node_id,
|
|
251
|
+
group_name: group_name,
|
|
252
|
+
originator: robot_name,
|
|
253
|
+
message: "Memory stored and synced to all group members"
|
|
254
|
+
}.to_json
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
Session.logger&.error "GroupRememberTool error: #{e.message}"
|
|
257
|
+
{ success: false, error: e.message }.to_json
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Tool: Recall from group's shared memory
|
|
262
|
+
class GroupRecallTool < FastMcp::Tool
|
|
263
|
+
description "Search and retrieve memories from a robot group's shared working memory"
|
|
264
|
+
|
|
265
|
+
arguments do
|
|
266
|
+
required(:group_name).filled(:string).description("Name of the robot group")
|
|
267
|
+
required(:query).filled(:string).description("Search query")
|
|
268
|
+
optional(:limit).filled(:integer).description("Maximum number of results (default: 10)")
|
|
269
|
+
optional(:strategy).filled(:string).description("Search strategy: 'vector', 'fulltext', or 'hybrid' (default: 'hybrid')")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def call(group_name:, query:, limit: 10, strategy: 'hybrid')
|
|
273
|
+
Session.logger&.info "GroupRecallTool called: group=#{group_name.inspect}, query=#{query.inspect}"
|
|
274
|
+
|
|
275
|
+
group = GroupSession.get_group(group_name)
|
|
276
|
+
unless group
|
|
277
|
+
return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
memories = group.recall(query, limit: limit, strategy: strategy.to_sym)
|
|
281
|
+
|
|
282
|
+
Session.logger&.info "GroupRecallTool complete: found #{memories.length} memories"
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
success: true,
|
|
286
|
+
group_name: group_name,
|
|
287
|
+
query: query,
|
|
288
|
+
strategy: strategy,
|
|
289
|
+
count: memories.length,
|
|
290
|
+
results: memories
|
|
291
|
+
}.to_json
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
Session.logger&.error "GroupRecallTool error: #{e.message}"
|
|
294
|
+
{ success: false, error: e.message }.to_json
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Tool: Promote a passive robot to active
|
|
299
|
+
class PromoteRobotTool < FastMcp::Tool
|
|
300
|
+
description "Promote a passive robot to active status in a group"
|
|
301
|
+
|
|
302
|
+
arguments do
|
|
303
|
+
required(:group_name).filled(:string).description("Name of the robot group")
|
|
304
|
+
required(:robot_name).filled(:string).description("Name of the passive robot to promote")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def call(group_name:, robot_name:)
|
|
308
|
+
Session.logger&.info "PromoteRobotTool called: group=#{group_name.inspect}, robot=#{robot_name.inspect}"
|
|
309
|
+
|
|
310
|
+
group = GroupSession.get_group(group_name)
|
|
311
|
+
unless group
|
|
312
|
+
return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
group.promote(robot_name)
|
|
316
|
+
|
|
317
|
+
Session.logger&.info "Robot #{robot_name} promoted to active in group #{group_name}"
|
|
318
|
+
|
|
319
|
+
{
|
|
320
|
+
success: true,
|
|
321
|
+
group_name: group_name,
|
|
322
|
+
robot_name: robot_name,
|
|
323
|
+
message: "Robot '#{robot_name}' promoted to active"
|
|
324
|
+
}.to_json
|
|
325
|
+
rescue ArgumentError => e
|
|
326
|
+
{ success: false, error: e.message }.to_json
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Tool: Trigger automatic failover
|
|
331
|
+
class FailoverTool < FastMcp::Tool
|
|
332
|
+
description "Trigger automatic failover - promotes first passive robot to active"
|
|
333
|
+
|
|
334
|
+
arguments do
|
|
335
|
+
required(:group_name).filled(:string).description("Name of the robot group")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def call(group_name:)
|
|
339
|
+
Session.logger&.info "FailoverTool called: group=#{group_name.inspect}"
|
|
340
|
+
|
|
341
|
+
group = GroupSession.get_group(group_name)
|
|
342
|
+
unless group
|
|
343
|
+
return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
promoted = group.failover!
|
|
347
|
+
|
|
348
|
+
Session.logger&.info "Failover complete: #{promoted} promoted to active in group #{group_name}"
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
success: true,
|
|
352
|
+
group_name: group_name,
|
|
353
|
+
promoted_robot: promoted,
|
|
354
|
+
message: "Failover complete. Robot '#{promoted}' is now active."
|
|
355
|
+
}.to_json
|
|
356
|
+
rescue RuntimeError => e
|
|
357
|
+
{ success: false, error: e.message }.to_json
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Tool: Force sync all group members
|
|
362
|
+
class SyncGroupTool < FastMcp::Tool
|
|
363
|
+
description "Force synchronization of all group members' working memory"
|
|
364
|
+
|
|
365
|
+
arguments do
|
|
366
|
+
required(:group_name).filled(:string).description("Name of the robot group")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def call(group_name:)
|
|
370
|
+
Session.logger&.info "SyncGroupTool called: group=#{group_name.inspect}"
|
|
371
|
+
|
|
372
|
+
group = GroupSession.get_group(group_name)
|
|
373
|
+
unless group
|
|
374
|
+
return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
result = group.sync_all
|
|
378
|
+
|
|
379
|
+
Session.logger&.info "SyncGroupTool complete: synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
|
|
380
|
+
|
|
381
|
+
{
|
|
382
|
+
success: true,
|
|
383
|
+
group_name: group_name,
|
|
384
|
+
synced_nodes: result[:synced_nodes],
|
|
385
|
+
members_updated: result[:members_updated],
|
|
386
|
+
in_sync: group.in_sync?,
|
|
387
|
+
message: "Synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
|
|
388
|
+
}.to_json
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Tool: Get group's shared working memory contents
|
|
393
|
+
class GetGroupWorkingMemoryTool < FastMcp::Tool
|
|
394
|
+
description "Get all nodes in a group's shared working memory"
|
|
395
|
+
|
|
396
|
+
arguments do
|
|
397
|
+
required(:group_name).filled(:string).description("Name of the robot group")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def call(group_name:)
|
|
401
|
+
Session.logger&.info "GetGroupWorkingMemoryTool called: group=#{group_name.inspect}"
|
|
402
|
+
|
|
403
|
+
group = GroupSession.get_group(group_name)
|
|
404
|
+
unless group
|
|
405
|
+
return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
contents = group.working_memory_contents.map do |node|
|
|
409
|
+
{
|
|
410
|
+
id: node.id,
|
|
411
|
+
content: node.content,
|
|
412
|
+
tags: node.tags.map(&:name),
|
|
413
|
+
created_at: node.created_at.iso8601
|
|
414
|
+
}
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
status = group.status
|
|
418
|
+
|
|
419
|
+
Session.logger&.info "GetGroupWorkingMemoryTool complete: #{contents.length} nodes"
|
|
420
|
+
|
|
421
|
+
{
|
|
422
|
+
success: true,
|
|
423
|
+
group_name: group_name,
|
|
424
|
+
count: contents.length,
|
|
425
|
+
token_count: status[:working_memory_tokens],
|
|
426
|
+
token_utilization: status[:token_utilization],
|
|
427
|
+
contents: contents
|
|
428
|
+
}.to_json
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Tool: Shutdown a robot group
|
|
433
|
+
class ShutdownGroupTool < FastMcp::Tool
|
|
434
|
+
description "Shutdown a robot group and release its resources"
|
|
435
|
+
|
|
436
|
+
arguments do
|
|
437
|
+
required(:name).filled(:string).description("Name of the robot group to shutdown")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def call(name:)
|
|
441
|
+
Session.logger&.info "ShutdownGroupTool called: name=#{name.inspect}"
|
|
442
|
+
|
|
443
|
+
group = GroupSession.get_group(name)
|
|
444
|
+
unless group
|
|
445
|
+
return { success: false, error: "Group '#{name}' not found in this session" }.to_json
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
GroupSession.remove_group(name)
|
|
449
|
+
|
|
450
|
+
Session.logger&.info "Group #{name} shutdown complete"
|
|
451
|
+
|
|
452
|
+
{
|
|
453
|
+
success: true,
|
|
454
|
+
group_name: name,
|
|
455
|
+
message: "Group '#{name}' has been shutdown"
|
|
456
|
+
}.to_json
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# All group tools for registration
|
|
461
|
+
GROUP_TOOLS = [
|
|
462
|
+
CreateGroupTool,
|
|
463
|
+
ListGroupsTool,
|
|
464
|
+
GetGroupStatusTool,
|
|
465
|
+
JoinGroupTool,
|
|
466
|
+
LeaveGroupTool,
|
|
467
|
+
GroupRememberTool,
|
|
468
|
+
GroupRecallTool,
|
|
469
|
+
GetGroupWorkingMemoryTool,
|
|
470
|
+
PromoteRobotTool,
|
|
471
|
+
FailoverTool,
|
|
472
|
+
SyncGroupTool,
|
|
473
|
+
ShutdownGroupTool
|
|
474
|
+
].freeze
|
|
475
|
+
end
|
|
476
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fast_mcp'
|
|
4
|
+
|
|
5
|
+
class HTM
|
|
6
|
+
module MCP
|
|
7
|
+
# Resource: Robot Groups
|
|
8
|
+
class RobotGroupsResource < FastMcp::Resource
|
|
9
|
+
uri "htm://groups"
|
|
10
|
+
resource_name "HTM Robot Groups"
|
|
11
|
+
mime_type "application/json"
|
|
12
|
+
|
|
13
|
+
def content
|
|
14
|
+
groups = GroupSession.group_names.map do |name|
|
|
15
|
+
group = GroupSession.get_group(name)
|
|
16
|
+
group.status
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
count: groups.length,
|
|
21
|
+
groups: groups
|
|
22
|
+
}.to_json
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resource: Memory Statistics
|
|
27
|
+
class MemoryStatsResource < FastMcp::Resource
|
|
28
|
+
uri "htm://statistics"
|
|
29
|
+
resource_name "HTM Memory Statistics"
|
|
30
|
+
mime_type "application/json"
|
|
31
|
+
|
|
32
|
+
def content
|
|
33
|
+
htm = Session.htm_instance
|
|
34
|
+
{
|
|
35
|
+
total_nodes: HTM::Models::Node.count,
|
|
36
|
+
total_tags: HTM::Models::Tag.count,
|
|
37
|
+
total_robots: HTM::Models::Robot.count,
|
|
38
|
+
current_robot: htm.robot_name,
|
|
39
|
+
robot_id: htm.robot_id,
|
|
40
|
+
robot_initialized: Session.robot_initialized?,
|
|
41
|
+
embedding_provider: HTM.configuration.embedding_provider,
|
|
42
|
+
embedding_model: HTM.configuration.embedding_model
|
|
43
|
+
}.to_json
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Resource: Tag Hierarchy
|
|
48
|
+
class TagHierarchyResource < FastMcp::Resource
|
|
49
|
+
uri "htm://tags/hierarchy"
|
|
50
|
+
resource_name "HTM Tag Hierarchy"
|
|
51
|
+
mime_type "text/plain"
|
|
52
|
+
|
|
53
|
+
def content
|
|
54
|
+
HTM::Models::Tag.all.tree_string
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Resource: Recent Memories
|
|
59
|
+
class RecentMemoriesResource < FastMcp::Resource
|
|
60
|
+
uri "htm://memories/recent"
|
|
61
|
+
resource_name "Recent HTM Memories"
|
|
62
|
+
mime_type "application/json"
|
|
63
|
+
|
|
64
|
+
def content
|
|
65
|
+
recent = HTM::Models::Node.includes(:tags)
|
|
66
|
+
.order(created_at: :desc)
|
|
67
|
+
.limit(20)
|
|
68
|
+
.map do |node|
|
|
69
|
+
{
|
|
70
|
+
id: node.id,
|
|
71
|
+
content: node.content[0..200],
|
|
72
|
+
tags: node.tags.map(&:name),
|
|
73
|
+
created_at: node.created_at.iso8601
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{ recent_memories: recent }.to_json
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# All resources for registration
|
|
82
|
+
RESOURCES = [
|
|
83
|
+
MemoryStatsResource,
|
|
84
|
+
TagHierarchyResource,
|
|
85
|
+
RecentMemoriesResource,
|
|
86
|
+
RobotGroupsResource
|
|
87
|
+
].freeze
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'fast_mcp'
|
|
5
|
+
require 'ruby_llm'
|
|
6
|
+
|
|
7
|
+
require_relative 'tools'
|
|
8
|
+
require_relative 'group_tools'
|
|
9
|
+
require_relative 'resources'
|
|
10
|
+
|
|
11
|
+
class HTM
|
|
12
|
+
module MCP
|
|
13
|
+
# MCP Server setup and lifecycle management
|
|
14
|
+
module Server
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def start
|
|
18
|
+
check_database_config!
|
|
19
|
+
verify_database_connection!
|
|
20
|
+
configure_logging!
|
|
21
|
+
configure_htm!
|
|
22
|
+
|
|
23
|
+
server = create_server
|
|
24
|
+
register_tools(server)
|
|
25
|
+
register_resources(server)
|
|
26
|
+
|
|
27
|
+
server.start
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_database_config!
|
|
31
|
+
unless ENV['HTM_DBURL'] || ENV['HTM_DBNAME']
|
|
32
|
+
warn "Error: Database not configured."
|
|
33
|
+
warn "Set HTM_DBURL or HTM_DBNAME environment variable."
|
|
34
|
+
warn "Run 'htm_mcp help' for details."
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify_database_connection!
|
|
40
|
+
HTM::ActiveRecordConfig.establish_connection!
|
|
41
|
+
# Quick connectivity test
|
|
42
|
+
ActiveRecord::Base.connection.execute("SELECT 1")
|
|
43
|
+
rescue => e
|
|
44
|
+
warn "Error: Cannot connect to database."
|
|
45
|
+
warn e.message
|
|
46
|
+
CLI.print_error_suggestion(e.message)
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def configure_logging!
|
|
51
|
+
# IMPORTANT: MCP uses STDIO for JSON-RPC communication.
|
|
52
|
+
# ALL logging must go to STDERR to avoid corrupting the protocol.
|
|
53
|
+
@stderr_logger = Logger.new($stderr)
|
|
54
|
+
@stderr_logger.level = Logger::INFO
|
|
55
|
+
@stderr_logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
56
|
+
"[MCP #{severity}] #{datetime.strftime('%H:%M:%S')} #{msg}\n"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Silent logger for RubyLLM/HTM internals (prevents STDOUT corruption)
|
|
60
|
+
@silent_logger = Logger.new(IO::NULL)
|
|
61
|
+
|
|
62
|
+
# Configure RubyLLM to not log to STDOUT (corrupts MCP protocol)
|
|
63
|
+
RubyLLM.configure do |config|
|
|
64
|
+
config.logger = @silent_logger
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Set logger for MCP session
|
|
68
|
+
Session.logger = @stderr_logger
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def configure_htm!
|
|
72
|
+
HTM.configure do |config|
|
|
73
|
+
config.job_backend = :inline # Synchronous for MCP responses
|
|
74
|
+
config.logger = @silent_logger # Silent logging for MCP
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def create_server
|
|
79
|
+
FastMcp::Server.new(
|
|
80
|
+
name: 'htm-memory-server',
|
|
81
|
+
version: HTM::VERSION
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def register_tools(server)
|
|
86
|
+
# Register individual robot/memory tools
|
|
87
|
+
TOOLS.each { |tool| server.register_tool(tool) }
|
|
88
|
+
|
|
89
|
+
# Register group tools
|
|
90
|
+
GROUP_TOOLS.each { |tool| server.register_tool(tool) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def register_resources(server)
|
|
94
|
+
RESOURCES.each { |resource| server.register_resource(resource) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|