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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. 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