rubyn-code 0.5.1 → 0.7.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 +4 -4
- data/README.md +120 -3
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +45 -2
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +37 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
require 'securerandom'
|
|
6
5
|
require_relative '../message_builder'
|
|
@@ -8,9 +7,11 @@ require_relative '../message_builder'
|
|
|
8
7
|
module RubynCode
|
|
9
8
|
module LLM
|
|
10
9
|
module Adapters
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- auth helpers + streaming + finalize are all needed
|
|
11
11
|
class Anthropic < Base
|
|
12
12
|
include JsonParsing
|
|
13
13
|
include PromptCaching
|
|
14
|
+
include TokenCaching
|
|
14
15
|
|
|
15
16
|
API_URL = 'https://api.anthropic.com/v1/messages'
|
|
16
17
|
ANTHROPIC_VERSION = '2023-06-01'
|
|
@@ -18,8 +19,9 @@ module RubynCode
|
|
|
18
19
|
RETRY_DELAYS = [2, 5, 10].freeze
|
|
19
20
|
|
|
20
21
|
AVAILABLE_MODELS = %w[
|
|
22
|
+
claude-fable-5
|
|
23
|
+
claude-opus-4-8
|
|
21
24
|
claude-sonnet-4-20250514
|
|
22
|
-
claude-opus-4-6
|
|
23
25
|
claude-haiku-4-20250506
|
|
24
26
|
].freeze
|
|
25
27
|
|
|
@@ -46,30 +48,73 @@ module RubynCode
|
|
|
46
48
|
execute_with_retries(body, on_text)
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
# -- Auth helpers ----------------------------------------------
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
# Validate the active auth path before sending the request.
|
|
54
|
+
# Raises Client::AuthExpiredError with a clear message instead
|
|
55
|
+
# of letting the HTTP request fail with a 401.
|
|
56
|
+
def ensure_valid_token!
|
|
57
|
+
return if token_valid? && access_token && !access_token.empty?
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
return if !oauth_token? && api_key && !api_key.empty?
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
access_token.include?('sk-ant-oat')
|
|
61
|
+
raise Client::AuthExpiredError, 'No valid authentication configured'
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
# @return [Boolean] true when using the OAuth token (vs API key).
|
|
65
|
+
# A token from the keychain (or marked source: :keychain /
|
|
66
|
+
# :oauth) is treated as OAuth; a token from :env or
|
|
67
|
+
# :tokens_file is treated as a long-lived API key.
|
|
68
|
+
def oauth_token?
|
|
69
|
+
token = raw_access_token
|
|
70
|
+
return false unless token.is_a?(Hash)
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
source = token[:source] || token['source']
|
|
73
|
+
[:keychain, 'keychain', :oauth].include?(source)
|
|
66
74
|
end
|
|
67
75
|
|
|
76
|
+
# @return [String, nil] the access token in use.
|
|
77
|
+
# Accepts either a raw String token or a Hash like
|
|
78
|
+
# { access_token: "...", expires_at: ..., source: :keychain }.
|
|
79
|
+
# Memoized per-instance so that repeated calls during one
|
|
80
|
+
# request don't re-hit the token store.
|
|
68
81
|
def access_token
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
return @access_token if defined?(@access_token)
|
|
83
|
+
|
|
84
|
+
token = raw_access_token
|
|
85
|
+
@access_token =
|
|
86
|
+
case token
|
|
87
|
+
when nil then nil
|
|
88
|
+
when String then token.empty? ? nil : token
|
|
89
|
+
when Hash then token[:access_token] || token['access_token']
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [String, nil] the API key (ANTHROPIC_API_KEY), or nil
|
|
94
|
+
def api_key
|
|
95
|
+
ENV.fetch('ANTHROPIC_API_KEY', nil)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Boolean] true if the token store says the active
|
|
99
|
+
# credential is still valid
|
|
100
|
+
def token_valid?
|
|
101
|
+
return true unless defined?(Auth::TokenStore)
|
|
102
|
+
return true unless Auth::TokenStore.respond_to?(:valid?)
|
|
103
|
+
|
|
104
|
+
Auth::TokenStore.valid?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def raw_access_token
|
|
108
|
+
return @raw_access_token if defined?(@raw_access_token)
|
|
71
109
|
|
|
72
|
-
|
|
110
|
+
@raw_access_token =
|
|
111
|
+
(Auth::TokenStore.load if defined?(Auth::TokenStore) && Auth::TokenStore.respond_to?(:load))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def api_url
|
|
117
|
+
API_URL
|
|
73
118
|
end
|
|
74
119
|
|
|
75
120
|
# -- Request execution --------------------------------------------
|
|
@@ -144,6 +189,7 @@ module RubynCode
|
|
|
144
189
|
|
|
145
190
|
error_msg = extract_error_message(error_chunks.join)
|
|
146
191
|
|
|
192
|
+
invalidate_token_cache! if response.status == 401
|
|
147
193
|
raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
148
194
|
raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
|
|
149
195
|
|
|
@@ -167,6 +213,7 @@ module RubynCode
|
|
|
167
213
|
end
|
|
168
214
|
|
|
169
215
|
def build_faraday_connection
|
|
216
|
+
require 'faraday'
|
|
170
217
|
Faraday.new do |f|
|
|
171
218
|
f.options.timeout = 300
|
|
172
219
|
f.options.open_timeout = 30
|
|
@@ -227,6 +274,7 @@ module RubynCode
|
|
|
227
274
|
error_type = body&.dig('error', 'type') || 'api_error'
|
|
228
275
|
|
|
229
276
|
log_api_error(response)
|
|
277
|
+
invalidate_token_cache! if response.status == 401
|
|
230
278
|
raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
231
279
|
raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
|
|
232
280
|
|
|
@@ -270,5 +318,6 @@ module RubynCode
|
|
|
270
318
|
end
|
|
271
319
|
end
|
|
272
320
|
end
|
|
321
|
+
# rubocop:enable Metrics/ClassLength
|
|
273
322
|
end
|
|
274
323
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
require_relative '../message_builder'
|
|
6
5
|
|
|
@@ -141,6 +140,7 @@ module RubynCode
|
|
|
141
140
|
end
|
|
142
141
|
|
|
143
142
|
def build_faraday_connection
|
|
143
|
+
require 'faraday'
|
|
144
144
|
Faraday.new do |f|
|
|
145
145
|
f.options.timeout = 300
|
|
146
146
|
f.options.open_timeout = 30
|
|
@@ -36,7 +36,11 @@ module RubynCode
|
|
|
36
36
|
def add_message_cache_breakpoint(messages)
|
|
37
37
|
return messages if messages.nil? || messages.empty?
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# Only the last message gets the cache_control tag, so only it
|
|
40
|
+
# needs to be duped — copying the whole history every call is
|
|
41
|
+
# wasted work on long conversations.
|
|
42
|
+
tagged = messages.dup
|
|
43
|
+
tagged[-1] = tagged[-1].dup
|
|
40
44
|
tag_last_message_content(tagged.last)
|
|
41
45
|
tagged
|
|
42
46
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module LLM
|
|
5
|
+
module Adapters
|
|
6
|
+
# Cached access to the Anthropic credential.
|
|
7
|
+
#
|
|
8
|
+
# TokenStore.load shells out to the macOS keychain, so the loaded
|
|
9
|
+
# tokens are cached per adapter instance. The cache drops itself once
|
|
10
|
+
# the token nears expiry (picking up an externally refreshed
|
|
11
|
+
# credential) and must be invalidated on 401 responses.
|
|
12
|
+
module TokenCaching
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def oauth_token?
|
|
16
|
+
return @oauth_token unless @oauth_token.nil?
|
|
17
|
+
|
|
18
|
+
@oauth_token = access_token.include?('sk-ant-oat')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ensure_valid_token!
|
|
22
|
+
return if Auth::TokenStore.valid_tokens?(tokens)
|
|
23
|
+
|
|
24
|
+
raise Client::AuthExpiredError,
|
|
25
|
+
'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def access_token
|
|
29
|
+
token = tokens&.fetch(:access_token, nil)
|
|
30
|
+
raise Client::AuthExpiredError, 'No stored access token' unless token
|
|
31
|
+
|
|
32
|
+
token
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def tokens
|
|
36
|
+
invalidate_token_cache! if token_cache_stale?
|
|
37
|
+
@tokens ||= Auth::TokenStore.load
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def token_cache_stale?
|
|
41
|
+
expires_at = @tokens&.fetch(:expires_at, nil)
|
|
42
|
+
return false unless expires_at
|
|
43
|
+
|
|
44
|
+
expires_at <= Time.now + Auth::TokenStore::EXPIRY_BUFFER_SECONDS
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def invalidate_token_cache!
|
|
48
|
+
@tokens = nil
|
|
49
|
+
@oauth_token = nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -14,7 +14,7 @@ module RubynCode
|
|
|
14
14
|
# models:
|
|
15
15
|
# cheap: claude-haiku-4-5
|
|
16
16
|
# mid: claude-sonnet-4-6
|
|
17
|
-
# top: claude-opus-4-
|
|
17
|
+
# top: claude-opus-4-8
|
|
18
18
|
# openai:
|
|
19
19
|
# env_key: OPENAI_API_KEY
|
|
20
20
|
# models:
|
|
@@ -50,7 +50,7 @@ module RubynCode
|
|
|
50
50
|
%w[openai gpt-5.4-mini]
|
|
51
51
|
].freeze,
|
|
52
52
|
top: [
|
|
53
|
-
%w[anthropic claude-opus-4-
|
|
53
|
+
%w[anthropic claude-opus-4-8],
|
|
54
54
|
%w[openai gpt-5.4]
|
|
55
55
|
].freeze
|
|
56
56
|
}.freeze
|
|
@@ -60,6 +60,63 @@ module RubynCode
|
|
|
60
60
|
})
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Returns the resources advertised by the server (empty unless the
|
|
64
|
+
# server declared the `resources` capability). Each entry is a Hash with
|
|
65
|
+
# "uri", "name", and optionally "description"/"mimeType".
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<Hash>]
|
|
68
|
+
def resources
|
|
69
|
+
return [] unless supports_resources?
|
|
70
|
+
|
|
71
|
+
@resources ||= @transport.send_request('resources/list')&.fetch('resources', []) || []
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
RubynCode::Debug.warn("MCP '#{@name}' resources/list failed: #{e.message}")
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Reads a single resource by URI.
|
|
78
|
+
#
|
|
79
|
+
# @param uri [String]
|
|
80
|
+
# @return [Hash] result with a "contents" array
|
|
81
|
+
def read_resource(uri)
|
|
82
|
+
ensure_connected!
|
|
83
|
+
@transport.send_request('resources/read', { uri: uri })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the prompts advertised by the server (empty unless the server
|
|
87
|
+
# declared the `prompts` capability). Each entry has "name" and
|
|
88
|
+
# optionally "description"/"arguments".
|
|
89
|
+
#
|
|
90
|
+
# @return [Array<Hash>]
|
|
91
|
+
def prompts
|
|
92
|
+
return [] unless supports_prompts?
|
|
93
|
+
|
|
94
|
+
@prompts ||= @transport.send_request('prompts/list')&.fetch('prompts', []) || []
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
RubynCode::Debug.warn("MCP '#{@name}' prompts/list failed: #{e.message}")
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Fetches a prompt, expanding its template with the given arguments.
|
|
101
|
+
#
|
|
102
|
+
# @param name [String]
|
|
103
|
+
# @param arguments [Hash]
|
|
104
|
+
# @return [Hash] result with "messages" (and optionally "description")
|
|
105
|
+
def get_prompt(name, arguments = {})
|
|
106
|
+
ensure_connected!
|
|
107
|
+
@transport.send_request('prompts/get', { name: name, arguments: arguments })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Boolean] whether the server advertised the resources capability
|
|
111
|
+
def supports_resources?
|
|
112
|
+
@server_capabilities.is_a?(Hash) && @server_capabilities.key?('resources')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Boolean] whether the server advertised the prompts capability
|
|
116
|
+
def supports_prompts?
|
|
117
|
+
@server_capabilities.is_a?(Hash) && @server_capabilities.key?('prompts')
|
|
118
|
+
end
|
|
119
|
+
|
|
63
120
|
# Gracefully disconnects from the MCP server.
|
|
64
121
|
#
|
|
65
122
|
# @return [void]
|
|
@@ -67,6 +124,8 @@ module RubynCode
|
|
|
67
124
|
@transport.stop!
|
|
68
125
|
@initialized = false
|
|
69
126
|
@tools = nil
|
|
127
|
+
@resources = nil
|
|
128
|
+
@prompts = nil
|
|
70
129
|
end
|
|
71
130
|
|
|
72
131
|
# Returns whether the client is connected and the transport is alive.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module MCP
|
|
5
|
+
# Bridges an MCP server's *resources* and *prompts* into native RubynCode
|
|
6
|
+
# tools — the non-"tools" half of the MCP surface. Split out of ToolBridge
|
|
7
|
+
# so tool bridging and resource/prompt bridging each stay one focused job.
|
|
8
|
+
#
|
|
9
|
+
# Each capability registers a single tool whose description lists what's
|
|
10
|
+
# available (resource URIs / prompt names) and reads or fetches any of them.
|
|
11
|
+
module ServerExtrasBridge
|
|
12
|
+
class << self
|
|
13
|
+
# If the server exposes resources, register one tool that lists the
|
|
14
|
+
# available URIs (in its description) and reads any of them.
|
|
15
|
+
#
|
|
16
|
+
# @return [Array<Class>] the [read_resource] tool class, or [] if none
|
|
17
|
+
def bridge_resources(mcp_client)
|
|
18
|
+
resources = safe_list(mcp_client, :resources)
|
|
19
|
+
return [] if resources.empty?
|
|
20
|
+
|
|
21
|
+
klass = create_resource_tool(mcp_client, resources)
|
|
22
|
+
Tools::Registry.register(klass)
|
|
23
|
+
[klass]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# If the server exposes prompts, register one tool that lists the
|
|
27
|
+
# available prompt names and fetches any of them (with arguments).
|
|
28
|
+
#
|
|
29
|
+
# @return [Array<Class>] the [get_prompt] tool class, or [] if none
|
|
30
|
+
def bridge_prompts(mcp_client)
|
|
31
|
+
prompts = safe_list(mcp_client, :prompts)
|
|
32
|
+
return [] if prompts.empty?
|
|
33
|
+
|
|
34
|
+
klass = create_prompt_tool(mcp_client, prompts)
|
|
35
|
+
Tools::Registry.register(klass)
|
|
36
|
+
[klass]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def safe_list(mcp_client, method)
|
|
42
|
+
Array(mcp_client.public_send(method))
|
|
43
|
+
rescue StandardError
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_resource_tool(mcp_client, resources)
|
|
48
|
+
listing = resources.map { |r| "- #{r['uri']}#{r['name'] && " (#{r['name']})"}" }.join("\n")
|
|
49
|
+
description = "Read a resource from MCP server '#{mcp_client.name}'. Available resources:\n#{listing}"
|
|
50
|
+
params = { uri: { type: :string, description: 'The resource URI to read', required: true } }
|
|
51
|
+
|
|
52
|
+
tool_name = "mcp_#{ToolBridge.sanitize_name(mcp_client.name)}_read_resource"
|
|
53
|
+
build_dynamic_tool(tool_name, description, params) do |args|
|
|
54
|
+
format_resource_contents(mcp_client.read_resource(args[:uri] || args['uri']))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def create_prompt_tool(mcp_client, prompts)
|
|
59
|
+
listing = prompts.map { |p| "- #{p['name']}#{p['description'] && ": #{p['description']}"}" }.join("\n")
|
|
60
|
+
description = "Fetch a prompt from MCP server '#{mcp_client.name}'. Available prompts:\n#{listing}"
|
|
61
|
+
params = {
|
|
62
|
+
name: { type: :string, description: 'The prompt name', required: true },
|
|
63
|
+
arguments: { type: :object, description: 'Prompt arguments (optional)', required: false }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
tool_name = "mcp_#{ToolBridge.sanitize_name(mcp_client.name)}_get_prompt"
|
|
67
|
+
build_dynamic_tool(tool_name, description, params) do |args|
|
|
68
|
+
result = mcp_client.get_prompt(args[:name] || args['name'], args[:arguments] || args['arguments'] || {})
|
|
69
|
+
format_prompt_messages(result)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Build a Tools::Base subclass whose #execute delegates to the block.
|
|
74
|
+
def build_dynamic_tool(tool_name, description, parameters, &handler)
|
|
75
|
+
Class.new(Tools::Base) do
|
|
76
|
+
const_set(:TOOL_NAME, tool_name)
|
|
77
|
+
const_set(:DESCRIPTION, description)
|
|
78
|
+
const_set(:PARAMETERS, parameters)
|
|
79
|
+
const_set(:RISK_LEVEL, :external)
|
|
80
|
+
const_set(:REQUIRES_CONFIRMATION, true)
|
|
81
|
+
|
|
82
|
+
define_method(:execute) { |**params| handler.call(params) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_resource_contents(result)
|
|
87
|
+
contents = result.is_a?(Hash) ? Array(result['contents']) : []
|
|
88
|
+
return result.to_s if contents.empty?
|
|
89
|
+
|
|
90
|
+
contents.map { |c| c['text'] || "[binary resource: #{c['uri']} #{c['mimeType']}]" }.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def format_prompt_messages(result)
|
|
94
|
+
messages = result.is_a?(Hash) ? Array(result['messages']) : []
|
|
95
|
+
return result.to_s if messages.empty?
|
|
96
|
+
|
|
97
|
+
messages.map { |m| "#{m['role']}: #{prompt_message_text(m['content'])}" }.join("\n\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def prompt_message_text(content)
|
|
101
|
+
case content
|
|
102
|
+
when Hash then content['text'] || content.to_s
|
|
103
|
+
when Array then content.map { |b| b.is_a?(Hash) ? b['text'] : b }.compact.join("\n")
|
|
104
|
+
else content.to_s
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
require 'uri'
|
|
6
5
|
|
|
@@ -111,6 +110,7 @@ module RubynCode
|
|
|
111
110
|
end
|
|
112
111
|
|
|
113
112
|
def connection
|
|
113
|
+
require 'faraday'
|
|
114
114
|
@connection ||= Faraday.new(url: base_url) do |f|
|
|
115
115
|
f.options.timeout = @timeout
|
|
116
116
|
f.options.open_timeout = @timeout
|
|
@@ -165,6 +165,7 @@ module RubynCode
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
def build_sse_connection
|
|
168
|
+
require 'faraday'
|
|
168
169
|
Faraday.new(url: base_url) do |f|
|
|
169
170
|
f.options.timeout = nil
|
|
170
171
|
f.options.open_timeout = @timeout
|
|
@@ -10,6 +10,9 @@ module RubynCode
|
|
|
10
10
|
# - Has RISK_LEVEL = :external
|
|
11
11
|
# - Delegates #execute to the MCP client's #call_tool
|
|
12
12
|
# - Registers itself with Tools::Registry
|
|
13
|
+
#
|
|
14
|
+
# A server's resources and prompts are bridged separately by
|
|
15
|
+
# {ServerExtrasBridge}, which #bridge folds into the returned classes.
|
|
13
16
|
module ToolBridge
|
|
14
17
|
JSON_TYPE_MAP = {
|
|
15
18
|
'string' => :string, 'integer' => :integer, 'number' => :number,
|
|
@@ -18,17 +21,24 @@ module RubynCode
|
|
|
18
21
|
|
|
19
22
|
class << self
|
|
20
23
|
# Discovers tools from an MCP client and creates corresponding
|
|
21
|
-
# RubynCode tool classes.
|
|
24
|
+
# RubynCode tool classes (plus any resource/prompt bridge tools).
|
|
22
25
|
#
|
|
23
26
|
# @param mcp_client [MCP::Client] a connected MCP client
|
|
24
27
|
# @return [Array<Class>] the dynamically created tool classes
|
|
25
28
|
def bridge(mcp_client)
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
classes = Array(mcp_client.tools).map { |tool_def| build_tool_class(mcp_client, tool_def) }
|
|
30
|
+
classes.concat(ServerExtrasBridge.bridge_resources(mcp_client))
|
|
31
|
+
classes.concat(ServerExtrasBridge.bridge_prompts(mcp_client))
|
|
32
|
+
classes
|
|
33
|
+
end
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
# Sanitizes a tool name into a Ruby-friendly identifier. Public so
|
|
36
|
+
# {ServerExtrasBridge} names its resource/prompt tools the same way.
|
|
37
|
+
#
|
|
38
|
+
# @param name [String] the original tool name
|
|
39
|
+
# @return [String] sanitized name
|
|
40
|
+
def sanitize_name(name)
|
|
41
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
private
|
|
@@ -119,14 +129,6 @@ module RubynCode
|
|
|
119
129
|
def map_json_type(json_type)
|
|
120
130
|
JSON_TYPE_MAP.fetch(json_type, :string)
|
|
121
131
|
end
|
|
122
|
-
|
|
123
|
-
# Sanitizes a tool name for use as a Ruby-friendly identifier.
|
|
124
|
-
#
|
|
125
|
-
# @param name [String] the original tool name
|
|
126
|
-
# @return [String] sanitized name
|
|
127
|
-
def sanitize_name(name)
|
|
128
|
-
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
|
|
129
|
-
end
|
|
130
132
|
end
|
|
131
133
|
end
|
|
132
134
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module Megaplan
|
|
@@ -17,7 +17,7 @@ module RubynCode
|
|
|
17
17
|
class CiRecovery
|
|
18
18
|
class RecoveryError < RubynCode::Error; end
|
|
19
19
|
|
|
20
|
-
SYSTEM_PROMPT = <<~PROMPT
|
|
20
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
21
21
|
You are Rubyn doing CI auto-recovery on a megaplan PR.
|
|
22
22
|
|
|
23
23
|
Read the failing job log, identify the root cause, push a fix
|
|
@@ -93,7 +93,7 @@ module RubynCode
|
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
def default_agent_invoker(
|
|
96
|
+
def default_agent_invoker(_prompt, _context)
|
|
97
97
|
# Stub for now — real wiring happens in RecoverCiHandler which has
|
|
98
98
|
# an Agent::Loop on hand. The handler injects its own invoker via
|
|
99
99
|
# the constructor.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'securerandom'
|
|
@@ -65,7 +65,7 @@ module RubynCode
|
|
|
65
65
|
# The skill teaches *what* a megaplan is and *how* to interview; this
|
|
66
66
|
# contract teaches the LLM the wire format the gem expects on every
|
|
67
67
|
# turn AND that its tool palette is read-only.
|
|
68
|
-
JSON_OUTPUT_CONTRACT = <<~CONTRACT
|
|
68
|
+
JSON_OUTPUT_CONTRACT = <<~CONTRACT
|
|
69
69
|
# Output contract (overrides any other formatting instinct)
|
|
70
70
|
|
|
71
71
|
You are an interviewer, not a coding agent. You have a READ-ONLY
|
|
@@ -192,13 +192,18 @@ module RubynCode
|
|
|
192
192
|
result = if INTERVIEW_TOOLS.include?(call.name.to_s)
|
|
193
193
|
@executor.execute(call.name, stringify_keys(call.input))
|
|
194
194
|
else
|
|
195
|
-
|
|
195
|
+
unavailable_tool_message(call.name)
|
|
196
196
|
end
|
|
197
197
|
{ type: 'tool_result', tool_use_id: call.id, content: result.to_s }
|
|
198
198
|
end
|
|
199
199
|
{ role: 'user', content: content }
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
def unavailable_tool_message(name)
|
|
203
|
+
"Tool '#{name}' is not available in interview mode " \
|
|
204
|
+
"(read-only palette: #{INTERVIEW_TOOLS.join(', ')})."
|
|
205
|
+
end
|
|
206
|
+
|
|
202
207
|
def stringify_keys(input)
|
|
203
208
|
return input unless input.is_a?(Hash)
|
|
204
209
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'securerandom'
|
|
@@ -18,7 +18,7 @@ module RubynCode
|
|
|
18
18
|
class InvalidProposalError < RubynCode::Error; end
|
|
19
19
|
|
|
20
20
|
MAX_PHASES = 12
|
|
21
|
-
DEFAULT_SYSTEM_PROMPT = <<~PROMPT
|
|
21
|
+
DEFAULT_SYSTEM_PROMPT = <<~PROMPT
|
|
22
22
|
You are a senior Ruby/Rails architect breaking a feature request into a megaplan.
|
|
23
23
|
|
|
24
24
|
A megaplan is a multi-phase development plan where each phase is a
|
|
@@ -127,7 +127,7 @@ module RubynCode
|
|
|
127
127
|
slug = slugify(feature) if slug.empty?
|
|
128
128
|
phases = payload['phases'].each_with_index.map do |phase, idx|
|
|
129
129
|
{
|
|
130
|
-
'number' => phase['number'] || idx + 1,
|
|
130
|
+
'number' => phase['number'] || (idx + 1),
|
|
131
131
|
'slug' => (phase['slug'].to_s.strip.empty? ? slugify(phase['name']) : phase['slug']),
|
|
132
132
|
'name' => phase['name'],
|
|
133
133
|
'summary' => phase['summary'],
|
|
@@ -6,9 +6,10 @@ require_relative 'models'
|
|
|
6
6
|
module RubynCode
|
|
7
7
|
module Memory
|
|
8
8
|
# Searches memories using SQLite FTS5 full-text search and standard
|
|
9
|
-
# queries.
|
|
10
|
-
#
|
|
11
|
-
# frequently-accessed memories against decay
|
|
9
|
+
# queries. Search methods automatically increment access_count and
|
|
10
|
+
# update last_accessed_at on returned records, reinforcing
|
|
11
|
+
# frequently-accessed memories against decay; #recent accepts
|
|
12
|
+
# touch: false to opt out for passive reads.
|
|
12
13
|
class Search
|
|
13
14
|
# @param db [DB::Connection] database connection
|
|
14
15
|
# @param project_path [String] scoping path for searches
|
|
@@ -60,8 +61,11 @@ module RubynCode
|
|
|
60
61
|
# Returns the most recently created memories.
|
|
61
62
|
#
|
|
62
63
|
# @param limit [Integer] maximum results (default 10)
|
|
64
|
+
# @param touch [Boolean] whether to record an access on returned
|
|
65
|
+
# records; pass false for passive reads (e.g. prompt assembly)
|
|
66
|
+
# that shouldn't reinforce memories or issue a write
|
|
63
67
|
# @return [Array<MemoryRecord>]
|
|
64
|
-
def recent(limit: 10)
|
|
68
|
+
def recent(limit: 10, touch: true)
|
|
65
69
|
rows = @db.query(<<~SQL, [@project_path, limit]).to_a
|
|
66
70
|
SELECT id, project_path, tier, category, content,
|
|
67
71
|
relevance_score, access_count, last_accessed_at,
|
|
@@ -73,7 +77,7 @@ module RubynCode
|
|
|
73
77
|
SQL
|
|
74
78
|
|
|
75
79
|
records = rows.map { |row| build_record(row) }
|
|
76
|
-
touch_accessed(records)
|
|
80
|
+
touch_accessed(records) if touch
|
|
77
81
|
records
|
|
78
82
|
end
|
|
79
83
|
|