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
|
@@ -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, Commands::Think, Commands::Effort, Commands::Export
|
|
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
|
|
@@ -29,11 +29,16 @@ module RubynCode
|
|
|
29
29
|
|
|
30
30
|
def setup_core_services!
|
|
31
31
|
@llm_client = LLM::Client.new
|
|
32
|
+
@llm_client.thinking_budget_tokens = Config::Settings.new.get('thinking_budget_tokens', 0).to_i
|
|
32
33
|
@conversation = Agent::Conversation.new
|
|
33
34
|
@tool_executor = Tools::Executor.new(project_root: @project_root)
|
|
34
35
|
@context_manager = Context::Manager.new(llm_client: @llm_client)
|
|
35
36
|
@hook_registry = Hooks::Registry.new
|
|
36
|
-
@
|
|
37
|
+
@external_hook_dispatcher = Hooks::ExternalDispatcher.new(project_root: @project_root)
|
|
38
|
+
@hook_runner = Hooks::Runner.new(
|
|
39
|
+
registry: @hook_registry,
|
|
40
|
+
external_dispatcher: @external_hook_dispatcher
|
|
41
|
+
)
|
|
37
42
|
@stall_detector = Agent::LoopDetector.new
|
|
38
43
|
end
|
|
39
44
|
|
|
@@ -45,6 +50,8 @@ module RubynCode
|
|
|
45
50
|
@skill_matcher = build_skill_matcher
|
|
46
51
|
@web_skill_autoload = build_web_skill_autoload
|
|
47
52
|
@session_persistence = Memory::SessionPersistence.new(@db)
|
|
53
|
+
@mention_expander = MentionExpander.new(project_root: @project_root)
|
|
54
|
+
@checkpoint_manager = Checkpoint::Manager.new(project_root: @project_root)
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
def build_skill_matcher
|
|
@@ -127,6 +134,8 @@ module RubynCode
|
|
|
127
134
|
def setup_hooks!
|
|
128
135
|
Hooks::BuiltIn.register_all!(@hook_registry)
|
|
129
136
|
Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
|
|
137
|
+
# Snapshot files before they are mutated so /rewind can restore them.
|
|
138
|
+
@hook_registry.on(:pre_tool_use, Checkpoint::Hook.new(manager: @checkpoint_manager), priority: 5)
|
|
130
139
|
end
|
|
131
140
|
|
|
132
141
|
def setup_agent_loop!
|
|
@@ -183,11 +192,11 @@ module RubynCode
|
|
|
183
192
|
|
|
184
193
|
def setup_mcp_servers!
|
|
185
194
|
@mcp_clients = []
|
|
186
|
-
|
|
187
|
-
return if
|
|
195
|
+
entries = MCP::Discovery.discover(@project_root)
|
|
196
|
+
return if entries.empty?
|
|
188
197
|
|
|
189
|
-
|
|
190
|
-
connect_mcp_server(
|
|
198
|
+
entries.each do |entry|
|
|
199
|
+
connect_mcp_server(entry)
|
|
191
200
|
end
|
|
192
201
|
|
|
193
202
|
at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
|
|
@@ -198,9 +207,10 @@ module RubynCode
|
|
|
198
207
|
client.connect!
|
|
199
208
|
MCP::ToolBridge.bridge(client)
|
|
200
209
|
@mcp_clients << client
|
|
201
|
-
|
|
210
|
+
source_tag = config.respond_to?(:source) && config.source == :project ? ' [project]' : ' [user]'
|
|
211
|
+
@renderer.info("MCP server '#{config.name}' connected#{source_tag} (#{client.tools.size} tools)")
|
|
202
212
|
rescue StandardError => e
|
|
203
|
-
warn "[MCP] Failed to connect '#{config
|
|
213
|
+
warn "[MCP] Failed to connect '#{config.name}': #{e.message}"
|
|
204
214
|
end
|
|
205
215
|
|
|
206
216
|
def disconnect_mcp_clients!
|
|
@@ -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-
|
|
14
|
+
DEFAULT_MODEL = 'claude-opus-5'
|
|
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,17 @@ 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
|
+
THINKING_BUDGET_TOKENS = 0
|
|
45
|
+
|
|
36
46
|
SESSION_BUDGET_USD = 5.00
|
|
37
47
|
DAILY_BUDGET_USD = 10.00
|
|
38
48
|
|
|
49
|
+
# Claude Code's credentials file (Linux/other — no system keychain)
|
|
50
|
+
CLAUDE_CREDENTIALS_FILE = File.expand_path('~/.claude/.credentials.json')
|
|
51
|
+
|
|
39
52
|
OAUTH_CLIENT_ID = 'rubyn-code'
|
|
40
53
|
OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
|
|
41
54
|
OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
|
|
@@ -43,6 +43,15 @@
|
|
|
43
43
|
"type": "number",
|
|
44
44
|
"minimum": 0.5,
|
|
45
45
|
"maximum": 500
|
|
46
|
+
},
|
|
47
|
+
"chisel_mode": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": ["off", "lite", "full", "ultra"]
|
|
50
|
+
},
|
|
51
|
+
"thinking_budget_tokens": {
|
|
52
|
+
"type": "integer",
|
|
53
|
+
"minimum": 0,
|
|
54
|
+
"maximum": 200000
|
|
46
55
|
}
|
|
47
56
|
},
|
|
48
57
|
"additionalProperties": true
|
|
@@ -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,8 @@ 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
|
|
22
|
+
thinking_budget_tokens
|
|
20
23
|
].freeze
|
|
21
24
|
|
|
22
25
|
DEFAULT_MAP = {
|
|
@@ -37,12 +40,20 @@ module RubynCode
|
|
|
37
40
|
oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
|
|
38
41
|
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
39
42
|
oauth_scopes: Defaults::OAUTH_SCOPES,
|
|
40
|
-
skills_autoload: Defaults::SKILLS_AUTOLOAD
|
|
43
|
+
skills_autoload: Defaults::SKILLS_AUTOLOAD,
|
|
44
|
+
chisel_mode: Defaults::CHISEL_MODE,
|
|
45
|
+
thinking_budget_tokens: Defaults::THINKING_BUDGET_TOKENS
|
|
41
46
|
}.freeze
|
|
42
47
|
|
|
43
48
|
attr_reader :config_path, :data
|
|
44
49
|
|
|
45
50
|
def initialize(config_path: Defaults::CONFIG_FILE)
|
|
51
|
+
# When tests run, isolate Settings from the developer's personal
|
|
52
|
+
# ~/.rubyn-code/config.yml so a stray `provider: minimax` can't
|
|
53
|
+
# shadow the test expectations. The test config lives in
|
|
54
|
+
# tmpdir, is process-pid-scoped, and is harmless if it leaks.
|
|
55
|
+
config_path = self.class.test_config_path if config_path == Defaults::CONFIG_FILE && ENV['RUBYN_TESTING']
|
|
56
|
+
|
|
46
57
|
@config_path = config_path
|
|
47
58
|
@data = {}
|
|
48
59
|
ensure_home_directory!
|
|
@@ -51,6 +62,12 @@ module RubynCode
|
|
|
51
62
|
backfill_provider_models!
|
|
52
63
|
end
|
|
53
64
|
|
|
65
|
+
# @return [String] a per-pid path under tmpdir used when
|
|
66
|
+
# RUBYN_TESTING is set
|
|
67
|
+
def self.test_config_path
|
|
68
|
+
@test_config_path ||= File.join(Dir.tmpdir, "rubyn-test-config-#{Process.pid}.yml")
|
|
69
|
+
end
|
|
70
|
+
|
|
54
71
|
# Define accessor methods for each configurable key
|
|
55
72
|
CONFIGURABLE_KEYS.each do |key|
|
|
56
73
|
define_method(key) do
|
|
@@ -141,7 +158,7 @@ module RubynCode
|
|
|
141
158
|
DEFAULT_PROVIDER_MODELS = {
|
|
142
159
|
'anthropic' => {
|
|
143
160
|
'env_key' => 'ANTHROPIC_API_KEY',
|
|
144
|
-
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-
|
|
161
|
+
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-5', 'top' => 'claude-opus-5' }
|
|
145
162
|
},
|
|
146
163
|
'openai' => {
|
|
147
164
|
'env_key' => 'OPENAI_API_KEY',
|
|
@@ -63,7 +63,7 @@ module RubynCode
|
|
|
63
63
|
]
|
|
64
64
|
|
|
65
65
|
options = {}
|
|
66
|
-
options[:model] = 'claude-sonnet-
|
|
66
|
+
options[:model] = 'claude-sonnet-5' if llm_client.respond_to?(:chat)
|
|
67
67
|
|
|
68
68
|
response = llm_client.chat(messages: summary_messages, **options)
|
|
69
69
|
|
|
@@ -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
|
|
@@ -68,7 +68,7 @@ module RubynCode
|
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
options = {}
|
|
71
|
-
options[:model] = 'claude-sonnet-
|
|
71
|
+
options[:model] = 'claude-sonnet-5' if llm_client.respond_to?(:chat)
|
|
72
72
|
|
|
73
73
|
response = llm_client.chat(messages: summary_messages, **options)
|
|
74
74
|
|
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
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
# Session goals: a goal is a plain-language condition the user wants
|
|
5
|
+
# satisfied before the agent stops working. The Goal::Evaluator judges,
|
|
6
|
+
# via a lightweight LLM call, whether the condition has been met based on
|
|
7
|
+
# the recent conversation. Used by Hooks::GoalHook on the :stop event.
|
|
8
|
+
module Goal
|
|
9
|
+
# Judges whether a goal condition has been satisfied.
|
|
10
|
+
#
|
|
11
|
+
# The evaluator is deliberately conservative: it returns true only when
|
|
12
|
+
# the model is confident the goal is genuinely complete. Any error or
|
|
13
|
+
# ambiguous answer is treated as "not met" so the agent keeps working
|
|
14
|
+
# rather than stopping prematurely.
|
|
15
|
+
class Evaluator
|
|
16
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
17
|
+
You are a strict completion judge. Given a GOAL and a transcript of an
|
|
18
|
+
AI coding agent's recent work, decide whether the goal is genuinely and
|
|
19
|
+
fully satisfied. Be conservative: if there is any doubt, or the work is
|
|
20
|
+
only partially done, answer NO. Answer with exactly one word on the
|
|
21
|
+
first line: YES or NO. Optionally add a short reason on the next line.
|
|
22
|
+
PROMPT
|
|
23
|
+
|
|
24
|
+
# Number of trailing conversation messages to show the judge.
|
|
25
|
+
TRANSCRIPT_WINDOW = 12
|
|
26
|
+
|
|
27
|
+
# @param llm_client [LLM::Client]
|
|
28
|
+
def initialize(llm_client:)
|
|
29
|
+
@llm_client = llm_client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param condition [String] the goal condition
|
|
33
|
+
# @param conversation [Agent::Conversation, nil] recent work to judge
|
|
34
|
+
# @return [Boolean] true only when the goal is confidently complete
|
|
35
|
+
def call(condition:, conversation: nil)
|
|
36
|
+
response = @llm_client.chat(
|
|
37
|
+
messages: [{ role: 'user', content: prompt(condition, conversation) }],
|
|
38
|
+
system: SYSTEM_PROMPT
|
|
39
|
+
)
|
|
40
|
+
verdict_yes?(answer_text(response))
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
RubynCode::Debug.warn("Goal evaluation failed: #{e.message}")
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def prompt(condition, conversation)
|
|
49
|
+
<<~TEXT
|
|
50
|
+
GOAL:
|
|
51
|
+
#{condition}
|
|
52
|
+
|
|
53
|
+
RECENT WORK:
|
|
54
|
+
#{transcript(conversation)}
|
|
55
|
+
|
|
56
|
+
Is the goal genuinely and fully satisfied? Answer YES or NO.
|
|
57
|
+
TEXT
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def transcript(conversation)
|
|
61
|
+
return '(no work recorded yet)' unless conversation.respond_to?(:messages)
|
|
62
|
+
|
|
63
|
+
Array(conversation.messages).last(TRANSCRIPT_WINDOW).map do |msg|
|
|
64
|
+
"#{msg[:role]}: #{message_text(msg[:content])}"
|
|
65
|
+
end.join("\n").slice(0, 6000)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def message_text(content)
|
|
69
|
+
case content
|
|
70
|
+
when String then content
|
|
71
|
+
when Array
|
|
72
|
+
content.filter_map { |b| b.is_a?(Hash) ? (b[:text] || b['text']) : nil }.join(' ')
|
|
73
|
+
else
|
|
74
|
+
content.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def answer_text(response)
|
|
79
|
+
if response.respond_to?(:content)
|
|
80
|
+
Array(response.content).filter_map { |b| b.respond_to?(:text) ? b.text : nil }.join("\n")
|
|
81
|
+
elsif response.is_a?(Hash)
|
|
82
|
+
Array(response[:content] || response['content'])
|
|
83
|
+
.filter_map { |b| b.is_a?(Hash) ? (b[:text] || b['text']) : nil }.join("\n")
|
|
84
|
+
else
|
|
85
|
+
response.to_s
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verdict_yes?(text)
|
|
90
|
+
first = text.to_s.strip.split("\n").first.to_s.strip.upcase
|
|
91
|
+
first.start_with?('YES')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Hooks
|
|
5
|
+
# Maps between internal hook event names (snake_case symbols used by the
|
|
6
|
+
# in-process Hooks::Runner) and the Claude Code hook event names
|
|
7
|
+
# (CamelCase strings used by external hooks configured in settings.json).
|
|
8
|
+
#
|
|
9
|
+
# Internal hooks fire at these 7 sites:
|
|
10
|
+
# :pre_tool_use — agent/tool_processor.rb#execute_tool
|
|
11
|
+
# :post_tool_use — agent/tool_processor.rb#execute_tool
|
|
12
|
+
# :pre_llm_call — agent/llm_caller.rb
|
|
13
|
+
# :post_llm_call — agent/llm_caller.rb
|
|
14
|
+
# :stop — agent/loop.rb#stop_blocked?
|
|
15
|
+
# :session_start — ide/handlers/prompt_handler.rb (IDE)
|
|
16
|
+
# :user_prompt_submit — ide/handlers/prompt_handler.rb (IDE)
|
|
17
|
+
#
|
|
18
|
+
# External hooks (Claude Code parity) consume these 9 event names:
|
|
19
|
+
# PreToolUse, PostToolUse, UserPromptSubmit, SessionStart,
|
|
20
|
+
# SessionEnd, Stop, SubagentStop, PreCompact, Notification
|
|
21
|
+
module EventMap
|
|
22
|
+
# Internal symbol => external string
|
|
23
|
+
TO_EXTERNAL = {
|
|
24
|
+
pre_tool_use: 'PreToolUse',
|
|
25
|
+
post_tool_use: 'PostToolUse',
|
|
26
|
+
pre_llm_call: 'PreCompact',
|
|
27
|
+
post_llm_call: 'Notification',
|
|
28
|
+
on_session_end: 'SessionEnd',
|
|
29
|
+
session_start: 'SessionStart',
|
|
30
|
+
user_prompt_submit: 'UserPromptSubmit',
|
|
31
|
+
stop: 'Stop',
|
|
32
|
+
on_subagent_stop: 'SubagentStop'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# External string => internal symbol
|
|
36
|
+
TO_INTERNAL = TO_EXTERNAL.invert.freeze
|
|
37
|
+
|
|
38
|
+
# Every external event name the dispatcher knows about.
|
|
39
|
+
EXTERNAL_EVENTS = TO_EXTERNAL.values.freeze
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# @param internal [Symbol]
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def external(internal)
|
|
46
|
+
TO_EXTERNAL[internal.to_sym]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param external [String, Symbol]
|
|
50
|
+
# @return [Symbol, nil]
|
|
51
|
+
def internal(external)
|
|
52
|
+
TO_INTERNAL[external.to_s]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|