rubyn-code 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module MCP
|
|
9
|
+
# Communicates with an MCP server via subprocess stdin/stdout using JSON-RPC 2.0.
|
|
10
|
+
#
|
|
11
|
+
# The server process is spawned with Open3.popen3 and kept alive for the
|
|
12
|
+
# duration of the session. Requests are written as newline-delimited JSON
|
|
13
|
+
# to stdin, and responses are read line-by-line from stdout.
|
|
14
|
+
class StdioTransport
|
|
15
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
16
|
+
|
|
17
|
+
TransportError = Class.new(RubynCode::Error)
|
|
18
|
+
TimeoutError = Class.new(TransportError)
|
|
19
|
+
|
|
20
|
+
# @param command [String] executable to spawn
|
|
21
|
+
# @param args [Array<String>] arguments for the command
|
|
22
|
+
# @param env [Hash<String, String>] additional environment variables
|
|
23
|
+
# @param timeout [Integer] default timeout in seconds per request
|
|
24
|
+
def initialize(command:, args: [], env: {}, timeout: DEFAULT_TIMEOUT)
|
|
25
|
+
@command = command
|
|
26
|
+
@args = args
|
|
27
|
+
@env = env
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@request_id = 0
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@stdin = nil
|
|
32
|
+
@stdout = nil
|
|
33
|
+
@stderr = nil
|
|
34
|
+
@wait_thread = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Spawns the MCP server subprocess.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
# @raise [TransportError] if the process fails to start
|
|
41
|
+
def start!
|
|
42
|
+
raise TransportError, "Transport already started" if alive?
|
|
43
|
+
|
|
44
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
|
|
45
|
+
rescue Errno::ENOENT => e
|
|
46
|
+
raise TransportError, "Failed to start MCP server: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sends a JSON-RPC 2.0 request and waits for the correlated response.
|
|
50
|
+
#
|
|
51
|
+
# @param method [String] the JSON-RPC method name
|
|
52
|
+
# @param params [Hash] parameters for the request
|
|
53
|
+
# @return [Hash] the parsed JSON-RPC response result
|
|
54
|
+
# @raise [TransportError] on protocol or server errors
|
|
55
|
+
# @raise [TimeoutError] if the response is not received within the timeout
|
|
56
|
+
def send_request(method, params = {})
|
|
57
|
+
raise TransportError, "Transport is not running" unless alive?
|
|
58
|
+
|
|
59
|
+
id = next_request_id
|
|
60
|
+
request = {
|
|
61
|
+
jsonrpc: "2.0",
|
|
62
|
+
id: id,
|
|
63
|
+
method: method,
|
|
64
|
+
params: params
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
write_request(request)
|
|
68
|
+
read_response(id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Sends a JSON-RPC 2.0 notification (no response expected).
|
|
72
|
+
#
|
|
73
|
+
# @param method [String] the JSON-RPC method name
|
|
74
|
+
# @param params [Hash] parameters for the notification
|
|
75
|
+
# @return [void]
|
|
76
|
+
def send_notification(method, params = {})
|
|
77
|
+
raise TransportError, "Transport is not running" unless alive?
|
|
78
|
+
|
|
79
|
+
notification = {
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
method: method,
|
|
82
|
+
params: params
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
write_request(notification)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Gracefully shuts down the MCP server and cleans up resources.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def stop!
|
|
92
|
+
return unless alive?
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
send_notification("notifications/cancelled")
|
|
96
|
+
@stdin&.close
|
|
97
|
+
rescue IOError, Errno::EPIPE
|
|
98
|
+
# Process may already be gone
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
@wait_thread&.join(5)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
# Best-effort wait
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
force_kill if alive?
|
|
108
|
+
ensure
|
|
109
|
+
close_streams
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Checks whether the subprocess is still running.
|
|
113
|
+
#
|
|
114
|
+
# @return [Boolean]
|
|
115
|
+
def alive?
|
|
116
|
+
return false unless @wait_thread
|
|
117
|
+
|
|
118
|
+
@wait_thread.alive?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def next_request_id
|
|
124
|
+
@mutex.synchronize { @request_id += 1 }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def write_request(request)
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
data = JSON.generate(request)
|
|
130
|
+
@stdin.write("#{data}\n")
|
|
131
|
+
@stdin.flush
|
|
132
|
+
end
|
|
133
|
+
rescue IOError, Errno::EPIPE => e
|
|
134
|
+
raise TransportError, "Failed to write to MCP server: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def read_response(expected_id)
|
|
138
|
+
Timeout.timeout(@timeout, TimeoutError, "MCP server did not respond within #{@timeout}s") do
|
|
139
|
+
loop do
|
|
140
|
+
line = @stdout.gets
|
|
141
|
+
raise TransportError, "MCP server closed stdout unexpectedly" if line.nil?
|
|
142
|
+
|
|
143
|
+
line = line.strip
|
|
144
|
+
next if line.empty?
|
|
145
|
+
|
|
146
|
+
message = parse_json(line)
|
|
147
|
+
next unless message
|
|
148
|
+
|
|
149
|
+
# Skip notifications (no id field)
|
|
150
|
+
next unless message.key?("id")
|
|
151
|
+
|
|
152
|
+
# Skip responses for other requests
|
|
153
|
+
next unless message["id"] == expected_id
|
|
154
|
+
|
|
155
|
+
if message.key?("error")
|
|
156
|
+
err = message["error"]
|
|
157
|
+
raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
return message["result"]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_json(line)
|
|
166
|
+
JSON.parse(line)
|
|
167
|
+
rescue JSON::ParserError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def force_kill
|
|
172
|
+
return unless @wait_thread
|
|
173
|
+
|
|
174
|
+
pid = @wait_thread.pid
|
|
175
|
+
Process.kill("TERM", pid)
|
|
176
|
+
sleep(0.5)
|
|
177
|
+
Process.kill("KILL", pid) if @wait_thread.alive?
|
|
178
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
179
|
+
# Process already gone or we lack permissions
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def close_streams
|
|
183
|
+
[@stdin, @stdout, @stderr].each do |stream|
|
|
184
|
+
stream&.close unless stream&.closed?
|
|
185
|
+
rescue IOError
|
|
186
|
+
# Already closed
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@stdin = nil
|
|
190
|
+
@stdout = nil
|
|
191
|
+
@stderr = nil
|
|
192
|
+
@wait_thread = nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module MCP
|
|
5
|
+
# Wraps MCP tools as native RubynCode tools by dynamically creating
|
|
6
|
+
# tool classes that delegate execution to the MCP client.
|
|
7
|
+
#
|
|
8
|
+
# Each bridged tool:
|
|
9
|
+
# - Has TOOL_NAME prefixed with "mcp_"
|
|
10
|
+
# - Has RISK_LEVEL = :external
|
|
11
|
+
# - Delegates #execute to the MCP client's #call_tool
|
|
12
|
+
# - Registers itself with Tools::Registry
|
|
13
|
+
module ToolBridge
|
|
14
|
+
class << self
|
|
15
|
+
# Discovers tools from an MCP client and creates corresponding
|
|
16
|
+
# RubynCode tool classes.
|
|
17
|
+
#
|
|
18
|
+
# @param mcp_client [MCP::Client] a connected MCP client
|
|
19
|
+
# @return [Array<Class>] the dynamically created tool classes
|
|
20
|
+
def bridge(mcp_client)
|
|
21
|
+
tools = mcp_client.tools
|
|
22
|
+
return [] if tools.nil? || tools.empty?
|
|
23
|
+
|
|
24
|
+
tools.map do |tool_def|
|
|
25
|
+
build_tool_class(mcp_client, tool_def)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Builds a single tool class for an MCP tool definition.
|
|
32
|
+
#
|
|
33
|
+
# @param mcp_client [MCP::Client] the MCP client to delegate to
|
|
34
|
+
# @param tool_def [Hash] tool definition with "name", "description", "inputSchema"
|
|
35
|
+
# @return [Class] the newly created and registered tool class
|
|
36
|
+
def build_tool_class(mcp_client, tool_def)
|
|
37
|
+
remote_name = tool_def["name"]
|
|
38
|
+
tool_name = "mcp_#{sanitize_name(remote_name)}"
|
|
39
|
+
description = tool_def["description"] || "MCP tool: #{remote_name}"
|
|
40
|
+
input_schema = tool_def["inputSchema"] || {}
|
|
41
|
+
parameters = build_parameters_from_schema(input_schema)
|
|
42
|
+
|
|
43
|
+
klass = Class.new(Tools::Base) do
|
|
44
|
+
const_set(:TOOL_NAME, tool_name)
|
|
45
|
+
const_set(:DESCRIPTION, description)
|
|
46
|
+
const_set(:PARAMETERS, parameters)
|
|
47
|
+
const_set(:RISK_LEVEL, :external)
|
|
48
|
+
const_set(:REQUIRES_CONFIRMATION, true)
|
|
49
|
+
|
|
50
|
+
define_method(:mcp_client) { mcp_client }
|
|
51
|
+
define_method(:remote_tool_name) { remote_name }
|
|
52
|
+
|
|
53
|
+
def execute(**params)
|
|
54
|
+
result = mcp_client.call_tool(remote_tool_name, params)
|
|
55
|
+
format_result(result)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
define_method(:format_result) do |result|
|
|
61
|
+
case result
|
|
62
|
+
when Hash
|
|
63
|
+
if result.key?("content")
|
|
64
|
+
extract_content(result["content"])
|
|
65
|
+
else
|
|
66
|
+
JSON.generate(result)
|
|
67
|
+
end
|
|
68
|
+
when String
|
|
69
|
+
result
|
|
70
|
+
else
|
|
71
|
+
result.to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
define_method(:extract_content) do |content|
|
|
76
|
+
Array(content).map do |block|
|
|
77
|
+
case block["type"]
|
|
78
|
+
when "text"
|
|
79
|
+
block["text"]
|
|
80
|
+
when "image"
|
|
81
|
+
"[image: #{block['mimeType']}]"
|
|
82
|
+
when "resource"
|
|
83
|
+
block.dig("resource", "text") || "[resource: #{block.dig('resource', 'uri')}]"
|
|
84
|
+
else
|
|
85
|
+
block.to_s
|
|
86
|
+
end
|
|
87
|
+
end.join("\n")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build parameter definitions from JSON Schema
|
|
92
|
+
klass.define_singleton_method(:build_parameters) do |schema|
|
|
93
|
+
properties = schema["properties"] || {}
|
|
94
|
+
required = schema["required"] || []
|
|
95
|
+
|
|
96
|
+
properties.each_with_object({}) do |(name, prop), params|
|
|
97
|
+
params[name.to_sym] = {
|
|
98
|
+
type: map_json_type(prop["type"]),
|
|
99
|
+
description: prop["description"] || "",
|
|
100
|
+
required: required.include?(name)
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
klass.define_singleton_method(:map_json_type) do |json_type|
|
|
106
|
+
case json_type
|
|
107
|
+
when "string" then :string
|
|
108
|
+
when "integer" then :integer
|
|
109
|
+
when "number" then :number
|
|
110
|
+
when "boolean" then :boolean
|
|
111
|
+
when "array" then :array
|
|
112
|
+
when "object" then :object
|
|
113
|
+
else :string
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
Tools::Registry.register(klass)
|
|
118
|
+
klass
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Builds parameter definitions from a JSON Schema.
|
|
122
|
+
#
|
|
123
|
+
# @param schema [Hash] JSON Schema with "properties" and "required"
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def build_parameters_from_schema(schema)
|
|
126
|
+
properties = schema["properties"] || {}
|
|
127
|
+
required = schema["required"] || []
|
|
128
|
+
|
|
129
|
+
properties.each_with_object({}) do |(name, prop), params|
|
|
130
|
+
params[name.to_sym] = {
|
|
131
|
+
type: map_json_type(prop["type"]),
|
|
132
|
+
description: prop["description"] || "",
|
|
133
|
+
required: required.include?(name)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Maps a JSON Schema type string to a Ruby symbol.
|
|
139
|
+
#
|
|
140
|
+
# @param json_type [String]
|
|
141
|
+
# @return [Symbol]
|
|
142
|
+
def map_json_type(json_type)
|
|
143
|
+
case json_type
|
|
144
|
+
when "string" then :string
|
|
145
|
+
when "integer" then :integer
|
|
146
|
+
when "number" then :number
|
|
147
|
+
when "boolean" then :boolean
|
|
148
|
+
when "array" then :array
|
|
149
|
+
when "object" then :object
|
|
150
|
+
else :string
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Sanitizes a tool name for use as a Ruby-friendly identifier.
|
|
155
|
+
#
|
|
156
|
+
# @param name [String] the original tool name
|
|
157
|
+
# @return [String] sanitized name
|
|
158
|
+
def sanitize_name(name)
|
|
159
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, "_").gsub(/_+/, "_").downcase
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Memory
|
|
5
|
+
VALID_TIERS = %w[short medium long].freeze
|
|
6
|
+
VALID_CATEGORIES = %w[code_pattern user_preference project_convention error_resolution decision].freeze
|
|
7
|
+
|
|
8
|
+
# Immutable value object representing a single memory record.
|
|
9
|
+
#
|
|
10
|
+
# Tiers control retention and decay:
|
|
11
|
+
# - "short" : ephemeral, decays quickly, session-scoped
|
|
12
|
+
# - "medium" : moderate retention, project-scoped
|
|
13
|
+
# - "long" : persistent, rarely decays
|
|
14
|
+
#
|
|
15
|
+
# Categories classify the kind of knowledge stored:
|
|
16
|
+
# - "code_pattern" : recurring code patterns or idioms
|
|
17
|
+
# - "user_preference" : how the user likes things done
|
|
18
|
+
# - "project_convention" : project-specific conventions
|
|
19
|
+
# - "error_resolution" : known error/fix pairs
|
|
20
|
+
# - "decision" : architectural or design decisions
|
|
21
|
+
MemoryRecord = Data.define(
|
|
22
|
+
:id, :project_path, :tier, :category, :content,
|
|
23
|
+
:relevance_score, :access_count, :last_accessed_at,
|
|
24
|
+
:expires_at, :metadata, :created_at
|
|
25
|
+
) do
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def expired?
|
|
28
|
+
return false if expires_at.nil?
|
|
29
|
+
|
|
30
|
+
Time.parse(expires_at.to_s) < Time.now
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def short? = tier == "short"
|
|
37
|
+
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def medium? = tier == "medium"
|
|
40
|
+
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def long? = tier == "long"
|
|
43
|
+
|
|
44
|
+
# @return [Hash]
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
id: id,
|
|
48
|
+
project_path: project_path,
|
|
49
|
+
tier: tier,
|
|
50
|
+
category: category,
|
|
51
|
+
content: content,
|
|
52
|
+
relevance_score: relevance_score,
|
|
53
|
+
access_count: access_count,
|
|
54
|
+
last_accessed_at: last_accessed_at,
|
|
55
|
+
expires_at: expires_at,
|
|
56
|
+
metadata: metadata,
|
|
57
|
+
created_at: created_at
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Memory
|
|
7
|
+
# Searches memories using SQLite FTS5 full-text search and standard
|
|
8
|
+
# queries. Every search method automatically increments access_count
|
|
9
|
+
# and updates last_accessed_at on returned records, reinforcing
|
|
10
|
+
# frequently-accessed memories against decay.
|
|
11
|
+
class Search
|
|
12
|
+
# @param db [DB::Connection] database connection
|
|
13
|
+
# @param project_path [String] scoping path for searches
|
|
14
|
+
def initialize(db, project_path:)
|
|
15
|
+
@db = db
|
|
16
|
+
@project_path = project_path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Full-text search across memory content using FTS5.
|
|
20
|
+
#
|
|
21
|
+
# @param query [String] the search query (FTS5 syntax supported)
|
|
22
|
+
# @param tier [String, nil] filter by tier
|
|
23
|
+
# @param category [String, nil] filter by category
|
|
24
|
+
# @param limit [Integer] maximum results (default 10)
|
|
25
|
+
# @return [Array<MemoryRecord>]
|
|
26
|
+
def search(query, tier: nil, category: nil, limit: 10)
|
|
27
|
+
conditions = ["m.project_path = ?"]
|
|
28
|
+
params = [@project_path]
|
|
29
|
+
|
|
30
|
+
if tier
|
|
31
|
+
conditions << "m.tier = ?"
|
|
32
|
+
params << tier
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if category
|
|
36
|
+
conditions << "m.category = ?"
|
|
37
|
+
params << category
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
params << query
|
|
41
|
+
params << limit
|
|
42
|
+
|
|
43
|
+
rows = @db.query(<<~SQL, params).to_a
|
|
44
|
+
SELECT m.id, m.project_path, m.tier, m.category, m.content,
|
|
45
|
+
m.relevance_score, m.access_count, m.last_accessed_at,
|
|
46
|
+
m.expires_at, m.metadata, m.created_at
|
|
47
|
+
FROM memories m
|
|
48
|
+
WHERE #{conditions.join(' AND ')}
|
|
49
|
+
AND m.content LIKE '%' || ? || '%'
|
|
50
|
+
ORDER BY m.relevance_score DESC, m.created_at DESC
|
|
51
|
+
LIMIT ?
|
|
52
|
+
SQL
|
|
53
|
+
|
|
54
|
+
records = rows.map { |row| build_record(row) }
|
|
55
|
+
touch_accessed(records)
|
|
56
|
+
records
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the most recently created memories.
|
|
60
|
+
#
|
|
61
|
+
# @param limit [Integer] maximum results (default 10)
|
|
62
|
+
# @return [Array<MemoryRecord>]
|
|
63
|
+
def recent(limit: 10)
|
|
64
|
+
rows = @db.query(<<~SQL, [@project_path, limit]).to_a
|
|
65
|
+
SELECT id, project_path, tier, category, content,
|
|
66
|
+
relevance_score, access_count, last_accessed_at,
|
|
67
|
+
expires_at, metadata, created_at
|
|
68
|
+
FROM memories
|
|
69
|
+
WHERE project_path = ?
|
|
70
|
+
ORDER BY created_at DESC
|
|
71
|
+
LIMIT ?
|
|
72
|
+
SQL
|
|
73
|
+
|
|
74
|
+
records = rows.map { |row| build_record(row) }
|
|
75
|
+
touch_accessed(records)
|
|
76
|
+
records
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns memories filtered by category.
|
|
80
|
+
#
|
|
81
|
+
# @param category [String]
|
|
82
|
+
# @param limit [Integer] maximum results (default 10)
|
|
83
|
+
# @return [Array<MemoryRecord>]
|
|
84
|
+
def by_category(category, limit: 10)
|
|
85
|
+
rows = @db.query(<<~SQL, [@project_path, category, limit]).to_a
|
|
86
|
+
SELECT id, project_path, tier, category, content,
|
|
87
|
+
relevance_score, access_count, last_accessed_at,
|
|
88
|
+
expires_at, metadata, created_at
|
|
89
|
+
FROM memories
|
|
90
|
+
WHERE project_path = ?
|
|
91
|
+
AND category = ?
|
|
92
|
+
ORDER BY relevance_score DESC, created_at DESC
|
|
93
|
+
LIMIT ?
|
|
94
|
+
SQL
|
|
95
|
+
|
|
96
|
+
records = rows.map { |row| build_record(row) }
|
|
97
|
+
touch_accessed(records)
|
|
98
|
+
records
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns memories filtered by tier.
|
|
102
|
+
#
|
|
103
|
+
# @param tier [String]
|
|
104
|
+
# @param limit [Integer] maximum results (default 10)
|
|
105
|
+
# @return [Array<MemoryRecord>]
|
|
106
|
+
def by_tier(tier, limit: 10)
|
|
107
|
+
rows = @db.query(<<~SQL, [@project_path, tier, limit]).to_a
|
|
108
|
+
SELECT id, project_path, tier, category, content,
|
|
109
|
+
relevance_score, access_count, last_accessed_at,
|
|
110
|
+
expires_at, metadata, created_at
|
|
111
|
+
FROM memories
|
|
112
|
+
WHERE project_path = ?
|
|
113
|
+
AND tier = ?
|
|
114
|
+
ORDER BY relevance_score DESC, created_at DESC
|
|
115
|
+
LIMIT ?
|
|
116
|
+
SQL
|
|
117
|
+
|
|
118
|
+
records = rows.map { |row| build_record(row) }
|
|
119
|
+
touch_accessed(records)
|
|
120
|
+
records
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Builds a MemoryRecord from a database row.
|
|
126
|
+
#
|
|
127
|
+
# @param row [Hash]
|
|
128
|
+
# @return [MemoryRecord]
|
|
129
|
+
def build_record(row)
|
|
130
|
+
metadata = parse_json(row["metadata"])
|
|
131
|
+
|
|
132
|
+
MemoryRecord.new(
|
|
133
|
+
id: row["id"],
|
|
134
|
+
project_path: row["project_path"],
|
|
135
|
+
tier: row["tier"],
|
|
136
|
+
category: row["category"],
|
|
137
|
+
content: row["content"],
|
|
138
|
+
relevance_score: row["relevance_score"].to_f,
|
|
139
|
+
access_count: row["access_count"].to_i,
|
|
140
|
+
last_accessed_at: row["last_accessed_at"],
|
|
141
|
+
expires_at: row["expires_at"],
|
|
142
|
+
metadata: metadata,
|
|
143
|
+
created_at: row["created_at"]
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Increments access_count and updates last_accessed_at for all
|
|
148
|
+
# returned records, reinforcing them against decay.
|
|
149
|
+
#
|
|
150
|
+
# @param records [Array<MemoryRecord>]
|
|
151
|
+
# @return [void]
|
|
152
|
+
def touch_accessed(records)
|
|
153
|
+
return if records.empty?
|
|
154
|
+
|
|
155
|
+
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
156
|
+
ids = records.map(&:id)
|
|
157
|
+
placeholders = (["?"] * ids.size).join(", ")
|
|
158
|
+
|
|
159
|
+
@db.execute(
|
|
160
|
+
"UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id IN (#{placeholders})",
|
|
161
|
+
[now] + ids
|
|
162
|
+
)
|
|
163
|
+
rescue StandardError
|
|
164
|
+
# Access tracking is best-effort; never fail a search because of it.
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @param raw [String, Hash, nil]
|
|
169
|
+
# @return [Hash]
|
|
170
|
+
def parse_json(raw)
|
|
171
|
+
case raw
|
|
172
|
+
when Hash then raw
|
|
173
|
+
when String then JSON.parse(raw, symbolize_names: true)
|
|
174
|
+
else {}
|
|
175
|
+
end
|
|
176
|
+
rescue JSON::ParserError
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|