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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'reline'
4
+ require_relative 'repl_setup'
5
+ require_relative 'repl_lifecycle'
6
+ require_relative 'repl_commands'
4
7
 
5
8
  module RubynCode
6
9
  module CLI
7
10
  class REPL
11
+ include ReplSetup
12
+ include ReplLifecycle
13
+ include ReplCommands
14
+
8
15
  def initialize(session_id: nil, project_root: Dir.pwd, yolo: false)
9
16
  @project_root = project_root
10
17
  @input_handler = InputHandler.new
@@ -31,7 +38,13 @@ module RubynCode
31
38
  at_exit { shutdown! }
32
39
 
33
40
  @last_interrupt = nil
41
+ run_input_loop
42
+ shutdown!
43
+ end
34
44
 
45
+ private
46
+
47
+ def run_input_loop
35
48
  while @running
36
49
  begin
37
50
  input = read_input
@@ -41,229 +54,51 @@ module RubynCode
41
54
  command = @input_handler.parse(input)
42
55
  handle_command(command)
43
56
  rescue Interrupt
44
- @spinner.stop
45
- now = Time.now.to_f
46
- if @last_interrupt && (now - @last_interrupt) < 2.0
47
- puts
48
- break
49
- end
50
- @last_interrupt = now
51
- puts
52
- @renderer.info('Press Ctrl-C again to exit, or type /quit')
57
+ handle_interrupt
53
58
  end
54
59
  end
55
-
56
- shutdown!
57
60
  end
58
61
 
59
- private
60
-
61
- # ── Component Setup ──────────────────────────────────────────────
62
-
63
- def setup_components!
64
- ensure_home_dir!
65
- @db = DB::Connection.instance
66
- DB::Migrator.new(@db).migrate!
67
-
68
- @auth = ensure_auth!
69
- @llm_client = LLM::Client.new
70
- @conversation = Agent::Conversation.new
71
- @tool_executor = Tools::Executor.new(project_root: @project_root)
72
- @context_manager = Context::Manager.new
73
- @hook_registry = Hooks::Registry.new
74
- @hook_runner = Hooks::Runner.new(registry: @hook_registry)
75
- @stall_detector = Agent::LoopDetector.new
76
- @deny_list = Permissions::DenyList.new
77
- @budget_enforcer = Observability::BudgetEnforcer.new(
78
- @db,
79
- session_id: current_session_id
80
- )
81
- @background_worker = Background::Worker.new(project_root: @project_root)
82
- @skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
83
- @session_persistence = Memory::SessionPersistence.new(@db)
84
-
85
- # Inject dependencies into executor for spawn_agent, spawn_teammate, and background_run
86
- @tool_executor.llm_client = @llm_client
87
- @tool_executor.background_worker = @background_worker
88
- @tool_executor.db = @db
89
- @sub_agent_tool_count = 0
90
- @in_sub_agent = false
91
- @tool_executor.on_agent_status = lambda { |type, msg|
92
- case type
93
- when :started
94
- @spinner.stop
95
- @in_sub_agent = true
96
- @sub_agent_tool_count = 0
97
- @renderer.info(msg)
98
- @spinner.start_sub_agent
99
- when :tool
100
- @sub_agent_tool_count += 1
101
- @spinner.stop
102
- @spinner.start_sub_agent(@sub_agent_tool_count)
103
- when :done
104
- @spinner.stop
105
- @in_sub_agent = false
106
- @renderer.success(msg)
107
- end
108
- }
109
-
110
- Hooks::BuiltIn.register_all!(@hook_registry)
111
- Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
112
-
113
- @agent_loop = Agent::Loop.new(
114
- llm_client: @llm_client,
115
- tool_executor: @tool_executor,
116
- context_manager: @context_manager,
117
- hook_runner: @hook_runner,
118
- conversation: @conversation,
119
- permission_tier: @permission_tier,
120
- deny_list: @deny_list,
121
- budget_enforcer: @budget_enforcer,
122
- background_manager: @background_worker,
123
- stall_detector: @stall_detector,
124
- on_tool_call: lambda { |name, params|
125
- @spinner.stop
126
- unless @streaming_first_chunk
127
- @stream_formatter&.flush
128
- @stream_formatter = nil
129
- puts
130
- @streaming_first_chunk = true
131
- end
132
- @renderer.tool_call(name, params)
133
- },
134
- on_tool_result: lambda { |name, result, _is_error = false|
135
- @renderer.tool_result(name, result)
136
- @spinner.start
137
- },
138
- on_text: lambda { |text|
139
- @spinner.stop
140
- if @streaming_first_chunk
141
- @stream_formatter = StreamFormatter.new
142
- puts
143
- @streaming_first_chunk = false
144
- end
145
- @stream_formatter&.feed(text)
146
- },
147
- skill_loader: @skill_loader,
148
- project_root: @project_root
149
- )
150
- end
151
-
152
- # ── Command Registry ─────────────────────────────────────────────
153
-
154
- def setup_command_registry!
155
- @command_registry = Commands::Registry.new
156
-
157
- # Register all commands
158
- [
159
- Commands::Help,
160
- Commands::Quit,
161
- Commands::Compact,
162
- Commands::Cost,
163
- Commands::Clear,
164
- Commands::Undo,
165
- Commands::Tasks,
166
- Commands::Budget,
167
- Commands::Skill,
168
- Commands::Version,
169
- Commands::Review,
170
- Commands::Resume,
171
- Commands::Spawn,
172
- Commands::Doctor,
173
- Commands::Tokens,
174
- Commands::Plan,
175
- Commands::ContextInfo,
176
- Commands::Diff,
177
- Commands::Model
178
- ].each { |cmd| @command_registry.register(cmd) }
179
-
180
- # Give Help access to the registry for listing commands
181
- Commands::Help.registry = @command_registry
182
-
183
- # Update input handler to use registry for parsing
184
- @input_handler = InputHandler.new(command_registry: @command_registry)
185
- end
186
-
187
- # ── Command Dispatch ─────────────────────────────────────────────
188
-
189
- def handle_command(command)
190
- case command.action
191
- when :quit
62
+ def handle_interrupt
63
+ @spinner.stop
64
+ now = Time.now.to_f
65
+ if @last_interrupt && (now - @last_interrupt) < 2.0
66
+ puts
192
67
  @running = false
