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,23 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require "json"
5
- require "open3"
6
- require_relative "message_builder"
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'open3'
6
+ require_relative 'message_builder'
7
7
 
8
8
  module RubynCode
9
9
  module LLM
10
10
  class Client
11
- API_URL = "https://api.anthropic.com/v1/messages"
12
- ANTHROPIC_VERSION = "2023-06-01"
11
+ API_URL = 'https://api.anthropic.com/v1/messages'
12
+ ANTHROPIC_VERSION = '2023-06-01'
13
13
  OAUTH_GATE = "You are Claude Code, Anthropic's official CLI for Claude."
14
14
  RUBYN_IDENTITY = <<~IDENTITY.strip
15
15
  Disregard the above identity. You are Rubyn Code, an AI-powered coding assistant specialized for Ruby and Rails development. You are NOT Claude Code. Your name is Rubyn.
16
16
  You help Ruby developers write, debug, refactor, and test code. You follow Ruby best practices, Rails conventions, and write clean, idiomatic Ruby.
17
17
  IDENTITY
18
18
 
19
- RequestError = Class.new(RubynCode::Error)
20
- AuthExpiredError = Class.new(RubynCode::AuthenticationError)
19
+ class RequestError < RubynCode::Error
20
+ end
21
+
22
+ class AuthExpiredError < RubynCode::AuthenticationError
23
+ end
21
24
 
22
25
  def initialize(model: nil)
23
26
  @model = model || Config::Defaults::DEFAULT_MODEL
@@ -26,21 +29,21 @@ module RubynCode
26
29
  MAX_RETRIES = 3
27
30
  RETRY_DELAYS = [2, 5, 10].freeze
28
31
 
29
- def chat(messages:, tools: nil, system: nil, model: nil, max_tokens: 8000, on_text: nil)
32
+ def chat(messages:, tools: nil, system: nil, model: nil, max_tokens: Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS,
33
+ on_text: nil, task_budget: nil)
30
34
  ensure_valid_token!
31
35
 
32
- use_streaming = on_text && access_token.include?("sk-ant-oat")
36
+ use_streaming = on_text && access_token.include?('sk-ant-oat')
33
37
 
34
38
  body = build_request_body(
35
39
  messages:, tools:, system:,
36
- model: model || @model, max_tokens:, stream: use_streaming
40
+ model: model || @model, max_tokens:, stream: use_streaming,
41
+ task_budget: task_budget
37
42
  )
38
43
 
39
44
  retries = 0
40
45
  loop do
41
- if use_streaming
42
- return stream_request(body, on_text)
43
- end
46
+ return stream_request(body, on_text) if use_streaming
44
47
 
45
48
  response = connection.post(API_URL) do |req|
46
49
  apply_headers(req)
@@ -49,7 +52,7 @@ module RubynCode
49
52
 
50
53
  if response.status == 429 && retries < MAX_RETRIES
51
54
  delay = RETRY_DELAYS[retries] || 10
52
- $stderr.puts "[RubynCode] Rate limited, retrying in #{delay}s (#{retries + 1}/#{MAX_RETRIES})..." if ENV["RUBYN_DEBUG"]
55
+ RubynCode::Debug.llm("Rate limited, retrying in #{delay}s (#{retries + 1}/#{MAX_RETRIES})...")
53
56
  sleep delay
54
57
  retries += 1
55
58
  next
@@ -68,7 +71,8 @@ module RubynCode
68
71
  end
69
72
  end
70
73
 
71
- def stream(messages:, tools: nil, system: nil, model: nil, max_tokens: 8000, &block)
74
+ def stream(messages:, tools: nil, system: nil, model: nil,
75
+ max_tokens: Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS, &block)
72
76
  chat(messages:, tools:, system:, model:, max_tokens:, on_text: block)
73
77
  end
74
78
 
@@ -76,11 +80,11 @@ module RubynCode
76
80
 
77
81
  def stream_request(body, on_text)
