rubyn-code 0.2.2 → 0.3.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -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,228 +54,50 @@ 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
101
  def handle_message(input)
267
102
  @spinner.start
268
103
  @streaming_first_chunk = true
@@ -287,75 +122,11 @@ module RubynCode
287
122
  @renderer.error("Error: #{e.message}")
288
123
  end
289
124
 
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
125
  def setup_readline!
351
126
  completions = @command_registry.completions
352
127
 
353
128
  Reline.completion_proc = proc do |input|
354
- if input.start_with?('/')
355
- completions.select { |c| c.start_with?(input) }
356
- else
357
- []
358
- end
129
+ input.start_with?('/') ? completions.select { |c| c.start_with?(input) } : []
359
130
  end
360
131
  Reline.completion_append_character = ' '
361
132
  end
@@ -379,108 +150,6 @@ module RubynCode
379
150
 
380
151
  lines.join("\n")
381
152
  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
153
  end
485
154
  end
486
155
  end
@@ -0,0 +1,176 @@
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
25
+ ].each { |cmd| @command_registry.register(cmd) }
26
+ end
27
+
28
+ def handle_command(command)
29
+ case command.action
30
+ when :quit then @running = false
31
+ when :message then handle_message(command.args.first)
32
+ when :empty then nil
33
+ when :list_commands then display_commands
34
+ when :unknown_command
35
+ @renderer.warning("Unknown command: #{command.args.first}. Type / to see available commands.")
36
+ when :slash_command then dispatch_slash_command(command.args[0], command.args[1..])
37
+ end
38
+ end
39
+
40
+ def dispatch_slash_command(name, args)
41
+ ctx = build_context
42
+ result = @command_registry.dispatch(name, args, ctx)
43
+
44
+ case result
45
+ when :quit then @running = false
46
+ when :unknown then @renderer.warning("Unknown command: #{name}. Type / to see available commands.")
47
+ when Hash then handle_command_result(result)
48
+ end
49
+ end
50
+
51
+ def build_context
52
+ Commands::Context.new(
53
+ renderer: @renderer,
54
+ conversation: @conversation,
55
+ agent_loop: @agent_loop,
56
+ context_manager: @context_manager,
57
+ budget_enforcer: @budget_enforcer,
58
+ llm_client: @llm_client,
59
+ db: @db,
60
+ session_id: current_session_id,
61
+ project_root: @project_root,
62
+ skill_loader: @skill_loader,
63
+ session_persistence: @session_persistence,
64
+ background_worker: @background_worker,
65
+ permission_tier: @permission_tier,
66
+ plan_mode: @plan_mode,
67
+ message_handler: method(:handle_message)
68
+ )
69
+ end
70
+
71
+ def handle_command_result(result)
72
+ case result
73
+ in { action: :set_budget, amount: Float => amount }
74
+ apply_budget(amount)
75
+ in { action: :set_plan_mode, enabled: true | false => enabled }
76
+ apply_plan_mode(enabled)
77
+ in { action: :set_session_id, session_id: String => sid }
78
+ start_new_session(sid)
79
+ in { action: :set_model, model: String => model }
80
+ apply_model(model)
81
+ in { action: :set_provider, provider: String => provider, **rest }
82
+ apply_provider(provider, rest[:model])
83
+ in { action: :spawn_teammate, name: String => name, role: String => role }
84
+ spawn_teammate(name, role)
85
+ else
86
+ # Unknown result hash — ignore
87
+ end
88
+ end
89
+
90
+ def start_new_session(new_id)
91
+ @session_id = new_id
92
+ @skills_injected = false # re-inject skills on next message
93
+ system('clear')
94
+ end
95
+
96
+ def apply_budget(amount)
97
+ @budget_enforcer = Observability::BudgetEnforcer.new(
98
+ @db, session_id: current_session_id, session_limit: amount
99
+ )
100
+ end
101
+
102
+ def apply_plan_mode(enabled)
103
+ @plan_mode = enabled
104
+ @agent_loop.plan_mode = enabled if @agent_loop.respond_to?(:plan_mode=)
105
+ end
106
+
107
+ def apply_model(model)
108
+ @llm_client.model = model
109
+ @renderer.info("Model set to #{model}")
110
+ end
111
+
112
+ def apply_provider(provider, model)
113
+ @llm_client.switch_provider!(provider, model: model)
114
+ label = "Provider set to #{provider}"
115
+ label += ", model: #{model}" if model
116
+ @renderer.info(label)
117
+ end
118
+
119
+ def display_commands
120
+ @renderer.info('Available commands:')
121
+ @command_registry.visible_commands.each do |cmd_class|
122
+ names = cmd_class.all_names.join(', ')
123
+ puts " #{names.ljust(25)} #{cmd_class.description}"
124
+ end
125
+ puts
126
+ end
127
+
128
+ def spawn_teammate(name, role)
129
+ mailbox = Teams::Mailbox.new(@db)
130
+ manager = Teams::Manager.new(@db, mailbox: mailbox)
131
+ teammate = manager.spawn(name: name, role: role)
132
+
133
+ Thread.new { run_teammate_loop(teammate, mailbox) }
134
+
135
+ @renderer.info("Spawned teammate #{name} as #{role}")
136
+ rescue StandardError => e
137
+ @renderer.error("Failed to spawn teammate: #{e.message}")
138
+ end
139
+
140
+ def run_teammate_loop(teammate, mailbox)
141
+ conversation = Agent::Conversation.new
142
+ tool_executor = Tools::Executor.new(project_root: @project_root)
143
+ tool_executor.llm_client = @llm_client
144
+
145
+ loop do
146
+ messages = mailbox.read_inbox(teammate.name)
147
+ break if messages.empty?
148
+
149
+ process_teammate_messages(teammate, mailbox, conversation, tool_executor, messages)
150
+ sleep 5
151
+ end
152
+ rescue StandardError => e
153
+ RubynCode::Debug.agent("Teammate #{teammate.name} error: #{e.message}")
154
+ end
155
+
156
+ def process_teammate_messages(teammate, mailbox, conversation, tool_executor, messages)
157
+ messages.each do |msg|
158
+ text = run_teammate_turn(teammate, conversation, tool_executor, msg[:content])
159
+ mailbox.send(from: teammate.name, to: msg[:from], content: text)
160
+ end
161
+ end
162
+
163
+ def run_teammate_turn(teammate, conversation, tool_executor, message_content)
164
+ conversation.add_user_message(message_content)
165
+ response = @llm_client.chat(
166
+ messages: conversation.to_api_format,
167
+ tools: tool_executor.tool_definitions,
168
+ system: "You are #{teammate.name}, a #{teammate.role} teammate agent. Complete tasks sent to your inbox."
169
+ )
170
+ content = response.respond_to?(:content) ? Array(response.content) : []
171
+ conversation.add_assistant_message(content)
172
+ content.select { |b| b.respond_to?(:text) }.map(&:text).join("\n")
173
+ end
174
+ end
175
+ end
176
+ end