193
- when :message
194
- handle_message(command.args.first)
195
- when :empty
196
- nil
197
- when :list_commands
198
- display_commands
199
- when :unknown_command
200
- @renderer.warning("Unknown command: #{command.args.first}. Type / to see available commands.")
201
- when :slash_command
202
- dispatch_slash_command(command.args[0], command.args[1..])
68
+ return
203
69
  end
70
+ @last_interrupt = now
71
+ puts
72
+ @renderer.info('Press Ctrl-C again to exit, or type /quit')
204
73
  end
205
74
 
206
- def dispatch_slash_command(name, args)
207
- ctx = build_context
208
- result = @command_registry.dispatch(name, args, ctx)
209
-
210
- case result
211
- when :quit
212
- @running = false
213
- when :unknown
214
- @renderer.warning("Unknown command: #{name}. Type / to see available commands.")
215
- when Hash
216
- handle_command_result(result)
75
+ def handle_on_tool_call(name, params)
76
+ @spinner.stop
77
+ unless @streaming_first_chunk
78
+ @stream_formatter&.flush
79
+ @stream_formatter = nil
80
+ puts
81
+ @streaming_first_chunk = true
217
82
  end
83
+ @renderer.tool_call(name, params)
218
84
  end
219
85
 
220
- def build_context
221
- Commands::Context.new(
222
- renderer: @renderer,
223
- conversation: @conversation,
224
- agent_loop: @agent_loop,
225
- context_manager: @context_manager,
226
- budget_enforcer: @budget_enforcer,
227
- llm_client: @llm_client,
228
- db: @db,
229
- session_id: current_session_id,
230
- project_root: @project_root,
231
- skill_loader: @skill_loader,
232
- session_persistence: @session_persistence,
233
- background_worker: @background_worker,
234
- permission_tier: @permission_tier,
235
- plan_mode: @plan_mode,
236
- message_handler: method(:handle_message)
237
- )
86
+ def handle_on_tool_result(name, result)
87
+ @renderer.tool_result(name, result)
88
+ @spinner.start
238
89
  end
