rubyn-code 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -19
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +32 -3
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +9 -1
  7. data/lib/rubyn_code/agent/loop.rb +7 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
  9. data/lib/rubyn_code/agent/tool_processor.rb +21 -1
  10. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  11. data/lib/rubyn_code/auth/token_store.rb +50 -9
  12. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  13. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  14. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  15. data/lib/rubyn_code/cli/app.rb +32 -1
  16. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  17. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  18. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  19. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  20. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  21. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  22. data/lib/rubyn_code/cli/first_run.rb +159 -0
  23. data/lib/rubyn_code/cli/repl.rb +6 -1
  24. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  25. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  26. data/lib/rubyn_code/cli/repl_setup.rb +36 -0
  27. data/lib/rubyn_code/config/defaults.rb +1 -0
  28. data/lib/rubyn_code/config/schema.json +49 -0
  29. data/lib/rubyn_code/config/settings.rb +7 -4
  30. data/lib/rubyn_code/config/validator.rb +63 -0
  31. data/lib/rubyn_code/context/context_budget.rb +16 -1
  32. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  33. data/lib/rubyn_code/context/manager.rb +37 -3
  34. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  35. data/lib/rubyn_code/hooks/registry.rb +4 -0
  36. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  37. data/lib/rubyn_code/ide/client.rb +110 -0
  38. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  39. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  40. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  41. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  42. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  43. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  44. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  45. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  46. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  47. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  48. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  49. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  50. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  51. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  52. data/lib/rubyn_code/ide/handlers.rb +76 -0
  53. data/lib/rubyn_code/ide/protocol.rb +111 -0
  54. data/lib/rubyn_code/ide/server.rb +186 -0
  55. data/lib/rubyn_code/index/codebase_index.rb +67 -1
  56. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  57. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  58. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  59. data/lib/rubyn_code/llm/client.rb +29 -4
  60. data/lib/rubyn_code/mcp/config.rb +2 -1
  61. data/lib/rubyn_code/memory/search.rb +1 -0
  62. data/lib/rubyn_code/self_test.rb +315 -0
  63. data/lib/rubyn_code/skills/catalog.rb +66 -0
  64. data/lib/rubyn_code/skills/loader.rb +43 -0
  65. data/lib/rubyn_code/tasks/models.rb +1 -0
  66. data/lib/rubyn_code/tools/base.rb +13 -0
  67. data/lib/rubyn_code/tools/bash.rb +5 -0
  68. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  69. data/lib/rubyn_code/tools/executor.rb +61 -6
  70. data/lib/rubyn_code/tools/glob.rb +6 -0
  71. data/lib/rubyn_code/tools/grep.rb +6 -0
  72. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  73. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  74. data/lib/rubyn_code/tools/output_compressor.rb +6 -1
  75. data/lib/rubyn_code/tools/read_file.rb +6 -0
  76. data/lib/rubyn_code/tools/registry.rb +11 -0
  77. data/lib/rubyn_code/tools/write_file.rb +17 -0
  78. data/lib/rubyn_code/version.rb +1 -1
  79. data/lib/rubyn_code.rb +22 -0
  80. data/skills/rubyn_self_test.md +13 -1
  81. metadata +31 -1
@@ -0,0 +1,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'])