rubyn-code 0.2.2 → 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.
- checksums.yaml +4 -4
- data/README.md +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +75 -247
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +10 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -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,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
|
|
@@ -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
|