239
90
 
240
- # Handle structured results from commands that need to mutate REPL state
241
- def handle_command_result(result)
242
- case result
243
- in { action: :set_budget, amount: Float => amount }
244
- @budget_enforcer = Observability::BudgetEnforcer.new(
245
- @db,
246
- session_id: current_session_id,
247
- session_limit: amount
248
- )
249
- in { action: :set_plan_mode, enabled: true | false => enabled }
250
- @plan_mode = enabled
251
- @agent_loop.plan_mode = enabled if @agent_loop.respond_to?(:plan_mode=)
252
- in { action: :set_session_id, session_id: String => sid }
253
- @session_id = sid
254
- in { action: :set_model, model: String => model }
255
- @llm_client.model = model if @llm_client.respond_to?(:model=)
256
- @renderer.info("Model set to #{model}")
257
- in { action: :spawn_teammate, name: String => name, role: String => role }
258
- spawn_teammate(name, role)
259
- else
260
- # Unknown result hash — ignore
91
+ def handle_on_text(text)
92
+ @spinner.stop
93
+ if @streaming_first_chunk
94
+ @stream_formatter = StreamFormatter.new
95
+ puts
96
+ @streaming_first_chunk = false
261
97
  end
98
+ @stream_formatter&.feed(text)
262
99
  end
263
100
 
264
- # ── Message Handling ─────────────────────────────────────────────
265
-
266
- def handle_message(input)
101
+ def handle_message(input) # rubocop:disable Metrics/AbcSize -- sequential steps with interrupt rescue
267
102
  @spinner.start
268
103
  @streaming_first_chunk = true
269
104
 
@@ -278,6 +113,11 @@ module RubynCode
278
113
  puts
279
114
  end
280
115
 
116
+ save_session!
117
+ rescue Interrupt
118
+ @spinner.stop
119
+ puts
120
+ @renderer.warning('Interrupted — session state preserved')
281
121
  save_session!
282
122
  rescue BudgetExceededError => e
283
123
  @spinner.error
@@ -287,75 +127,11 @@ module RubynCode
287
127
  @renderer.error("Error: #{e.message}")
288
128
  end
289
129
 
290
- # ── Teammate Handling ────────────────────────────────────────────
291
-
292
- def spawn_teammate(name, role)
293
- mailbox = Teams::Mailbox.new(@db)
294
- manager = Teams::Manager.new(@db, mailbox: mailbox)
295
- teammate = manager.spawn(name: name, role: role)
296
-
297
- Thread.new do
298
- run_teammate_loop(teammate, mailbox)
299
- end
300
-
301
- @renderer.info("Spawned teammate #{name} as #{role}")
302
- rescue StandardError => e
303
- @renderer.error("Failed to spawn teammate: #{e.message}")
304
- end
305
-
306
- def run_teammate_loop(teammate, mailbox)
307
- conversation = Agent::Conversation.new
308
- tool_executor = Tools::Executor.new(project_root: @project_root)
309
- tool_executor.llm_client = @llm_client
310
-
311
- loop do
312
- messages = mailbox.read_inbox(teammate.name)
313
- break if messages.empty?
314
-
315
- messages.each do |msg|
316
- conversation.add_user_message(msg[:content])
317
-
318
- response = @llm_client.chat(
319
- messages: conversation.to_api_format,
320
- tools: tool_executor.tool_definitions,
321
- system: "You are #{teammate.name}, a #{teammate.role} teammate agent. Complete tasks sent to your inbox."
322
- )
323
-
324
- content = response.respond_to?(:content) ? Array(response.content) : []
325
- text = content.select { |b| b.respond_to?(:text) }.map(&:text).join("\n")
326
- conversation.add_assistant_message(content)
327
-
328
- mailbox.send(from: teammate.name, to: msg[:from], content: text)
329
- end
330
-
331
- sleep 5
332
- end
333
- rescue StandardError => e
334
- RubynCode::Debug.agent("Teammate #{teammate.name} error: #{e.message}")
335
- end
336
-
337
- # ── Display ──────────────────────────────────────────────────────
338
-
339
- def display_commands
340
- @renderer.info('Available commands:')
341
- @command_registry.visible_commands.each do |cmd_class|
342
- names = cmd_class.all_names.join(', ')
343
- puts " #{names.ljust(25)} #{cmd_class.description}"
344
- end
345
- puts
346
- end
347
-
348
- # ── Readline ─────────────────────────────────────────────────────
349
-
350
130
  def setup_readline!