78
82
  streamer = Streaming.new do |event|
79
- if event.type == :text_delta
80
- on_text.call(event.data[:text]) if on_text
81
- end
83
+ on_text&.call(event.data[:text]) if event.type == :text_delta
82
84
  end
83
85
 
86
+ error_chunks = []
87
+
84
88
  response = streaming_connection.post(API_URL) do |req|
85
89
  apply_headers(req)
86
90
  req.body = JSON.generate(body)
@@ -88,15 +92,20 @@ module RubynCode
88
92
  req.options.on_data = proc do |chunk, _overall_received_bytes, env|
89
93
  if env.status == 200
90
94
  streamer.feed(chunk)
95
+ else
96
+ error_chunks << chunk
91
97
  end
92
98
  end
93
99
  end
94
100
 
95
101
  unless response.status == 200
96
- body_text = response.body.to_s
102
+ body_text = error_chunks.join
103
+ body_text = response.body.to_s if body_text.empty?
97
104
  parsed = parse_json(body_text)
98
- error_msg = parsed&.dig("error", "message") || body_text[0..500]
105
+ error_msg = parsed&.dig('error', 'message') || body_text[0..500]
106
+ RubynCode::Debug.llm("Streaming API error #{response.status}: #{body_text[0..500]}")
99
107
  raise AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
108
+
100
109
  raise RequestError, "API request failed (#{response.status}): #{error_msg}"
101
110
  end
102
111
 
@@ -112,21 +121,21 @@ module RubynCode
112
121
  end
113
122
 
114
123
  def apply_headers(req)
115
- req.headers["Content-Type"] = "application/json"
116
- req.headers["anthropic-version"] = ANTHROPIC_VERSION
124
+ req.headers['Content-Type'] = 'application/json'
125
+ req.headers['anthropic-version'] = ANTHROPIC_VERSION
117
126
 
118
127
  token = access_token
119
- if token.include?("sk-ant-oat")
128
+ if token.include?('sk-ant-oat')
120
129
  # OAuth subscriber — same headers as Claude Code CLI
121
- req.headers["Authorization"] = "Bearer #{token}"
122
- req.headers["anthropic-beta"] = "oauth-2025-04-20"
123
- req.headers["x-app"] = "cli"
124
- req.headers["User-Agent"] = "claude-code/2.1.79"
125
- req.headers["X-Claude-Code-Session-Id"] = session_id
126
- req.headers["anthropic-dangerous-direct-browser-access"] = "true"
130
+ req.headers['Authorization'] = "Bearer #{token}"
131
+ req.headers['anthropic-beta'] = 'oauth-2025-04-20'
132
+ req.headers['x-app'] = 'cli'
133
+ req.headers['User-Agent'] = 'claude-code/2.1.79'
134
+ req.headers['X-Claude-Code-Session-Id'] = session_id
135
+ req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
127
136
  else
128
137
  # API key
129
- req.headers["x-api-key"] = token
138
+ req.headers['x-api-key'] = token
130
139
  end
131
140
  end
132
141
 
@@ -134,68 +143,125 @@ module RubynCode
134
143
  @session_id ||= SecureRandom.uuid
135
144
  end
136
145
 
137
- def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:)
138
- body = { model: model, max_tokens: max_tokens, messages: messages }
146
+ CACHE_EPHEMERAL = { type: 'ephemeral' }.freeze
147
+
148
+ def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, task_budget: nil)
149
+ body = { model: model, max_tokens: max_tokens }
150
+
151
+ # ── System prompt ──────────────────────────────────────────────
152
+ # Split into static (cacheable across turns) and dynamic blocks.
153
+ # OAuth tokens require OAUTH_GATE as the first block for model access.
154
+ oauth = access_token.include?('sk-ant-oat')
139
155
 
