rubyn-code 0.1.0 → 0.2.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module RubynCode
6
6
  module MCP
@@ -9,7 +9,8 @@ module RubynCode
9
9
  class Client
10
10
  INITIALIZE_TIMEOUT = 10
11
11
 
12
- ClientError = Class.new(RubynCode::Error)
12
+ class ClientError < RubynCode::Error
13
+ end
13
14
 
14
15
  attr_reader :name, :transport
15
16
 
@@ -41,7 +42,7 @@ module RubynCode
41
42
  #
42
43
  # @return [Array<Hash>] tool definitions in JSON Schema format
43
44
  def tools
44
- @tools_cache ||= discover_tools
45
+ @tools ||= discover_tools
45
46
  end
46
47
 
47
48
  # Invokes a tool on the MCP server.
@@ -53,10 +54,10 @@ module RubynCode
53
54
  def call_tool(tool_name, arguments = {})
54
55
  ensure_connected!
55
56
 
56
- @transport.send_request("tools/call", {
57
- name: tool_name,
58
- arguments: arguments
59
- })
57
+ @transport.send_request('tools/call', {
58
+ name: tool_name,
59
+ arguments: arguments
60
+ })
60
61
  end
61
62
 
62
63
  # Gracefully disconnects from the MCP server.
@@ -107,28 +108,28 @@ module RubynCode
107
108
  private
108
109
 
109
110
  def perform_initialize
110
- result = @transport.send_request("initialize", {
111
- protocolVersion: "2024-11-05",
112
- capabilities: {
113
- tools: {}
114
- },
115
- clientInfo: {
116
- name: "rubyn-code",
117
- version: RubynCode::VERSION
118
- }
119
- })
120
-
121
- @server_info = result&.dig("serverInfo")
122
- @server_capabilities = result&.dig("capabilities")
123
-
124
- @transport.send_notification("notifications/initialized") if @transport.respond_to?(:send_notification)
111
+ result = @transport.send_request('initialize', {
112
+ protocolVersion: '2024-11-05',
113
+ capabilities: {
114
+ tools: {}
115
+ },
116
+ clientInfo: {
117
+ name: 'rubyn-code',
118
+ version: RubynCode::VERSION
119
+ }
120
+ })
121
+
122
+ @server_info = result&.dig('serverInfo')
123
+ @server_capabilities = result&.dig('capabilities')
124
+
125
+ @transport.send_notification('notifications/initialized') if @transport.respond_to?(:send_notification)
125
126
  end
126
127
 
127
128
  def discover_tools
128
129
  ensure_connected!
129
130
 
130
- result = @transport.send_request("tools/list")
131
- result&.fetch("tools", []) || []
131
+ result = @transport.send_request('tools/list')
132
+ result&.fetch('tools', []) || []
132
133
  end
133
134
 
134
135
  def ensure_connected!
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module RubynCode
6
6
  module MCP
@@ -17,7 +17,7 @@ module RubynCode
17
17
  # }
18
18
  # }
19
19
  module Config
20
- CONFIG_FILENAME = ".rubyn-code/mcp.json"
20
+ CONFIG_FILENAME = '.rubyn-code/mcp.json'
21
21
 
22
22
  ENV_VAR_PATTERN = /\$\{([^}]+)\}/
23
23
 
@@ -32,14 +32,14 @@ module RubynCode
32
32
 
33
33
  raw = File.read(config_path)
34
34
  data = JSON.parse(raw)
35
- servers = data["mcpServers"] || {}
35
+ servers = data['mcpServers'] || {}
36
36
 
37
37
  servers.map do |name, server_def|
38
38
  {
39
39
  name: name,
40
- command: server_def["command"],
41
- args: Array(server_def["args"]),
42
- env: expand_env(server_def["env"] || {})
40
+ command: server_def['command'],
41
+ args: Array(server_def['args']),
42
+ env: expand_env(server_def['env'] || {})
43
43
  }
44
44
  end
45
45
  rescue JSON::ParserError => e
@@ -73,7 +73,7 @@ module RubynCode
73
73
  env_name = ::Regexp.last_match(1)
74
74
  ENV.fetch(env_name) do
75
75
  warn "[MCP::Config] Environment variable #{env_name} is not set"
76
- ""
76
+ ''
77
77
  end
