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.
- checksums.yaml +4 -4
- data/README.md +263 -21
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +34 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
- data/lib/rubyn_code/agent/llm_caller.rb +11 -1
- data/lib/rubyn_code/agent/loop.rb +14 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
- data/lib/rubyn_code/agent/tool_processor.rb +25 -3
- 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 +116 -11
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -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 +124 -0
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +54 -3
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- 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 +15 -0
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +74 -1
- data/lib/rubyn_code/config/defaults.rb +3 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +12 -6
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +18 -2
- 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 +218 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -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 +112 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +69 -2
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- 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/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +316 -0
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +76 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -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 +65 -8
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +7 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +9 -7
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +34 -0
- data/skills/rubyn_self_test.md +88 -1
- metadata +43 -1
|
@@ -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,112 @@
|
|
|
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
|
+
# -- JSON-RPC validation checks
|
|
29
|
+
def parse(line)
|
|
30
|
+
begin
|
|
31
|
+
data = JSON.parse(line)
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
return error(nil, PARSE_ERROR, 'Parse error: invalid JSON')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
return error(nil, INVALID_REQUEST, 'Invalid request: expected JSON object') unless data.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
unless data['jsonrpc'] == JSONRPC_VERSION
|
|
39
|
+
return error(data['id'], INVALID_REQUEST, 'Invalid request: missing or wrong "jsonrpc" version')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Response objects (containing "result" or "error") are valid
|
|
43
|
+
# JSON-RPC 2.0 messages that don't carry a "method".
|
|
44
|
+
is_response = data.key?('result') || data.key?('error')
|
|
45
|
+
|
|
46
|
+
unless is_response || data['method'].is_a?(String)
|
|
47
|
+
return error(data['id'], INVALID_REQUEST, 'Invalid request: "method" must be a string')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if data.key?('params') && !data['params'].is_a?(Hash) && !data['params'].is_a?(Array)
|
|
51
|
+
return error(data['id'], INVALID_PARAMS, 'Invalid params: "params" must be an object or array')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
data
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build a success response hash.
|
|
58
|
+
def response(id, result)
|
|
59
|
+
{
|
|
60
|
+
'jsonrpc' => JSONRPC_VERSION,
|
|
61
|
+
'id' => id,
|
|
62
|
+
'result' => stringify_keys_deep(result)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Build an error response hash.
|
|
67
|
+
def error(id, code, message)
|
|
68
|
+
{
|
|
69
|
+
'jsonrpc' => JSONRPC_VERSION,
|
|
70
|
+
'id' => id,
|
|
71
|
+
'error' => {
|
|
72
|
+
'code' => code,
|
|
73
|
+
'message' => message
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Build a notification hash (no id).
|
|
79
|
+
def notification(method, params)
|
|
80
|
+
{
|
|
81
|
+
'jsonrpc' => JSONRPC_VERSION,
|
|
82
|
+
'method' => method,
|
|
83
|
+
'params' => stringify_keys_deep(params)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Serialise a hash to a JSON string terminated by a newline.
|
|
88
|
+
def serialize(hash)
|
|
89
|
+
"#{JSON.generate(hash)}\n"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ── Helpers ────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
# Recursively convert symbol keys to strings so every hash that
|
|
95
|
+
# leaves this module uses string keys for JSON compatibility.
|
|
96
|
+
def stringify_keys_deep(obj)
|
|
97
|
+
case obj
|
|
98
|
+
when Hash
|
|
99
|
+
obj.each_with_object({}) do |(k, v), memo|
|
|
100
|
+
memo[k.to_s] = stringify_keys_deep(v)
|
|
101
|
+
end
|
|
102
|
+
when Array
|
|
103
|
+
obj.map { |v| stringify_keys_deep(v) }
|
|
104
|
+
else
|
|
105
|
+
obj
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private_class_method :stringify_keys_deep
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
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'])
|
|
@@ -194,7 +260,8 @@ module RubynCode
|
|
|
194
260
|
end
|
|
195
261
|
end
|
|
196
262
|
|
|
197
|
-
|
|
263
|
+
# -- Rails directory mapping
|
|
264
|
+
def classify_node(file, type)
|
|
198
265
|
return 'model' if file.include?('app/models/')
|
|
199
266
|
return 'controller' if file.include?('app/controllers/')
|
|
200
267
|
return 'service' if file.include?('app/services/')
|
|
@@ -82,7 +82,8 @@ module RubynCode
|
|
|
82
82
|
messages.map { |m| format_turn(m) }.join("\n\n")
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
# -- content polymorphism
|
|
86
|
+
def format_turn(msg)
|
|
86
87
|
role = (msg[:role] || msg['role'] || 'unknown').capitalize
|
|
87
88
|
content = msg[:content] || msg['content']
|
|
88
89
|
text = if content.is_a?(Array)
|
|
@@ -121,7 +122,8 @@ module RubynCode
|
|
|
121
122
|
)
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
|
|
125
|
+
# -- response parsing with multiple fallbacks
|
|
126
|
+
def parse_response(response)
|
|
125
127
|
return [] if response.nil?
|
|
126
128
|
|
|
127
129
|
text = if response.respond_to?(:content)
|
|
@@ -48,6 +48,10 @@ module RubynCode
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
+
def api_url
|
|
52
|
+
API_URL
|
|
53
|
+
end
|
|
54
|
+
|
|
51
55
|
# -- Auth ---------------------------------------------------------
|
|
52
56
|
|
|
53
57
|
def oauth_token?
|
|
@@ -88,7 +92,7 @@ module RubynCode
|
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
def post_request(body)
|
|
91
|
-
connection.post(
|
|
95
|
+
connection.post(api_url) do |req|
|
|
92
96
|
apply_headers(req)
|
|
93
97
|
req.body = JSON.generate(body)
|
|
94
98
|
end
|
|
@@ -113,7 +117,7 @@ module RubynCode
|
|
|
113
117
|
streamer = build_streamer(on_text)
|
|
114
118
|
error_chunks = []
|
|
115
119
|
|
|
116
|
-
response = streaming_connection.post(
|
|
120
|
+
response = streaming_connection.post(api_url) do |req|
|
|
117
121
|
apply_headers(req)
|
|
118
122
|
req.body = JSON.generate(body)
|
|
119
123
|
req.options.on_data = on_data_proc(streamer, error_chunks)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module LLM
|
|
5
|
+
module Adapters
|
|
6
|
+
# Adapter for Anthropic-compatible providers that use the Messages API format.
|
|
7
|
+
#
|
|
8
|
+
# Inherits all Anthropic logic but overrides the base URL, provider name,
|
|
9
|
+
# available models, and API key resolution.
|
|
10
|
+
class AnthropicCompatible < Anthropic
|
|
11
|
+
def initialize(provider:, base_url:, api_key: nil, available_models: [])
|
|
12
|
+
super()
|
|
13
|
+
@provider = provider
|
|
14
|
+
@base_url = base_url
|
|
15
|
+
@api_key = api_key
|
|
16
|
+
@available_models = available_models.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def provider_name
|
|
20
|
+
@provider
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def models
|
|
24
|
+
@available_models
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def api_url
|
|
30
|
+
"#{@base_url}/messages"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ensure_valid_token!
|
|
34
|
+
resolve_api_key # raises if missing
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def oauth_token?
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def access_token
|
|
42
|
+
resolve_api_key
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve_api_key
|
|
46
|
+
return @api_key if @api_key
|
|
47
|
+
|
|
48
|
+
stored = Auth::TokenStore.load_provider_key(@provider)
|
|
49
|
+
return stored if stored
|
|
50
|
+
|
|
51
|
+
env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
|
|
52
|
+
ENV.fetch(env_key) do
|
|
53
|
+
raise Client::AuthExpiredError,
|
|
54
|
+
"No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -27,11 +27,15 @@ module RubynCode
|
|
|
27
27
|
def resolve_api_key
|
|
28
28
|
return @api_key if @api_key
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
stored = Auth::TokenStore.load_provider_key(@provider)
|
|
31
|
+
return stored if stored
|
|
32
|
+
|
|
33
|
+
env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
|
|
31
34
|
ENV.fetch(env_key) do
|
|
32
35
|
return 'no-key-required' if local_provider?
|
|
33
36
|
|
|
34
|
-
raise Client::AuthExpiredError,
|
|
37
|
+
raise Client::AuthExpiredError,
|
|
38
|
+
"No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|