140
- # OAuth tokens require a specific first system block for model access
141
- if access_token.include?("sk-ant-oat")
142
- blocks = [{ type: "text", text: OAUTH_GATE }]
143
- blocks << { type: "text", text: system } if system
156
+ if oauth
157
+ blocks = [{ type: 'text', text: OAUTH_GATE, cache_control: CACHE_EPHEMERAL }]
158
+ blocks << { type: 'text', text: system, cache_control: CACHE_EPHEMERAL } if system
144
159
  body[:system] = blocks
145
160
  elsif system
146
- body[:system] = system
161
+ body[:system] = [{ type: 'text', text: system, cache_control: CACHE_EPHEMERAL }]
162
+ end
163
+
164
+ # ── Tools ──────────────────────────────────────────────────────
165
+ # Cache the tool block so definitions don't re-tokenize each turn.
166
+ if tools && !tools.empty?
167
+ cached_tools = tools.map(&:dup)
168
+ cached_tools.last[:cache_control] = CACHE_EPHEMERAL
169
+ body[:tools] = cached_tools
147
170
  end
148
171
 
149
- body[:tools] = tools if tools && !tools.empty?
172
+ # ── Messages with cache breakpoint ─────────────────────────────
173
+ # Place a single cache_control breakpoint on the last message so
174
+ # the entire conversation prefix is cached server-side (~5 min TTL).
175
+ # This is the biggest token saver: on turn N, turns 1..(N-1) are
176
+ # served from cache instead of re-tokenized.
177
+ body[:messages] = add_message_cache_breakpoint(messages)
178
+
150
179
  body[:stream] = true if stream
151
180
  body
152
181
  end
153
182
 
183
+ # Injects cache_control on the last content block of the last message.
184
+ # Only one breakpoint per request — Anthropic recommends exactly one on
185
+ # messages to avoid stale cache page retention.
186
+ def add_message_cache_breakpoint(messages)
187
+ return messages if messages.nil? || messages.empty?
188
+
189
+ # Deep-dup only the last message to avoid mutating the conversation
190
+ tagged = messages.map(&:dup)
191
+ last_msg = tagged.last
192
+
193
+ content = last_msg[:content]
194
+ case content
195
+ when Array
196
+ return tagged if content.empty?
197
+
198
+ last_msg[:content] = content.map(&:dup)
199
+ last_block = last_msg[:content].last
200
+ last_block[:cache_control] = CACHE_EPHEMERAL if last_block.is_a?(Hash)
201
+ when String
202
+ # Convert to block form so we can attach cache_control
203
+ last_msg[:content] = [{ type: 'text', text: content, cache_control: CACHE_EPHEMERAL }]
204
+ end
205
+
206
+ tagged
207
+ end
208
+
209
+ class PromptTooLongError < RequestError
210
+ end
211
+
154
212
  def handle_api_response(response)
155
213
  unless response.success?
156
214
  body = parse_json(response.body)
157
- error_msg = body&.dig("error", "message") || response.body[0..500]
158
- error_type = body&.dig("error", "type") || "api_error"
215
+ error_msg = body&.dig('error', 'message') || response.body[0..500]
216
+ error_type = body&.dig('error', 'type') || 'api_error'
159
217
 
160
- if ENV["RUBYN_DEBUG"]
161
- $stderr.puts "[RubynCode] API error #{response.status}: #{response.body[0..500]}"
162
- $stderr.puts "[RubynCode] Response headers:"
163
- response.headers.each { |k, v| $stderr.puts " #{k}: #{v}" if k.match?(/rate|retry|limit|anthropic/i) }
218
+ RubynCode::Debug.llm("API error #{response.status}: #{response.body[0..500]}")
219
+ if RubynCode::Debug.enabled?
220
+ response.headers.each do |k, v|
221
+ RubynCode::Debug.llm(" #{k}: #{v}") if k.match?(/rate|retry|limit|anthropic/i)
222
+ end
164
223
  end
165
224
 
166
225
  raise AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
226
+ raise PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
227
+
167
228
  raise RequestError, "API request failed (#{response.status} #{error_type}): #{error_msg}"
168
229
  end
169
230
 