78
78
  end
79
79
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require "json"
5
- require "uri"
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'uri'
6
6
 
7
7
  module RubynCode
8
8
  module MCP
@@ -15,8 +15,11 @@ module RubynCode
15
15
  class SSETransport
16
16
  DEFAULT_TIMEOUT = 30 # seconds
17
17
 
18
- TransportError = Class.new(RubynCode::Error)
19
- TimeoutError = Class.new(TransportError)
18
+ class TransportError < RubynCode::Error
19
+ end
20
+
21
+ class TimeoutError < TransportError
22
+ end
20
23
 
21
24
  # @param url [String] the SSE endpoint URL of the MCP server
22
25
  # @param timeout [Integer] default timeout in seconds per request
@@ -36,7 +39,7 @@ module RubynCode
36
39
  # @return [void]
37
40
  # @raise [TransportError] if the connection cannot be established
38
41
  def start!
39
- raise TransportError, "Transport already started" if @connected
42
+ raise TransportError, 'Transport already started' if @connected
40
43
 
41
44
  @pending_responses = {}
42
45
  @sse_thread = Thread.new { run_sse_listener }
@@ -61,14 +64,14 @@ module RubynCode
61
64
  # @raise [TransportError] on protocol or server errors
62
65
  # @raise [TimeoutError] if the response is not received in time
63
66
  def send_request(method, params = {})
64
- raise TransportError, "Transport is not connected" unless @connected
67
+ raise TransportError, 'Transport is not connected' unless @connected
65
68
 
66
69
  id = next_request_id
67
70
  queue = Queue.new
68
71
  @mutex.synchronize { @pending_responses[id] = queue }
69
72
 
