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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- 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
|