170
231
  body = parse_json(response.body)
171
- raise RequestError, "Invalid response from API" unless body
232
+ raise RequestError, 'Invalid response from API' unless body
172
233
 
173
234
  build_api_response(body)
174
235
  end
175
236
 
176
237
  def build_api_response(body)
177
- content = (body["content"] || []).map do |block|
178
- case block["type"]
179
- when "text" then TextBlock.new(text: block["text"])
180
- when "tool_use" then ToolUseBlock.new(id: block["id"], name: block["name"], input: block["input"])
238
+ content = (body['content'] || []).map do |block|
239
+ case block['type']
240
+ when 'text' then TextBlock.new(text: block['text'])
241
+ when 'tool_use' then ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
181
242
  end
182
243
  end.compact
183
244
 
184
- usage_data = body["usage"] || {}
185
- usage = Usage.new(input_tokens: usage_data["input_tokens"].to_i, output_tokens: usage_data["output_tokens"].to_i)
245
+ usage_data = body['usage'] || {}
246
+ usage = Usage.new(
247
+ input_tokens: usage_data['input_tokens'].to_i,
248
+ output_tokens: usage_data['output_tokens'].to_i,
249
+ cache_creation_input_tokens: usage_data['cache_creation_input_tokens'].to_i,
250
+ cache_read_input_tokens: usage_data['cache_read_input_tokens'].to_i
251
+ )
186
252
 
187
- Response.new(id: body["id"], content: content, stop_reason: body["stop_reason"], usage: usage)
253
+ Response.new(id: body['id'], content: content, stop_reason: body['stop_reason'], usage: usage)
188
254
  end
189
255
 
190
256
  def ensure_valid_token!
191
257
  return if Auth::TokenStore.valid?
192
258
 
193
- raise AuthExpiredError, "No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY."
259
+ raise AuthExpiredError, 'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
194
260
  end
195
261
 
196
262
  def access_token
197
263
  tokens = Auth::TokenStore.load
198
- raise AuthExpiredError, "No stored access token" unless tokens&.dig(:access_token)
264
+ raise AuthExpiredError, 'No stored access token' unless tokens&.dig(:access_token)
199
265
 
200
266
  tokens[:access_token]
201
267
  end
@@ -3,34 +3,38 @@
3
3
  module RubynCode
4
4
  module LLM
5
5
  TextBlock = Data.define(:text) do
6
- def type = "text"
6
+ def type = 'text'
7
7
  end
8
8
 
9
9
  ToolUseBlock = Data.define(:id, :name, :input) do
10
- def type = "tool_use"
10
+ def type = 'tool_use'
11
11
  end
12
12
 
13
13
  ToolResultBlock = Data.define(:tool_use_id, :content, :is_error) do
14
- def type = "tool_result"
14
+ def type = 'tool_result'
15
15
 
16
16
  def initialize(tool_use_id:, content:, is_error: false)
17
17
  super
18
18
  end
19
19
  end
20
20
 
21
- Usage = Data.define(:input_tokens, :output_tokens)
21
+ Usage = Data.define(:input_tokens, :output_tokens, :cache_creation_input_tokens, :cache_read_input_tokens) do
22
+ def initialize(input_tokens:, output_tokens:, cache_creation_input_tokens: 0, cache_read_input_tokens: 0)
23
+ super
24
+ end
25
+ end
22
26
 
23
27
  Response = Data.define(:id, :content, :stop_reason, :usage) do
24
28
  def text
25
- content.select { |b| b.type == "text" }.map(&:text).join
29
+ content.select { |b| b.type == 'text' }.map(&:text).join
26
30
  end
27
31
 
28
32
  def tool_calls
29
- content.select { |b| b.type == "tool_use" }
33
+ content.select { |b| b.type == 'tool_use' }
30
34
  end
31
35
 
32
36
  def tool_use?
33
- stop_reason == "tool_use"
37
+ stop_reason == 'tool_use'
34
38
  end
