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.
- checksums.yaml +4 -4
- data/README.md +77 -19
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +32 -3
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
- data/lib/rubyn_code/agent/llm_caller.rb +9 -1
- data/lib/rubyn_code/agent/loop.rb +7 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
- data/lib/rubyn_code/agent/tool_processor.rb +21 -1
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +32 -1
- 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 +32 -2
- 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 +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +6 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +36 -0
- data/lib/rubyn_code/config/defaults.rb +1 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +7 -4
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +16 -1
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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 +67 -1
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +61 -6
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +6 -0
- 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/output_compressor.rb +6 -1
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +22 -0
- data/skills/rubyn_self_test.md +13 -1
- metadata +31 -1
|
@@ -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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles the "session/resume" JSON-RPC request.
|
|
7
|
+
#
|
|
8
|
+
# Loads a previously persisted session from SessionPersistence and
|
|
9
|
+
# pre-populates the PromptHandler's conversation cache so that the
|
|
10
|
+
# next prompt continues from where the session left off.
|
|
11
|
+
class SessionResumeHandler
|
|
12
|
+
def initialize(server)
|
|
13
|
+
@server = server
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(params)
|
|
17
|
+
session_id = params['sessionId']
|
|
18
|
+
return { 'resumed' => false, 'error' => 'Missing sessionId' } unless session_id
|
|
19
|
+
|
|
20
|
+
persistence = @server.session_persistence
|
|
21
|
+
return { 'resumed' => false, 'error' => 'Session persistence not available' } unless persistence
|
|
22
|
+
|
|
23
|
+
data = persistence.load_session(session_id)
|
|
24
|
+
return { 'resumed' => false, 'error' => 'Session not found' } unless data
|
|
25
|
+
|
|
26
|
+
messages = data[:messages] || []
|
|
27
|
+
|
|
28
|
+
# Pre-populate the prompt handler's conversation cache so the next
|
|
29
|
+
# prompt picks up from the restored history.
|
|
30
|
+
prompt = @server.handler_instance(:prompt)
|
|
31
|
+
if prompt
|
|
32
|
+
conversation = Agent::Conversation.new
|
|
33
|
+
messages.each { |msg| conversation.messages << msg }
|
|
34
|
+
prompt.inject_conversation(session_id, conversation)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{ 'resumed' => true, 'sessionId' => session_id, 'messages' => messages }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles the "shutdown" JSON-RPC request.
|
|
7
|
+
#
|
|
8
|
+
# Triggers session persistence, signals the server to stop its
|
|
9
|
+
# read loop, and returns confirmation.
|
|
10
|
+
class ShutdownHandler
|
|
11
|
+
def initialize(server)
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(_params)
|
|
16
|
+
warn '[ShutdownHandler] shutdown requested'
|
|
17
|
+
|
|
18
|
+
save_session!
|
|
19
|
+
@server.stop!
|
|
20
|
+
|
|
21
|
+
{ 'shutdown' => true }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def save_session!
|
|
27
|
+
return unless defined?(RubynCode::Memory::SessionPersistence)
|
|
28
|
+
|
|
29
|
+
persistence = @server.session_persistence
|
|
30
|
+
persistence&.save
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
warn "[ShutdownHandler] session save failed: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'handlers/initialize_handler'
|
|
4
|
+
require_relative 'handlers/prompt_handler'
|
|
5
|
+
require_relative 'handlers/cancel_handler'
|
|
6
|
+
require_relative 'handlers/review_handler'
|
|
7
|
+
require_relative 'handlers/approve_tool_use_handler'
|
|
8
|
+
require_relative 'handlers/accept_edit_handler'
|
|
9
|
+
require_relative 'handlers/shutdown_handler'
|
|
10
|
+
require_relative 'handlers/config_get_handler'
|
|
11
|
+
require_relative 'handlers/config_set_handler'
|
|
12
|
+
require_relative 'handlers/models_list_handler'
|
|
13
|
+
require_relative 'handlers/session_reset_handler'
|
|
14
|
+
require_relative 'handlers/session_list_handler'
|
|
15
|
+
require_relative 'handlers/session_resume_handler'
|
|
16
|
+
require_relative 'handlers/session_fork_handler'
|
|
17
|
+
|
|
18
|
+
module RubynCode
|
|
19
|
+
module IDE
|
|
20
|
+
module Handlers
|
|
21
|
+
# Method name => Handler class mapping.
|
|
22
|
+
REGISTRY = {
|
|
23
|
+
'initialize' => InitializeHandler,
|
|
24
|
+
'prompt' => PromptHandler,
|
|
25
|
+
'cancel' => CancelHandler,
|
|
26
|
+
'review' => ReviewHandler,
|
|
27
|
+
'approveToolUse' => ApproveToolUseHandler,
|
|
28
|
+
'acceptEdit' => AcceptEditHandler,
|
|
29
|
+
'shutdown' => ShutdownHandler,
|
|
30
|
+
'config/get' => ConfigGetHandler,
|
|
31
|
+
'config/set' => ConfigSetHandler,
|
|
32
|
+
'models/list' => ModelsListHandler,
|
|
33
|
+
'session/reset' => SessionResetHandler,
|
|
34
|
+
'session/list' => SessionListHandler,
|
|
35
|
+
'session/resume' => SessionResumeHandler,
|
|
36
|
+
'session/fork' => SessionForkHandler
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Short name => method name mapping (for handler_instance lookups).
|
|
40
|
+
SHORT_NAMES = {
|
|
41
|
+
prompt: 'prompt',
|
|
42
|
+
cancel: 'cancel',
|
|
43
|
+
review: 'review',
|
|
44
|
+
approve_tool_use: 'approveToolUse',
|
|
45
|
+
accept_edit: 'acceptEdit',
|
|
46
|
+
shutdown: 'shutdown',
|
|
47
|
+
initialize: 'initialize',
|
|
48
|
+
config_get: 'config/get',
|
|
49
|
+
config_set: 'config/set',
|
|
50
|
+
models_list: 'models/list',
|
|
51
|
+
session_reset: 'session/reset',
|
|
52
|
+
session_list: 'session/list',
|
|
53
|
+
session_resume: 'session/resume',
|
|
54
|
+
session_fork: 'session/fork'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# Register all handlers on the given server instance.
|
|
58
|
+
#
|
|
59
|
+
# @param server [RubynCode::IDE::Server] the IDE server
|
|
60
|
+
def self.register_all(server)
|
|
61
|
+
instances = {}
|
|
62
|
+
|
|
63
|
+
REGISTRY.each do |method, handler_class|
|
|
64
|
+
handler = handler_class.new(server)
|
|
65
|
+
instances[method] = handler
|
|
66
|
+
|
|
67
|
+
server.on(method) do |params, _id|
|
|
68
|
+
handler.call(params)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
server.handler_instances = instances
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
# JSON-RPC 2.0 protocol layer for the IDE server.
|
|
8
|
+
# Pure data — no side effects, no I/O beyond JSON serialisation.
|
|
9
|
+
module Protocol
|
|
10
|
+
JSONRPC_VERSION = '2.0'
|
|
11
|
+
|
|
12
|
+
# ── Standard JSON-RPC 2.0 error codes ──────────────────────────────
|
|
13
|
+
PARSE_ERROR = -32_700
|
|
14
|
+
INVALID_REQUEST = -32_600
|
|
15
|
+
METHOD_NOT_FOUND = -32_601
|
|
16
|
+
INVALID_PARAMS = -32_602
|
|
17
|
+
INTERNAL_ERROR = -32_603
|
|
18
|
+
|
|
19
|
+
# ── Custom error codes ─────────────────────────────────────────────
|
|
20
|
+
AGENT_BUSY = -1
|
|
21
|
+
SESSION_NOT_FOUND = -2
|
|
22
|
+
BUDGET_EXCEEDED = -3
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Parse a JSON string into a request hash.
|
|
27
|
+
# Returns either a valid request hash or an error response hash.
|
|
28
|
+
def parse(line) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- JSON-RPC validation checks
|
|
29
|
+
begin
|
|
30
|
+
data = JSON.parse(line)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
return error(nil, PARSE_ERROR, 'Parse error: invalid JSON')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return error(nil, INVALID_REQUEST, 'Invalid request: expected JSON object') unless data.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
unless data['jsonrpc'] == JSONRPC_VERSION
|
|
38
|
+
return error(data['id'], INVALID_REQUEST, 'Invalid request: missing or wrong "jsonrpc" version')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Response objects (containing "result" or "error") are valid
|
|
42
|
+
# JSON-RPC 2.0 messages that don't carry a "method".
|
|
43
|
+
is_response = data.key?('result') || data.key?('error')
|
|
44
|
+
|
|
45
|
+
unless is_response || data['method'].is_a?(String)
|
|
46
|
+
return error(data['id'], INVALID_REQUEST, 'Invalid request: "method" must be a string')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if data.key?('params') && !data['params'].is_a?(Hash) && !data['params'].is_a?(Array)
|
|
50
|
+
return error(data['id'], INVALID_PARAMS, 'Invalid params: "params" must be an object or array')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a success response hash.
|
|
57
|
+
def response(id, result)
|
|
58
|
+
{
|
|
59
|
+
'jsonrpc' => JSONRPC_VERSION,
|
|
60
|
+
'id' => id,
|
|
61
|
+
'result' => stringify_keys_deep(result)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build an error response hash.
|
|
66
|
+
def error(id, code, message)
|
|
67
|
+
{
|
|
68
|
+
'jsonrpc' => JSONRPC_VERSION,
|
|
69
|
+
'id' => id,
|
|
70
|
+
'error' => {
|
|
71
|
+
'code' => code,
|
|
72
|
+
'message' => message
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Build a notification hash (no id).
|
|
78
|
+
def notification(method, params)
|
|
79
|
+
{
|
|
80
|
+
'jsonrpc' => JSONRPC_VERSION,
|
|
81
|
+
'method' => method,
|
|
82
|
+
'params' => stringify_keys_deep(params)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Serialise a hash to a JSON string terminated by a newline.
|
|
87
|
+
def serialize(hash)
|
|
88
|
+
"#{JSON.generate(hash)}\n"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Helpers ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# Recursively convert symbol keys to strings so every hash that
|
|
94
|
+
# leaves this module uses string keys for JSON compatibility.
|
|
95
|
+
def stringify_keys_deep(obj)
|
|
96
|
+
case obj
|
|
97
|
+
when Hash
|
|
98
|
+
obj.each_with_object({}) do |(k, v), memo|
|
|
99
|
+
memo[k.to_s] = stringify_keys_deep(v)
|
|
100
|
+
end
|
|
101
|
+
when Array
|
|
102
|
+
obj.map { |v| stringify_keys_deep(v) }
|
|
103
|
+
else
|
|
104
|
+
obj
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method :stringify_keys_deep
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'protocol'
|
|
5
|
+
require_relative 'client'
|
|
6
|
+
require_relative 'handlers'
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module IDE
|
|
10
|
+
# JSON-RPC 2.0 server for the VS Code extension.
|
|
11
|
+
#
|
|
12
|
+
# Reads newline-delimited JSON from $stdin, dispatches each request
|
|
13
|
+
# to a handler, and writes JSON-RPC responses/notifications to $stdout.
|
|
14
|
+
# All debug output goes to $stderr — never protocol data.
|
|
15
|
+
#
|
|
16
|
+
# Processes one request at a time on the main thread.
|
|
17
|
+
class Server
|
|
18
|
+
# Attributes set by handlers during the session lifecycle.
|
|
19
|
+
attr_accessor :workspace_path, :extension_version, :client_capabilities,
|
|
20
|
+
:session_persistence, :handler_instances, :tool_output_adapter,
|
|
21
|
+
:permission_mode
|
|
22
|
+
attr_reader :ide_client
|
|
23
|
+
|
|
24
|
+
def initialize(permission_mode: :default, yolo: false)
|
|
25
|
+
@permission_mode = yolo ? :bypass : permission_mode.to_sym
|
|
26
|
+
@running = false
|
|
27
|
+
@write_mutex = Mutex.new
|
|
28
|
+
@handlers = {}
|
|
29
|
+
@handler_instances = {}
|
|
30
|
+
@workspace_path = nil
|
|
31
|
+
@extension_version = nil
|
|
32
|
+
@client_capabilities = {}
|
|
33
|
+
@session_persistence = nil
|
|
34
|
+
@tool_output_adapter = nil
|
|
35
|
+
@ide_client = Client.new(self)
|
|
36
|
+
|
|
37
|
+
Handlers.register_all(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Backward-compatible reader: true when permission_mode is :bypass.
|
|
41
|
+
def yolo
|
|
42
|
+
@permission_mode == :bypass
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
@running = true
|
|
47
|
+
setup_signal_traps!
|
|
48
|
+
|
|
49
|
+
warn "[IDE::Server] started (pid=#{Process.pid})"
|
|
50
|
+
$stdout.sync = true
|
|
51
|
+
|
|
52
|
+
read_loop
|
|
53
|
+
ensure
|
|
54
|
+
graceful_shutdown!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── Public helpers ──────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
# Send a JSON-RPC notification (no id) to stdout.
|
|
60
|
+
def notify(method, params = {})
|
|
61
|
+
write(Protocol.notification(method, params))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Register a handler for a given JSON-RPC method.
|
|
65
|
+
# The block receives (params, id) and must return a result hash.
|
|
66
|
+
def on(method, &block)
|
|
67
|
+
@handlers[method] = block
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Look up a handler instance by its short name (e.g. :prompt, :cancel).
|
|
71
|
+
# Returns nil if the handler is not registered.
|
|
72
|
+
def handler_instance(short_name)
|
|
73
|
+
method_name = Handlers::SHORT_NAMES[short_name.to_sym]
|
|
74
|
+
return nil unless method_name
|
|
75
|
+
|
|
76
|
+
@handler_instances[method_name]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Signal the server to stop its read loop.
|
|
80
|
+
def stop!
|
|
81
|
+
@running = false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# ── Main loop ───────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def read_loop
|
|
89
|
+
while @running
|
|
90
|
+
line = $stdin.gets
|
|
91
|
+
break if line.nil? # EOF — client disconnected
|
|
92
|
+
|
|
93
|
+
line = line.strip
|
|
94
|
+
next if line.empty?
|
|
95
|
+
|
|
96
|
+
handle_line(line)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_line(line)
|
|
101
|
+
msg = Protocol.parse(line)
|
|
102
|
+
|
|
103
|
+
# Protocol.parse returns an error response hash when parsing fails.
|
|
104
|
+
if msg.key?('error')
|
|
105
|
+
write(msg)
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
dispatch(msg)
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
warn "[IDE::Server] error handling message: #{e.message}"
|
|
112
|
+
warn e.backtrace&.first(5)&.join("\n")
|
|
113
|
+
|
|
114
|
+
id = msg.is_a?(Hash) ? msg['id'] : nil
|
|
115
|
+
write(Protocol.error(id, Protocol::INTERNAL_ERROR, "Internal error: #{e.message}"))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── Dispatch ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def dispatch(msg)
|
|
121
|
+
# Response messages from the extension (for our outbound requests via ide_client).
|
|
122
|
+
# These have id + (result or error) but no method.
|
|
123
|
+
if !msg.key?('method') && msg.key?('id') && (msg.key?('result') || msg.key?('error'))
|
|
124
|
+
@ide_client.resolve(
|
|
125
|
+
msg['id'],
|
|
126
|
+
result: msg['result'],
|
|
127
|
+
error: msg['error']
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
method = msg['method']
|
|
133
|
+
params = msg['params'] || {}
|
|
134
|
+
id = msg['id']
|
|
135
|
+
|
|
136
|
+
handler = @handlers[method]
|
|
137
|
+
|
|
138
|
+
unless handler
|
|
139
|
+
write(Protocol.error(id, Protocol::METHOD_NOT_FOUND, "Method not found: #{method}")) if id
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
result = handler.call(params, id)
|
|
144
|
+
|
|
145
|
+
# Only send a response for requests (those with an id).
|
|
146
|
+
# Notifications (no id) do not get responses.
|
|
147
|
+
write(Protocol.response(id, result)) if id
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── Wire output ─────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def write(hash)
|
|
153
|
+
serialized = Protocol.serialize(hash)
|
|
154
|
+
@write_mutex.synchronize do
|
|
155
|
+
$stdout.write(serialized)
|
|
156
|
+
$stdout.flush
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ── Signal handling ─────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def setup_signal_traps!
|
|
163
|
+
%w[TERM INT].each do |sig|
|
|
164
|
+
trap(sig) do
|
|
165
|
+
warn "[IDE::Server] received SIG#{sig}, shutting down"
|
|
166
|
+
@running = false
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ── Shutdown ────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def graceful_shutdown!
|
|
174
|
+
warn '[IDE::Server] shutting down'
|
|
175
|
+
save_session!
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def save_session!
|
|
179
|
+
# Delegate to Memory::SessionPersistence if available.
|
|
180
|
+
@session_persistence.save if defined?(RubynCode::Memory::SessionPersistence) && @session_persistence
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
warn "[IDE::Server] session save failed: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -9,9 +9,10 @@ module RubynCode
|
|
|
9
9
|
# Stores classes, modules, methods, associations, and Rails edges in a
|
|
10
10
|
# JSON file for fast session startup. First build scans all .rb files;
|
|
11
11
|
# incremental updates re-index only changed files.
|
|
12
|
-
class CodebaseIndex
|
|
12
|
+
class CodebaseIndex # rubocop:disable Metrics/ClassLength -- structural summary methods
|
|
13
13
|
INDEX_DIR = '.rubyn-code'
|
|
14
14
|
INDEX_FILE = 'codebase_index.json'
|
|
15
|
+
CHARS_PER_TOKEN = 4
|
|
15
16
|
|
|
16
17
|
attr_reader :nodes, :edges, :index_path
|
|
17
18
|
|
|
@@ -103,6 +104,20 @@ module RubynCode
|
|
|
103
104
|
lines.join("\n")
|
|
104
105
|
end
|
|
105
106
|
|
|
107
|
+
# Structural map for system prompt: model names with associations,
|
|
108
|
+
# controllers, and service objects. Capped to stay within token budget.
|
|
109
|
+
def to_structural_summary(max_tokens: 500)
|
|
110
|
+
budget = max_tokens * CHARS_PER_TOKEN
|
|
111
|
+
lines = ['Codebase Structure:']
|
|
112
|
+
|
|
113
|
+
append_model_section(lines)
|
|
114
|
+
append_controller_section(lines)
|
|
115
|
+
append_service_section(lines)
|
|
116
|
+
append_stats_section(lines)
|
|
117
|
+
|
|
118
|
+
truncate_to_budget(lines, budget)
|
|
119
|
+
end
|
|
120
|
+
|
|
106
121
|
def stats
|
|
107
122
|
{
|
|
108
123
|
files_indexed: @file_mtimes.size,
|
|
@@ -113,6 +128,57 @@ module RubynCode
|
|
|
113
128
|
|
|
114
129
|
private
|
|
115
130
|
|
|
131
|
+
def append_model_section(lines)
|
|
132
|
+
models = @nodes.select { |n| n['type'] == 'model' && (n['name'] || '').match?(/\A[A-Z]/) }
|
|
133
|
+
return if models.empty?
|
|
134
|
+
|
|
135
|
+
lines << 'Models:'
|
|
136
|
+
models.each do |model|
|
|
137
|
+
assocs = associations_for_file(model['file'])
|
|
138
|
+
desc = assocs.empty? ? model['name'] : "#{model['name']} #{assocs.join(', ')}"
|
|
139
|
+
lines << " #{desc}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def append_controller_section(lines)
|
|
144
|
+
controllers = @nodes.select { |n| n['type'] == 'controller' && (n['name'] || '').match?(/\A[A-Z]/) }
|
|
145
|
+
return if controllers.empty?
|
|
146
|
+
|
|
147
|
+
lines << 'Controllers:'
|
|
148
|
+
controllers.each { |c| lines << " #{c['name']} (#{c['file']})" }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def append_service_section(lines)
|
|
152
|
+
services = @nodes.select { |n| n['type'] == 'service' && (n['name'] || '').match?(/\A[A-Z]/) }
|
|
153
|
+
return if services.empty?
|
|
154
|
+
|
|
155
|
+
lines << 'Services:'
|
|
156
|
+
services.each { |s| lines << " #{s['name']} (#{s['file']})" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def append_stats_section(lines)
|
|
160
|
+
counts = node_type_counts
|
|
161
|
+
lines << "Stats: #{counts['class'] || 0} classes, #{counts['method'] || 0} methods, #{@edges.size} edges"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def associations_for_file(file)
|
|
165
|
+
@edges.select { |e| e['from'] == file && e['relationship'] == 'association' }
|
|
166
|
+
.map { |e| "#{e['type']} :#{e['to']}" }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def truncate_to_budget(lines, budget)
|
|
170
|
+
result = []
|
|
171
|
+
total = 0
|
|
172
|
+
lines.each do |line|
|
|
173
|
+
line_size = line.bytesize + 1 # +1 for newline
|
|
174
|
+
break if total + line_size > budget
|
|
175
|
+
|
|
176
|
+
result << line
|
|
177
|
+
total += line_size
|
|
178
|
+
end
|
|
179
|
+
result.join("\n")
|
|
180
|
+
end
|
|
181
|
+
|
|
116
182
|
def edges_involving(names)
|
|
117
183
|
@edges.select do |e|
|
|
118
184
|
names.include?(e['from']) || names.include?(e['to'])
|