rubyn-code 0.1.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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "readline"
4
+
5
+ module RubynCode
6
+ module CLI
7
+ class REPL
8
+ def initialize(session_id: nil, project_root: Dir.pwd, yolo: false)
9
+ @project_root = project_root
10
+ @input_handler = InputHandler.new
11
+ @renderer = Renderer.new
12
+ @renderer.yolo = yolo
13
+ @spinner = Spinner.new
14
+ @running = true
15
+ @session_id = session_id
16
+ @permission_tier = yolo ? :unrestricted : :allow_read
17
+
18
+ setup_readline!
19
+ setup_components!
20
+ end
21
+
22
+ def run
23
+ @renderer.welcome
24
+
25
+ at_exit { shutdown! }
26
+
27
+ @last_interrupt = nil
28
+
29
+ while @running
30
+ begin
31
+ input = read_input
32
+ break if input.nil?
33
+
34
+ @last_interrupt = nil
35
+ command = @input_handler.parse(input)
36
+ handle_command(command)
37
+ rescue Interrupt
38
+ @spinner.stop
39
+ now = Time.now.to_f
40
+ if @last_interrupt && (now - @last_interrupt) < 2.0
41
+ puts
42
+ break
43
+ end
44
+ @last_interrupt = now
45
+ puts
46
+ @renderer.info("Press Ctrl-C again to exit, or type /quit")
47
+ end
48
+ end
49
+
50
+ shutdown!
51
+ end
52
+
53
+ private
54
+
55
+ def setup_components!
56
+ ensure_home_dir!
57
+ @db = DB::Connection.instance
58
+ DB::Migrator.new(@db).migrate!
59
+
60
+ @auth = ensure_auth!
61
+ @llm_client = LLM::Client.new
62
+ @conversation = Agent::Conversation.new
63
+ @tool_executor = Tools::Executor.new(project_root: @project_root)
64
+ @context_manager = Context::Manager.new
65
+ @hook_registry = Hooks::Registry.new
66
+ @hook_runner = Hooks::Runner.new(registry: @hook_registry)
67
+ @stall_detector = Agent::LoopDetector.new
68
+ @deny_list = Permissions::DenyList.new
69
+ @budget_enforcer = Observability::BudgetEnforcer.new(
70
+ @db,
71
+ session_id: current_session_id
72
+ )
73
+ @background_worker = Background::Worker.new(project_root: @project_root)
74
+ @skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
75
+ @session_persistence = Memory::SessionPersistence.new(@db)
76
+
77
+ # Inject dependencies into executor for spawn_agent, spawn_teammate, and background_run
78
+ @tool_executor.llm_client = @llm_client
79
+ @tool_executor.background_worker = @background_worker
80
+ @tool_executor.db = @db
81
+ @sub_agent_tool_count = 0
82
+ @in_sub_agent = false
83
+ @tool_executor.on_agent_status = ->(type, msg) {
84
+ case type
85
+ when :started
86
+ @spinner.stop
87
+ @in_sub_agent = true
88
+ @sub_agent_tool_count = 0
89
+ @renderer.info(msg)
90
+ @spinner.start_sub_agent
91
+ when :tool
92
+ @sub_agent_tool_count += 1
93
+ @spinner.stop
94
+ @spinner.start_sub_agent(@sub_agent_tool_count)
95
+ when :done
96
+ @spinner.stop
97
+ @in_sub_agent = false
98
+ @renderer.success(msg)
99
+ end
100
+ }
101
+
102
+ Hooks::BuiltIn.register_all!(@hook_registry)
103
+ Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
104
+
105
+ @agent_loop = Agent::Loop.new(
106
+ llm_client: @llm_client,
107
+ tool_executor: @tool_executor,
108
+ context_manager: @context_manager,
109
+ hook_runner: @hook_runner,
110
+ conversation: @conversation,
111
+ permission_tier: @permission_tier,
112
+ deny_list: @deny_list,
113
+ budget_enforcer: @budget_enforcer,
114
+ background_manager: @background_worker,
115
+ stall_detector: @stall_detector,
116
+ on_tool_call: ->(name, params) {
117
+ @spinner.stop
118
+ unless @streaming_first_chunk
119
+ @stream_formatter&.flush
120
+ @stream_formatter = nil
121
+ puts
122
+ @streaming_first_chunk = true
123
+ end
124
+ @renderer.tool_call(name, params)
125
+ },
126
+ on_tool_result: ->(name, result, is_error) {
127
+ unless @in_sub_agent
128
+ @renderer.tool_result(name, result)
129
+ end
130
+ @streaming_first_chunk = true
131
+ @spinner.start unless @in_sub_agent
132
+ },
133
+ on_text: ->(text) {
134
+ if @streaming_first_chunk
135
+ @spinner.stop
136
+ @streaming_first_chunk = false
137
+ @stream_formatter ||= StreamFormatter.new(@renderer)
138
+ end
139
+ @spinner.stop if @spinner.spinning?
140
+ @stream_formatter.feed(text)
141
+ },
142
+ skill_loader: @skill_loader,
143
+ project_root: @project_root
144
+ )
145
+
146
+ resume_session! if @session_id
147
+ end
148
+
149
+ def handle_command(command)
150
+ case command.action
151
+ when :quit
152
+ @running = false
153
+ when :message
154
+ handle_message(command.args.first)
155
+ when :compact
156
+ handle_compact(command.args.first)
157
+ when :cost
158
+ handle_cost
159
+ when :clear
160
+ system("clear")
161
+ when :undo
162
+ @conversation.undo_last!
163
+ @renderer.info("Last exchange removed.")
164
+ when :help
165
+ display_help
166
+ when :tasks
167
+ handle_tasks
168
+ when :budget
169
+ handle_budget(command.args.first)
170
+ when :skill
171
+ handle_skill(command.args.first)
172
+ when :version
173
+ @renderer.info("Rubyn Code v#{RubynCode::VERSION}")
174
+ when :review
175
+ handle_review(command.args)
176
+ when :spawn_teammate
177
+ handle_spawn_teammate(command.args)
178
+ when :resume
179
+ handle_resume(command.args.first)
180
+ when :empty
181
+ nil
182
+ when :list_commands
183
+ display_commands
184
+ when :unknown_command
185
+ @renderer.warning("Unknown command: #{command.args.first}. Type / to see available commands.")
186
+ end
187
+ end
188
+
189
+ def handle_message(input)
190
+ @spinner.start
191
+ @streaming_first_chunk = true
192
+
193
+ response = @agent_loop.send_message(input)
194
+
195
+ @spinner.stop
196
+ if @streaming_first_chunk
197
+ @renderer.display(response)
198
+ else
199
+ @stream_formatter&.flush
200
+ @stream_formatter = nil
201
+ puts
202
+ end
203
+
204
+ save_session!
205
+ rescue BudgetExceededError => e
206
+ @spinner.error
207
+ @renderer.error("Budget exceeded: #{e.message}")
208
+ rescue StandardError => e
209
+ @spinner.error
210
+ @renderer.error("Error: #{e.message}")
211
+ end
212
+
213
+ def handle_compact(focus = nil)
214
+ @spinner.start("Compacting context...")
215
+ compactor = Context::Compactor.new(llm_client: @llm_client)
216
+ new_messages = compactor.manual_compact!(@conversation.messages, focus: focus)
217
+ @conversation.replace!(new_messages)
218
+ @spinner.success
219
+ @renderer.info("Context compacted. #{@conversation.length} messages remaining.")
220
+ end
221
+
222
+ def handle_cost
223
+ @renderer.cost_summary(
224
+ session_cost: @budget_enforcer.session_cost,
225
+ daily_cost: @budget_enforcer.daily_cost,
226
+ tokens: {
227
+ input: @context_manager.total_input_tokens,
228
+ output: @context_manager.total_output_tokens
229
+ }
230
+ )
231
+ end
232
+
233
+ def handle_tasks
234
+ task_manager = Tasks::Manager.new(@db)
235
+ tasks = task_manager.list
236
+ if tasks.empty?
237
+ @renderer.info("No tasks.")
238
+ else
239
+ tasks.each do |t|
240
+ status_color = case t[:status]
241
+ when "completed" then :green
242
+ when "in_progress" then :yellow
243
+ when "blocked" then :red
244
+ else :white
245
+ end
246
+ puts " [#{t[:status]}] #{t[:title]} (#{t[:id][0..7]})"
247
+ end
248
+ end
249
+ end
250
+
251
+ def handle_budget(amount)
252
+ if amount
253
+ @budget_enforcer = Observability::BudgetEnforcer.new(
254
+ @db,
255
+ session_id: current_session_id,
256
+ session_limit: amount.to_f
257
+ )
258
+ @renderer.info("Session budget set to $#{amount}")
259
+ else
260
+ @renderer.info("Remaining budget: $#{'%.4f' % @budget_enforcer.remaining_budget}")
261
+ end
262
+ end
263
+
264
+ def handle_skill(name)
265
+ if name
266
+ content = @skill_loader.load(name)
267
+ @renderer.info("Loaded skill: #{name}")
268
+ @conversation.add_user_message("<skill>#{content}</skill>")
269
+ else
270
+ @renderer.info("Available skills:")
271
+ puts @skill_loader.descriptions_for_prompt
272
+ end
273
+ end
274
+
275
+ def handle_resume(session_id)
276
+ if session_id
277
+ data = @session_persistence.load_session(session_id)
278
+ if data
279
+ @conversation.replace!(data[:messages])
280
+ @session_id = session_id
281
+ @renderer.info("Resumed session #{session_id[0..7]}")
282
+ else
283
+ @renderer.error("Session not found: #{session_id}")
284
+ end
285
+ else
286
+ sessions = @session_persistence.list_sessions(project_path: @project_root, limit: 10)
287
+ if sessions.empty?
288
+ @renderer.info("No previous sessions.")
289
+ else
290
+ sessions.each do |s|
291
+ puts " #{s[:id][0..7]} | #{s[:title] || 'untitled'} | #{s[:created_at]}"
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ def handle_review(args)
298
+ base = args[0] || "main"
299
+ focus = args[1] || "all"
300
+ handle_message("Use the review_pr tool to review my current branch against #{base}. Focus: #{focus}. Load relevant best practice skills for any issues you find.")
301
+ end
302
+
303
+ def handle_spawn_teammate(args)
304
+ name = args[0]
305
+ unless name
306
+ @renderer.error("Usage: /spawn <name> [role]")
307
+ return
308
+ end
309
+
310
+ role = args[1] || "coder"
311
+
312
+ mailbox = Teams::Mailbox.new(@db)
313
+ manager = Teams::Manager.new(@db, mailbox: mailbox)
314
+ teammate = manager.spawn(name: name, role: role)
315
+
316
+ Thread.new do
317
+ run_teammate_loop(teammate, mailbox)
318
+ end
319
+
320
+ @renderer.info("Spawned teammate #{name} as #{role}")
321
+ rescue StandardError => e
322
+ @renderer.error("Failed to spawn teammate: #{e.message}")
323
+ end
324
+
325
+ def run_teammate_loop(teammate, mailbox)
326
+ conversation = Agent::Conversation.new
327
+ tool_executor = Tools::Executor.new(project_root: @project_root)
328
+ tool_executor.llm_client = @llm_client
329
+
330
+ loop do
331
+ messages = mailbox.read_inbox(teammate.name)
332
+ break if messages.empty?
333
+
334
+ messages.each do |msg|
335
+ conversation.add_user_message(msg[:content])
336
+
337
+ response = @llm_client.chat(
338
+ messages: conversation.to_api_format,
339
+ tools: tool_executor.tool_definitions,
340
+ system: "You are #{teammate.name}, a #{teammate.role} teammate agent. Complete tasks sent to your inbox."
341
+ )
342
+
343
+ content = response.respond_to?(:content) ? Array(response.content) : []
344
+ text = content.select { |b| b.respond_to?(:text) }.map(&:text).join("\n")
345
+ conversation.add_assistant_message(content)
346
+
347
+ mailbox.send(from: teammate.name, to: msg[:from], content: text)
348
+ end
349
+
350
+ sleep 5
351
+ end
352
+ rescue StandardError => e
353
+ $stderr.puts "[Teammate #{teammate.name}] Error: #{e.message}" if ENV["RUBYN_DEBUG"]
354
+ end
355
+
356
+ def display_commands
357
+ @renderer.info("Available commands:")
358
+ CLI::InputHandler::SLASH_COMMANDS.each do |cmd, action|
359
+ puts " #{cmd.ljust(15)} #{action}"
360
+ end
361
+ puts ""
362
+ end
363
+
364
+ def display_help
365
+ puts <<~HELP
366
+ Commands:
367
+ /help Show this help message
368
+ /quit Exit Rubyn Code
369
+ /compact Compress conversation context
370
+ /cost Show token usage and costs
371
+ /clear Clear the terminal
372
+ /undo Remove last exchange
373
+ /tasks List all tasks
374
+ /budget [amt] Show or set session budget
375
+ /skill [name] Load a skill or list available skills
376
+ /resume [id] Resume a session or list recent sessions
377
+ /version Show version
378
+
379
+ Tips:
380
+ - Use @filename to include file contents in your message
381
+ - End a line with \\ for multiline input
382
+ HELP
383
+ end
384
+
385
+ def setup_readline!
386
+ slash_commands = CLI::InputHandler::SLASH_COMMANDS.keys
387
+
388
+ Readline.completion_proc = proc do |input|
389
+ if input.start_with?("/")
390
+ slash_commands.select { |c| c.start_with?(input) }
391
+ else
392
+ []
393
+ end
394
+ end
395
+ Readline.completion_append_character = " "
396
+ end
397
+
398
+ def read_input
399
+ lines = []
400
+ prompt_str = lines.empty? ? @renderer.prompt : " ... "
401
+
402
+ loop do
403
+ line = Readline.readline(prompt_str, true)
404
+ return nil if line.nil?
405
+
406
+ if @input_handler.multiline?(line)
407
+ lines << @input_handler.strip_continuation(line)
408
+ prompt_str = " ... "
409
+ else
410
+ lines << line
411
+ break
412
+ end
413
+ end
414
+
415
+ lines.join("\n")
416
+ end
417
+
418
+ def ensure_home_dir!
419
+ dir = Config::Defaults::HOME_DIR
420
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
421
+ end
422
+
423
+ def ensure_auth!
424
+ if Auth::TokenStore.valid?
425
+ tokens = Auth::TokenStore.load
426
+ source = tokens&.fetch(:source, :unknown)
427
+ @renderer.info("Authenticated via #{source}") if source == :keychain
428
+ return true
429
+ end
430
+
431
+ @renderer.error("No valid authentication found.")
432
+ @renderer.info("Options:")
433
+ @renderer.info(" 1. Run Claude Code once to authenticate (Rubyn Code reads the keychain token)")
434
+ @renderer.info(" 2. Set ANTHROPIC_API_KEY environment variable")
435
+ @renderer.info(" 3. Run 'rubyn-code --auth' to enter an API key")
436
+ exit(1)
437
+ end
438
+
439
+ def skill_dirs
440
+ dirs = [File.expand_path("../../../skills", __dir__)]
441
+ project_skills = File.join(@project_root, ".rubyn-code", "skills")
442
+ dirs << project_skills if Dir.exist?(project_skills)
443
+ user_skills = File.join(Config::Defaults::HOME_DIR, "skills")
444
+ dirs << user_skills if Dir.exist?(user_skills)
445
+ dirs
446
+ end
447
+
448
+ def current_session_id
449
+ @session_id ||= SecureRandom.hex(16)
450
+ end
451
+
452
+ def save_session!
453
+ @session_persistence.save_session(
454
+ session_id: current_session_id,
455
+ project_path: @project_root,
456
+ messages: @conversation.messages,
457
+ model: Config::Defaults::DEFAULT_MODEL
458
+ )
459
+ end
460
+
461
+ def resume_session!
462
+ data = @session_persistence.load_session(@session_id)
463
+ return unless data
464
+
465
+ @conversation.replace!(data[:messages])
466
+ @renderer.info("Resumed session #{@session_id[0..7]}")
467
+ end
468
+
469
+ GOODBYE_MESSAGES = [
470
+ "Freezing strings and saving memories... See ya! 💎",
471
+ "Memoizing this session... Until next time! 🧠",
472
+ "Committing learnings to memory... Later! 🤙",
473
+ "Saving state, yielding control... Bye for now! 👋",
474
+ "Session.save! && Rubyn.sleep... Catch you later! 😴",
475
+ "GC.start on this session... Stay Ruby, friend! ✌️",
476
+ "Writing instincts to disk... Don't forget me! 💾",
477
+ "at_exit { puts 'Thanks for coding with Rubyn!' } 🎸",
478
+ ].freeze
479
+
480
+ def shutdown!
481
+ return if @shutdown_complete
482
+
483
+ @shutdown_complete = true
484
+ @spinner.stop
485
+ puts
486
+ @renderer.info(GOODBYE_MESSAGES.sample)
487
+
488
+ @renderer.info("Saving session...")
489
+ save_session!
490
+ @background_worker&.shutdown!
491
+
492
+ if @conversation.length > 5
493
+ begin
494
+ @renderer.info("Extracting learnings from this session...")
495
+ Learning::Extractor.call(
496
+ @conversation.messages,
497
+ llm_client: @llm_client,
498
+ project_path: @project_root
499
+ )
500
+ @renderer.success("Instincts saved.")
501
+ rescue StandardError => e
502
+ @renderer.warning("Instinct extraction skipped: #{e.message}") if ENV["RUBYN_DEBUG"]
503
+ end
504
+ end
505
+
506
+ begin
507
+ db = DB::Connection.instance
508
+ Learning::InstinctMethods.decay_all(db, project_path: @project_root)
509
+ rescue StandardError
510
+ # Silent — decay is best-effort
511
+ end
512
+
513
+ @renderer.info("Session saved. Rubyn out. ✌️")
514
+ rescue StandardError
515
+ # Best effort on shutdown
516
+ end
517
+ end
518
+ end
519
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-spinner"
4
+
5
+ module RubynCode
6
+ module CLI
7
+ class Spinner
8
+ THINKING_MESSAGES = [
9
+ "Massaging the hash...",
10
+ "Refactoring in my head...",
11
+ "Consulting Matz...",
12
+ "Freezing strings...",
13
+ "Monkey-patching reality...",
14
+ "Yielding to the block...",
15
+ "Enumerating possibilities...",
16
+ "Injecting dependencies...",
17
+ "Guard clause-ing my thoughts...",
18
+ "Sharpening the gems...",
19
+ "Duck typing furiously...",
20
+ "Reducing complexity...",
21
+ "Mapping it out...",
22
+ "Selecting the right approach...",
23
+ "Running the mental specs...",
24
+ "Composing a module...",
25
+ "Memoizing the answer...",
26
+ "Digging through the hash...",
27
+ "Pattern matching on this...",
28
+ "Raising my standards...",
29
+ "Rescuing the situation...",
30
+ "Benchmarking my thoughts...",
31
+ "Sending :think to self...",
32
+ "Evaluating the proc...",
33
+ "Opening the eigenclass...",
34
+ "Calling .new on an idea...",
35
+ "Plucking the good bits...",
36
+ "Finding each solution...",
37
+ "Requiring more context...",
38
+ "Bundling my thoughts...",
39
+ ].freeze
40
+
41
+ SUB_AGENT_MESSAGES = [
42
+ "Sub-agent is spelunking...",
43
+ "Agent exploring the codebase...",
44
+ "Reading all the things...",
45
+ "Sub-agent doing the legwork...",
46
+ "Agent grepping through files...",
47
+ "Dispatching the intern...",
48
+ ].freeze
49
+
50
+ def initialize
51
+ @spinner = nil
52
+ end
53
+
54
+ def start(message = nil)
55
+ message ||= THINKING_MESSAGES.sample
56
+ @spinner = TTY::Spinner.new(
57
+ "[:spinner] #{message}",
58
+ format: :dots,
59
+ clear: true
60
+ )
61
+ @spinner.auto_spin
62
+ end
63
+
64
+ def start_sub_agent(tool_count = 0)
65
+ msg = if tool_count > 0
66
+ "#{SUB_AGENT_MESSAGES.sample} (#{tool_count} tools)"
67
+ else
68
+ SUB_AGENT_MESSAGES.sample
69
+ end
70
+ start(msg)
71
+ end
72
+
73
+ def update(message)
74
+ return start(message) unless spinning?
75
+
76
+ stop
77
+ start(message)
78
+ end
79
+
80
+ def success(message = "Done")
81
+ @spinner&.success("(#{message})")
82
+ @spinner = nil
83
+ end
84
+
85
+ def error(message = "Failed")
86
+ @spinner&.error("(#{message})")
87
+ @spinner = nil
88
+ end
89
+
90
+ def stop
91
+ @spinner&.stop
92
+ @spinner = nil
93
+ end
94
+
95
+ def spinning?
96
+ @spinner&.spinning? || false
97
+ end
98
+ end
99
+ end
100
+ end