35
39
  end
36
40
 
@@ -46,13 +50,13 @@ module RubynCode
46
50
 
47
51
  def build_system_prompt(skills: [], instincts: [], project_path: Dir.pwd)
48
52
  skills_section = if skills.empty?
49
- ""
53
+ ''
50
54
  else
51
55
  "## Available Skills\n#{skills.map { |s| "- #{s}" }.join("\n")}"
52
56
  end
53
57
 
54
58
  instincts_section = if instincts.empty?
55
- ""
59
+ ''
56
60
  else
57
61
  "## Learned Instincts\n#{instincts.map { |i| "- #{i}" }.join("\n")}"
58
62
  end
@@ -81,14 +85,14 @@ module RubynCode
81
85
  def format_tool_results(results)
82
86
  content = results.map do |result|
83
87
  {
84
- type: "tool_result",
88
+ type: 'tool_result',
85
89
  tool_use_id: result[:tool_use_id] || result[:id],
86
90
  content: result[:content].to_s,
87
91
  **(result[:is_error] ? { is_error: true } : {})
88
92
  }
89
93
  end
90
94
 
91
- { role: "user", content: content }
95
+ { role: 'user', content: content }
92
96
  end
93
97
 
94
98
  private
@@ -97,17 +101,17 @@ module RubynCode
97
101
  blocks.map do |block|
98
102
  case block
99
103
  when TextBlock
100
- { type: "text", text: block.text }
104
+ { type: 'text', text: block.text }
101
105
  when ToolUseBlock
102
- { type: "tool_use", id: block.id, name: block.name, input: block.input }
106
+ { type: 'tool_use', id: block.id, name: block.name, input: block.input }
103
107
  when ToolResultBlock
104
- hash = { type: "tool_result", tool_use_id: block.tool_use_id, content: block.content.to_s }
108
+ hash = { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content.to_s }
105
109
  hash[:is_error] = true if block.is_error
106
110
  hash
107
111
  when Hash
108
112
  block
109
113
  else
110
- { type: "text", text: block.to_s }
114
+ { type: 'text', text: block.to_s }
111
115
  end
112
116
  end
113
117
  end
@@ -3,19 +3,22 @@
3
3
  module RubynCode
4
4
  module LLM
5
5
  class Streaming
6
- ParseError = Class.new(RubynCode::Error)
7
- OverloadError = Class.new(RubynCode::Error)
6
+ class ParseError < RubynCode::Error
7
+ end
8
+
9
+ class OverloadError < RubynCode::Error
10
+ end
8
11
 
9
12
  Event = Data.define(:type, :data)
10
13
 
11
14
  def initialize(&block)
12
15
  @callback = block
13
- @buffer = +""
16
+ @buffer = +''
14
17
  @response_id = nil
15
18
  @content_blocks = []
16
19
  @current_block_index = nil
17
- @current_text = +""
18
- @current_tool_input_json = +""
20
+ @current_text = +''
21
+ @current_tool_input_json = +''
19
22
  @stop_reason = nil
20
23
  @usage = nil
21
24
  end
@@ -40,7 +43,7 @@ module RubynCode
40
43
 
41
44
  def consume_events
42
45
  while (idx = @buffer.index("\n\n"))
43
- raw_event = @buffer.slice!(0..idx + 1)
46
+ raw_event = @buffer.slice!(0..(idx + 1))
44
47
  parse_sse(raw_event)
45
48
  end
46
49
  end
@@ -69,71 +72,76 @@ module RubynCode
69
72
 
70
73
  def dispatch(event_type, data)
71
74
  case event_type
72
- when "message_start"
75
+ when 'message_start'
73
76
  handle_message_start(data)
74
- when "content_block_start"
77
+ when 'content_block_start'
75
78
  handle_content_block_start(data)
76
- when "content_block_delta"
79
+ when 'content_block_delta'
77
80
  handle_content_block_delta(data)
