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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
data/lib/rubyn_code/cli/repl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
@
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|