rubyn-code 0.5.1 → 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 +120 -3
- 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/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 +1 -1
- 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 +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- 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 +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 +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 +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 +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 +45 -2
- 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 +37 -1
|
@@ -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
|
|
|
@@ -24,8 +24,25 @@ module RubynCode
|
|
|
24
24
|
Commands::Model, Commands::NewSession, Commands::Mcp,
|
|
25
25
|
Commands::Provider, Commands::InstallSkills,
|
|
26
26
|
Commands::RemoveSkills, Commands::Skills,
|
|
27
|
-
Commands::Megaplan
|
|
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
|
|
28
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}")
|
|
29
46
|
end
|
|
30
47
|
|
|
31
48
|
def handle_command(command)
|
|
@@ -67,7 +84,9 @@ module RubynCode
|
|
|
67
84
|
background_worker: @background_worker,
|
|
68
85
|
permission_tier: @permission_tier,
|
|
69
86
|
plan_mode: @plan_mode,
|
|
70
|
-
message_handler: method(:handle_message)
|
|
87
|
+
message_handler: method(:handle_message),
|
|
88
|
+
hook_registry: @hook_registry,
|
|
89
|
+
checkpoint_manager: @checkpoint_manager
|
|
71
90
|
)
|
|
72
91
|
end
|
|
73
92
|
|
|
@@ -85,11 +104,66 @@ module RubynCode
|
|
|
85
104
|
apply_provider(provider, rest[:model])
|
|
86
105
|
in { action: :spawn_teammate, name: String => name, role: String => role }
|
|
87
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!
|
|
88
111
|
else
|
|
89
112
|
# Unknown result hash — ignore
|
|
90
113
|
end
|
|
91
114
|
end
|
|
92
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
|
+
|
|
93
167
|
def start_new_session(new_id)
|
|
94
168
|
@session_id = new_id
|
|
95
169
|
@skills_injected = false # re-inject skills on next message
|
|
@@ -33,7 +33,11 @@ module RubynCode
|
|
|
33
33
|
@tool_executor = Tools::Executor.new(project_root: @project_root)
|
|
34
34
|
@context_manager = Context::Manager.new(llm_client: @llm_client)
|
|
35
35
|
@hook_registry = Hooks::Registry.new
|
|
36
|
-
@
|
|
36
|
+
@external_hook_dispatcher = Hooks::ExternalDispatcher.new(project_root: @project_root)
|
|
37
|
+
@hook_runner = Hooks::Runner.new(
|
|
38
|
+
registry: @hook_registry,
|
|
39
|
+
external_dispatcher: @external_hook_dispatcher
|
|
40
|
+
)
|
|
37
41
|
@stall_detector = Agent::LoopDetector.new
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -45,6 +49,8 @@ module RubynCode
|
|
|
45
49
|
@skill_matcher = build_skill_matcher
|
|
46
50
|
@web_skill_autoload = build_web_skill_autoload
|
|
47
51
|
@session_persistence = Memory::SessionPersistence.new(@db)
|
|
52
|
+
@mention_expander = MentionExpander.new(project_root: @project_root)
|
|
53
|
+
@checkpoint_manager = Checkpoint::Manager.new(project_root: @project_root)
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
def build_skill_matcher
|
|
@@ -127,6 +133,8 @@ module RubynCode
|
|
|
127
133
|
def setup_hooks!
|
|
128
134
|
Hooks::BuiltIn.register_all!(@hook_registry)
|
|
129
135
|
Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
|
|
136
|
+
# Snapshot files before they are mutated so /rewind can restore them.
|
|
137
|
+
@hook_registry.on(:pre_tool_use, Checkpoint::Hook.new(manager: @checkpoint_manager), priority: 5)
|
|
130
138
|
end
|
|
131
139
|
|
|
132
140
|
def setup_agent_loop!
|
|
@@ -1,7 +1,6 @@
|
|
|
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
|
|
@@ -11,7 +10,6 @@ module RubynCode
|
|
|
11
10
|
class StreamFormatter
|
|
12
11
|
def initialize(_renderer = nil)
|
|
13
12
|
@pastel = Pastel.new
|
|
14
|
-
@rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
15
13
|
@buffer = +''
|
|
16
14
|
@in_code_block = false
|
|
17
15
|
@code_lang = nil
|
|
@@ -98,6 +96,9 @@ module RubynCode
|
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
def output_highlighted_code
|
|
99
|
+
# Lazy so REPL boot never pays for rouge until a code block is rendered.
|
|
100
|
+
require 'rouge'
|
|
101
|
+
@rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
101
102
|
lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
|
|
102
103
|
highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
|
|
103
104
|
border = @pastel.dim(' │ ')
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
|
|
6
5
|
module RubynCode
|
|
@@ -16,6 +15,7 @@ module RubynCode
|
|
|
16
15
|
def initialize(renderer:)
|
|
17
16
|
@renderer = renderer
|
|
18
17
|
@thread = nil
|
|
18
|
+
@notified = false
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Kicks off a background check. Call `notify` later to display results.
|
|
@@ -26,11 +26,17 @@ module RubynCode
|
|
|
26
26
|
@thread.abort_on_exception = false
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
|
|
29
|
+
# Prints a message if outdated. Non-blocking by default: if the check
|
|
30
|
+
# hasn't finished yet, returns immediately so it can be retried before
|
|
31
|
+
# the next prompt. Notifies at most once.
|
|
32
|
+
def notify(timeout: 0)
|
|
33
|
+
return if @notified
|
|
31
34
|
return unless @thread
|
|
32
35
|
|
|
33
36
|
@thread.join(timeout)
|
|
37
|
+
return if @thread.alive?
|
|
38
|
+
|
|
39
|
+
@notified = true
|
|
34
40
|
return unless @result
|
|
35
41
|
|
|
36
42
|
return unless newer?(@result, RubynCode::VERSION)
|
|
@@ -60,6 +66,7 @@ module RubynCode
|
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def fetch_latest_version
|
|
69
|
+
require 'faraday'
|
|
63
70
|
conn = Faraday.new do |f|
|
|
64
71
|
f.options.timeout = 5
|
|
65
72
|
f.options.open_timeout = 3
|
|
@@ -11,9 +11,14 @@ module RubynCode
|
|
|
11
11
|
MEMORIES_DIR = File.join(HOME_DIR, 'memories')
|
|
12
12
|
|
|
13
13
|
DEFAULT_PROVIDER = 'anthropic'
|
|
14
|
-
DEFAULT_MODEL = 'claude-opus-4-
|
|
14
|
+
DEFAULT_MODEL = 'claude-opus-4-8'
|
|
15
15
|
MODEL_MODE = 'auto' # 'auto' or 'manual'
|
|
16
16
|
MAX_ITERATIONS = 200
|
|
17
|
+
# Hard ceiling when a Stop hook (e.g. an active /goal) keeps the agent
|
|
18
|
+
# working past MAX_ITERATIONS. A goal can legitimately need more tool
|
|
19
|
+
# turns than a single request; the GoalHook's own max-attempts valve is
|
|
20
|
+
# the real terminator — this only guards against a runaway loop.
|
|
21
|
+
GOAL_MAX_ITERATIONS = 2_000
|
|
17
22
|
MAX_SUB_AGENT_ITERATIONS = 200
|
|
18
23
|
MAX_EXPLORE_AGENT_ITERATIONS = 200
|
|
19
24
|
|
|
@@ -33,9 +38,16 @@ module RubynCode
|
|
|
33
38
|
|
|
34
39
|
SKILLS_AUTOLOAD = true
|
|
35
40
|
|
|
41
|
+
# Chisel: opt-in "write the minimum that works" enforcement. Off by
|
|
42
|
+
# default — only changes agent behavior once the user turns it on.
|
|
43
|
+
CHISEL_MODE = 'off'
|
|
44
|
+
|
|
36
45
|
SESSION_BUDGET_USD = 5.00
|
|
37
46
|
DAILY_BUDGET_USD = 10.00
|
|
38
47
|
|
|
48
|
+
# Claude Code's credentials file (Linux/other — no system keychain)
|
|
49
|
+
CLAUDE_CREDENTIALS_FILE = File.expand_path('~/.claude/.credentials.json')
|
|
50
|
+
|
|
39
51
|
OAUTH_CLIENT_ID = 'rubyn-code'
|
|
40
52
|
OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
|
|
41
53
|
OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require 'tmpdir'
|
|
4
5
|
require 'fileutils'
|
|
5
6
|
require_relative 'defaults'
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ module RubynCode
|
|
|
17
18
|
oauth_client_id oauth_redirect_uri oauth_authorize_url
|
|
18
19
|
oauth_token_url oauth_scopes
|
|
19
20
|
skills_autoload
|
|
21
|
+
chisel_mode
|
|
20
22
|
].freeze
|
|
21
23
|
|
|
22
24
|
DEFAULT_MAP = {
|
|
@@ -37,12 +39,19 @@ module RubynCode
|
|
|
37
39
|
oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
|
|
38
40
|
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
39
41
|
oauth_scopes: Defaults::OAUTH_SCOPES,
|
|
40
|
-
skills_autoload: Defaults::SKILLS_AUTOLOAD
|
|
42
|
+
skills_autoload: Defaults::SKILLS_AUTOLOAD,
|
|
43
|
+
chisel_mode: Defaults::CHISEL_MODE
|
|
41
44
|
}.freeze
|
|
42
45
|
|
|
43
46
|
attr_reader :config_path, :data
|
|
44
47
|
|
|
45
48
|
def initialize(config_path: Defaults::CONFIG_FILE)
|
|
49
|
+
# When tests run, isolate Settings from the developer's personal
|
|
50
|
+
# ~/.rubyn-code/config.yml so a stray `provider: minimax` can't
|
|
51
|
+
# shadow the test expectations. The test config lives in
|
|
52
|
+
# tmpdir, is process-pid-scoped, and is harmless if it leaks.
|
|
53
|
+
config_path = self.class.test_config_path if config_path == Defaults::CONFIG_FILE && ENV['RUBYN_TESTING']
|
|
54
|
+
|
|
46
55
|
@config_path = config_path
|
|
47
56
|
@data = {}
|
|
48
57
|
ensure_home_directory!
|
|
@@ -51,6 +60,12 @@ module RubynCode
|
|
|
51
60
|
backfill_provider_models!
|
|
52
61
|
end
|
|
53
62
|
|
|
63
|
+
# @return [String] a per-pid path under tmpdir used when
|
|
64
|
+
# RUBYN_TESTING is set
|
|
65
|
+
def self.test_config_path
|
|
66
|
+
@test_config_path ||= File.join(Dir.tmpdir, "rubyn-test-config-#{Process.pid}.yml")
|
|
67
|
+
end
|
|
68
|
+
|
|
54
69
|
# Define accessor methods for each configurable key
|
|
55
70
|
CONFIGURABLE_KEYS.each do |key|
|
|
56
71
|
define_method(key) do
|
|
@@ -141,7 +156,7 @@ module RubynCode
|
|
|
141
156
|
DEFAULT_PROVIDER_MODELS = {
|
|
142
157
|
'anthropic' => {
|
|
143
158
|
'env_key' => 'ANTHROPIC_API_KEY',
|
|
144
|
-
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-
|
|
159
|
+
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-8' }
|
|
145
160
|
},
|
|
146
161
|
'openai' => {
|
|
147
162
|
'env_key' => 'OPENAI_API_KEY',
|
|
@@ -39,24 +39,30 @@ module RubynCode
|
|
|
39
39
|
@total_output_tokens += usage.output_tokens.to_i
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# Rough estimate of token count
|
|
43
|
-
#
|
|
42
|
+
# Rough estimate of token count based on JSON-serialized character
|
|
43
|
+
# length (~4 chars per token). Accepts either a raw messages array or
|
|
44
|
+
# an object exposing #estimated_json_chars (e.g. Agent::Conversation),
|
|
45
|
+
# which avoids re-serializing the whole history on every call.
|
|
44
46
|
#
|
|
45
|
-
# @param
|
|
47
|
+
# @param source [Array<Hash>, #estimated_json_chars] conversation messages
|
|
46
48
|
# @return [Integer] estimated token count
|
|
47
|
-
def estimated_tokens(
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
def estimated_tokens(source)
|
|
50
|
+
chars = if source.respond_to?(:estimated_json_chars)
|
|
51
|
+
source.estimated_json_chars
|
|
52
|
+
else
|
|
53
|
+
JSON.generate(source).length
|
|
54
|
+
end
|
|
55
|
+
(chars.to_f / CHARS_PER_TOKEN).ceil
|
|
50
56
|
rescue JSON::GeneratorError
|
|
51
57
|
0
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# Returns true if the estimated token count exceeds the threshold.
|
|
55
61
|
#
|
|
56
|
-
# @param
|
|
62
|
+
# @param source [Array<Hash>, #estimated_json_chars] conversation messages
|
|
57
63
|
# @return [Boolean]
|
|
58
|
-
def needs_compaction?(
|
|
59
|
-
estimated_tokens(
|
|
64
|
+
def needs_compaction?(source)
|
|
65
|
+
estimated_tokens(source) > @threshold
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
# Runs micro-compaction every turn and auto-compaction when the context
|
|
@@ -78,14 +84,17 @@ module RubynCode
|
|
|
78
84
|
return if @last_compaction_turn == @current_turn
|
|
79
85
|
|
|
80
86
|
messages = conversation.messages
|
|
87
|
+
estimate_source = conversation.respond_to?(:estimated_json_chars) ? conversation : messages
|
|
81
88
|
|
|
82
89
|
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
83
90
|
# the compaction threshold. Running it every turn mutates old messages,
|
|
84
91
|
# which invalidates the prompt cache prefix and wastes tokens.
|
|
85
|
-
est = estimated_tokens(
|
|
86
|
-
|
|
92
|
+
est = estimated_tokens(estimate_source)
|
|
93
|
+
if est > (@threshold * micro_compact_ratio) && MicroCompact.call(messages).to_i.positive?
|
|
94
|
+
refresh_conversation_estimate(conversation)
|
|
95
|
+
end
|
|
87
96
|
|
|
88
|
-
return unless needs_compaction?(
|
|
97
|
+
return unless needs_compaction?(estimate_source)
|
|
89
98
|
|
|
90
99
|
# Step 2: Try context collapse (snip old messages, no LLM call)
|
|
91
100
|
collapsed = ContextCollapse.call(messages, threshold: @threshold)
|
|
@@ -138,6 +147,14 @@ module RubynCode
|
|
|
138
147
|
elsif conversation.respond_to?(:messages=)
|
|
139
148
|
conversation.messages = new_messages
|
|
140
149
|
end
|
|
150
|
+
refresh_conversation_estimate(conversation)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# MicroCompact and the compaction strategies mutate or replace messages
|
|
154
|
+
# outside the conversation's own append path, so its incremental token
|
|
155
|
+
# bookkeeping must be invalidated afterwards.
|
|
156
|
+
def refresh_conversation_estimate(conversation)
|
|
157
|
+
conversation.refresh_derived_state! if conversation.respond_to?(:refresh_derived_state!)
|
|
141
158
|
end
|
|
142
159
|
end
|
|
143
160
|
end
|
data/lib/rubyn_code/debug.rb
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'pastel'
|
|
4
|
-
|
|
5
3
|
module RubynCode
|
|
6
4
|
module Debug
|
|
7
|
-
PASTEL = Pastel.new
|
|
8
|
-
|
|
9
5
|
@enabled = false
|
|
10
6
|
@output = $stderr
|
|
11
7
|
|
|
@@ -32,7 +28,7 @@ module RubynCode
|
|
|
32
28
|
return unless enabled?
|
|
33
29
|
|
|
34
30
|
timestamp = Time.now.strftime('%H:%M:%S.%L')
|
|
35
|
-
prefix = "#{
|
|
31
|
+
prefix = "#{pastel.dim("[#{timestamp}]")} #{pastel.send(color, "[#{tag}]")}"
|
|
36
32
|
@output.puts "#{prefix} #{message}"
|
|
37
33
|
end
|
|
38
34
|
|
|
@@ -69,6 +65,16 @@ module RubynCode
|
|
|
69
65
|
def error(message)
|
|
70
66
|
log('error', message, color: :red)
|
|
71
67
|
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Lazy so boot never pays for pastel when debug output is off.
|
|
72
|
+
def pastel
|
|
73
|
+
@pastel ||= begin
|
|
74
|
+
require 'pastel'
|
|
75
|
+
Pastel.new
|
|
76
|
+
end
|
|
77
|
+
end
|
|
72
78
|
end
|
|
73
79
|
end
|
|
74
80
|
end
|