rubyn-code 0.3.0 → 0.5.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
@@ -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,218 @@
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
+ # -- orchestrates agent lifecycle with notifications
66
+ def run_agent(session_id, text, context)
67
+ @server.notify('agent/status', {
68
+ 'sessionId' => session_id,
69
+ 'status' => 'thinking'
70
+ })
71
+
72
+ # Fire IDE hooks for prompt lifecycle
73
+ ide_hook_runner = build_ide_hook_runner
74
+ unless @started_sessions.include?(session_id)
75
+ @started_sessions.add(session_id)
76
+ ide_hook_runner.fire(:session_start, session_id: session_id)
77
+ end
78
+ ide_hook_runner.fire(:user_prompt_submit, session_id: session_id, text: text)
79
+
80
+ workspace = context['workspacePath'] || @server.workspace_path || Dir.pwd
81
+ agent_loop = build_agent_loop(session_id, workspace)
82
+
83
+ enriched_input = build_enriched_input(text, context)
84
+
85
+ @server.notify('agent/status', {
86
+ 'sessionId' => session_id,
87
+ 'status' => 'streaming'
88
+ })
89
+
90
+ response = agent_loop.send_message(enriched_input)
91
+
92
+ @server.notify('agent/status', {
93
+ 'sessionId' => session_id,
94
+ 'status' => 'done'
95
+ })
96
+
97
+ @server.notify('stream/text', {
98
+ 'sessionId' => session_id,
99
+ 'text' => response,
100
+ 'final' => true
101
+ })
102
+ rescue Interrupt
103
+ @server.notify('agent/status', {
104
+ 'sessionId' => session_id,
105
+ 'status' => 'cancelled'
106
+ })
107
+ rescue StandardError => e
108
+ warn "[PromptHandler] error: #{e.message}"
109
+ warn e.backtrace&.first(5)&.join("\n")
110
+ @server.notify('agent/status', {
111
+ 'sessionId' => session_id,
112
+ 'status' => 'error',
113
+ 'error' => e.message
114
+ })
115
+ ensure
116
+ @sessions.delete(session_id)
117
+ end
118
+
119
+ def build_agent_loop(session_id, workspace)
120
+ llm_client = LLM::Client.new
121
+ # Reuse the conversation across prompts in the same session so the
122
+ # model has multi-turn memory — same model the CLI REPL uses. A
123
+ # fresh Agent::Loop is built per prompt (cheap, bundle of refs),
124
+ # but the conversation (messages array) persists. `session/reset`
125
+ # drops the cached entry; the next prompt starts a fresh one.
126
+ conversation = @conversations[session_id] ||= Agent::Conversation.new
127
+
128
+ # Register IDE-only tools (diagnostics, symbols) when running in IDE mode.
129
+ Tools::Registry.load_ide_tools! if @server.ide_client
130
+
131
+ tool_executor = Tools::Executor.new(project_root: workspace, ide_client: @server.ide_client)
132
+ context_manager = Context::Manager.new(llm_client: llm_client)
133
+ hook_registry = Hooks::Registry.new
134
+ hook_runner = Hooks::Runner.new(registry: hook_registry)
135
+ stall_detector = Agent::LoopDetector.new
136
+
137
+ Hooks::BuiltIn.register_all!(hook_registry)
138
+
139
+ tool_executor.llm_client = llm_client
140
+
141
+ adapter = build_tool_output_adapter
142
+ tool_wrapper = lambda do |name, input, &blk|
143
+ adapter.wrap_execution(name, input, &blk)
144
+ end
145
+
146
+ Agent::Loop.new(
147
+ llm_client: llm_client,
148
+ tool_executor: tool_executor,
149
+ context_manager: context_manager,
150
+ hook_runner: hook_runner,
151
+ conversation: conversation,
152
+ # Gating happens in the ToolOutput adapter (per-tool, via JSON-RPC).
153
+ # The policy tier must not intercept — it has no way to prompt the
154
+ # user in IDE mode and would otherwise fall back to the TTY prompter
155
+ # which corrupts the JSON-RPC stream on stdout.
156
+ permission_tier: :unrestricted,
157
+ deny_list: Permissions::DenyList.new,
158
+ stall_detector: stall_detector,
159
+ tool_wrapper: tool_wrapper,
160
+ on_text: build_text_callback(session_id),
161
+ project_root: workspace,
162
+ ide_client: @server.ide_client
163
+ )
164
+ end
165
+
166
+ # Install a ToolOutput adapter on the server so AcceptEdit /
167
+ # ApproveToolUse handlers can route responses back to this session.
168
+ def build_tool_output_adapter
169
+ adapter = IDE::Adapters::ToolOutput.new(@server, permission_mode: @server.permission_mode,
170
+ hook_runner: build_ide_hook_runner)
171
+ @server.tool_output_adapter = adapter
172
+ adapter
173
+ end
174
+
175
+ def build_text_callback(session_id)
176
+ lambda { |text|
177
+ @server.notify('agent/status', {
178
+ 'sessionId' => session_id,
179
+ 'status' => 'streaming'
180
+ })
181
+ @server.notify('stream/text', {
182
+ 'sessionId' => session_id,
183
+ 'text' => text,
184
+ 'final' => false
185
+ })
186
+ }
187
+ end
188
+
189
+ def build_ide_hook_runner
190
+ registry = Hooks::Registry.new
191
+ Hooks::BuiltIn.register_all!(registry)
192
+ Hooks::Runner.new(registry: registry)
193
+ end
194
+
195
+ # -- assembles context parts from multiple optional fields
196
+ def build_enriched_input(text, context)
197
+ parts = []
198
+
199
+ parts << "[Active file: #{context['activeFile']}]" if context['activeFile']
200
+
201
+ if context['selection']
202
+ sel = context['selection']
203
+ range = "lines #{sel['startLine']}-#{sel['endLine']}"
204
+ parts << "[Selection (#{range}):\n#{sel['text']}\n]"
205
+ end
206
+
207
+ parts << "[Open files: #{context['openFiles'].join(', ')}]" if context['openFiles']&.any?
208
+
209
+ if parts.any?
210
+ "#{parts.join("\n")}\n\n#{text}"
211
+ else
212
+ text
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../skills/pack_context'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Handlers
8
+ # Handles the "review" JSON-RPC request.
9
+ #
10
+ # Delegates to the existing ReviewPr tool, running it in a
11
+ # background thread. Emits review/finding notifications as
12
+ # findings are extracted from the review output.
13
+ class ReviewHandler
14
+ def initialize(server)
15
+ @server = server
16
+ end
17
+
18
+ def call(params)
19
+ base_branch = params['baseBranch'] || 'main'
20
+ focus = params['focus'] || 'all'
21
+ session_id = params['sessionId'] || SecureRandom.uuid
22
+
23
+ Thread.new do
24
+ run_review(session_id, base_branch, focus)
25
+ end
26
+
27
+ { 'accepted' => true, 'sessionId' => session_id }
28
+ end
29
+
30
+ # Extract structured findings from the raw review text.
31
+ # Looks for severity markers like [critical], [warning], etc.
32
+ SEVERITY_PATTERN = /\[(critical|warning|suggestion|nitpick)\]/i
33
+
34
+ private
35
+
36
+ def run_review(session_id, base_branch, focus) # rubocop:disable Metrics/MethodLength -- review lifecycle with finding notifications
37
+ @server.notify('agent/status', {
38
+ 'sessionId' => session_id,
39
+ 'status' => 'reviewing'
40
+ })
41
+
42
+ workspace = @server.workspace_path || Dir.pwd
43
+ pack_context = build_pack_context(workspace)
44
+ review_tool = Tools::ReviewPr.new(project_root: workspace)
45
+ result = review_tool.execute(base_branch: base_branch, focus: focus, pack_context: pack_context)
46
+
47
+ # Parse the review output into individual findings and emit them
48
+ findings = extract_findings(result)
49
+ findings.each_with_index do |finding, idx|
50
+ @server.notify('review/finding', {
51
+ 'sessionId' => session_id,
52
+ 'index' => idx,
53
+ 'severity' => finding[:severity],
54
+ 'message' => finding[:message],
55
+ 'file' => finding[:file],
56
+ 'line' => finding[:line]
57
+ })
58
+ end
59
+
60
+ @server.notify('agent/status', {
61
+ 'sessionId' => session_id,
62
+ 'status' => 'done',
63
+ 'summary' => "Review complete: #{findings.size} finding(s)"
64
+ })
65
+ rescue StandardError => e
66
+ warn "[ReviewHandler] error: #{e.message}"
67
+ @server.notify('agent/status', {
68
+ 'sessionId' => session_id,
69
+ 'status' => 'error',
70
+ 'error' => e.message
71
+ })
72
+ end
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
+
88
+ def extract_findings(review_text)
89
+ return [] unless review_text.is_a?(String)
90
+
91
+ findings = []
92
+ current_finding = nil
93
+
94
+ review_text.each_line do |line|
95
+ if (match = line.match(SEVERITY_PATTERN))
96
+ # Save previous finding
97
+ findings << current_finding if current_finding
98
+
99
+ current_finding = {
100
+ severity: match[1].downcase,
101
+ message: line.strip,
102
+ file: extract_file_reference(line),
103
+ line: extract_line_number(line)
104
+ }
105
+ elsif current_finding
106
+ # Append continuation lines to the current finding
107
+ current_finding[:message] = "#{current_finding[:message]}\n#{line.rstrip}"
108
+ end
109
+ end
110
+
111
+ findings << current_finding if current_finding
112
+ findings
113
+ end
114
+
115
+ def extract_file_reference(line)
116
+ match = line.match(%r{(?:^|\s)([\w/\-_.]+\.\w+)})
117
+ match ? match[1] : nil
118
+ end
119
+
120
+ def extract_line_number(line)
121
+ match = line.match(/(?:line\s+|L)(\d+)/i)
122
+ match ? match[1].to_i : nil
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Handlers
8
+ # Handles the "session/fork" JSON-RPC request.
9
+ #
10
+ # Loads an existing session, truncates its messages at the given index,
11
+ # and saves the truncated history as a brand-new session. The original
12
+ # session is left untouched.
13
+ class SessionForkHandler
14
+ def initialize(server)
15
+ @server = server
16
+ end
17
+
18
+ def call(params)
19
+ session_id = params['sessionId']
20
+ message_index = params['messageIndex']
21
+
22
+ return { 'forked' => false, 'error' => 'Missing sessionId' } unless session_id
23
+ return { 'forked' => false, 'error' => 'Missing messageIndex' } unless message_index
24
+
25
+ persistence = @server.session_persistence
26
+ return { 'forked' => false, 'error' => 'Session persistence not available' } unless persistence
27
+
28
+ data = persistence.load_session(session_id)
29
+ return { 'forked' => false, 'error' => 'Session not found' } unless data
30
+
31
+ messages = data[:messages] || []
32
+ truncated = messages[0, message_index.to_i]
33
+
34
+ new_session_id = SecureRandom.uuid
35
+ persistence.save_session(
36
+ session_id: new_session_id,
37
+ project_path: data[:project_path] || '',
38
+ messages: truncated,
39
+ title: data[:title] ? "Fork of #{data[:title]}" : nil,
40
+ model: data[:model],
41
+ metadata: { message_count: truncated.size, forked_from: session_id }
42
+ )
43
+
44
+ { 'forked' => true, 'newSessionId' => new_session_id }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "session/list" JSON-RPC request.
7
+ #
8
+ # Returns a list of past sessions from SessionPersistence, optionally
9
+ # filtered by project path. If SessionPersistence is not available
10
+ # (e.g. no database configured), returns an empty sessions array.
11
+ class SessionListHandler
12
+ def initialize(server)
13
+ @server = server
14
+ end
15
+
16
+ def call(params)
17
+ persistence = @server.session_persistence
18
+ unless persistence
19
+ return { 'sessions' => [] }
20
+ end
21
+
22
+ project_path = params['projectPath']
23
+ limit = params['limit'] || 20
24
+
25
+ summaries = persistence.list_sessions(project_path: project_path, limit: limit)
26
+
27
+ sessions = summaries.map do |s|
28
+ {
29
+ 'id' => s[:id],
30
+ 'title' => s[:title],
31
+ 'updatedAt' => s[:updated_at],
32
+ 'messageCount' => (s[:metadata] || {})[:message_count] || 0
33
+ }
34
+ end
35
+
36
+ { 'sessions' => sessions }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "session/reset" JSON-RPC request.
7
+ #
8
+ # Called when the user clicks "New Session" in the chat UI. Delegates
9
+ # to PromptHandler#reset_session which cancels any in-flight agent
10
+ # thread for that sessionId and drops the cached Agent::Conversation,
11
+ # so the next prompt starts with empty message history — parity with
12
+ # the CLI REPL's `/new` command.
13
+ class SessionResetHandler
14
+ def initialize(server)
15
+ @server = server
16
+ end
17
+
18
+ def call(params)
19
+ session_id = params['sessionId']
20
+ return { 'reset' => false, 'error' => 'Missing sessionId' } unless session_id
21
+
22
+ prompt = @server.handler_instance(:prompt)
23
+ return { 'reset' => false, 'error' => 'Prompt handler not available' } unless prompt
24
+
25
+ prompt.reset_session(session_id)
26
+ { 'reset' => true, 'sessionId' => session_id }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end