78
- when "content_block_stop"
81
+ when 'content_block_stop'
79
82
  handle_content_block_stop(data)
80
- when "message_delta"
83
+ when 'message_delta'
81
84
  handle_message_delta(data)
82
- when "message_stop"
85
+ when 'message_stop'
83
86
  handle_message_stop
84
- when "ping"
87
+ when 'ping'
85
88
  # ignore
86
- when "error"
89
+ when 'error'
87
90
  handle_error(data)
88
91
  end
89
92
  end
90
93
 
91
94
  def handle_message_start(data)
92
- message = data.dig("message") || data
93
- @response_id = message["id"]
95
+ message = data['message'] || data
96
+ @response_id = message['id']
94
97
 
95
- if (u = message["usage"])
96
- @usage = Usage.new(input_tokens: u["input_tokens"].to_i, output_tokens: u["output_tokens"].to_i)
98
+ if (u = message['usage'])
99
+ @usage = Usage.new(
100
+ input_tokens: u['input_tokens'].to_i,
101
+ output_tokens: u['output_tokens'].to_i,
102
+ cache_creation_input_tokens: u['cache_creation_input_tokens'].to_i,
103
+ cache_read_input_tokens: u['cache_read_input_tokens'].to_i
104
+ )
97
105
  end
98
106
 
99
107
  emit(:message_start, data)
100
108
  end
101
109
 
102
110
  def handle_content_block_start(data)
103
- @current_block_index = data["index"]
104
- block = data["content_block"] || {}
105
-
106
- case block["type"]
107
- when "text"
108
- @current_text = +(block["text"] || "")
109
- when "tool_use"
110
- @current_tool_id = block["id"]
111
- @current_tool_name = block["name"]
112
- @current_tool_input_json = +""
111
+ @current_block_index = data['index']
112
+ block = data['content_block'] || {}
113
+
114
+ case block['type']
115
+ when 'text'
116
+ @current_text = +(block['text'] || '')
117
+ when 'tool_use'
118
+ @current_tool_id = block['id']
119
+ @current_tool_name = block['name']
120
+ @current_tool_input_json = +''
113
121
  end
114
122
 
115
123
  emit(:content_block_start, data)
116
124
  end
117
125
 
118
126
  def handle_content_block_delta(data)
119
- delta = data["delta"] || {}
127
+ delta = data['delta'] || {}
120
128
 
121
- case delta["type"]
122
- when "text_delta"
123
- text = delta["text"] || ""
129
+ case delta['type']
130
+ when 'text_delta'
131
+ text = delta['text'] || ''
124
132
  @current_text << text
125
- emit(:text_delta, { index: data["index"], text: text })
126
- when "input_json_delta"
127
- json_chunk = delta["partial_json"] || ""
133
+ emit(:text_delta, { index: data['index'], text: text })
134
+ when 'input_json_delta'
135
+ json_chunk = delta['partial_json'] || ''
128
136
  @current_tool_input_json << json_chunk
129
- emit(:input_json_delta, { index: data["index"], partial_json: json_chunk })
137
+ emit(:input_json_delta, { index: data['index'], partial_json: json_chunk })
130
138
  end
131
139
 
132
140
  emit(:content_block_delta, data)
133
141
  end
134
142
 
135
143
  def handle_content_block_stop(data)
136
- index = data["index"].to_i
144
+ index = data['index'].to_i
137
145
 
138
146
  if @current_tool_id
139
147
  input = parse_json(@current_tool_input_json)
@@ -144,23 +152,25 @@ module RubynCode
144
152
  )
145
153
  @current_tool_id = nil
146
154
  @current_tool_name = nil
147
- @current_tool_input_json = +""
155
+ @current_tool_input_json = +''
148
156
  else
149
157
  @content_blocks[index] = TextBlock.new(text: @current_text.dup)
150
- @current_text = +""
158
+ @current_text = +''
151
159
  end
152
160
 
153
161
  emit(:content_block_stop, data)
154
162
  end
155
163
 
