rubyn-code 0.5.1 → 0.8.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 +137 -4
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +100 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +7 -3
- data/lib/rubyn_code/agent/loop.rb +106 -12
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
- data/lib/rubyn_code/agent/tool_processor.rb +13 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- 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/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 +30 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -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 +49 -12
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/commands/think.rb +47 -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 +111 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +68 -15
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +17 -7
- 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 +14 -1
- data/lib/rubyn_code/config/schema.json +9 -0
- data/lib/rubyn_code/config/settings.rb +19 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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 +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +103 -4
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/discovery.rb +93 -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 +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- 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 +8 -3
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- 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/tasks/models.rb +0 -16
- 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 +3 -14
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +9 -4
- 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/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- 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 +57 -9
- data/skills/rubyn_self_test.md +462 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +45 -6
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -1,48 +1,119 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
module RubynCode
|
|
4
6
|
module CLI
|
|
5
7
|
module Commands
|
|
8
|
+
# List saved sessions or restore one into the current conversation.
|
|
9
|
+
#
|
|
10
|
+
# /resume # list sessions, recent first
|
|
11
|
+
# /resume <id> # load session <id> (prompts before clobber)
|
|
12
|
+
# /resume <id> --force # skip the confirm prompt
|
|
6
13
|
class Resume < Base
|
|
7
14
|
def self.command_name = '/resume'
|
|
8
|
-
def self.description = '
|
|
15
|
+
def self.description = 'List saved sessions or restore one into the current conversation'
|
|
16
|
+
|
|
17
|
+
MAX_LIST_ENTRIES = 10
|
|
9
18
|
|
|
10
19
|
def execute(args, ctx)
|
|
11
|
-
|
|
20
|
+
persistence = ctx.session_persistence
|
|
21
|
+
if persistence.nil?
|
|
22
|
+
ctx.renderer.error('No session persistence wired in. Run a prompt first.')
|
|
23
|
+
return
|
|
24
|
+
end
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
list_sessions(ctx)
|
|
26
|
+
opts = parse_args(args)
|
|
27
|
+
|
|
28
|
+
if opts[:id].nil?
|
|
29
|
+
list_sessions(persistence, ctx)
|
|
30
|
+
return
|
|
17
31
|
end
|
|
32
|
+
|
|
33
|
+
restore_session(persistence, opts, ctx)
|
|
18
34
|
end
|
|
19
35
|
|
|
20
36
|
private
|
|
21
37
|
|
|
22
|
-
def
|
|
23
|
-
|
|
38
|
+
def parse_args(args)
|
|
39
|
+
args.delete('--force')
|
|
40
|
+
force = args.delete('-y')
|
|
41
|
+
{ id: args.first, force: force }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list_sessions(persistence, ctx)
|
|
45
|
+
rows = persistence.list_sessions
|
|
46
|
+
if rows.empty?
|
|
47
|
+
ctx.renderer.info('No saved sessions yet.')
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ctx.renderer.info("Saved sessions (most recent first, up to #{MAX_LIST_ENTRIES}):")
|
|
52
|
+
rows.first(MAX_LIST_ENTRIES).each do |row|
|
|
53
|
+
count = row[:message_count].to_i
|
|
54
|
+
time = row[:last_activity]
|
|
55
|
+
title = row[:title].to_s.empty? ? '(untitled)' : row[:title]
|
|
56
|
+
sid = row[:session_id]
|
|
57
|
+
ctx.renderer.info(" #{sid} #{count} msg · #{format_time(time)} · #{title}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def restore_session(persistence, opts, ctx)
|
|
62
|
+
loaded = persistence.load_session(opts[:id])
|
|
63
|
+
if loaded.nil?
|
|
64
|
+
ctx.renderer.error("Session not found: #{opts[:id]}")
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
messages = loaded[:messages] || []
|
|
69
|
+
if messages.empty?
|
|
70
|
+
ctx.renderer.warning("Session #{opts[:id]} has no messages — nothing to restore.")
|
|
71
|
+
return
|
|
72
|
+
end
|
|
24
73
|
|
|
25
|
-
|
|
26
|
-
ctx.
|
|
27
|
-
|
|
28
|
-
{ action: :set_session_id, session_id: session_id }
|
|
29
|
-
else
|
|
30
|
-
ctx.renderer.error("Session not found: #{session_id}")
|
|
74
|
+
unless confirm_replace(ctx, opts, loaded, messages)
|
|
75
|
+
ctx.renderer.info('Resume cancelled.')
|
|
76
|
+
return
|
|
31
77
|
end
|
|
78
|
+
|
|
79
|
+
ctx.conversation.replace!(messages)
|
|
80
|
+
title = loaded[:title].to_s.empty? ? '(untitled)' : loaded[:title]
|
|
81
|
+
ctx.renderer.info("✓ Resumed session #{opts[:id]} — #{messages.size} messages from '#{title}'")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def confirm_replace(ctx, opts, loaded, messages)
|
|
85
|
+
return true if opts[:force]
|
|
86
|
+
return true if ctx.conversation.nil?
|
|
87
|
+
return true if ctx.conversation.messages.empty?
|
|
88
|
+
|
|
89
|
+
current = ctx.conversation.messages.size
|
|
90
|
+
target = messages.size
|
|
91
|
+
title = loaded[:title].to_s.empty? ? '(untitled)' : loaded[:title]
|
|
92
|
+
prompt = "Replace current conversation (#{current} messages) " \
|
|
93
|
+
"with session #{opts[:id]} '#{title}' (#{target} messages)? [y/N]"
|
|
94
|
+
|
|
95
|
+
return false unless ctx.renderer.respond_to?(:ask)
|
|
96
|
+
|
|
97
|
+
ctx.renderer.ask(prompt, default: false)
|
|
32
98
|
end
|
|
33
99
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
100
|
+
def format_time(time)
|
|
101
|
+
return 'never' if time.nil?
|
|
102
|
+
|
|
103
|
+
t = begin
|
|
104
|
+
time.is_a?(Time) ? time : Time.parse(time.to_s)
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
return 'never' if t.nil?
|
|
109
|
+
|
|
110
|
+
delta = Time.now - t
|
|
111
|
+
case delta
|
|
112
|
+
when 0...60 then 'just now'
|
|
113
|
+
when 60...3600 then "#{delta.to_i / 60} min ago"
|
|
114
|
+
when 3600...86_400 then "#{delta.to_i / 3600} hr ago"
|
|
115
|
+
when 86_400...604_800 then "#{delta.to_i / 86_400} d ago"
|
|
116
|
+
else t.strftime('%Y-%m-%d')
|
|
46
117
|
end
|
|
47
118
|
end
|
|
48
119
|
end
|
|
@@ -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,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Toggle extended thinking on/off and optionally set the budget.
|
|
7
|
+
#
|
|
8
|
+
# /think # show current budget
|
|
9
|
+
# /think off # disable
|
|
10
|
+
# /think <budget> # enable with <budget> tokens (e.g. /think 8192)
|
|
11
|
+
class Think < Base
|
|
12
|
+
DEFAULT_BUDGET = 8_192
|
|
13
|
+
|
|
14
|
+
def self.command_name = '/think'
|
|
15
|
+
def self.description = 'Toggle or set extended thinking budget (tokens)'
|
|
16
|
+
|
|
17
|
+
def execute(args, ctx)
|
|
18
|
+
arg = args.first
|
|
19
|
+
|
|
20
|
+
current = ctx.llm_client.respond_to?(:thinking_budget_tokens) ? ctx.llm_client.thinking_budget_tokens : 0
|
|
21
|
+
|
|
22
|
+
case arg
|
|
23
|
+
when nil
|
|
24
|
+
show_status(current, ctx)
|
|
25
|
+
when 'off', '0'
|
|
26
|
+
ctx.llm_client.thinking_budget_tokens = 0
|
|
27
|
+
ctx.renderer.info('Extended thinking OFF 🧠✋')
|
|
28
|
+
when /\A\d+\z/
|
|
29
|
+
budget = arg.to_i
|
|
30
|
+
ctx.llm_client.thinking_budget_tokens = budget
|
|
31
|
+
ctx.renderer.info("Extended thinking ON — budget: #{budget} tokens 🧠")
|
|
32
|
+
else
|
|
33
|
+
ctx.renderer.warning("Usage: /think [off|<budget>]. Got: #{arg}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def show_status(current, ctx)
|
|
40
|
+
state = current.to_i.positive? ? "ON (#{current} tokens)" : 'OFF'
|
|
41
|
+
ctx.renderer.info("Extended thinking: #{state}")
|
|
42
|
+
ctx.renderer.info('Usage: /think <budget> e.g. /think 8192 (or /think off)')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubyn_code/llm/image_reader'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module CLI
|
|
7
|
+
# Expands `@path/to/file` mentions in user input into inline file content,
|
|
8
|
+
# mirroring Claude Code / Codex `@`-mentions. The original text is kept and
|
|
9
|
+
# the referenced files are appended as labeled, fenced blocks so the agent
|
|
10
|
+
# sees both the request and the files it points at.
|
|
11
|
+
#
|
|
12
|
+
# Conservative by design: only existing, readable, reasonably-sized regular
|
|
13
|
+
# files inside the project are expanded. Unresolved mentions (and things
|
|
14
|
+
# that merely look like mentions, e.g. email addresses) are left untouched.
|
|
15
|
+
class MentionExpander
|
|
16
|
+
# A mention is an "@" that doesn't follow a word char or another "@"
|
|
17
|
+
# (so emails like foo@bar.com don't match), followed by a path-ish run.
|
|
18
|
+
MENTION = /(?<![\w@])@([^\s@]+)/
|
|
19
|
+
TRAILING_PUNCT = /[).,;:!?'"]+\z/
|
|
20
|
+
MAX_FILE_BYTES = 64 * 1024
|
|
21
|
+
MAX_FILES = 10
|
|
22
|
+
|
|
23
|
+
# @param project_root [String]
|
|
24
|
+
def initialize(project_root:)
|
|
25
|
+
@project_root = project_root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param input [String] raw user input
|
|
29
|
+
# @return [Array(String, Array<String>)] expanded text + resolved rel paths
|
|
30
|
+
def expand(input)
|
|
31
|
+
return [input, []] unless input.is_a?(String) && input.include?('@')
|
|
32
|
+
|
|
33
|
+
resolved = scan(input)
|
|
34
|
+
return [input, []] if resolved.empty?
|
|
35
|
+
|
|
36
|
+
blocks = resolved.map { |rel, abs| file_block(rel, abs) }
|
|
37
|
+
["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extract image references from input and return them as a list of
|
|
41
|
+
# LLM::ImageBlock instances. Image files (.png/.jpg/.jpeg/.gif/.webp)
|
|
42
|
+
# are NOT included in `expand` because they're sent as image content
|
|
43
|
+
# blocks, not as text fances. Non-image files are returned as nil.
|
|
44
|
+
#
|
|
45
|
+
# @param input [String]
|
|
46
|
+
# @return [Array<LLM::ImageBlock>] resolved image blocks (in order)
|
|
47
|
+
def expand_images(input)
|
|
48
|
+
return [] unless input.is_a?(String) && input.include?('@')
|
|
49
|
+
|
|
50
|
+
scan(input).filter_map do |rel, abs|
|
|
51
|
+
next unless LLM::ImageReader.image_extension?(rel)
|
|
52
|
+
|
|
53
|
+
LLM::ImageReader.for_path(abs)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
|
|
60
|
+
def scan(input)
|
|
61
|
+
seen = {}
|
|
62
|
+
input.scan(MENTION).each do |(raw)|
|
|
63
|
+
rel = raw.sub(TRAILING_PUNCT, '')
|
|
64
|
+
next if rel.empty?
|
|
65
|
+
|
|
66
|
+
abs = resolve(rel)
|
|
67
|
+
next unless abs && !seen.key?(abs)
|
|
68
|
+
|
|
69
|
+
seen[abs] = rel
|
|
70
|
+
break if seen.size >= MAX_FILES
|
|
71
|
+
end
|
|
72
|
+
seen.map { |abs, rel| [rel, abs] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Resolve a mention to an absolute path inside the project, or nil.
|
|
76
|
+
def resolve(rel)
|
|
77
|
+
abs = File.expand_path(rel, @project_root)
|
|
78
|
+
return nil unless inside_project?(abs)
|
|
79
|
+
return nil unless File.file?(abs)
|
|
80
|
+
|
|
81
|
+
abs
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inside_project?(abs)
|
|
87
|
+
root = File.expand_path(@project_root)
|
|
88
|
+
abs == root || abs.start_with?("#{root}#{File::SEPARATOR}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def file_block(rel, abs)
|
|
92
|
+
body = read_truncated(abs)
|
|
93
|
+
<<~BLOCK.strip
|
|
94
|
+
@#{rel}:
|
|
95
|
+
```
|
|
96
|
+
#{body}
|
|
97
|
+
```
|
|
98
|
+
BLOCK
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def read_truncated(abs)
|
|
102
|
+
content = File.read(abs, MAX_FILE_BYTES + 1)
|
|
103
|
+
return content if content.bytesize <= MAX_FILE_BYTES
|
|
104
|
+
|
|
105
|
+
"#{content.byteslice(0, MAX_FILE_BYTES)}\n… [truncated]"
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
"[could not read file: #{e.message}]"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
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
|
|
|
@@ -94,9 +97,22 @@ module RubynCode
|
|
|
94
97
|
|
|
95
98
|
def handle_on_tool_result(name, result)
|
|
96
99
|
@renderer.tool_result(name, result)
|
|
100
|
+
render_todo_checklist
|
|
97
101
|
@spinner.start
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
# Refresh the in-turn checklist above the spinner after every tool call.
|
|
105
|
+
# Only printed if the TodoWrite tool has been used in this turn.
|
|
106
|
+
def render_todo_checklist
|
|
107
|
+
return unless @agent_loop.respond_to?(:todo_store)
|
|
108
|
+
|
|
109
|
+
store = @agent_loop.todo_store
|
|
110
|
+
return if store.nil? || store.empty?
|
|
111
|
+
return if @spinner.done
|
|
112
|
+
|
|
113
|
+
@renderer.info("☐ Checklist:\n#{store.render}")
|
|
114
|
+
end
|
|
115
|
+
|
|
100
116
|
def handle_on_text(text)
|
|
101
117
|
@spinner.stop
|
|
102
118
|
if @streaming_first_chunk
|
|
@@ -109,26 +125,27 @@ module RubynCode
|
|
|
109
125
|
|
|
110
126
|
# -- sequential steps with interrupt rescue
|
|
111
127
|
def handle_message(input)
|
|
128
|
+
# Checkpoint before the turn (raw input as label), then expand
|
|
129
|
+
# @-mentions so the agent sees referenced file contents.
|
|
130
|
+
@checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
|
|
131
|
+
image_blocks = expand_image_mentions(input)
|
|
132
|
+
input = expand_mentions(input)
|
|
112
133
|
@spinner.start
|
|
113
134
|
@streaming_first_chunk = true
|
|
114
135
|
|
|
115
|
-
response = @agent_loop.send_message(input)
|
|
136
|
+
response = @agent_loop.send_message(input, blocks: image_blocks.empty? ? nil : image_blocks)
|
|
116
137
|
|
|
117
138
|
@spinner.stop
|
|
118
|
-
|
|
119
|
-
@renderer.display(response)
|
|
120
|
-
else
|
|
121
|
-
@stream_formatter&.flush
|
|
122
|
-
@stream_formatter = nil
|
|
123
|
-
puts
|
|
124
|
-
end
|
|
125
|
-
|
|
139
|
+
render_response(response)
|
|
126
140
|
save_session!
|
|
141
|
+
response
|
|
127
142
|
rescue Interrupt
|
|
128
143
|
@spinner.stop
|
|
129
144
|
puts
|
|
130
145
|
@renderer.warning('Interrupted — session state preserved')
|
|
146
|
+
@current_loop&.stop! # Ctrl-C during a /loop iteration stops the loop
|
|
131
147
|
save_session!
|
|
148
|
+
nil
|
|
132
149
|
rescue BudgetExceededError => e
|
|
133
150
|
@spinner.error
|
|
134
151
|
@renderer.error("Budget exceeded: #{e.message}")
|
|
@@ -137,6 +154,42 @@ module RubynCode
|
|
|
137
154
|
@renderer.error("Error: #{e.message}")
|
|
138
155
|
end
|
|
139
156
|
|
|
157
|
+
def render_response(response)
|
|
158
|
+
if @streaming_first_chunk
|
|
159
|
+
@renderer.display(response)
|
|
160
|
+
else
|
|
161
|
+
@stream_formatter&.flush
|
|
162
|
+
@stream_formatter = nil
|
|
163
|
+
puts
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Expand @path file mentions into inline content before the agent sees
|
|
168
|
+
# the message. Surfaces what was attached so the user knows.
|
|
169
|
+
def expand_mentions(input)
|
|
170
|
+
expanded, paths = @mention_expander.expand(input)
|
|
171
|
+
@renderer.info("📎 Attached: #{paths.join(', ')}") unless paths.empty?
|
|
172
|
+
expanded
|
|
173
|
+
rescue StandardError
|
|
174
|
+
input
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Collect image references from @path/to/image.png style mentions and
|
|
178
|
+
# return them as a list of LLM::ImageBlock hashes ready for the
|
|
179
|
+
# Agent::Loop. Falls back to [] when no images are attached.
|
|
180
|
+
def expand_image_mentions(input)
|
|
181
|
+
blocks = @mention_expander.expand_images(input)
|
|
182
|
+
if blocks.empty?
|
|
183
|
+
[]
|
|
184
|
+
else
|
|
185
|
+
@renderer.info("🖼 Attached images: #{blocks.size}")
|
|
186
|
+
# Translate via MessageBuilder for adapter-aware shape
|
|
187
|
+
RubynCode::LLM::MessageBuilder.new.format_content_blocks(blocks)
|
|
188
|
+
end
|
|
189
|
+
rescue StandardError
|
|
190
|
+
[]
|
|
191
|
+
end
|
|
192
|
+
|
|
140
193
|
def setup_readline!
|
|
141
194
|
completions = @command_registry.completions
|
|
142
195
|
|