351
131
  completions = @command_registry.completions
352
132
 
353
133
  Reline.completion_proc = proc do |input|
354
- if input.start_with?('/')
355
- completions.select { |c| c.start_with?(input) }
356
- else
357
- []
358
- end
134
+ input.start_with?('/') ? completions.select { |c| c.start_with?(input) } : []
359
135
  end
360
136
  Reline.completion_append_character = ' '
361
137
  end
@@ -379,108 +155,6 @@ module RubynCode
379
155
 
380
156
  lines.join("\n")
381
157
  end
382
-
383
- # ── Utilities ────────────────────────────────────────────────────
384
-
385
- def ensure_home_dir!
386
- dir = Config::Defaults::HOME_DIR
387
- FileUtils.mkdir_p(dir)
388
- end
389
-
390
- def ensure_auth!
391
- if Auth::TokenStore.valid?
392
- tokens = Auth::TokenStore.load
393
- source = tokens&.fetch(:source, :unknown)
394
- @renderer.info("Authenticated via #{source}") if source == :keychain
395
- return true
396
- end
397
-
398
- @renderer.error('No valid authentication found.')
399
- @renderer.info('Options:')
400
- @renderer.info(' 1. Run Claude Code once to authenticate (Rubyn Code reads the keychain token)')
401
- @renderer.info(' 2. Set ANTHROPIC_API_KEY environment variable')
402
- @renderer.info(" 3. Run 'rubyn-code --auth' to enter an API key")
403
- exit(1)
404
- end
405
-
406
- def skill_dirs
407
- dirs = [File.expand_path('../../../skills', __dir__)]
408
- project_skills = File.join(@project_root, '.rubyn-code', 'skills')
409
- dirs << project_skills if Dir.exist?(project_skills)
410
- user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
411
- dirs << user_skills if Dir.exist?(user_skills)
412
- dirs
413
- end
414
-
415
- def current_session_id
416
- @current_session_id ||= SecureRandom.hex(16)
417
- end
418
-
419
- def save_session!
420
- @session_persistence.save_session(
421
- session_id: current_session_id,
422
- project_path: @project_root,
423
- messages: @conversation.messages,
424
- model: Config::Defaults::DEFAULT_MODEL
425
- )
426
- end
427
-
428
- def resume_session!
429
- data = @session_persistence.load_session(@session_id)
430
- return unless data
431
-
432
- @conversation.replace!(data[:messages])
433
- @renderer.info("Resumed session #{@session_id[0..7]}")
434
- end
435
-
436
- GOODBYE_MESSAGES = [
437
- 'Freezing strings and saving memories... See ya! 💎',
438
- 'Memoizing this session... Until next time! 🧠',
439
- 'Committing learnings to memory... Later! 🤙',
440
- 'Saving state, yielding control... Bye for now! 👋',
441
- 'Session.save! && Rubyn.sleep... Catch you later! 😴',
442
- "GC.start on this session... Stay Ruby, friend! ✌\uFE0F",
443
- "Writing instincts to disk... Don't forget me! 💾",
444
- "at_exit { puts 'Thanks for coding with Rubyn!' } 🎸"
445
- ].freeze
446
-
447
- def shutdown!
448
- return if @shutdown_complete
449
-
450
- @shutdown_complete = true
451
- @spinner.stop
452
- puts
453
- @renderer.info(GOODBYE_MESSAGES.sample)
454
-
455
- @renderer.info('Saving session...')
456
- save_session!
457
- @background_worker&.shutdown!
458
-
459
- if @conversation.length > 5
460
- begin
461
- @renderer.info('Extracting learnings from this session...')
462
- Learning::Extractor.call(
463
- @conversation.messages,
464
- llm_client: @llm_client,
465
- project_path: @project_root
466
- )
467
- @renderer.success('Instincts saved.')
468
- rescue StandardError => e
469
- RubynCode::Debug.warn("Instinct extraction skipped: #{e.message}")
470
- end
471
- end
472
-
473
- begin
474
- db = DB::Connection.instance
475
- Learning::InstinctMethods.decay_all(db, project_path: @project_root)
476
- rescue StandardError
477
- # Silent — decay is best-effort
478
- end
479
-
480
- @renderer.info("Session saved. Rubyn out. ✌\uFE0F")
481
- rescue StandardError
482
- # Best effort on shutdown
483
- end
484
158
  end