70
73
  request = {
71
- jsonrpc: "2.0",
74
+ jsonrpc: '2.0',
72
75
  id: id,
73
76
  method: method,
74
77
  params: params
@@ -111,7 +114,7 @@ module RubynCode
111
114
  @connection ||= Faraday.new(url: base_url) do |f|
112
115
  f.options.timeout = @timeout
113
116
  f.options.open_timeout = @timeout
114
- f.headers["Content-Type"] = "application/json"
117
+ f.headers['Content-Type'] = 'application/json'
115
118
  f.adapter Faraday.default_adapter
116
119
  end
117
120
  end
@@ -121,9 +124,7 @@ module RubynCode
121
124
  req.body = JSON.generate(request)
122
125
  end
123
126
 
124
- unless response.success?
125
- raise TransportError, "MCP server returned HTTP #{response.status}: #{response.body}"
126
- end
127
+ raise TransportError, "MCP server returned HTTP #{response.status}: #{response.body}" unless response.success?
127
128
  rescue Faraday::Error => e
128
129
  raise TransportError, "Failed to send request to MCP server: #{e.message}"
129
130
  end
@@ -138,8 +139,8 @@ module RubynCode
138
139
  @mutex.synchronize { @pending_responses.delete(id) }
139
140
  end
140
141
 
141
- if result.is_a?(Hash) && result.key?("error")
142
- err = result["error"]
142
+ if result.is_a?(Hash) && result.key?('error')
143
+ err = result['error']
143
144
  raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
144
145
  end
145
146
 
@@ -150,11 +151,11 @@ module RubynCode
150
151
  sse_connection = Faraday.new(url: base_url) do |f|
151
152
  f.options.timeout = nil # Keep-alive
152
153
  f.options.open_timeout = @timeout
153
- f.headers["Accept"] = "text/event-stream"
154
+ f.headers['Accept'] = 'text/event-stream'
154
155
  f.adapter Faraday.default_adapter
155
156
  end
156
157
 
157
- buffer = +""
158
+ buffer = +''
158
159
 
159
160
  sse_connection.get(@url) do |req|
160
161
  req.options.on_data = proc do |chunk, _bytes, _env|
@@ -181,10 +182,10 @@ module RubynCode
181
182
 
182
183
  raw.each_line do |line|
183
184
  line = line.chomp
184
- if line.start_with?("event:")
185
- event_type = line.sub("event:", "").strip
186
- elsif line.start_with?("data:")
187
- data_lines << line.sub("data:", "").strip
185
+ if line.start_with?('event:')
186
+ event_type = line.sub('event:', '').strip
187
+ elsif line.start_with?('data:')
188
+ data_lines << line.sub('data:', '').strip
188
189
  end
189
190
  end
190
191
 
@@ -195,9 +196,9 @@ module RubynCode
195
196
 
196
197
  def handle_sse_event(event)
197
198
  case event[:type]
198
- when "endpoint"
199
+ when 'endpoint'
199
200
  @post_endpoint = event[:data]
200
- when "message"
201
+ when 'message'
201
202
  dispatch_message(event[:data])
202
203
  else
203
204
  dispatch_message(event[:data])
@@ -206,16 +207,16 @@ module RubynCode
206
207
 
207
208
  def dispatch_message(data)
208
209
  message = JSON.parse(data)
209
- return unless message.is_a?(Hash) && message.key?("id")
210
+ return unless message.is_a?(Hash) && message.key?('id')
210
211
 
211
- id = message["id"]
212
+ id = message['id']
212
213
  queue = @mutex.synchronize { @pending_responses[id] }
213
214
  return unless queue
214
215
 
215
- if message.key?("error")
216
+ if message.key?('error')
216
217
  queue.push(message)
217
218
  else
218
- queue.push(message["result"])
219
+ queue.push(message['result'])
219
220
  end
220
221
  rescue JSON::ParserError
221
222
  # Ignore malformed messages
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "open3"
5
- require "timeout"
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
6
 
7
7
  module RubynCode
8
8
  module MCP
@@ -14,8 +14,11 @@ module RubynCode
14
14
  class StdioTransport
15
15
  DEFAULT_TIMEOUT = 30 # seconds
16
16
 
17
- TransportError = Class.new(RubynCode::Error)
18
- TimeoutError = Class.new(TransportError)
17
+ class TransportError < RubynCode::Error
18
+ end
19
+
20
+ class TimeoutError < TransportError
21
+ end
19
22
 
20
23
  # @param command [String] executable to spawn
21
24
  # @param args [Array<String>] arguments for the command
@@ -39,7 +42,7 @@ module RubynCode
39
42
  # @return [void]
40
43
  # @raise [TransportError] if the process fails to start
41
44
  def start!
42
- raise TransportError, "Transport already started" if alive?
45
+ raise TransportError, 'Transport already started' if alive?
43
46
 
44
47
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
45
48
  rescue Errno::ENOENT => e
@@ -54,11 +57,11 @@ module RubynCode
54
57
  # @raise [TransportError] on protocol or server errors
55
58
  # @raise [TimeoutError] if the response is not received within the timeout
56
59
  def send_request(method, params = {})
57
- raise TransportError, "Transport is not running" unless alive?
60
+ raise TransportError, 'Transport is not running' unless alive?
58
61
 
59
62
  id = next_request_id
60
63
  request = {
61
- jsonrpc: "2.0",
64
+ jsonrpc: '2.0',
62
65
  id: id,
63
66
  method: method,
64
67
  params: params
@@ -74,10 +77,10 @@ module RubynCode
74
77
  # @param params [Hash] parameters for the notification
75
78
  # @return [void]
76
79
  def send_notification(method, params = {})
77
- raise TransportError, "Transport is not running" unless alive?
80
+ raise TransportError, 'Transport is not running' unless alive?
78
81
 
79
82
  notification = {
80
- jsonrpc: "2.0",
83
+ jsonrpc: '2.0',
81
84
  method: method,
82
85
  params: params
83
86
  }
@@ -92,7 +95,7 @@ module RubynCode
92
95
  return unless alive?
93
96
 
94
97
  begin
95
- send_notification("notifications/cancelled")
98
+ send_notification('notifications/cancelled')
96
99
  @stdin&.close
97
100
  rescue IOError, Errno::EPIPE
98
101
  # Process may already be gone
@@ -138,7 +141,7 @@ module RubynCode
138
141
  Timeout.timeout(@timeout, TimeoutError, "MCP server did not respond within #{@timeout}s") do
139
142
  loop do
140
143
  line = @stdout.gets
141
- raise TransportError, "MCP server closed stdout unexpectedly" if line.nil?
144
+ raise TransportError, 'MCP server closed stdout unexpectedly' if line.nil?
142
145
 
143
146
  line = line.strip
144
147
  next if line.empty?
@@ -147,17 +150,17 @@ module RubynCode
147
150
  next unless message
148
151
 
149
152
  # Skip notifications (no id field)
150
- next unless message.key?("id")
153
+ next unless message.key?('id')
151
154
 
152
155
  # Skip responses for other requests
153
- next unless message["id"] == expected_id
156
+ next unless message['id'] == expected_id
154
157
 
155
- if message.key?("error")
156
- err = message["error"]
158
+ if message.key?('error')
159
+ err = message['error']
157
160
  raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
158
161
  end
159
162
 
160
- return message["result"]
163
+ return message['result']
161
164
  end
162
165
  end
163
166
  end
@@ -172,9 +175,9 @@ module RubynCode
172
175
  return unless @wait_thread
173
176
 
174
177
  pid = @wait_thread.pid
175
- Process.kill("TERM", pid)
178
+ Process.kill('TERM', pid)
176
179
  sleep(0.5)
177
- Process.kill("KILL", pid) if @wait_thread.alive?
180
+ Process.kill('KILL', pid) if @wait_thread.alive?
178
181
  rescue Errno::ESRCH, Errno::EPERM
179
182
  # Process already gone or we lack permissions
180
183
  end
@@ -34,10 +34,10 @@ module RubynCode
34
34
  # @param tool_def [Hash] tool definition with "name", "description", "inputSchema"
35
35
  # @return [Class] the newly created and registered tool class
36
36
  def build_tool_class(mcp_client, tool_def)
37
- remote_name = tool_def["name"]
37
+ remote_name = tool_def['name']
38
38
  tool_name = "mcp_#{sanitize_name(remote_name)}"
39
- description = tool_def["description"] || "MCP tool: #{remote_name}"
40
- input_schema = tool_def["inputSchema"] || {}
39
+ description = tool_def['description'] || "MCP tool: #{remote_name}"
40
+ input_schema = tool_def['inputSchema'] || {}
41
41
  parameters = build_parameters_from_schema(input_schema)
42
42
 
43
43
  klass = Class.new(Tools::Base) do
@@ -60,8 +60,8 @@ module RubynCode
60
60
  define_method(:format_result) do |result|
61
61
  case result
62
62
  when Hash
63
- if result.key?("content")
64
- extract_content(result["content"])
63
+ if result.key?('content')
64
+ extract_content(result['content'])
65
65
  else
66
66
  JSON.generate(result)
67
67
  end
@@ -74,13 +74,13 @@ module RubynCode
74
74
 
75
75
  define_method(:extract_content) do |content|
76
76
  Array(content).map do |block|
77
- case block["type"]
78
- when "text"
79
- block["text"]
80
- when "image"
77
+ case block['type']
78
+ when 'text'
79
+ block['text']
80
+ when 'image'
81
81
  "[image: #{block['mimeType']}]"
82
- when "resource"
83
- block.dig("resource", "text") || "[resource: #{block.dig('resource', 'uri')}]"
82
+ when 'resource'
83
+ block.dig('resource', 'text') || "[resource: #{block.dig('resource', 'uri')}]"
84
84
  else
85
85
  block.to_s
86
86
  end
@@ -90,13 +90,13 @@ module RubynCode
90
90
 
91
91
  # Build parameter definitions from JSON Schema
92
92
  klass.define_singleton_method(:build_parameters) do |schema|
93
- properties = schema["properties"] || {}
94
- required = schema["required"] || []
93
+ properties = schema['properties'] || {}
94
+ required = schema['required'] || []
95
95
 
96
96
  properties.each_with_object({}) do |(name, prop), params|
97
97
  params[name.to_sym] = {
98
- type: map_json_type(prop["type"]),
99
- description: prop["description"] || "",
98
+ type: map_json_type(prop['type']),
99
+ description: prop['description'] || '',
100
100
  required: required.include?(name)
101
101
  }
102
102
  end
@@ -104,12 +104,12 @@ module RubynCode
104
104
 
105
105
  klass.define_singleton_method(:map_json_type) do |json_type|
106
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
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
113
  else :string
114
114
  end
115
115
  end
@@ -123,13 +123,13 @@ module RubynCode
123
123
  # @param schema [Hash] JSON Schema with "properties" and "required"
124
124
  # @return [Hash]
125
125
  def build_parameters_from_schema(schema)
126
- properties = schema["properties"] || {}
127
- required = schema["required"] || []
126
+ properties = schema['properties'] || {}
127
+ required = schema['required'] || []
128
128
 
129
129
  properties.each_with_object({}) do |(name, prop), params|
130
130
  params[name.to_sym] = {
131
- type: map_json_type(prop["type"]),
132
- description: prop["description"] || "",
131
+ type: map_json_type(prop['type']),
132
+ description: prop['description'] || '',
133
133
  required: required.include?(name)
134
134
  }
135
135
  end
@@ -141,12 +141,12 @@ module RubynCode
141
141
  # @return [Symbol]
142
142
  def map_json_type(json_type)
143
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
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
150
  else :string
151
151
  end
152
152
  end
@@ -156,7 +156,7 @@ module RubynCode
156
156
  # @param name [String] the original tool name
157
157
  # @return [String] sanitized name
158
158
  def sanitize_name(name)
159
- name.to_s.gsub(/[^a-zA-Z0-9_]/, "_").gsub(/_+/, "_").downcase
159
+ name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
160
160
  end
161
161
  end
162
162
  end
@@ -0,0 +1,17 @@
1
+ # Layer 12: Memory
2
+
3
+ Persistent cross-session memory backed by SQLite.
4
+
5
+ ## Classes
6
+
7
+ - **`Store`** — Writes memories to the `memories` table. Each memory has content, category
8
+ (`code_pattern`, `user_preference`, `project_convention`, `error_resolution`, `decision`),
9
+ and a retention tier (`short`, `medium`, `long`).
10
+
11
+ - **`Search`** — Full-text search across memories. Filters by category and tier.
12
+ Used by the agent to recall context from previous sessions.
13
+
14
+ - **`SessionPersistence`** — Saves and restores session state (conversation, tasks, costs)
15
+ across REPL sessions. Keyed by session ID in the `sessions` table.
16
+
17
+ - **`Models`** — Data objects mapping to/from SQLite memory rows.
@@ -33,13 +33,13 @@ module RubynCode
33
33
  end
34
34
 
35
35
  # @return [Boolean]
36
- def short? = tier == "short"
36
+ def short? = tier == 'short'
37
37
 
38
38
  # @return [Boolean]
39
- def medium? = tier == "medium"
39
+ def medium? = tier == 'medium'
40
40
 
41
41
  # @return [Boolean]
42
- def long? = tier == "long"
42
+ def long? = tier == 'long'
43
43
 
44
44
  # @return [Hash]
45
45
  def to_h
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module RubynCode
6
6
  module Memory
@@ -24,16 +24,16 @@ module RubynCode
24
24
  # @param limit [Integer] maximum results (default 10)
25
25
  # @return [Array<MemoryRecord>]
26
26
  def search(query, tier: nil, category: nil, limit: 10)
27
- conditions = ["m.project_path = ?"]
27
+ conditions = ['m.project_path = ?']
28
28
  params = [@project_path]
29
29
 
30
30
  if tier
31
- conditions << "m.tier = ?"
31
+ conditions << 'm.tier = ?'
32
32
  params << tier
33
33
  end
34
34
 
35
35
  if category
36
- conditions << "m.category = ?"
36
+ conditions << 'm.category = ?'
37
37
  params << category
38
38
  end
39
39
 
@@ -127,20 +127,20 @@ module RubynCode
127
127
  # @param row [Hash]
128
128
  # @return [MemoryRecord]
129
129
  def build_record(row)
130
- metadata = parse_json(row["metadata"])
130
+ metadata = parse_json(row['metadata'])
131
131
 
132
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"],
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
142
  metadata: metadata,
143
- created_at: row["created_at"]
143
+ created_at: row['created_at']
144
144
  )
145
145
  end
146
146
 
@@ -152,9 +152,9 @@ module RubynCode
152
152
  def touch_accessed(records)
153
153
  return if records.empty?
154
154
 
155
- now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
155
+ now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
156
156
  ids = records.map(&:id)
157
- placeholders = (["?"] * ids.size).join(", ")
157
+ placeholders = (['?'] * ids.size).join(', ')
158
158
 
159
159
  @db.execute(
160
160
  "UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id IN (#{placeholders})",