rubyn-code 0.5.0 → 0.7.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 +182 -11
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/app.rb +2 -2
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +77 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +15 -0
- data/lib/rubyn_code/ide/server.rb +39 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +53 -2
- data/skills/megaplan/megaplan.md +156 -0
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +49 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/loop` — run a prompt or slash command repeatedly, mirroring Claude
|
|
7
|
+
# Code's /loop. Either on a fixed interval, or self-paced (the agent
|
|
8
|
+
# decides when the recurring task is done).
|
|
9
|
+
#
|
|
10
|
+
# /loop 5m /review run /review every 5 minutes
|
|
11
|
+
# /loop 30s check the deploy send a prompt every 30 seconds
|
|
12
|
+
# /loop x10 5m /babysit-prs ...at most 10 times
|
|
13
|
+
# /loop keep triaging issues self-paced (until the agent emits LOOP_DONE)
|
|
14
|
+
#
|
|
15
|
+
# Returns a :run_loop action; the REPL owns the actual loop so Ctrl-C
|
|
16
|
+
# stops it cleanly and slash payloads can be re-dispatched.
|
|
17
|
+
class Loop < Base
|
|
18
|
+
def self.command_name = '/loop'
|
|
19
|
+
def self.description = 'Repeat a prompt or slash command on an interval (/loop 5m /review)'
|
|
20
|
+
|
|
21
|
+
MAX_TOKEN = /\Ax(\d+)\z/i
|
|
22
|
+
|
|
23
|
+
def execute(args, ctx)
|
|
24
|
+
return usage(ctx) if args.empty?
|
|
25
|
+
|
|
26
|
+
max, rest = extract_max(args)
|
|
27
|
+
interval = LoopRunner.parse_interval(rest.first)
|
|
28
|
+
payload = (interval ? rest[1..] : rest).join(' ').strip
|
|
29
|
+
return usage(ctx) if payload.empty?
|
|
30
|
+
|
|
31
|
+
{ action: :run_loop, interval: interval, max: max, payload: payload }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Pull an optional leading "xN" max-iterations token from anywhere in
|
|
37
|
+
# the first two positions (so both `/loop x5 5m ...` and `/loop 5m ...`
|
|
38
|
+
# read naturally).
|
|
39
|
+
def extract_max(args)
|
|
40
|
+
idx = args[0, 2].index { |a| a.match?(MAX_TOKEN) }
|
|
41
|
+
return [LoopRunner::DEFAULT_MAX_ITERATIONS, args] unless idx
|
|
42
|
+
|
|
43
|
+
max = args[idx][MAX_TOKEN, 1].to_i
|
|
44
|
+
[max.positive? ? max : LoopRunner::DEFAULT_MAX_ITERATIONS, args[0...idx] + args[(idx + 1)..]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def usage(ctx)
|
|
48
|
+
ctx.renderer.info('Usage: /loop [xN] [interval] <prompt-or-/command>')
|
|
49
|
+
ctx.renderer.info(' /loop 5m /review — run /review every 5 minutes')
|
|
50
|
+
ctx.renderer.info(' /loop 30s check status — send a prompt every 30s')
|
|
51
|
+
ctx.renderer.info(' /loop x3 1m /babysit — at most 3 times')
|
|
52
|
+
ctx.renderer.info(' /loop keep triaging — self-paced (Ctrl-C to stop)')
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -30,24 +30,37 @@ module RubynCode
|
|
|
30
30
|
|
|
31
31
|
def render_server(cfg)
|
|
32
32
|
client = build_client(cfg)
|
|
33
|
-
status,
|
|
33
|
+
status, counts = probe_server(client)
|
|
34
34
|
icon = status_icon(status)
|
|
35
|
-
tools_label = tool_count ? " (#{tool_count} tools)" : ''
|
|
36
35
|
|
|
37
|
-
puts " #{icon} #{cfg[:name]} [#{status}]#{
|
|
36
|
+
puts " #{icon} #{cfg[:name]} [#{status}]#{capability_label(counts)}"
|
|
38
37
|
render_transport_info(cfg)
|
|
39
38
|
ensure
|
|
40
39
|
client&.disconnect! if client&.connected?
|
|
41
40
|
end
|
|
42
41
|
|
|
42
|
+
def capability_label(counts)
|
|
43
|
+
return '' unless counts
|
|
44
|
+
|
|
45
|
+
parts = []
|
|
46
|
+
parts << "#{counts[:tools]} tools"
|
|
47
|
+
parts << "#{counts[:resources]} resources" if counts[:resources].positive?
|
|
48
|
+
parts << "#{counts[:prompts]} prompts" if counts[:prompts].positive?
|
|
49
|
+
" (#{parts.join(', ')})"
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def build_client(cfg)
|
|
44
53
|
MCP::Client.from_config(cfg)
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
def probe_server(client)
|
|
48
57
|
client.connect!
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
counts = {
|
|
59
|
+
tools: client.tools.size,
|
|
60
|
+
resources: client.resources.size,
|
|
61
|
+
prompts: client.prompts.size
|
|
62
|
+
}
|
|
63
|
+
[:connected, counts]
|
|
51
64
|
rescue StandardError
|
|
52
65
|
[:error, nil]
|
|
53
66
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/megaplan` REPL command — mirrors the VS Code chat's chat-resident
|
|
7
|
+
# megaplan entry. Loads the megaplan skill (from the shared
|
|
8
|
+
# skills catalog) into the conversation, flips plan mode ON so the
|
|
9
|
+
# agent is restricted to read-only tools, and kicks off a
|
|
10
|
+
# conversational interview.
|
|
11
|
+
#
|
|
12
|
+
# The interview itself is chat-style here (multi-turn natural-
|
|
13
|
+
# language Q&A), not the structured question-card UI the VS Code
|
|
14
|
+
# extension uses. Same skill content driving both surfaces.
|
|
15
|
+
class Megaplan < Base
|
|
16
|
+
def self.command_name = '/megaplan'
|
|
17
|
+
def self.description = 'Plan a feature in phases (interview, then numbered phase docs)'
|
|
18
|
+
def self.aliases = ['/mega-plan'].freeze
|
|
19
|
+
|
|
20
|
+
def execute(args, ctx)
|
|
21
|
+
content = ctx.skill_loader.load('megaplan')
|
|
22
|
+
ctx.conversation.add_user_message("<skill>#{content}</skill>")
|
|
23
|
+
ctx.renderer.info('Megaplan mode — interviewer with read-only tools 🧠')
|
|
24
|
+
|
|
25
|
+
ctx.send_message(build_prompt(args.join(' ').strip))
|
|
26
|
+
|
|
27
|
+
{ action: :set_plan_mode, enabled: true }
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
ctx.renderer.error("Megaplan error: #{e.message}")
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_prompt(feature)
|
|
36
|
+
base = <<~PROMPT.strip
|
|
37
|
+
Conduct a megaplan interview, following the megaplan skill loaded above.
|
|
38
|
+
Stay strictly read-only: you may inspect files, search code, and check
|
|
39
|
+
git state, but do NOT edit, write, run shell mutations, or call any
|
|
40
|
+
destructive tool. Ask ONE question at a time. When you have enough,
|
|
41
|
+
output the final phase breakdown as a numbered outline.
|
|
42
|
+
PROMPT
|
|
43
|
+
return base if feature.empty?
|
|
44
|
+
|
|
45
|
+
"#{base}\n\nThe feature to plan: #{feature}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -13,14 +13,16 @@ module RubynCode
|
|
|
13
13
|
@classes = []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Register a command class
|
|
16
|
+
# Register a command. Accepts either a command class (built-ins, which
|
|
17
|
+
# are instantiated per dispatch) or a ready command instance
|
|
18
|
+
# (user-defined; see Commands::CustomCommand).
|
|
17
19
|
#
|
|
18
|
-
# @param
|
|
20
|
+
# @param command [Class<Commands::Base>, #execute]
|
|
19
21
|
# @return [void]
|
|
20
|
-
def register(
|
|
21
|
-
@classes <<
|
|
22
|
-
|
|
23
|
-
@commands[name] =
|
|
22
|
+
def register(command)
|
|
23
|
+
@classes << command
|
|
24
|
+
command.all_names.each do |name|
|
|
25
|
+
@commands[name] = command
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
|
|
@@ -31,10 +33,13 @@ module RubynCode
|
|
|
31
33
|
# @param ctx [Commands::Context] shared context
|
|
32
34
|
# @return [Symbol, nil] :quit if the command signals exit, nil otherwise
|
|
33
35
|
def dispatch(name, args, ctx)
|
|
34
|
-
|
|
35
|
-
return :unknown unless
|
|
36
|
+
command = @commands[name]
|
|
37
|
+
return :unknown unless command
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
# Built-ins are registered as classes (instantiate per call);
|
|
40
|
+
# user-defined commands are registered as ready instances.
|
|
41
|
+
instance = command.respond_to?(:new) ? command.new : command
|
|
42
|
+
instance.execute(args, ctx)
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
# All registered command names (for tab completion).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/rewind` — restore the session to an earlier checkpoint, mirroring
|
|
7
|
+
# Claude Code's /rewind. A checkpoint is taken at the start of each user
|
|
8
|
+
# turn, capturing the conversation and the original contents of any files
|
|
9
|
+
# changed that turn.
|
|
10
|
+
#
|
|
11
|
+
# /rewind list checkpoints
|
|
12
|
+
# /rewind <id> restore code + conversation to checkpoint <id>
|
|
13
|
+
# /rewind <id> code restore only the files
|
|
14
|
+
# /rewind <id> chat restore only the conversation
|
|
15
|
+
class Rewind < Base
|
|
16
|
+
def self.command_name = '/rewind'
|
|
17
|
+
def self.description = 'Rewind to an earlier checkpoint (/rewind to list)'
|
|
18
|
+
|
|
19
|
+
SCOPES = { 'code' => :code, 'chat' => :chat, 'both' => :both }.freeze
|
|
20
|
+
|
|
21
|
+
def execute(args, ctx)
|
|
22
|
+
manager = ctx.checkpoint_manager
|
|
23
|
+
return ctx.renderer.info('Checkpoints are not available in this session.') unless manager
|
|
24
|
+
|
|
25
|
+
id = args.first
|
|
26
|
+
return list(manager, ctx) if id.nil?
|
|
27
|
+
|
|
28
|
+
restore(manager, ctx, id, args[1])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def list(manager, ctx)
|
|
34
|
+
checkpoints = manager.list
|
|
35
|
+
return ctx.renderer.info('No checkpoints yet — they are created as you work.') if checkpoints.empty?
|
|
36
|
+
|
|
37
|
+
ctx.renderer.info('Checkpoints (newest last):')
|
|
38
|
+
checkpoints.each do |cp|
|
|
39
|
+
puts " #{cp[:id].to_s.rjust(3)} #{cp[:files]} file(s) — #{cp[:label]}"
|
|
40
|
+
end
|
|
41
|
+
puts
|
|
42
|
+
ctx.renderer.info('Restore with: /rewind <id> [code|chat]')
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def restore(manager, ctx, id_arg, scope_arg)
|
|
47
|
+
scope = SCOPES.fetch(scope_arg.to_s, :both)
|
|
48
|
+
result = manager.restore(id_arg.to_i, ctx.conversation, scope: scope)
|
|
49
|
+
return ctx.renderer.warning("No checkpoint ##{id_arg}.") unless result
|
|
50
|
+
|
|
51
|
+
ctx.renderer.info(restored_message(result, scope))
|
|
52
|
+
{ action: :rewound }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def restored_message(result, scope)
|
|
56
|
+
case scope
|
|
57
|
+
when :code then "⏪ Restored #{result[:files_restored]} file(s) to checkpoint ##{result[:id]}."
|
|
58
|
+
when :chat then "⏪ Restored conversation to checkpoint ##{result[:id]}."
|
|
59
|
+
else "⏪ Rewound to checkpoint ##{result[:id]} (#{result[:files_restored]} file(s) + conversation)."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
# Drives a repeating execution of a payload (a prompt or slash command).
|
|
6
|
+
#
|
|
7
|
+
# Pure orchestration with injected dependencies so it can be unit-tested
|
|
8
|
+
# without a REPL: the caller supplies a `runner` callable that performs one
|
|
9
|
+
# iteration and a `sleeper` for the wait between iterations.
|
|
10
|
+
#
|
|
11
|
+
# Two modes:
|
|
12
|
+
# - interval mode (interval is a positive number of seconds): run, wait,
|
|
13
|
+
# run, ... until max_iterations or stopped.
|
|
14
|
+
# - self-paced mode (interval is nil): run back-to-back until the runner
|
|
15
|
+
# returns :stop, emits the DONE_SENTINEL, or max_iterations is hit.
|
|
16
|
+
class LoopRunner
|
|
17
|
+
DEFAULT_MAX_ITERATIONS = 50
|
|
18
|
+
DONE_SENTINEL = 'LOOP_DONE'
|
|
19
|
+
UNITS = { 's' => 1, 'm' => 60, 'h' => 3600 }.freeze
|
|
20
|
+
|
|
21
|
+
# Parse an interval token like "30s", "5m", "2h", or a bare "45"
|
|
22
|
+
# (seconds). Returns nil when the token is not a valid interval.
|
|
23
|
+
#
|
|
24
|
+
# @param token [String, nil]
|
|
25
|
+
# @return [Integer, nil] seconds, or nil
|
|
26
|
+
def self.parse_interval(token)
|
|
27
|
+
return nil if token.nil?
|
|
28
|
+
|
|
29
|
+
match = token.strip.match(/\A(\d+)\s*([smh]?)\z/i)
|
|
30
|
+
return nil unless match
|
|
31
|
+
|
|
32
|
+
amount = match[1].to_i
|
|
33
|
+
return nil if amount <= 0
|
|
34
|
+
|
|
35
|
+
amount * UNITS.fetch(match[2].downcase, 1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param interval [Integer, nil] seconds between runs; nil => self-paced
|
|
39
|
+
# @param runner [#call] ->(iteration_index) => String|:stop
|
|
40
|
+
# @param max_iterations [Integer] hard cap on iterations
|
|
41
|
+
# @param sleeper [#call, nil] ->(seconds); defaults to Kernel#sleep
|
|
42
|
+
# @param on_iteration [#call, nil] ->(n, total) UI callback before each run
|
|
43
|
+
def initialize(runner:, interval: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
44
|
+
sleeper: nil, on_iteration: nil)
|
|
45
|
+
@interval = interval
|
|
46
|
+
@runner = runner
|
|
47
|
+
@max_iterations = max_iterations
|
|
48
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
49
|
+
@on_iteration = on_iteration
|
|
50
|
+
@stopped = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Request the loop to stop after the current iteration.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def stop!
|
|
56
|
+
@stopped = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def stopped? = @stopped
|
|
61
|
+
|
|
62
|
+
# Run the loop.
|
|
63
|
+
#
|
|
64
|
+
# @return [Integer] number of iterations completed
|
|
65
|
+
def run
|
|
66
|
+
completed = 0
|
|
67
|
+
@max_iterations.times do |index|
|
|
68
|
+
break if @stopped
|
|
69
|
+
|
|
70
|
+
@on_iteration&.call(index + 1, @max_iterations)
|
|
71
|
+
result = @runner.call(index)
|
|
72
|
+
completed += 1
|
|
73
|
+
|
|
74
|
+
break if @stopped || done?(result)
|
|
75
|
+
|
|
76
|
+
wait_before_next(index)
|
|
77
|
+
end
|
|
78
|
+
completed
|
|
79
|
+
rescue Interrupt
|
|
80
|
+
completed
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def wait_before_next(index)
|
|
86
|
+
return unless @interval && index < @max_iterations - 1
|
|
87
|
+
|
|
88
|
+
@sleeper.call(@interval)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def done?(result)
|
|
92
|
+
return true if result == :stop
|
|
93
|
+
|
|
94
|
+
result.is_a?(String) && result.include?(DONE_SENTINEL)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
# Expands `@path/to/file` mentions in user input into inline file content,
|
|
6
|
+
# mirroring Claude Code / Codex `@`-mentions. The original text is kept and
|
|
7
|
+
# the referenced files are appended as labeled, fenced blocks so the agent
|
|
8
|
+
# sees both the request and the files it points at.
|
|
9
|
+
#
|
|
10
|
+
# Conservative by design: only existing, readable, reasonably-sized regular
|
|
11
|
+
# files inside the project are expanded. Unresolved mentions (and things
|
|
12
|
+
# that merely look like mentions, e.g. email addresses) are left untouched.
|
|
13
|
+
class MentionExpander
|
|
14
|
+
# A mention is an "@" that doesn't follow a word char or another "@"
|
|
15
|
+
# (so emails like foo@bar.com don't match), followed by a path-ish run.
|
|
16
|
+
MENTION = /(?<![\w@])@([^\s@]+)/
|
|
17
|
+
TRAILING_PUNCT = /[).,;:!?'"]+\z/
|
|
18
|
+
MAX_FILE_BYTES = 64 * 1024
|
|
19
|
+
MAX_FILES = 10
|
|
20
|
+
|
|
21
|
+
# @param project_root [String]
|
|
22
|
+
def initialize(project_root:)
|
|
23
|
+
@project_root = project_root
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param input [String] raw user input
|
|
27
|
+
# @return [Array(String, Array<String>)] expanded text + resolved rel paths
|
|
28
|
+
def expand(input)
|
|
29
|
+
return [input, []] unless input.is_a?(String) && input.include?('@')
|
|
30
|
+
|
|
31
|
+
resolved = scan(input)
|
|
32
|
+
return [input, []] if resolved.empty?
|
|
33
|
+
|
|
34
|
+
blocks = resolved.map { |rel, abs| file_block(rel, abs) }
|
|
35
|
+
["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
|
|
41
|
+
def scan(input)
|
|
42
|
+
seen = {}
|
|
43
|
+
input.scan(MENTION).each do |(raw)|
|
|
44
|
+
rel = raw.sub(TRAILING_PUNCT, '')
|
|
45
|
+
next if rel.empty?
|
|
46
|
+
|
|
47
|
+
abs = resolve(rel)
|
|
48
|
+
next unless abs && !seen.key?(abs)
|
|
49
|
+
|
|
50
|
+
seen[abs] = rel
|
|
51
|
+
break if seen.size >= MAX_FILES
|
|
52
|
+
end
|
|
53
|
+
seen.map { |abs, rel| [rel, abs] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolve a mention to an absolute path inside the project, or nil.
|
|
57
|
+
def resolve(rel)
|
|
58
|
+
abs = File.expand_path(rel, @project_root)
|
|
59
|
+
return nil unless inside_project?(abs)
|
|
60
|
+
return nil unless File.file?(abs)
|
|
61
|
+
|
|
62
|
+
abs
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def inside_project?(abs)
|
|
68
|
+
root = File.expand_path(@project_root)
|
|
69
|
+
abs == root || abs.start_with?("#{root}#{File::SEPARATOR}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def file_block(rel, abs)
|
|
73
|
+
body = read_truncated(abs)
|
|
74
|
+
<<~BLOCK.strip
|
|
75
|
+
@#{rel}:
|
|
76
|
+
```
|
|
77
|
+
#{body}
|
|
78
|
+
```
|
|
79
|
+
BLOCK
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_truncated(abs)
|
|
83
|
+
content = File.read(abs, MAX_FILE_BYTES + 1)
|
|
84
|
+
return content if content.bytesize <= MAX_FILE_BYTES
|
|
85
|
+
|
|
86
|
+
"#{content.byteslice(0, MAX_FILE_BYTES)}\n… [truncated]"
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
"[could not read file: #{e.message}]"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'pastel'
|
|
4
|
-
require 'rouge'
|
|
5
4
|
|
|
6
5
|
module RubynCode
|
|
7
6
|
module CLI
|
|
8
7
|
class Renderer
|
|
9
8
|
def initialize
|
|
10
9
|
@pastel = Pastel.new
|
|
11
|
-
@rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
12
10
|
end
|
|
13
11
|
|
|
14
12
|
attr_writer :yolo
|
|
@@ -177,6 +175,9 @@ module RubynCode
|
|
|
177
175
|
end
|
|
178
176
|
|
|
179
177
|
def render_code_block(code, lang)
|
|
178
|
+
# Lazy so REPL boot never pays for rouge until a code block is rendered.
|
|
179
|
+
require 'rouge'
|
|
180
|
+
@rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
180
181
|
lexer = Rouge::Lexer.find(lang) || Rouge::Lexers::PlainText.new
|
|
181
182
|
highlighted = @rouge_formatter.format(lexer.lex(code))
|
|
182
183
|
border = @pastel.dim(' │ ')
|
data/lib/rubyn_code/cli/repl.rb
CHANGED
|
@@ -31,10 +31,10 @@ module RubynCode
|
|
|
31
31
|
def run
|
|
32
32
|
@version_check = VersionCheck.new(renderer: @renderer)
|
|
33
33
|
@version_check.start
|
|
34
|
+
@auto_suggest = Skills::AutoSuggest.new(project_root: @project_root)
|
|
35
|
+
@auto_suggest.start
|
|
34
36
|
|
|
35
37
|
@renderer.welcome
|
|
36
|
-
@version_check.notify
|
|
37
|
-
check_skill_suggestions!
|
|
38
38
|
|
|
39
39
|
at_exit { shutdown! }
|
|
40
40
|
|
|
@@ -45,17 +45,20 @@ module RubynCode
|
|
|
45
45
|
|
|
46
46
|
private
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
# Surface results of the background startup checks (version, skill
|
|
49
|
+
# suggestions) between prompts — never blocking input on the network.
|
|
50
|
+
def flush_startup_notices!
|
|
51
|
+
@version_check&.notify
|
|
52
|
+
message = @auto_suggest&.pending_message
|
|
51
53
|
@renderer.info(message) if message
|
|
52
54
|
rescue StandardError
|
|
53
|
-
# Never block
|
|
55
|
+
# Never block the prompt on startup notices
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def run_input_loop
|
|
57
59
|
while @running
|
|
58
60
|
begin
|
|
61
|
+
flush_startup_notices!
|
|
59
62
|
input = read_input
|
|
60
63
|
break if input.nil?
|
|
61
64
|
|
|
@@ -109,26 +112,26 @@ module RubynCode
|
|
|
109
112
|
|
|
110
113
|
# -- sequential steps with interrupt rescue
|
|
111
114
|
def handle_message(input)
|
|
115
|
+
# Checkpoint before the turn (raw input as label), then expand
|
|
116
|
+
# @-mentions so the agent sees referenced file contents.
|
|
117
|
+
@checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
|
|
118
|
+
input = expand_mentions(input)
|
|
112
119
|
@spinner.start
|
|
113
120
|
@streaming_first_chunk = true
|
|
114
121
|
|
|
115
122
|
response = @agent_loop.send_message(input)
|
|
116
123
|
|
|
117
124
|
@spinner.stop
|
|
118
|
-
|
|
119
|
-
@renderer.display(response)
|
|
120
|
-
else
|
|
121
|
-
@stream_formatter&.flush
|
|
122
|
-
@stream_formatter = nil
|
|
123
|
-
puts
|
|
124
|
-
end
|
|
125
|
-
|
|
125
|
+
render_response(response)
|
|
126
126
|
save_session!
|
|
127
|
+
response
|
|
127
128
|
rescue Interrupt
|
|
128
129
|
@spinner.stop
|
|
129
130
|
puts
|
|
130
131
|
@renderer.warning('Interrupted — session state preserved')
|
|
132
|
+
@current_loop&.stop! # Ctrl-C during a /loop iteration stops the loop
|
|
131
133
|
save_session!
|
|
134
|
+
nil
|
|
132
135
|
rescue BudgetExceededError => e
|
|
133
136
|
@spinner.error
|
|
134
137
|
@renderer.error("Budget exceeded: #{e.message}")
|
|
@@ -137,6 +140,26 @@ module RubynCode
|
|
|
137
140
|
@renderer.error("Error: #{e.message}")
|
|
138
141
|
end
|
|
139
142
|
|
|
143
|
+
def render_response(response)
|
|
144
|
+
if @streaming_first_chunk
|
|
145
|
+
@renderer.display(response)
|
|
146
|
+
else
|
|
147
|
+
@stream_formatter&.flush
|
|
148
|
+
@stream_formatter = nil
|
|
149
|
+
puts
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Expand @path file mentions into inline content before the agent sees
|
|
154
|
+
# the message. Surfaces what was attached so the user knows.
|
|
155
|
+
def expand_mentions(input)
|
|
156
|
+
expanded, paths = @mention_expander.expand(input)
|
|
157
|
+
@renderer.info("📎 Attached: #{paths.join(', ')}") unless paths.empty?
|
|
158
|
+
expanded
|
|
159
|
+
rescue StandardError
|
|
160
|
+
input
|
|
161
|
+
end
|
|
162
|
+
|
|
140
163
|
def setup_readline!
|
|
141
164
|
completions = @command_registry.completions
|
|
142
165
|
|
|
@@ -23,8 +23,26 @@ module RubynCode
|
|
|
23
23
|
Commands::Plan, Commands::ContextInfo, Commands::Diff,
|
|
24
24
|
Commands::Model, Commands::NewSession, Commands::Mcp,
|
|
25
25
|
Commands::Provider, Commands::InstallSkills,
|
|
26
|
-
Commands::RemoveSkills, Commands::Skills
|
|
26
|
+
Commands::RemoveSkills, Commands::Skills,
|
|
27
|
+
Commands::Megaplan, Commands::Goal, Commands::Loop,
|
|
28
|
+
Commands::Agents, Commands::Learning, Commands::Rewind,
|
|
29
|
+
Commands::Chisel, Commands::ChiselReview, Commands::ChiselAudit,
|
|
30
|
+
Commands::ChiselDebt, Commands::ChiselGain
|
|
27
31
|
].each { |cmd| @command_registry.register(cmd) }
|
|
32
|
+
register_custom_commands!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Load user-defined slash commands from .rubyn-code/commands/*.md and
|
|
36
|
+
# ~/.rubyn-code/commands/*.md. Built-in commands always win — a custom
|
|
37
|
+
# file can't shadow /help, /quit, etc.
|
|
38
|
+
def register_custom_commands!
|
|
39
|
+
Commands::CustomLoader.load_all(project_root: @project_root).each do |cmd|
|
|
40
|
+
next if @command_registry.known?(cmd.command_name)
|
|
41
|
+
|
|
42
|
+
@command_registry.register(cmd)
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
RubynCode::Debug.warn("Custom command load failed: #{e.message}")
|
|
28
46
|
end
|
|
29
47
|
|
|
30
48
|
def handle_command(command)
|
|
@@ -66,7 +84,9 @@ module RubynCode
|
|
|
66
84
|
background_worker: @background_worker,
|
|
67
85
|
permission_tier: @permission_tier,
|
|
68
86
|
plan_mode: @plan_mode,
|
|
69
|
-
message_handler: method(:handle_message)
|
|
87
|
+
message_handler: method(:handle_message),
|
|
88
|
+
hook_registry: @hook_registry,
|
|
89
|
+
checkpoint_manager: @checkpoint_manager
|
|
70
90
|
)
|
|
71
91
|
end
|
|
72
92
|
|
|
@@ -84,11 +104,66 @@ module RubynCode
|
|
|
84
104
|
apply_provider(provider, rest[:model])
|
|
85
105
|
in { action: :spawn_teammate, name: String => name, role: String => role }
|
|
86
106
|
spawn_teammate(name, role)
|
|
107
|
+
in { action: :run_loop, interval:, max: Integer => max, payload: String => payload }
|
|
108
|
+
run_loop(interval, max, payload)
|
|
109
|
+
in { action: :rewound }
|
|
110
|
+
save_session!
|
|
87
111
|
else
|
|
88
112
|
# Unknown result hash — ignore
|
|
89
113
|
end
|
|
90
114
|
end
|
|
91
115
|
|
|
116
|
+
# Drive a /loop. Owned by the REPL so it runs on the main thread (Ctrl-C
|
|
117
|
+
# stops it) and can re-dispatch slash-command payloads.
|
|
118
|
+
def run_loop(interval, max, payload)
|
|
119
|
+
announce_loop(interval, max)
|
|
120
|
+
payload = decorate_self_paced(payload) unless interval
|
|
121
|
+
@current_loop = CLI::LoopRunner.new(
|
|
122
|
+
interval: interval, max_iterations: max,
|
|
123
|
+
runner: ->(_i) { run_loop_payload(payload) },
|
|
124
|
+
on_iteration: ->(n, total) { @renderer.system_message("🔁 loop #{n}/#{total}") }
|
|
125
|
+
)
|
|
126
|
+
completed = @current_loop.run
|
|
127
|
+
@renderer.info("🔁 Loop finished after #{completed} iteration#{'s' unless completed == 1}.")
|
|
128
|
+
rescue Interrupt
|
|
129
|
+
@renderer.warning('Loop interrupted.')
|
|
130
|
+
ensure
|
|
131
|
+
@current_loop = nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def announce_loop(interval, max)
|
|
135
|
+
cadence = interval ? "every #{format_interval(interval)}" : 'self-paced'
|
|
136
|
+
@renderer.info("🔁 Looping #{cadence} (up to #{max}×). Press Ctrl-C to stop.")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Execute one iteration. Slash-command payloads are re-dispatched; plain
|
|
140
|
+
# prompts go through the agent. Returns the agent's text so self-paced
|
|
141
|
+
# loops can detect the LOOP_DONE sentinel.
|
|
142
|
+
def run_loop_payload(payload)
|
|
143
|
+
if payload.start_with?('/')
|
|
144
|
+
name, *rest = payload.split
|
|
145
|
+
dispatch_slash_command(name, rest)
|
|
146
|
+
''
|
|
147
|
+
else
|
|
148
|
+
handle_message(payload).to_s
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def decorate_self_paced(payload)
|
|
153
|
+
return payload if payload.start_with?('/')
|
|
154
|
+
|
|
155
|
+
"#{payload}\n\n(You are in a self-paced loop. Keep handling this " \
|
|
156
|
+
'recurring task. When it no longer needs to run, end your reply ' \
|
|
157
|
+
"with #{CLI::LoopRunner::DONE_SENTINEL}.)"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def format_interval(seconds)
|
|
161
|
+
return "#{seconds / 3600}h" if (seconds % 3600).zero? && seconds >= 3600
|
|
162
|
+
return "#{seconds / 60}m" if (seconds % 60).zero? && seconds >= 60
|
|
163
|
+
|
|
164
|
+
"#{seconds}s"
|
|
165
|
+
end
|
|
166
|
+
|
|
92
167
|
def start_new_session(new_id)
|
|
93
168
|
@session_id = new_id
|
|
94
169
|
@skills_injected = false # re-inject skills on next message
|