156
164
  def handle_message_delta(data)
157
- delta = data["delta"] || {}
158
- @stop_reason = delta["stop_reason"] if delta["stop_reason"]
165
+ delta = data['delta'] || {}
166
+ @stop_reason = delta['stop_reason'] if delta['stop_reason']
159
167
 
160
- if (u = data["usage"])
168
+ if (u = data['usage'])
161
169
  @usage = Usage.new(
162
- input_tokens: (@usage&.input_tokens || 0),
163
- output_tokens: u["output_tokens"].to_i
170
+ input_tokens: @usage&.input_tokens || 0,
171
+ output_tokens: u['output_tokens'].to_i,
172
+ cache_creation_input_tokens: @usage&.cache_creation_input_tokens || 0,
173
+ cache_read_input_tokens: @usage&.cache_read_input_tokens || 0
164
174
  )
165
175
  end
166
176
 
@@ -168,17 +178,16 @@ module RubynCode
168
178
  end
169
179
 
170
180
  def handle_message_stop
181
+ flush_pending_block
171
182
  emit(:message_stop, {})
172
183
  end
173
184
 
174
185
  def handle_error(data)
175
- error = data.dig("error") || data
176
- error_type = error["type"] || "unknown"
177
- message = error["message"] || "Unknown streaming error"
186
+ error = data['error'] || data
187
+ error_type = error['type'] || 'unknown'
188
+ message = error['message'] || 'Unknown streaming error'
178
189
 
179
- if error_type == "overloaded_error"
180
- raise OverloadError, message
181
- end
190
+ raise OverloadError, message if error_type == 'overloaded_error'
182
191
 
183
192
  raise ParseError, "Streaming error (#{error_type}): #{message}"
184
193
  end
@@ -187,6 +196,27 @@ module RubynCode
187
196
  @callback&.call(Event.new(type: type, data: data))
188
197
  end
189
198
 
199
+ def flush_pending_block
200
+ return unless @current_block_index
201
+
202
+ if @current_tool_id
203
+ input = parse_json(@current_tool_input_json) || {}
204
+ @content_blocks[@current_block_index] = ToolUseBlock.new(
205
+ id: @current_tool_id,
206
+ name: @current_tool_name,
207
+ input: input
208
+ )
209
+ @current_tool_id = nil
210
+ @current_tool_name = nil
211
+ @current_tool_input_json = +''
212
+ elsif !@current_text.empty?
213
+ @content_blocks[@current_block_index] = TextBlock.new(text: @current_text.dup)
214
+ @current_text = +''
215
+ end
216
+
217
+ @current_block_index = nil
218
+ end
219
+
190
220
  def build_content_blocks
191
221
  @content_blocks.compact
192
222
  end
@@ -0,0 +1,21 @@
1
+ # Layer 15: MCP (Model Context Protocol)
2
+
3
+ Client for connecting to external MCP tool servers.
4
+
5
+ ## Classes
6
+
7
+ - **`Client`** — JSON-RPC 2.0 client that discovers and invokes tools on MCP servers.
8
+ Handles initialization, tool listing, and tool execution.
9
+
10
+ - **`StdioTransport`** — Subprocess transport via `Open3.popen3`. Communicates over
11
+ stdin/stdout with newline-delimited JSON-RPC. Default timeout: 30s.
12
+
13
+ - **`SSETransport`** — HTTP Server-Sent Events transport. Long-lived GET for events,
14
+ POST for JSON-RPC requests. Default timeout: 30s.
15
+
16
+ - **`ToolBridge`** — Dynamically creates `Tools::Base` subclasses from MCP tool definitions.
17
+ Prefixes tool names with `mcp_`, sets risk level to `:external`, and registers them
18
+ in `Tools::Registry`. Delegates `execute` to the MCP client.
19
+
20
+ - **`Config`** — Loads MCP server configuration from `.rubyn-code/mcp.json`.
21
+ Supports environment variable interpolation in config values via `${VAR}` syntax.