rubyn-code 0.4.0 → 0.5.1
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 +247 -9
- data/lib/rubyn_code/agent/conversation.rb +2 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
- data/lib/rubyn_code/agent/llm_caller.rb +4 -2
- data/lib/rubyn_code/agent/loop.rb +7 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
- data/lib/rubyn_code/agent/tool_processor.rb +4 -2
- data/lib/rubyn_code/cli/app.rb +87 -13
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
- data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
- data/lib/rubyn_code/cli/commands/provider.rb +2 -1
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +4 -2
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- data/lib/rubyn_code/cli/repl.rb +11 -1
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_setup.rb +38 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- data/lib/rubyn_code/config/defaults.rb +2 -0
- data/lib/rubyn_code/config/settings.rb +5 -2
- data/lib/rubyn_code/context/context_budget.rb +2 -1
- data/lib/rubyn_code/context/manager.rb +3 -3
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +132 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +17 -1
- data/lib/rubyn_code/ide/server.rb +39 -1
- data/lib/rubyn_code/index/codebase_index.rb +2 -1
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- data/lib/rubyn_code/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +245 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +2 -1
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +10 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +1 -1
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -0
- data/lib/rubyn_code/tools/executor.rb +4 -2
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
- data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +3 -6
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +20 -0
- data/skills/megaplan/megaplan.md +156 -0
- data/skills/rubyn_self_test.md +75 -0
- metadata +25 -4
data/lib/rubyn_code/cli/setup.rb
CHANGED
|
@@ -95,6 +95,19 @@ module RubynCode
|
|
|
95
95
|
#
|
|
96
96
|
# To regenerate: rubyn-code --setup
|
|
97
97
|
# To remove: rm #{path}
|
|
98
|
+
if [ ! -x "#{pinned_ruby}" ] || [ ! -f "#{gem_wrapper}" ]; then
|
|
99
|
+
echo "rubyn-code: launcher target missing" >&2
|
|
100
|
+
[ ! -x "#{pinned_ruby}" ] && echo " pinned Ruby: #{pinned_ruby}" >&2
|
|
101
|
+
[ ! -f "#{gem_wrapper}" ] && echo " gem wrapper: #{gem_wrapper}" >&2
|
|
102
|
+
echo >&2
|
|
103
|
+
echo "The Ruby that 'rubyn-code --setup' was pinned against (or the gem" >&2
|
|
104
|
+
echo "itself) was removed. To recover under your current Ruby:" >&2
|
|
105
|
+
echo >&2
|
|
106
|
+
echo " rm '$0'" >&2
|
|
107
|
+
echo " gem install rubyn-code" >&2
|
|
108
|
+
echo " rubyn-code --setup" >&2
|
|
109
|
+
exit 127
|
|
110
|
+
fi
|
|
98
111
|
exec "#{pinned_ruby}" "#{gem_wrapper}" "$@"
|
|
99
112
|
BASH
|
|
100
113
|
end
|
|
@@ -16,6 +16,7 @@ module RubynCode
|
|
|
16
16
|
session_budget_usd daily_budget_usd
|
|
17
17
|
oauth_client_id oauth_redirect_uri oauth_authorize_url
|
|
18
18
|
oauth_token_url oauth_scopes
|
|
19
|
+
skills_autoload
|
|
19
20
|
].freeze
|
|
20
21
|
|
|
21
22
|
DEFAULT_MAP = {
|
|
@@ -35,7 +36,8 @@ module RubynCode
|
|
|
35
36
|
oauth_redirect_uri: Defaults::OAUTH_REDIRECT_URI,
|
|
36
37
|
oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
|
|
37
38
|
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
38
|
-
oauth_scopes: Defaults::OAUTH_SCOPES
|
|
39
|
+
oauth_scopes: Defaults::OAUTH_SCOPES,
|
|
40
|
+
skills_autoload: Defaults::SKILLS_AUTOLOAD
|
|
39
41
|
}.freeze
|
|
40
42
|
|
|
41
43
|
attr_reader :config_path, :data
|
|
@@ -160,7 +162,8 @@ module RubynCode
|
|
|
160
162
|
|
|
161
163
|
# Backfills missing 'models' keys into existing provider configs.
|
|
162
164
|
# Never overwrites user-set values — only adds what's missing.
|
|
163
|
-
|
|
165
|
+
# -- iterates providers with guard clauses
|
|
166
|
+
def backfill_provider_models!
|
|
164
167
|
providers = @data['providers']
|
|
165
168
|
return unless providers.is_a?(Hash)
|
|
166
169
|
|
|
@@ -130,7 +130,8 @@ module RubynCode
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
# -- signature extraction dispatch
|
|
134
|
+
def process_signature_line(line, signatures, indent_stack)
|
|
134
135
|
stripped = line.strip
|
|
135
136
|
if signature_line?(stripped)
|
|
136
137
|
signatures << line
|
|
@@ -74,11 +74,9 @@ module RubynCode
|
|
|
74
74
|
MICRO_COMPACT_RATIO_UNCACHED = 0.5
|
|
75
75
|
|
|
76
76
|
def check_compaction!(conversation)
|
|
77
|
-
# Guard: skip if compaction already
|
|
77
|
+
# Guard: skip if compaction already succeeded this turn
|
|
78
78
|
return if @last_compaction_turn == @current_turn
|
|
79
79
|
|
|
80
|
-
@last_compaction_turn = @current_turn
|
|
81
|
-
|
|
82
80
|
messages = conversation.messages
|
|
83
81
|
|
|
84
82
|
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
@@ -93,6 +91,7 @@ module RubynCode
|
|
|
93
91
|
collapsed = ContextCollapse.call(messages, threshold: @threshold)
|
|
94
92
|
if collapsed
|
|
95
93
|
apply_compacted_messages(conversation, collapsed)
|
|
94
|
+
@last_compaction_turn = @current_turn
|
|
96
95
|
return
|
|
97
96
|
end
|
|
98
97
|
|
|
@@ -102,6 +101,7 @@ module RubynCode
|
|
|
102
101
|
compactor = Compactor.new(llm_client: @llm_client, threshold: @threshold)
|
|
103
102
|
new_messages = compactor.auto_compact!(messages)
|
|
104
103
|
apply_compacted_messages(conversation, new_messages)
|
|
104
|
+
@last_compaction_turn = @current_turn
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
# Resets cumulative token counters to zero.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles "plan/interview/answer" — feeds the user's answer into the
|
|
7
|
+
# active InterviewSession, emits the next question OR the final plan
|
|
8
|
+
# via notifications, and returns an empty ack to the extension.
|
|
9
|
+
class PlanInterviewAnswerHandler
|
|
10
|
+
SESSION_NOT_FOUND_CODE = -32_011
|
|
11
|
+
INVALID_INTERVIEW_CODE = -32_010
|
|
12
|
+
|
|
13
|
+
def initialize(server)
|
|
14
|
+
@server = server
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(params)
|
|
18
|
+
session_id = params['sessionId'].to_s
|
|
19
|
+
question_id = params['questionId'].to_s
|
|
20
|
+
answer = params['answer'].to_s
|
|
21
|
+
|
|
22
|
+
session = @server.lookup_interview_session(session_id)
|
|
23
|
+
unless session
|
|
24
|
+
raise Protocol::JsonRpcError.new(SESSION_NOT_FOUND_CODE,
|
|
25
|
+
"Unknown interview session: #{session_id}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
outcome = session.answer(question_id, answer)
|
|
29
|
+
emit_outcome(session, outcome)
|
|
30
|
+
{}
|
|
31
|
+
rescue Megaplan::InterviewSession::InvalidAnswerError => e
|
|
32
|
+
raise Protocol::JsonRpcError.new(Protocol::INVALID_PARAMS, e.message)
|
|
33
|
+
rescue Megaplan::InterviewSession::MalformedResponseError,
|
|
34
|
+
Megaplan::PlanProposer::InvalidProposalError => e
|
|
35
|
+
warn "[PlanInterviewAnswerHandler] interview failed: #{e.message}"
|
|
36
|
+
@server.notify('plan/interview/error', {
|
|
37
|
+
'sessionId' => params['sessionId'],
|
|
38
|
+
'message' => e.message
|
|
39
|
+
})
|
|
40
|
+
@server.drop_interview_session(params['sessionId'].to_s)
|
|
41
|
+
raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def emit_outcome(session, outcome)
|
|
47
|
+
if outcome.is_a?(Megaplan::InterviewSession::Question)
|
|
48
|
+
@server.notify('plan/interview/question', {
|
|
49
|
+
'sessionId' => session.session_id,
|
|
50
|
+
'questionId' => outcome.id,
|
|
51
|
+
'text' => outcome.text,
|
|
52
|
+
'options' => outcome.options
|
|
53
|
+
})
|
|
54
|
+
else
|
|
55
|
+
@server.notify('plan/interview/done', {
|
|
56
|
+
'sessionId' => session.session_id,
|
|
57
|
+
'plan' => outcome
|
|
58
|
+
})
|
|
59
|
+
@server.drop_interview_session(session.session_id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles "plan/interview/cancel" — drops the named InterviewSession.
|
|
7
|
+
# Notification handler (no id), so it never sends a response. Unknown
|
|
8
|
+
# sessionIds are no-ops; the extension treats cancel as best-effort.
|
|
9
|
+
class PlanInterviewCancelHandler
|
|
10
|
+
def initialize(server)
|
|
11
|
+
@server = server
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(params)
|
|
15
|
+
session_id = params['sessionId'].to_s
|
|
16
|
+
@server.drop_interview_session(session_id)
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles "plan/interview/start" — kicks off a megaplan interview
|
|
7
|
+
# session. Creates the InterviewSession, fires the first LLM turn,
|
|
8
|
+
# and emits either a plan/interview/question or plan/interview/done
|
|
9
|
+
# notification before returning the new sessionId to the extension.
|
|
10
|
+
class PlanInterviewStartHandler
|
|
11
|
+
INVALID_INTERVIEW_CODE = -32_010
|
|
12
|
+
|
|
13
|
+
def initialize(server, factory: nil)
|
|
14
|
+
@server = server
|
|
15
|
+
@factory = factory || ->(workspace_path:) {
|
|
16
|
+
Megaplan::InterviewSession.new(workspace_path: workspace_path)
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(_params)
|
|
21
|
+
session = @factory.call(workspace_path: @server.workspace_path || Dir.pwd)
|
|
22
|
+
@server.register_interview_session(session)
|
|
23
|
+
outcome = session.start
|
|
24
|
+
emit_outcome(session, outcome)
|
|
25
|
+
{ 'sessionId' => session.session_id }
|
|
26
|
+
rescue Megaplan::InterviewSession::MalformedResponseError,
|
|
27
|
+
Megaplan::PlanProposer::InvalidProposalError => e
|
|
28
|
+
warn "[PlanInterviewStartHandler] interview failed: #{e.message}"
|
|
29
|
+
raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def emit_outcome(session, outcome)
|
|
35
|
+
if outcome.is_a?(Megaplan::InterviewSession::Question)
|
|
36
|
+
@server.notify('plan/interview/question', {
|
|
37
|
+
'sessionId' => session.session_id,
|
|
38
|
+
'questionId' => outcome.id,
|
|
39
|
+
'text' => outcome.text,
|
|
40
|
+
'options' => outcome.options
|
|
41
|
+
})
|
|
42
|
+
else
|
|
43
|
+
@server.notify('plan/interview/done', {
|
|
44
|
+
'sessionId' => session.session_id,
|
|
45
|
+
'plan' => outcome
|
|
46
|
+
})
|
|
47
|
+
@server.drop_interview_session(session.session_id)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
module Handlers
|
|
8
|
+
# Handles the "plan/propose" JSON-RPC request.
|
|
9
|
+
#
|
|
10
|
+
# Synchronous: blocks until the LLM returns a structured plan or
|
|
11
|
+
# raises. The extension shows a progress spinner during the call;
|
|
12
|
+
# plan generation typically takes 5-30s.
|
|
13
|
+
#
|
|
14
|
+
# Response shape mirrors what the extension's PlanManager.toPlan
|
|
15
|
+
# expects: { slug, feature, phases: [{ number, name, slug, summary,
|
|
16
|
+
# requirements_md, design_md, tasks_md }] }.
|
|
17
|
+
class PlanProposeHandler
|
|
18
|
+
# JSON-RPC error code: a clear signal to the extension that the
|
|
19
|
+
# LLM produced an unparseable / malformed plan_proposal. The
|
|
20
|
+
# extension surfaces this with the actual error message.
|
|
21
|
+
INVALID_PROPOSAL_CODE = -32_001
|
|
22
|
+
|
|
23
|
+
def initialize(server, proposer: nil)
|
|
24
|
+
@server = server
|
|
25
|
+
@proposer = proposer
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(params)
|
|
29
|
+
feature = params['feature'].to_s.strip
|
|
30
|
+
raise Protocol::JsonRpcError.new(Protocol::INVALID_PARAMS, 'feature is required') if feature.empty?
|
|
31
|
+
|
|
32
|
+
proposer = @proposer || Megaplan::PlanProposer.new
|
|
33
|
+
proposer.propose(feature)
|
|
34
|
+
rescue Megaplan::PlanProposer::InvalidProposalError => e
|
|
35
|
+
warn "[PlanProposeHandler] invalid proposal: #{e.message}"
|
|
36
|
+
raise Protocol::JsonRpcError.new(INVALID_PROPOSAL_CODE, e.message)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -62,7 +62,8 @@ module RubynCode
|
|
|
62
62
|
|
|
63
63
|
private
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
# -- orchestrates agent lifecycle with notifications
|
|
66
|
+
def run_agent(session_id, text, context)
|
|
66
67
|
@server.notify('agent/status', {
|
|
67
68
|
'sessionId' => session_id,
|
|
68
69
|
'status' => 'thinking'
|
|
@@ -165,7 +166,8 @@ module RubynCode
|
|
|
165
166
|
# Install a ToolOutput adapter on the server so AcceptEdit /
|
|
166
167
|
# ApproveToolUse handlers can route responses back to this session.
|
|
167
168
|
def build_tool_output_adapter
|
|
168
|
-
adapter = IDE::Adapters::ToolOutput.new(@server, permission_mode: @server.permission_mode,
|
|
169
|
+
adapter = IDE::Adapters::ToolOutput.new(@server, permission_mode: @server.permission_mode,
|
|
170
|
+
hook_runner: build_ide_hook_runner)
|
|
169
171
|
@server.tool_output_adapter = adapter
|
|
170
172
|
adapter
|
|
171
173
|
end
|
|
@@ -190,7 +192,8 @@ module RubynCode
|
|
|
190
192
|
Hooks::Runner.new(registry: registry)
|
|
191
193
|
end
|
|
192
194
|
|
|
193
|
-
|
|
195
|
+
# -- assembles context parts from multiple optional fields
|
|
196
|
+
def build_enriched_input(text, context)
|
|
194
197
|
parts = []
|
|
195
198
|
|
|
196
199
|
parts << "[Active file: #{context['activeFile']}]" if context['activeFile']
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
module Handlers
|
|
8
|
+
# Handles the "recover_ci" JSON-RPC request.
|
|
9
|
+
#
|
|
10
|
+
# Takes the recovery context the extension packaged (failing log,
|
|
11
|
+
# phase docs, branch, attempt counts) and runs the agent against
|
|
12
|
+
# it. Returns the recovery_outcome { kind, commit_sha?, summary? }.
|
|
13
|
+
#
|
|
14
|
+
# The handler spawns the actual agent work in a background thread
|
|
15
|
+
# so the JSON-RPC reply can include a sessionId immediately. The
|
|
16
|
+
# extension watches the session via the existing agent/status
|
|
17
|
+
# notifications + a terminal recovery/outcome notification carrying
|
|
18
|
+
# the structured result.
|
|
19
|
+
class RecoverCiHandler
|
|
20
|
+
def initialize(server, recovery: nil)
|
|
21
|
+
@server = server
|
|
22
|
+
@recovery = recovery
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(params)
|
|
26
|
+
context = normalize_params(params)
|
|
27
|
+
return error_response('context required') if context.nil?
|
|
28
|
+
|
|
29
|
+
session_id = params['sessionId'] || SecureRandom.uuid
|
|
30
|
+
Thread.new do
|
|
31
|
+
run_recovery(session_id, context)
|
|
32
|
+
end
|
|
33
|
+
{ 'accepted' => true, 'sessionId' => session_id }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def run_recovery(session_id, context)
|
|
39
|
+
@server.notify('agent/status', {
|
|
40
|
+
'sessionId' => session_id,
|
|
41
|
+
'status' => 'recovering',
|
|
42
|
+
'phaseNumber' => context['phase_number'],
|
|
43
|
+
'attemptNumber' => context['attempt_number']
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
recovery = @recovery || Megaplan::CiRecovery.new(
|
|
47
|
+
agent_invoker: build_invoker(session_id)
|
|
48
|
+
)
|
|
49
|
+
outcome = recovery.recover(context)
|
|
50
|
+
|
|
51
|
+
@server.notify('recovery/outcome', {
|
|
52
|
+
'sessionId' => session_id,
|
|
53
|
+
'planId' => context['plan_id'],
|
|
54
|
+
'phaseNumber' => context['phase_number'],
|
|
55
|
+
'kind' => outcome['kind'],
|
|
56
|
+
'commitSha' => outcome['commit_sha'],
|
|
57
|
+
'summary' => outcome['summary']
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
@server.notify('agent/status', {
|
|
61
|
+
'sessionId' => session_id,
|
|
62
|
+
'status' => 'done',
|
|
63
|
+
'summary' => outcome['summary']
|
|
64
|
+
})
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
warn "[RecoverCiHandler] error: #{e.message}"
|
|
67
|
+
@server.notify('recovery/outcome', {
|
|
68
|
+
'sessionId' => session_id,
|
|
69
|
+
'planId' => context['plan_id'],
|
|
70
|
+
'phaseNumber' => context['phase_number'],
|
|
71
|
+
'kind' => 'errored',
|
|
72
|
+
'summary' => e.message
|
|
73
|
+
})
|
|
74
|
+
@server.notify('agent/status', {
|
|
75
|
+
'sessionId' => session_id,
|
|
76
|
+
'status' => 'error',
|
|
77
|
+
'error' => e.message
|
|
78
|
+
})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build an agent invoker that routes the recovery prompt through
|
|
82
|
+
# the same Agent::Loop the existing PromptHandler uses, but with
|
|
83
|
+
# the streaming text wired back as agent/status notifications
|
|
84
|
+
# tagged with the recovery session id.
|
|
85
|
+
def build_invoker(session_id)
|
|
86
|
+
lambda do |prompt, _context|
|
|
87
|
+
llm_client = LLM::Client.new
|
|
88
|
+
response = llm_client.chat(
|
|
89
|
+
messages: [{ role: 'user', content: prompt }],
|
|
90
|
+
on_text: ->(text) {
|
|
91
|
+
@server.notify('stream/text', {
|
|
92
|
+
'sessionId' => session_id,
|
|
93
|
+
'text' => text,
|
|
94
|
+
'final' => false
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
response.is_a?(Hash) ? response : { 'text' => response.to_s }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_params(params)
|
|
103
|
+
return nil unless params.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
# Accept both snake_case and camelCase keys — the extension
|
|
106
|
+
# produces camelCase, but the LLM-side service uses snake_case
|
|
107
|
+
# internally. Normalize once at the boundary.
|
|
108
|
+
mapping = {
|
|
109
|
+
'planId' => 'plan_id',
|
|
110
|
+
'phaseNumber' => 'phase_number',
|
|
111
|
+
'prNumber' => 'pr_number',
|
|
112
|
+
'failingCheckName' => 'failing_check_name',
|
|
113
|
+
'fullLog' => 'full_log',
|
|
114
|
+
'trimmedLog' => 'trimmed_log',
|
|
115
|
+
'commitSha' => 'commit_sha',
|
|
116
|
+
'attemptNumber' => 'attempt_number',
|
|
117
|
+
'maxAttempts' => 'max_attempts'
|
|
118
|
+
}
|
|
119
|
+
out = params.dup
|
|
120
|
+
mapping.each do |camel, snake|
|
|
121
|
+
out[snake] = out.delete(camel) if out.key?(camel) && !out.key?(snake)
|
|
122
|
+
end
|
|
123
|
+
out
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def error_response(message, code: -32_602)
|
|
127
|
+
{ 'error' => { 'code' => code, 'message' => message } }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../../skills/pack_context'
|
|
4
|
+
|
|
3
5
|
module RubynCode
|
|
4
6
|
module IDE
|
|
5
7
|
module Handlers
|
|
@@ -31,15 +33,16 @@ module RubynCode
|
|
|
31
33
|
|
|
32
34
|
private
|
|
33
35
|
|
|
34
|
-
def run_review(session_id, base_branch, focus) # rubocop:disable Metrics/
|
|
36
|
+
def run_review(session_id, base_branch, focus) # rubocop:disable Metrics/MethodLength -- review lifecycle with finding notifications
|
|
35
37
|
@server.notify('agent/status', {
|
|
36
38
|
'sessionId' => session_id,
|
|
37
39
|
'status' => 'reviewing'
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
workspace = @server.workspace_path || Dir.pwd
|
|
43
|
+
pack_context = build_pack_context(workspace)
|
|
41
44
|
review_tool = Tools::ReviewPr.new(project_root: workspace)
|
|
42
|
-
result = review_tool.execute(base_branch: base_branch, focus: focus)
|
|
45
|
+
result = review_tool.execute(base_branch: base_branch, focus: focus, pack_context: pack_context)
|
|
43
46
|
|
|
44
47
|
# Parse the review output into individual findings and emit them
|
|
45
48
|
findings = extract_findings(result)
|
|
@@ -68,6 +71,20 @@ module RubynCode
|
|
|
68
71
|
})
|
|
69
72
|
end
|
|
70
73
|
|
|
74
|
+
# Fetch skill pack context for gems detected in the repo's Gemfile.
|
|
75
|
+
# Returns nil on any failure — pack context is best-effort and must never
|
|
76
|
+
# block the review from running.
|
|
77
|
+
#
|
|
78
|
+
# @param workspace [String] absolute path to the repository
|
|
79
|
+
# @return [String, nil] formatted context block or nil
|
|
80
|
+
def build_pack_context(workspace)
|
|
81
|
+
context = Skills::PackContext.for_repo(project_root: workspace)
|
|
82
|
+
block = context.build_context_block
|
|
83
|
+
block.empty? ? nil : block
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
71
88
|
def extract_findings(review_text)
|
|
72
89
|
return [] unless review_text.is_a?(String)
|
|
73
90
|
|
|
@@ -14,6 +14,11 @@ require_relative 'handlers/session_reset_handler'
|
|
|
14
14
|
require_relative 'handlers/session_list_handler'
|
|
15
15
|
require_relative 'handlers/session_resume_handler'
|
|
16
16
|
require_relative 'handlers/session_fork_handler'
|
|
17
|
+
require_relative 'handlers/plan_propose_handler'
|
|
18
|
+
require_relative 'handlers/plan_interview_start_handler'
|
|
19
|
+
require_relative 'handlers/plan_interview_answer_handler'
|
|
20
|
+
require_relative 'handlers/plan_interview_cancel_handler'
|
|
21
|
+
require_relative 'handlers/recover_ci_handler'
|
|
17
22
|
|
|
18
23
|
module RubynCode
|
|
19
24
|
module IDE
|
|
@@ -33,7 +38,12 @@ module RubynCode
|
|
|
33
38
|
'session/reset' => SessionResetHandler,
|
|
34
39
|
'session/list' => SessionListHandler,
|
|
35
40
|
'session/resume' => SessionResumeHandler,
|
|
36
|
-
'session/fork' => SessionForkHandler
|
|
41
|
+
'session/fork' => SessionForkHandler,
|
|
42
|
+
'plan/propose' => PlanProposeHandler,
|
|
43
|
+
'plan/interview/start' => PlanInterviewStartHandler,
|
|
44
|
+
'plan/interview/answer' => PlanInterviewAnswerHandler,
|
|
45
|
+
'plan/interview/cancel' => PlanInterviewCancelHandler,
|
|
46
|
+
'recover_ci' => RecoverCiHandler
|
|
37
47
|
}.freeze
|
|
38
48
|
|
|
39
49
|
# Short name => method name mapping (for handler_instance lookups).
|
|
@@ -51,7 +61,12 @@ module RubynCode
|
|
|
51
61
|
session_reset: 'session/reset',
|
|
52
62
|
session_list: 'session/list',
|
|
53
63
|
session_resume: 'session/resume',
|
|
54
|
-
session_fork: 'session/fork'
|
|
64
|
+
session_fork: 'session/fork',
|
|
65
|
+
plan_propose: 'plan/propose',
|
|
66
|
+
plan_interview_start: 'plan/interview/start',
|
|
67
|
+
plan_interview_answer: 'plan/interview/answer',
|
|
68
|
+
plan_interview_cancel: 'plan/interview/cancel',
|
|
69
|
+
recover_ci: 'recover_ci'
|
|
55
70
|
}.freeze
|
|
56
71
|
|
|
57
72
|
# Register all handlers on the given server instance.
|
|
@@ -21,11 +21,27 @@ module RubynCode
|
|
|
21
21
|
SESSION_NOT_FOUND = -2
|
|
22
22
|
BUDGET_EXCEEDED = -3
|
|
23
23
|
|
|
24
|
+
# Raise from a handler to emit a JSON-RPC error response with a
|
|
25
|
+
# specific code. The dispatcher catches this and converts it to a
|
|
26
|
+
# proper `error` envelope — handlers don't have to know how to write
|
|
27
|
+
# to the wire. Use this instead of returning `{ 'error' => ... }` as
|
|
28
|
+
# the handler result, which the dispatcher would otherwise serialize
|
|
29
|
+
# as a successful `result` payload (the very bug this class fixes).
|
|
30
|
+
class JsonRpcError < StandardError
|
|
31
|
+
attr_reader :code
|
|
32
|
+
|
|
33
|
+
def initialize(code, message)
|
|
34
|
+
super(message)
|
|
35
|
+
@code = code
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
24
39
|
module_function
|
|
25
40
|
|
|
26
41
|
# Parse a JSON string into a request hash.
|
|
27
42
|
# Returns either a valid request hash or an error response hash.
|
|
28
|
-
|
|
43
|
+
# -- JSON-RPC validation checks
|
|
44
|
+
def parse(line)
|
|
29
45
|
begin
|
|
30
46
|
data = JSON.parse(line)
|
|
31
47
|
rescue JSON::ParserError
|
|
@@ -21,7 +21,7 @@ module RubynCode
|
|
|
21
21
|
:permission_mode
|
|
22
22
|
attr_reader :ide_client
|
|
23
23
|
|
|
24
|
-
def initialize(permission_mode: :default, yolo: false)
|
|
24
|
+
def initialize(permission_mode: :default, yolo: false, workspace_path: nil)
|
|
25
25
|
@permission_mode = yolo ? :bypass : permission_mode.to_sym
|
|
26
26
|
@running = false
|
|
27
27
|
@write_mutex = Mutex.new
|
|
@@ -33,10 +33,45 @@ module RubynCode
|
|
|
33
33
|
@session_persistence = nil
|
|
34
34
|
@tool_output_adapter = nil
|
|
35
35
|
@ide_client = Client.new(self)
|
|
36
|
+
@interview_sessions = {}
|
|
37
|
+
|
|
38
|
+
apply_initial_workspace(workspace_path)
|
|
36
39
|
|
|
37
40
|
Handlers.register_all(self)
|
|
38
41
|
end
|
|
39
42
|
|
|
43
|
+
# ── Interview session registry ──────────────────────────────────
|
|
44
|
+
# Owned by the IDE Server so handlers can look up the same session
|
|
45
|
+
# across start / answer / cancel JSON-RPC calls.
|
|
46
|
+
|
|
47
|
+
def register_interview_session(session)
|
|
48
|
+
@interview_sessions[session.session_id] = session
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def lookup_interview_session(session_id)
|
|
52
|
+
@interview_sessions[session_id]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def drop_interview_session(session_id)
|
|
56
|
+
@interview_sessions.delete(session_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Adopt a workspace path supplied on the command line (--dir). Done
|
|
60
|
+
# before the `initialize` JSON-RPC handshake so tools that resolve
|
|
61
|
+
# their project_root at construction time don't fall back to Dir.pwd
|
|
62
|
+
# — which in some launch contexts (Docker, double-clicked VS Code on
|
|
63
|
+
# macOS) is something useless like `/app` or `/`.
|
|
64
|
+
def apply_initial_workspace(path)
|
|
65
|
+
return unless path && !path.empty?
|
|
66
|
+
|
|
67
|
+
if Dir.exist?(path)
|
|
68
|
+
Dir.chdir(path)
|
|
69
|
+
@workspace_path = path
|
|
70
|
+
else
|
|
71
|
+
warn "[IDE::Server] --dir path does not exist, ignoring: #{path}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
40
75
|
# Backward-compatible reader: true when permission_mode is :bypass.
|
|
41
76
|
def yolo
|
|
42
77
|
@permission_mode == :bypass
|
|
@@ -107,6 +142,9 @@ module RubynCode
|
|
|
107
142
|
end
|
|
108
143
|
|
|
109
144
|
dispatch(msg)
|
|
145
|
+
rescue Protocol::JsonRpcError => e
|
|
146
|
+
id = msg.is_a?(Hash) ? msg['id'] : nil
|
|
147
|
+
write(Protocol.error(id, e.code, e.message)) if id
|
|
110
148
|
rescue StandardError => e
|
|
111
149
|
warn "[IDE::Server] error handling message: #{e.message}"
|
|
112
150
|
warn e.backtrace&.first(5)&.join("\n")
|
|
@@ -260,7 +260,8 @@ module RubynCode
|
|
|
260
260
|
end
|
|
261
261
|
end
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
# -- Rails directory mapping
|
|
264
|
+
def classify_node(file, type)
|
|
264
265
|
return 'model' if file.include?('app/models/')
|
|
265
266
|
return 'controller' if file.include?('app/controllers/')
|
|
266
267
|
return 'service' if file.include?('app/services/')
|
|
@@ -82,7 +82,8 @@ module RubynCode
|
|
|
82
82
|
messages.map { |m| format_turn(m) }.join("\n\n")
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
# -- content polymorphism
|
|
86
|
+
def format_turn(msg)
|
|
86
87
|
role = (msg[:role] || msg['role'] || 'unknown').capitalize
|
|
87
88
|
content = msg[:content] || msg['content']
|
|
88
89
|
text = if content.is_a?(Array)
|
|
@@ -121,7 +122,8 @@ module RubynCode
|
|
|
121
122
|
)
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
|
|
125
|
+
# -- response parsing with multiple fallbacks
|
|
126
|
+
def parse_response(response)
|
|
125
127
|
return [] if response.nil?
|
|
126
128
|
|
|
127
129
|
text = if response.respond_to?(:content)
|
|
@@ -84,7 +84,8 @@ module RubynCode
|
|
|
84
84
|
# @param task_type [Symbol]
|
|
85
85
|
# @param client [LLM::Client, nil] active client (for provider checks)
|
|
86
86
|
# @return [Hash] { provider:, model: }
|
|
87
|
-
|
|
87
|
+
# -- multi-source fallback chain
|
|
88
|
+
def resolve(task_type, client: nil)
|
|
88
89
|
tier = tier_for(task_type)
|
|
89
90
|
active = active_provider
|
|
90
91
|
|