rubyn-code 0.3.0 → 0.4.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -19
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +32 -3
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +9 -1
  7. data/lib/rubyn_code/agent/loop.rb +7 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
  9. data/lib/rubyn_code/agent/tool_processor.rb +21 -1
  10. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  11. data/lib/rubyn_code/auth/token_store.rb +50 -9
  12. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  13. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  14. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  15. data/lib/rubyn_code/cli/app.rb +32 -1
  16. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  17. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  18. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  19. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  20. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  21. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  22. data/lib/rubyn_code/cli/first_run.rb +159 -0
  23. data/lib/rubyn_code/cli/repl.rb +6 -1
  24. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  25. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  26. data/lib/rubyn_code/cli/repl_setup.rb +36 -0
  27. data/lib/rubyn_code/config/defaults.rb +1 -0
  28. data/lib/rubyn_code/config/schema.json +49 -0
  29. data/lib/rubyn_code/config/settings.rb +7 -4
  30. data/lib/rubyn_code/config/validator.rb +63 -0
  31. data/lib/rubyn_code/context/context_budget.rb +16 -1
  32. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  33. data/lib/rubyn_code/context/manager.rb +37 -3
  34. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  35. data/lib/rubyn_code/hooks/registry.rb +4 -0
  36. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  37. data/lib/rubyn_code/ide/client.rb +110 -0
  38. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  39. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  40. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  41. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  42. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  43. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  44. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  45. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  46. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  47. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  48. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  49. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  50. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  51. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  52. data/lib/rubyn_code/ide/handlers.rb +76 -0
  53. data/lib/rubyn_code/ide/protocol.rb +111 -0
  54. data/lib/rubyn_code/ide/server.rb +186 -0
  55. data/lib/rubyn_code/index/codebase_index.rb +67 -1
  56. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  57. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  58. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  59. data/lib/rubyn_code/llm/client.rb +29 -4
  60. data/lib/rubyn_code/mcp/config.rb +2 -1
  61. data/lib/rubyn_code/memory/search.rb +1 -0
  62. data/lib/rubyn_code/self_test.rb +315 -0
  63. data/lib/rubyn_code/skills/catalog.rb +66 -0
  64. data/lib/rubyn_code/skills/loader.rb +43 -0
  65. data/lib/rubyn_code/tasks/models.rb +1 -0
  66. data/lib/rubyn_code/tools/base.rb +13 -0
  67. data/lib/rubyn_code/tools/bash.rb +5 -0
  68. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  69. data/lib/rubyn_code/tools/executor.rb +61 -6
  70. data/lib/rubyn_code/tools/glob.rb +6 -0
  71. data/lib/rubyn_code/tools/grep.rb +6 -0
  72. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  73. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  74. data/lib/rubyn_code/tools/output_compressor.rb +6 -1
  75. data/lib/rubyn_code/tools/read_file.rb +6 -0
  76. data/lib/rubyn_code/tools/registry.rb +11 -0
  77. data/lib/rubyn_code/tools/write_file.rb +17 -0
  78. data/lib/rubyn_code/version.rb +1 -1
  79. data/lib/rubyn_code.rb +22 -0
  80. data/skills/rubyn_self_test.md +13 -1
  81. metadata +31 -1
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "approveToolUse" JSON-RPC request.
7
+ #
8
+ # The extension sends this after the user approves or denies a tool
9
+ # invocation surfaced with requiresApproval=true. All pending-approval
10
+ # state lives in the per-session ToolOutput adapter; this handler is a
11
+ # thin delegate so the server has something registered at the method name.
12
+ class ApproveToolUseHandler
13
+ def initialize(server)
14
+ @server = server
15
+ end
16
+
17
+ def call(params)
18
+ request_id = params['requestId']
19
+ approved = params['approved']
20
+
21
+ return { 'resolved' => false, 'error' => 'Missing requestId' } unless request_id
22
+
23
+ adapter = @server.tool_output_adapter
24
+ return { 'resolved' => false, 'error' => 'No active session' } unless adapter
25
+
26
+ resolved = adapter.resolve_approval(request_id, approved ? true : false)
27
+ return { 'resolved' => false, 'error' => "No pending request: #{request_id}" } unless resolved
28
+
29
+ { 'resolved' => true, 'requestId' => request_id }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "cancel" JSON-RPC request.
7
+ #
8
+ # Signals the agent loop thread for the given session to stop,
9
+ # then returns confirmation.
10
+ class CancelHandler
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ def call(params)
16
+ session_id = params['sessionId']
17
+
18
+ return { 'cancelled' => false, 'error' => 'Missing sessionId' } unless session_id
19
+
20
+ # Delegate to the PromptHandler which owns the session threads
21
+ prompt_handler = @server.handler_instance(:prompt)
22
+ prompt_handler&.cancel_session(session_id)
23
+
24
+ # Fire the stop hook so extensions can react to session cancellation
25
+ fire_stop_hook(session_id)
26
+
27
+ { 'cancelled' => true, 'sessionId' => session_id }
28
+ end
29
+
30
+ private
31
+
32
+ def fire_stop_hook(session_id)
33
+ hook_registry = Hooks::Registry.new
34
+ hook_runner = Hooks::Runner.new(registry: hook_registry)
35
+ Hooks::BuiltIn.register_all!(hook_registry)
36
+ hook_runner.fire(:stop, session_id: session_id)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles "config/get" JSON-RPC requests from the IDE extension.
7
+ #
8
+ # When a key is provided, returns that single setting with its source.
9
+ # When no key is provided, returns all configurable settings plus
10
+ # provider definitions so the extension can populate its UI.
11
+ class ConfigGetHandler
12
+ EXPOSED_KEYS = %w[
13
+ provider model model_mode max_iterations max_sub_agent_iterations max_output_chars
14
+ context_threshold_tokens session_budget_usd daily_budget_usd permission_mode
15
+ ].freeze
16
+
17
+ def initialize(server)
18
+ @server = server
19
+ end
20
+
21
+ def call(params)
22
+ settings = Config::Settings.new
23
+ key = params['key']
24
+
25
+ if key
26
+ single_key_response(settings, key)
27
+ else
28
+ all_settings_response(settings)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def single_key_response(settings, key)
35
+ unless EXPOSED_KEYS.include?(key.to_s)
36
+ return { 'key' => key, 'value' => nil, 'source' => 'unknown',
37
+ 'error' => "Unknown config key: #{key}" }
38
+ end
39
+
40
+ value = settings.get(key)
41
+ source = settings.data.key?(key.to_s) ? 'config_file' : 'default'
42
+
43
+ { 'key' => key, 'value' => value, 'source' => source }
44
+ end
45
+
46
+ def all_settings_response(settings)
47
+ result = {}
48
+
49
+ EXPOSED_KEYS.each do |key|
50
+ sym = key.to_sym
51
+ value = settings.get(key)
52
+ default = Config::Settings::DEFAULT_MAP[sym]
53
+ result[key] = { 'value' => value, 'default' => default }
54
+ end
55
+
56
+ providers = settings.data['providers'] || {}
57
+
58
+ { 'settings' => result, 'providers' => providers }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../config/validator'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Handlers
8
+ # Handles "config/set" JSON-RPC requests from the IDE extension.
9
+ #
10
+ # Validates the key is in the allowed list, coerces types as needed,
11
+ # persists the change, and notifies the client via config/changed.
12
+ class ConfigSetHandler
13
+ EXPOSED_KEYS = ConfigGetHandler::EXPOSED_KEYS
14
+
15
+ NUMERIC_KEYS = %w[
16
+ max_iterations max_sub_agent_iterations max_output_chars
17
+ context_threshold_tokens session_budget_usd daily_budget_usd
18
+ ].freeze
19
+
20
+ STRING_KEYS = %w[provider model model_mode].freeze
21
+
22
+ VALID_PERMISSION_MODES = %w[default accept_edits plan_only auto dont_ask bypass].freeze
23
+
24
+ def initialize(server)
25
+ @server = server
26
+ end
27
+
28
+ def call(params)
29
+ key = params['key'].to_s
30
+ value = params['value']
31
+
32
+ return { 'updated' => false, 'error' => "Unknown config key: #{key}" } unless EXPOSED_KEYS.include?(key)
33
+
34
+ # permission_mode is a runtime-only setting on the server, not persisted to config.
35
+ if key == 'permission_mode'
36
+ mode = value.to_s
37
+ unless VALID_PERMISSION_MODES.include?(mode)
38
+ return { 'updated' => false,
39
+ 'error' => "Invalid permission mode: #{mode}. " \
40
+ "Valid modes: #{VALID_PERMISSION_MODES.join(', ')}" }
41
+ end
42
+
43
+ @server.permission_mode = mode.to_sym
44
+ @server.tool_output_adapter&.permission_mode = mode.to_sym
45
+ @server.notify('config/changed', { 'key' => key, 'value' => mode })
46
+ return { 'updated' => true, 'key' => key, 'value' => mode }
47
+ end
48
+
49
+ value = coerce(key, value)
50
+
51
+ result = Config::Validator.new.validate(key, value)
52
+ unless result[:valid]
53
+ return { 'updated' => false, 'error' => result[:errors].join('; ') }
54
+ end
55
+
56
+ settings = Config::Settings.new
57
+ settings.set(key, value)
58
+ settings.save!
59
+
60
+ @server.notify('config/changed', { 'key' => key, 'value' => value })
61
+
62
+ { 'updated' => true, 'key' => key, 'value' => value }
63
+ end
64
+
65
+ private
66
+
67
+ def coerce(key, value)
68
+ if NUMERIC_KEYS.include?(key)
69
+ numeric_value(value)
70
+ else
71
+ value.to_s
72
+ end
73
+ end
74
+
75
+ def numeric_value(value)
76
+ return value if value.is_a?(Numeric)
77
+
78
+ str = value.to_s
79
+ str.include?('.') ? Float(str) : Integer(str)
80
+ rescue ArgumentError, TypeError
81
+ value
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "initialize" JSON-RPC request from the IDE extension.
7
+ #
8
+ # Accepts workspace path, extension metadata, and client capabilities.
9
+ # Sets the working directory and returns server capabilities so the
10
+ # extension knows what features are available.
11
+ class InitializeHandler
12
+ def initialize(server)
13
+ @server = server
14
+ end
15
+
16
+ def call(params) # rubocop:disable Metrics/MethodLength -- builds full capability handshake response
17
+ workspace = params['workspacePath']
18
+ extension_version = params['extensionVersion']
19
+ client_caps = params['capabilities'] || {}
20
+
21
+ if workspace && Dir.exist?(workspace)
22
+ Dir.chdir(workspace)
23
+ @server.workspace_path = workspace
24
+ end
25
+
26
+ @server.extension_version = extension_version
27
+ @server.client_capabilities = client_caps
28
+
29
+ tool_count = tool_count!
30
+ skill_count = skill_count!
31
+
32
+ {
33
+ 'serverVersion' => RubynCode::VERSION,
34
+ 'protocolVersion' => '1.0',
35
+ 'workspacePath' => Dir.pwd,
36
+ 'capabilities' => {
37
+ 'tools' => tool_count,
38
+ 'skills' => skill_count,
39
+ 'streaming' => true,
40
+ 'review' => true,
41
+ 'memory' => true,
42
+ 'teams' => true,
43
+ 'toolApproval' => true,
44
+ 'editApproval' => true
45
+ }
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def tool_count!
52
+ Tools::Registry.load_all!
53
+ Tools::Registry.tool_names.size
54
+ rescue StandardError
55
+ 0
56
+ end
57
+
58
+ def skill_count!
59
+ dirs = default_skill_dirs
60
+ catalog = Skills::Catalog.new(dirs)
61
+ catalog.available.size
62
+ rescue StandardError
63
+ 0
64
+ end
65
+
66
+ def default_skill_dirs
67
+ dirs = [File.expand_path('../../../../skills', __dir__)]
68
+ if @server.workspace_path
69
+ project_skills = File.join(@server.workspace_path, '.rubyn-code', 'skills')
70
+ dirs << project_skills if Dir.exist?(project_skills)
71
+ end
72
+ user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
73
+ dirs << user_skills if Dir.exist?(user_skills)
74
+ dirs
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ class ModelsListHandler
7
+ def initialize(server)
8
+ @server = server
9
+ end
10
+
11
+ def call(_params)
12
+ settings = Config::Settings.new
13
+ providers = settings.data['providers'] || {}
14
+
15
+ {
16
+ 'models' => collect_models(providers),
17
+ 'activeProvider' => settings.provider,
18
+ 'activeModel' => settings.model,
19
+ 'modelMode' => settings.get('model_mode', 'auto')
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def collect_models(providers)
26
+ models = []
27
+ providers.each do |name, cfg|
28
+ next unless cfg.is_a?(Hash) && cfg['models'].is_a?(Hash)
29
+
30
+ cfg['models'].each do |tier, model_name|
31
+ models << { 'provider' => name, 'model' => model_name, 'tier' => tier }
32
+ end
33
+ end
34
+ models
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Handlers
8
+ # Handles the "prompt" JSON-RPC request — the main chat entry point.
9
+ #
10
+ # Returns immediately with { "accepted" => true } and spawns a
11
+ # background thread that runs the agent loop. As the agent works,
12
+ # it emits stream/text, tool/use, tool/result, and agent/status
13
+ # notifications over the JSON-RPC transport.
14
+ class PromptHandler
15
+ def initialize(server)
16
+ @server = server
17
+ @sessions = {} # sessionId => Thread
18
+ @conversations = {} # sessionId => Agent::Conversation (persists across prompts)
19
+ @started_sessions = Set.new # tracks which sessions have fired session_start
20
+ end
21
+
22
+ # Called by SessionResumeHandler to inject a restored conversation
23
+ # into the cache so the next prompt continues from the loaded history.
24
+ def inject_conversation(session_id, conversation)
25
+ cancel_session(session_id)
26
+ @conversations[session_id] = conversation
27
+ end
28
+
29
+ # Called by SessionResetHandler when the user clicks "New Session"
30
+ # in the chat UI. Drops the cached conversation for this session so
31
+ # the next prompt starts fresh — parity with the CLI's `/new`.
32
+ def reset_session(session_id)
33
+ cancel_session(session_id)
34
+ @conversations.delete(session_id)
35
+ @started_sessions.delete(session_id)
36
+ end
37
+
38
+ def call(params)
39
+ text = params['text'] || ''
40
+ context = params['context'] || {}
41
+ session_id = params['sessionId'] || SecureRandom.uuid
42
+
43
+ # Cancel any existing agent thread for this session
44
+ cancel_session(session_id)
45
+
46
+ # Spawn the agent loop in a background thread
47
+ @sessions[session_id] = Thread.new do
48
+ run_agent(session_id, text, context)
49
+ end
50
+
51
+ { 'accepted' => true, 'sessionId' => session_id }
52
+ end
53
+
54
+ # Called by CancelHandler to stop a running session.
55
+ def cancel_session(session_id)
56
+ thread = @sessions.delete(session_id)
57
+ return unless thread&.alive?
58
+
59
+ thread.raise(Interrupt)
60
+ thread.join(2) # give it a moment to clean up
61
+ end
62
+
63
+ private
64
+
65
+ def run_agent(session_id, text, context) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- orchestrates agent lifecycle with notifications
66
+ @server.notify('agent/status', {
67
+ 'sessionId' => session_id,
68
+ 'status' => 'thinking'
69
+ })
70
+
71
+ # Fire IDE hooks for prompt lifecycle
72
+ ide_hook_runner = build_ide_hook_runner
73
+ unless @started_sessions.include?(session_id)
74
+ @started_sessions.add(session_id)
75
+ ide_hook_runner.fire(:session_start, session_id: session_id)
76
+ end
77
+ ide_hook_runner.fire(:user_prompt_submit, session_id: session_id, text: text)
78
+
79
+ workspace = context['workspacePath'] || @server.workspace_path || Dir.pwd
80
+ agent_loop = build_agent_loop(session_id, workspace)
81
+
82
+ enriched_input = build_enriched_input(text, context)
83
+
84
+ @server.notify('agent/status', {
85
+ 'sessionId' => session_id,
86
+ 'status' => 'streaming'
87
+ })
88
+
89
+ response = agent_loop.send_message(enriched_input)
90
+
91
+ @server.notify('agent/status', {
92
+ 'sessionId' => session_id,
93
+ 'status' => 'done'
94
+ })
95
+
96
+ @server.notify('stream/text', {
97
+ 'sessionId' => session_id,
98
+ 'text' => response,
99
+ 'final' => true
100
+ })
101
+ rescue Interrupt
102
+ @server.notify('agent/status', {
103
+ 'sessionId' => session_id,
104
+ 'status' => 'cancelled'
105
+ })
106
+ rescue StandardError => e
107
+ warn "[PromptHandler] error: #{e.message}"
108
+ warn e.backtrace&.first(5)&.join("\n")
109
+ @server.notify('agent/status', {
110
+ 'sessionId' => session_id,
111
+ 'status' => 'error',
112
+ 'error' => e.message
113
+ })
114
+ ensure
115
+ @sessions.delete(session_id)
116
+ end
117
+
118
+ def build_agent_loop(session_id, workspace)
119
+ llm_client = LLM::Client.new
120
+ # Reuse the conversation across prompts in the same session so the
121
+ # model has multi-turn memory — same model the CLI REPL uses. A
122
+ # fresh Agent::Loop is built per prompt (cheap, bundle of refs),
123
+ # but the conversation (messages array) persists. `session/reset`
124
+ # drops the cached entry; the next prompt starts a fresh one.
125
+ conversation = @conversations[session_id] ||= Agent::Conversation.new
126
+
127
+ # Register IDE-only tools (diagnostics, symbols) when running in IDE mode.
128
+ Tools::Registry.load_ide_tools! if @server.ide_client
129
+
130
+ tool_executor = Tools::Executor.new(project_root: workspace, ide_client: @server.ide_client)
131
+ context_manager = Context::Manager.new(llm_client: llm_client)
132
+ hook_registry = Hooks::Registry.new
133
+ hook_runner = Hooks::Runner.new(registry: hook_registry)
134
+ stall_detector = Agent::LoopDetector.new
135
+
136
+ Hooks::BuiltIn.register_all!(hook_registry)
137
+
138
+ tool_executor.llm_client = llm_client
139
+
140
+ adapter = build_tool_output_adapter
141
+ tool_wrapper = lambda do |name, input, &blk|
142
+ adapter.wrap_execution(name, input, &blk)
143
+ end
144
+
145
+ Agent::Loop.new(
146
+ llm_client: llm_client,
147
+ tool_executor: tool_executor,
148
+ context_manager: context_manager,
149
+ hook_runner: hook_runner,
150
+ conversation: conversation,
151
+ # Gating happens in the ToolOutput adapter (per-tool, via JSON-RPC).
152
+ # The policy tier must not intercept — it has no way to prompt the
153
+ # user in IDE mode and would otherwise fall back to the TTY prompter
154
+ # which corrupts the JSON-RPC stream on stdout.
155
+ permission_tier: :unrestricted,
156
+ deny_list: Permissions::DenyList.new,
157
+ stall_detector: stall_detector,
158
+ tool_wrapper: tool_wrapper,
159
+ on_text: build_text_callback(session_id),
160
+ project_root: workspace,
161
+ ide_client: @server.ide_client
162
+ )
163
+ end
164
+
165
+ # Install a ToolOutput adapter on the server so AcceptEdit /
166
+ # ApproveToolUse handlers can route responses back to this session.
167
+ def build_tool_output_adapter
168
+ adapter = IDE::Adapters::ToolOutput.new(@server, permission_mode: @server.permission_mode, hook_runner: build_ide_hook_runner)
169
+ @server.tool_output_adapter = adapter
170
+ adapter
171
+ end
172
+
173
+ def build_text_callback(session_id)
174
+ lambda { |text|
175
+ @server.notify('agent/status', {
176
+ 'sessionId' => session_id,
177
+ 'status' => 'streaming'
178
+ })
179
+ @server.notify('stream/text', {
180
+ 'sessionId' => session_id,
181
+ 'text' => text,
182
+ 'final' => false
183
+ })
184
+ }
185
+ end
186
+
187
+ def build_ide_hook_runner
188
+ registry = Hooks::Registry.new
189
+ Hooks::BuiltIn.register_all!(registry)
190
+ Hooks::Runner.new(registry: registry)
191
+ end
192
+
193
+ def build_enriched_input(text, context) # rubocop:disable Metrics/AbcSize -- assembles context parts from multiple optional fields
194
+ parts = []
195
+
196
+ parts << "[Active file: #{context['activeFile']}]" if context['activeFile']
197
+
198
+ if context['selection']
199
+ sel = context['selection']
200
+ range = "lines #{sel['startLine']}-#{sel['endLine']}"
201
+ parts << "[Selection (#{range}):\n#{sel['text']}\n]"
202
+ end
203
+
204
+ parts << "[Open files: #{context['openFiles'].join(', ')}]" if context['openFiles']&.any?
205
+
206
+ if parts.any?
207
+ "#{parts.join("\n")}\n\n#{text}"
208
+ else
209
+ text
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "review" JSON-RPC request.
7
+ #
8
+ # Delegates to the existing ReviewPr tool, running it in a
9
+ # background thread. Emits review/finding notifications as
10
+ # findings are extracted from the review output.
11
+ class ReviewHandler
12
+ def initialize(server)
13
+ @server = server
14
+ end
15
+
16
+ def call(params)
17
+ base_branch = params['baseBranch'] || 'main'
18
+ focus = params['focus'] || 'all'
19
+ session_id = params['sessionId'] || SecureRandom.uuid
20
+
21
+ Thread.new do
22
+ run_review(session_id, base_branch, focus)
23
+ end
24
+
25
+ { 'accepted' => true, 'sessionId' => session_id }
26
+ end
27
+
28
+ # Extract structured findings from the raw review text.
29
+ # Looks for severity markers like [critical], [warning], etc.
30
+ SEVERITY_PATTERN = /\[(critical|warning|suggestion|nitpick)\]/i
31
+
32
+ private
33
+
34
+ def run_review(session_id, base_branch, focus) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- review lifecycle with finding notifications
35
+ @server.notify('agent/status', {
36
+ 'sessionId' => session_id,
37
+ 'status' => 'reviewing'
38
+ })
39
+
40
+ workspace = @server.workspace_path || Dir.pwd
41
+ review_tool = Tools::ReviewPr.new(project_root: workspace)
42
+ result = review_tool.execute(base_branch: base_branch, focus: focus)
43
+
44
+ # Parse the review output into individual findings and emit them
45
+ findings = extract_findings(result)
46
+ findings.each_with_index do |finding, idx|
47
+ @server.notify('review/finding', {
48
+ 'sessionId' => session_id,
49
+ 'index' => idx,
50
+ 'severity' => finding[:severity],
51
+ 'message' => finding[:message],
52
+ 'file' => finding[:file],
53
+ 'line' => finding[:line]
54
+ })
55
+ end
56
+
57
+ @server.notify('agent/status', {
58
+ 'sessionId' => session_id,
59
+ 'status' => 'done',
60
+ 'summary' => "Review complete: #{findings.size} finding(s)"
61
+ })
62
+ rescue StandardError => e
63
+ warn "[ReviewHandler] error: #{e.message}"
64
+ @server.notify('agent/status', {
65
+ 'sessionId' => session_id,
66
+ 'status' => 'error',
67
+ 'error' => e.message
68
+ })
69
+ end
70
+
71
+ def extract_findings(review_text)
72
+ return [] unless review_text.is_a?(String)
73
+
74
+ findings = []
75
+ current_finding = nil
76
+
77
+ review_text.each_line do |line|
78
+ if (match = line.match(SEVERITY_PATTERN))
79
+ # Save previous finding
80
+ findings << current_finding if current_finding
81
+
82
+ current_finding = {
83
+ severity: match[1].downcase,
84
+ message: line.strip,
85
+ file: extract_file_reference(line),
86
+ line: extract_line_number(line)
87
+ }
88
+ elsif current_finding
89
+ # Append continuation lines to the current finding
90
+ current_finding[:message] = "#{current_finding[:message]}\n#{line.rstrip}"
91
+ end
92
+ end
93
+
94
+ findings << current_finding if current_finding
95
+ findings
96
+ end
97
+
98
+ def extract_file_reference(line)
99
+ match = line.match(%r{(?:^|\s)([\w/\-_.]+\.\w+)})
100
+ match ? match[1] : nil
101
+ end
102
+
103
+ def extract_line_number(line)
104
+ match = line.match(/(?:line\s+|L)(\d+)/i)
105
+ match ? match[1].to_i : nil
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end