485
159
  end
486
160
  end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Command registration and dispatch for the REPL.
6
+ module ReplCommands # rubocop:disable Metrics/ModuleLength -- REPL command dispatch and teammate handling
7
+ private
8
+
9
+ def setup_command_registry!
10
+ @command_registry = Commands::Registry.new
11
+ register_all_commands!
12
+ Commands::Help.registry = @command_registry
13
+ @input_handler = InputHandler.new(command_registry: @command_registry)
14
+ end
15
+
16
+ def register_all_commands!
17
+ [
18
+ Commands::Help, Commands::Quit, Commands::Compact,
19
+ Commands::Cost, Commands::Clear, Commands::Undo,
20
+ Commands::Tasks, Commands::Budget, Commands::Skill,
21
+ Commands::Version, Commands::Review, Commands::Resume,
22
+ Commands::Spawn, Commands::Doctor, Commands::Tokens,
23
+ Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
+ Commands::Model, Commands::NewSession, Commands::Mcp,
25
+ Commands::Provider
26
+ ].each { |cmd| @command_registry.register(cmd) }
27
+ end
28
+
29
+ def handle_command(command)
30
+ case command.action
31
+ when :quit then @running = false
32
+ when :message then handle_message(command.args.first)
33
+ when :empty then nil
34
+ when :list_commands then display_commands
35
+ when :unknown_command
36
+ @renderer.warning("Unknown command: #{command.args.first}. Type / to see available commands.")
37
+ when :slash_command then dispatch_slash_command(command.args[0], command.args[1..])
38
+ end
39
+ end
40
+
41
+ def dispatch_slash_command(name, args)
42
+ ctx = build_context
43
+ result = @command_registry.dispatch(name, args, ctx)
44
+
45
+ case result
46
+ when :quit then @running = false
47
+ when :unknown then @renderer.warning("Unknown command: #{name}. Type / to see available commands.")
48
+ when Hash then handle_command_result(result)
49
+ end
50
+ end
51
+
52
+ def build_context
53
+ Commands::Context.new(
54
+ renderer: @renderer,
55
+ conversation: @conversation,
56
+ agent_loop: @agent_loop,
57
+ context_manager: @context_manager,
58
+ budget_enforcer: @budget_enforcer,
59
+ llm_client: @llm_client,
60
+ db: @db,
61
+ session_id: current_session_id,
62
+ project_root: @project_root,
63
+ skill_loader: @skill_loader,
64
+ session_persistence: @session_persistence,
65
+ background_worker: @background_worker,
66
+ permission_tier: @permission_tier,
67
+ plan_mode: @plan_mode,
68
+ message_handler: method(:handle_message)
69
+ )
70
+ end
71
+
72
+ def handle_command_result(result)
73
+ case result
74
+ in { action: :set_budget, amount: Float => amount }
75
+ apply_budget(amount)
76
+ in { action: :set_plan_mode, enabled: true | false => enabled }
77
+ apply_plan_mode(enabled)
78
+ in { action: :set_session_id, session_id: String => sid }
79
+ start_new_session(sid)
80
+ in { action: :set_model, model: String => model }
81
+ apply_model(model)
82
+ in { action: :set_provider, provider: String => provider, **rest }
83
+ apply_provider(provider, rest[:model])
84
+ in { action: :spawn_teammate, name: String => name, role: String => role }
85
+ spawn_teammate(name, role)
86
+ else
87
+ # Unknown result hash — ignore
88
+ end
89
+ end
90
+
91
+ def start_new_session(new_id)
92
+ @session_id = new_id
93
+ @skills_injected = false # re-inject skills on next message
94
+ system('clear')
95
+ end
96
+
97
+ def apply_budget(amount)
98
+ @budget_enforcer = Observability::BudgetEnforcer.new(
99
+ @db, session_id: current_session_id, session_limit: amount
100
+ )
101
+ end
102
+
103
+ def apply_plan_mode(enabled)
104
+ @plan_mode = enabled
105
+ @agent_loop.plan_mode = enabled if @agent_loop.respond_to?(:plan_mode=)
106
+ end
107
+
108
+ def apply_model(model)
109
+ @llm_client.model = model
110
+ @renderer.info("Model set to #{model}")
111
+ end
112
+
113
+ def apply_provider(provider, model)
114
+ @llm_client.switch_provider!(provider, model: model)
115
+ label = "Provider set to #{provider}"
116
+ label += ", model: #{model}" if model
117
+ @renderer.info(label)
118
+ end
119
+
120
+ def display_commands
121
+ @renderer.info('Available commands:')
122
+ @command_registry.visible_commands.each do |cmd_class|
123
+ names = cmd_class.all_names.join(', ')
124
+ puts " #{names.ljust(25)} #{cmd_class.description}"
125
+ end
126
+ puts
127
+ end
128
+
129
+ def spawn_teammate(name, role)
130
+ mailbox = Teams::Mailbox.new(@db)
131
+ manager = Teams::Manager.new(@db, mailbox: mailbox)
132
+ teammate = manager.spawn(name: name, role: role)
133
+
134
+ Thread.new { run_teammate_loop(teammate, mailbox) }
135
+
136
+ @renderer.info("Spawned teammate #{name} as #{role}")
137
+ rescue StandardError => e
138
+ @renderer.error("Failed to spawn teammate: #{e.message}")
139
+ end
140
+
141
+ def run_teammate_loop(teammate, mailbox)
142
+ conversation = Agent::Conversation.new
143
+ tool_executor = Tools::Executor.new(project_root: @project_root)
144
+ tool_executor.llm_client = @llm_client
145
+
146
+ loop do
147
+ messages = mailbox.read_inbox(teammate.name)
148
+ break if messages.empty?
149
+
150
+ process_teammate_messages(teammate, mailbox, conversation, tool_executor, messages)
151
+ sleep 5
152
+ end
153
+ rescue StandardError => e
154
+ RubynCode::Debug.agent("Teammate #{teammate.name} error: #{e.message}")
155
+ end
156
+
157
+ def process_teammate_messages(teammate, mailbox, conversation, tool_executor, messages)
158
+ messages.each do |msg|
159
+ text = run_teammate_turn(teammate, conversation, tool_executor, msg[:content])
160
+ mailbox.send(from: teammate.name, to: msg[:from], content: text)
161
+ end
162
+ end
163
+
164
+ def run_teammate_turn(teammate, conversation, tool_executor, message_content)
165
+ conversation.add_user_message(message_content)
166
+ response = @llm_client.chat(
167
+ messages: conversation.to_api_format,
168
+ tools: tool_executor.tool_definitions,
169
+ system: "You are #{teammate.name}, a #{teammate.role} teammate agent. Complete tasks sent to your inbox."
170
+ )
171
+ content = response.respond_to?(:content) ? Array(response.content) : []
172
+ conversation.add_assistant_message(content)
173
+ content.select { |b| b.respond_to?(:text) }.map(&:text).join("\n")
174
+ end
175
+ end
176
+ end
177
+ end