rubyn-code 0.5.0 → 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 +182 -11
- 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/app.rb +2 -2
- 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 +50 -0
- 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 +77 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- 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 +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 +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +15 -0
- data/lib/rubyn_code/ide/server.rb +39 -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 +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- 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 +53 -2
- data/skills/megaplan/megaplan.md +156 -0
- 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 +49 -4
|
@@ -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 || lambda { |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
|
|
@@ -57,7 +57,15 @@ module RubynCode
|
|
|
57
57
|
return unless thread&.alive?
|
|
58
58
|
|
|
59
59
|
thread.raise(Interrupt)
|
|
60
|
-
|
|
60
|
+
begin
|
|
61
|
+
thread.join(2) # give it a moment to clean up
|
|
62
|
+
rescue Interrupt
|
|
63
|
+
# We asked the thread to stop, so its Interrupt is expected. If it
|
|
64
|
+
# lands before the agent installs its own rescue, Thread#join would
|
|
65
|
+
# otherwise re-raise it into the caller (the cancel path) and, in
|
|
66
|
+
# tests, escape the example and abort the whole run.
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
61
69
|
end
|
|
62
70
|
|
|
63
71
|
private
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
notify_recovering(session_id, context)
|
|
40
|
+
|
|
41
|
+
recovery = @recovery || Megaplan::CiRecovery.new(
|
|
42
|
+
agent_invoker: build_invoker(session_id)
|
|
43
|
+
)
|
|
44
|
+
outcome = recovery.recover(context)
|
|
45
|
+
|
|
46
|
+
notify_outcome(session_id, context, outcome)
|
|
47
|
+
@server.notify('agent/status', {
|
|
48
|
+
'sessionId' => session_id,
|
|
49
|
+
'status' => 'done',
|
|
50
|
+
'summary' => outcome['summary']
|
|
51
|
+
})
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
warn "[RecoverCiHandler] error: #{e.message}"
|
|
54
|
+
notify_error(session_id, context, e)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def notify_recovering(session_id, context)
|
|
58
|
+
@server.notify('agent/status', {
|
|
59
|
+
'sessionId' => session_id,
|
|
60
|
+
'status' => 'recovering',
|
|
61
|
+
'phaseNumber' => context['phase_number'],
|
|
62
|
+
'attemptNumber' => context['attempt_number']
|
|
63
|
+
})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def notify_outcome(session_id, context, outcome)
|
|
67
|
+
@server.notify('recovery/outcome', {
|
|
68
|
+
'sessionId' => session_id,
|
|
69
|
+
'planId' => context['plan_id'],
|
|
70
|
+
'phaseNumber' => context['phase_number'],
|
|
71
|
+
'kind' => outcome['kind'],
|
|
72
|
+
'commitSha' => outcome['commit_sha'],
|
|
73
|
+
'summary' => outcome['summary']
|
|
74
|
+
})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def notify_error(session_id, context, error)
|
|
78
|
+
@server.notify('recovery/outcome', {
|
|
79
|
+
'sessionId' => session_id,
|
|
80
|
+
'planId' => context['plan_id'],
|
|
81
|
+
'phaseNumber' => context['phase_number'],
|
|
82
|
+
'kind' => 'errored',
|
|
83
|
+
'summary' => error.message
|
|
84
|
+
})
|
|
85
|
+
@server.notify('agent/status', {
|
|
86
|
+
'sessionId' => session_id,
|
|
87
|
+
'status' => 'error',
|
|
88
|
+
'error' => error.message
|
|
89
|
+
})
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Build an agent invoker that routes the recovery prompt through
|
|
93
|
+
# the same Agent::Loop the existing PromptHandler uses, but with
|
|
94
|
+
# the streaming text wired back as agent/status notifications
|
|
95
|
+
# tagged with the recovery session id.
|
|
96
|
+
def build_invoker(session_id)
|
|
97
|
+
lambda do |prompt, _context|
|
|
98
|
+
llm_client = LLM::Client.new
|
|
99
|
+
response = llm_client.chat(
|
|
100
|
+
messages: [{ role: 'user', content: prompt }],
|
|
101
|
+
on_text: lambda { |text|
|
|
102
|
+
@server.notify('stream/text', {
|
|
103
|
+
'sessionId' => session_id,
|
|
104
|
+
'text' => text,
|
|
105
|
+
'final' => false
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
response.is_a?(Hash) ? response : { 'text' => response.to_s }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def normalize_params(params)
|
|
114
|
+
return nil unless params.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
# Accept both snake_case and camelCase keys — the extension
|
|
117
|
+
# produces camelCase, but the LLM-side service uses snake_case
|
|
118
|
+
# internally. Normalize once at the boundary.
|
|
119
|
+
mapping = {
|
|
120
|
+
'planId' => 'plan_id',
|
|
121
|
+
'phaseNumber' => 'phase_number',
|
|
122
|
+
'prNumber' => 'pr_number',
|
|
123
|
+
'failingCheckName' => 'failing_check_name',
|
|
124
|
+
'fullLog' => 'full_log',
|
|
125
|
+
'trimmedLog' => 'trimmed_log',
|
|
126
|
+
'commitSha' => 'commit_sha',
|
|
127
|
+
'attemptNumber' => 'attempt_number',
|
|
128
|
+
'maxAttempts' => 'max_attempts'
|
|
129
|
+
}
|
|
130
|
+
out = params.dup
|
|
131
|
+
mapping.each do |camel, snake|
|
|
132
|
+
out[snake] = out.delete(camel) if out.key?(camel) && !out.key?(snake)
|
|
133
|
+
end
|
|
134
|
+
out
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def error_response(message, code: -32_602)
|
|
138
|
+
{ 'error' => { 'code' => code, 'message' => message } }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -30,7 +30,7 @@ module RubynCode
|
|
|
30
30
|
prompt = @server.handler_instance(:prompt)
|
|
31
31
|
if prompt
|
|
32
32
|
conversation = Agent::Conversation.new
|
|
33
|
-
|
|
33
|
+
conversation.replace!(messages.dup)
|
|
34
34
|
prompt.inject_conversation(session_id, conversation)
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -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,6 +21,21 @@ 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.
|
|
@@ -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")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'open3'
|
|
5
6
|
|
|
6
7
|
module RubynCode
|
|
7
8
|
module Index
|
|
@@ -42,7 +43,9 @@ module RubynCode
|
|
|
42
43
|
|
|
43
44
|
data = JSON.parse(File.read(@index_path))
|
|
44
45
|
@nodes = data['nodes'] || []
|
|
45
|
-
|
|
46
|
+
# uniq drops duplicate edges accumulated by older versions, which
|
|
47
|
+
# appended tests edges on every update! without dedup.
|
|
48
|
+
@edges = (data['edges'] || []).uniq
|
|
46
49
|
@file_mtimes = data['file_mtimes'] || {}
|
|
47
50
|
self
|
|
48
51
|
rescue StandardError
|
|
@@ -69,6 +72,19 @@ module RubynCode
|
|
|
69
72
|
self
|
|
70
73
|
end
|
|
71
74
|
|
|
75
|
+
# Incremental update for a single known-changed file (e.g. after a
|
|
76
|
+
# write_file/edit_file tool call). Avoids the full-tree scan in update!.
|
|
77
|
+
def update_file!(path)
|
|
78
|
+
absolute = File.expand_path(path, @project_root)
|
|
79
|
+
return self unless absolute.start_with?("#{@project_root}/")
|
|
80
|
+
|
|
81
|
+
remove_nodes_for(absolute)
|
|
82
|
+
index_file(absolute) if File.exist?(absolute)
|
|
83
|
+
extract_rails_edges
|
|
84
|
+
save!
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
72
88
|
# Query the index for symbols matching a search term.
|
|
73
89
|
def query(term)
|
|
74
90
|
pattern = term.to_s.downcase
|
|
@@ -192,6 +208,26 @@ module RubynCode
|
|
|
192
208
|
end
|
|
193
209
|
|
|
194
210
|
def ruby_files
|
|
211
|
+
git_ruby_files || glob_ruby_files
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Prefer git's file list when available: it skips ignored dirs
|
|
215
|
+
# (tmp/, log/, coverage/, db/) the glob would index. --others picks up
|
|
216
|
+
# untracked-but-not-ignored files so freshly created ones still appear.
|
|
217
|
+
def git_ruby_files
|
|
218
|
+
return nil unless File.exist?(File.join(@project_root, '.git'))
|
|
219
|
+
|
|
220
|
+
stdout, status = Open3.capture2(
|
|
221
|
+
'git', '-C', @project_root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.rb'
|
|
222
|
+
)
|
|
223
|
+
return nil unless status.success?
|
|
224
|
+
|
|
225
|
+
stdout.split("\0").map { |f| File.join(@project_root, f) }.select { |f| File.file?(f) }
|
|
226
|
+
rescue StandardError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def glob_ruby_files
|
|
195
231
|
Dir.glob(File.join(@project_root, '**', '*.rb'))
|
|
196
232
|
.reject { |f| f.include?('/vendor/') || f.include?('/node_modules/') }
|
|
197
233
|
end
|
|
@@ -253,6 +289,8 @@ module RubynCode
|
|
|
253
289
|
end
|
|
254
290
|
|
|
255
291
|
def extract_rails_edges
|
|
292
|
+
# Rebuild tests edges from scratch so repeated updates stay idempotent.
|
|
293
|
+
@edges.reject! { |e| e['relationship'] == 'tests' }
|
|
256
294
|
spec_files = @file_mtimes.keys.select { |f| f.include?('spec/') || f.include?('test/') }
|
|
257
295
|
spec_files.each do |spec_file|
|
|
258
296
|
source = spec_file.sub(%r{spec/}, 'app/').sub(/_spec\.rb$/, '.rb')
|