rubyn-code 0.7.0 → 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 +17 -1
- data/lib/rubyn_code/agent/conversation.rb +11 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +5 -1
- data/lib/rubyn_code/agent/loop.rb +57 -3
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
- data/lib/rubyn_code/agent/tool_processor.rb +10 -0
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/cli/commands/context.rb +27 -0
- data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
- data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
- 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/mcp.rb +32 -8
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- 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/mention_expander.rb +19 -0
- data/lib/rubyn_code/cli/repl.rb +31 -1
- data/lib/rubyn_code/cli/repl_commands.rb +1 -1
- data/lib/rubyn_code/cli/repl_setup.rb +8 -6
- data/lib/rubyn_code/config/defaults.rb +2 -1
- data/lib/rubyn_code/config/schema.json +5 -0
- data/lib/rubyn_code/config/settings.rb +4 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +64 -3
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
- 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 +1 -1
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- 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/discovery.rb +93 -0
- data/lib/rubyn_code/memory/session_persistence.rb +1 -1
- data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/teammate.rb +0 -15
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- 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 +4 -1
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +13 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +10 -7
- 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/adapters/token_caching.rb +0 -54
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -8,37 +8,61 @@ module RubynCode
|
|
|
8
8
|
def self.description = 'MCP server status'
|
|
9
9
|
|
|
10
10
|
def execute(_args, ctx)
|
|
11
|
-
|
|
11
|
+
entries = load_entries(ctx.project_root)
|
|
12
12
|
|
|
13
|
-
if
|
|
13
|
+
if entries.empty?
|
|
14
14
|
ctx.renderer.info('No MCP servers configured.')
|
|
15
15
|
puts ' Add servers to .rubyn-code/mcp.json — see docs/MCP.md for details.'
|
|
16
16
|
return
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
ctx.renderer.info("MCP servers (#{
|
|
19
|
+
ctx.renderer.info("MCP servers (#{entries.size}):")
|
|
20
20
|
puts
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
entries.each { |entry| render_server(entry) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# Load merged user + project entries via MCP::Discovery so the
|
|
28
|
+
# output can show which servers came from `~/.rubyn-code/mcp.json`
|
|
29
|
+
# vs the project-root `.mcp.json`.
|
|
30
|
+
def load_entries(project_root)
|
|
31
|
+
MCP::Discovery.discover(project_root)
|
|
29
32
|
end
|
|
30
33
|
|
|
31
|
-
def
|
|
34
|
+
def source_label(source)
|
|
35
|
+
case source
|
|
36
|
+
when :project then '[project]'
|
|
37
|
+
when :user then '[user]'
|
|
38
|
+
else '[-]'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_server(entry)
|
|
43
|
+
cfg = entry_to_config(entry)
|
|
32
44
|
client = build_client(cfg)
|
|
33
45
|
status, counts = probe_server(client)
|
|
34
46
|
icon = status_icon(status)
|
|
47
|
+
label = source_label(entry.respond_to?(:source) ? entry.source : :user)
|
|
35
48
|
|
|
36
|
-
puts " #{icon} #{
|
|
49
|
+
puts " #{icon} #{entry.name} #{label} [#{status}]#{capability_label(counts)}"
|
|
37
50
|
render_transport_info(cfg)
|
|
38
51
|
ensure
|
|
39
52
|
client&.disconnect! if client&.connected?
|
|
40
53
|
end
|
|
41
54
|
|
|
55
|
+
# Discovery::Entry → Config hash shape so existing renderers work.
|
|
56
|
+
def entry_to_config(entry)
|
|
57
|
+
{
|
|
58
|
+
name: entry.name,
|
|
59
|
+
command: entry.command,
|
|
60
|
+
args: entry.args,
|
|
61
|
+
env: entry.env,
|
|
62
|
+
url: entry.url
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
42
66
|
def capability_label(counts)
|
|
43
67
|
return '' unless counts
|
|
44
68
|
|
|
@@ -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,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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'rubyn_code/llm/image_reader'
|
|
4
|
+
|
|
3
5
|
module RubynCode
|
|
4
6
|
module CLI
|
|
5
7
|
# Expands `@path/to/file` mentions in user input into inline file content,
|
|
@@ -35,6 +37,23 @@ module RubynCode
|
|
|
35
37
|
["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
|
|
36
38
|
end
|
|
37
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
|
+
|
|
38
57
|
private
|
|
39
58
|
|
|
40
59
|
# @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
|
data/lib/rubyn_code/cli/repl.rb
CHANGED
|
@@ -97,9 +97,22 @@ module RubynCode
|
|
|
97
97
|
|
|
98
98
|
def handle_on_tool_result(name, result)
|
|
99
99
|
@renderer.tool_result(name, result)
|
|
100
|
+
render_todo_checklist
|
|
100
101
|
@spinner.start
|
|
101
102
|
end
|
|
102
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
|
+
|
|
103
116
|
def handle_on_text(text)
|
|
104
117
|
@spinner.stop
|
|
105
118
|
if @streaming_first_chunk
|
|
@@ -115,11 +128,12 @@ module RubynCode
|
|
|
115
128
|
# Checkpoint before the turn (raw input as label), then expand
|
|
116
129
|
# @-mentions so the agent sees referenced file contents.
|
|
117
130
|
@checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
|
|
131
|
+
image_blocks = expand_image_mentions(input)
|
|
118
132
|
input = expand_mentions(input)
|
|
119
133
|
@spinner.start
|
|
120
134
|
@streaming_first_chunk = true
|
|
121
135
|
|
|
122
|
-
response = @agent_loop.send_message(input)
|
|
136
|
+
response = @agent_loop.send_message(input, blocks: image_blocks.empty? ? nil : image_blocks)
|
|
123
137
|
|
|
124
138
|
@spinner.stop
|
|
125
139
|
render_response(response)
|
|
@@ -160,6 +174,22 @@ module RubynCode
|
|
|
160
174
|
input
|
|
161
175
|
end
|
|
162
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
|
+
|
|
163
193
|
def setup_readline!
|
|
164
194
|
completions = @command_registry.completions
|
|
165
195
|
|
|
@@ -27,7 +27,7 @@ module RubynCode
|
|
|
27
27
|
Commands::Megaplan, Commands::Goal, Commands::Loop,
|
|
28
28
|
Commands::Agents, Commands::Learning, Commands::Rewind,
|
|
29
29
|
Commands::Chisel, Commands::ChiselReview, Commands::ChiselAudit,
|
|
30
|
-
Commands::ChiselDebt, Commands::ChiselGain
|
|
30
|
+
Commands::ChiselDebt, Commands::ChiselGain, Commands::Think, Commands::Effort, Commands::Export
|
|
31
31
|
].each { |cmd| @command_registry.register(cmd) }
|
|
32
32
|
register_custom_commands!
|
|
33
33
|
end
|
|
@@ -29,6 +29,7 @@ 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)
|
|
@@ -191,11 +192,11 @@ module RubynCode
|
|
|
191
192
|
|
|
192
193
|
def setup_mcp_servers!
|
|
193
194
|
@mcp_clients = []
|
|
194
|
-
|
|
195
|
-
return if
|
|
195
|
+
entries = MCP::Discovery.discover(@project_root)
|
|
196
|
+
return if entries.empty?
|
|
196
197
|
|
|
197
|
-
|
|
198
|
-
connect_mcp_server(
|
|
198
|
+
entries.each do |entry|
|
|
199
|
+
connect_mcp_server(entry)
|
|
199
200
|
end
|
|
200
201
|
|
|
201
202
|
at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
|
|
@@ -206,9 +207,10 @@ module RubynCode
|
|
|
206
207
|
client.connect!
|
|
207
208
|
MCP::ToolBridge.bridge(client)
|
|
208
209
|
@mcp_clients << client
|
|
209
|
-
|
|
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)")
|
|
210
212
|
rescue StandardError => e
|
|
211
|
-
warn "[MCP] Failed to connect '#{config
|
|
213
|
+
warn "[MCP] Failed to connect '#{config.name}': #{e.message}"
|
|
212
214
|
end
|
|
213
215
|
|
|
214
216
|
def disconnect_mcp_clients!
|
|
@@ -11,7 +11,7 @@ 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
17
|
# Hard ceiling when a Stop hook (e.g. an active /goal) keeps the agent
|
|
@@ -41,6 +41,7 @@ module RubynCode
|
|
|
41
41
|
# Chisel: opt-in "write the minimum that works" enforcement. Off by
|
|
42
42
|
# default — only changes agent behavior once the user turns it on.
|
|
43
43
|
CHISEL_MODE = 'off'
|
|
44
|
+
THINKING_BUDGET_TOKENS = 0
|
|
44
45
|
|
|
45
46
|
SESSION_BUDGET_USD = 5.00
|
|
46
47
|
DAILY_BUDGET_USD = 10.00
|
|
@@ -19,6 +19,7 @@ module RubynCode
|
|
|
19
19
|
oauth_token_url oauth_scopes
|
|
20
20
|
skills_autoload
|
|
21
21
|
chisel_mode
|
|
22
|
+
thinking_budget_tokens
|
|
22
23
|
].freeze
|
|
23
24
|
|
|
24
25
|
DEFAULT_MAP = {
|
|
@@ -40,7 +41,8 @@ module RubynCode
|
|
|
40
41
|
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
41
42
|
oauth_scopes: Defaults::OAUTH_SCOPES,
|
|
42
43
|
skills_autoload: Defaults::SKILLS_AUTOLOAD,
|
|
43
|
-
chisel_mode: Defaults::CHISEL_MODE
|
|
44
|
+
chisel_mode: Defaults::CHISEL_MODE,
|
|
45
|
+
thinking_budget_tokens: Defaults::THINKING_BUDGET_TOKENS
|
|
44
46
|
}.freeze
|
|
45
47
|
|
|
46
48
|
attr_reader :config_path, :data
|
|
@@ -156,7 +158,7 @@ module RubynCode
|
|
|
156
158
|
DEFAULT_PROVIDER_MODELS = {
|
|
157
159
|
'anthropic' => {
|
|
158
160
|
'env_key' => 'ANTHROPIC_API_KEY',
|
|
159
|
-
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-
|
|
161
|
+
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-5', 'top' => 'claude-opus-5' }
|
|
160
162
|
},
|
|
161
163
|
'openai' => {
|
|
162
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
|
|
|
@@ -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
|
|
|
@@ -14,6 +14,9 @@ module RubynCode
|
|
|
14
14
|
INDEX_DIR = '.rubyn-code'
|
|
15
15
|
INDEX_FILE = 'codebase_index.json'
|
|
16
16
|
CHARS_PER_TOKEN = 4
|
|
17
|
+
# Bump when the stored shape changes so stale indexes rebuild instead
|
|
18
|
+
# of silently serving degraded data. 2 = Prism spans + call edges.
|
|
19
|
+
FORMAT_VERSION = 2
|
|
17
20
|
|
|
18
21
|
attr_reader :nodes, :edges, :index_path
|
|
19
22
|
|
|
@@ -33,6 +36,7 @@ module RubynCode
|
|
|
33
36
|
|
|
34
37
|
ruby_files.each { |file| index_file(file) }
|
|
35
38
|
extract_rails_edges
|
|
39
|
+
prune_call_edges!
|
|
36
40
|
save!
|
|
37
41
|
self
|
|
38
42
|
end
|
|
@@ -42,6 +46,8 @@ module RubynCode
|
|
|
42
46
|
return nil unless File.exist?(@index_path)
|
|
43
47
|
|
|
44
48
|
data = JSON.parse(File.read(@index_path))
|
|
49
|
+
return nil unless data['format_version'] == FORMAT_VERSION
|
|
50
|
+
|
|
45
51
|
@nodes = data['nodes'] || []
|
|
46
52
|
# uniq drops duplicate edges accumulated by older versions, which
|
|
47
53
|
# appended tests edges on every update! without dedup.
|
|
@@ -68,6 +74,7 @@ module RubynCode
|
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
extract_rails_edges
|
|
77
|
+
prune_call_edges!
|
|
71
78
|
save!
|
|
72
79
|
self
|
|
73
80
|
end
|
|
@@ -81,6 +88,7 @@ module RubynCode
|
|
|
81
88
|
remove_nodes_for(absolute)
|
|
82
89
|
index_file(absolute) if File.exist?(absolute)
|
|
83
90
|
extract_rails_edges
|
|
91
|
+
prune_call_edges!
|
|
84
92
|
save!
|
|
85
93
|
self
|
|
86
94
|
end
|
|
@@ -237,14 +245,66 @@ module RubynCode
|
|
|
237
245
|
content = File.read(file)
|
|
238
246
|
@file_mtimes[relative] = File.mtime(file).to_i
|
|
239
247
|
|
|
240
|
-
|
|
241
|
-
extract_methods(content, relative)
|
|
248
|
+
extract_symbols(content, relative)
|
|
242
249
|
extract_associations(content, relative)
|
|
243
250
|
extract_rails_patterns(content, relative)
|
|
244
251
|
rescue StandardError => e
|
|
245
252
|
RubynCode::Debug.warn("Index: failed to parse #{file}: #{e.message}")
|
|
246
253
|
end
|
|
247
254
|
|
|
255
|
+
# Prism gives real line spans (needed for verbatim source in code_graph)
|
|
256
|
+
# and call edges. Files that don't parse fall back to the regex pass,
|
|
257
|
+
# which produces the same node shapes minus end_line/owner/calls.
|
|
258
|
+
def extract_symbols(content, file)
|
|
259
|
+
extracted = PrismExtractor.extract(content)
|
|
260
|
+
unless extracted
|
|
261
|
+
extract_classes(content, file)
|
|
262
|
+
extract_methods(content, file)
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
add_class_nodes(extracted.classes, file)
|
|
267
|
+
add_method_nodes(extracted.defs, file)
|
|
268
|
+
add_call_edges(extracted.calls, file)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def add_class_nodes(classes, file)
|
|
272
|
+
classes.each do |c|
|
|
273
|
+
@nodes << {
|
|
274
|
+
'type' => classify_node(file, c[:kind]), 'name' => c[:name],
|
|
275
|
+
'file' => file, 'line' => c[:line], 'end_line' => c[:end_line]
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def add_method_nodes(methods, file)
|
|
281
|
+
methods.each do |m|
|
|
282
|
+
@nodes << {
|
|
283
|
+
'type' => 'method', 'name' => m[:name], 'file' => file,
|
|
284
|
+
'line' => m[:line], 'end_line' => m[:end_line],
|
|
285
|
+
'owner' => m[:owner], 'params' => m[:params], 'visibility' => 'public'
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# `from` stays the file path so remove_nodes_for's from-based cleanup
|
|
291
|
+
# applies to call edges too; the calling method rides in from_method.
|
|
292
|
+
def add_call_edges(calls, file)
|
|
293
|
+
calls.each do |c|
|
|
294
|
+
@edges << {
|
|
295
|
+
'from' => file, 'from_method' => c[:from], 'to' => c[:to],
|
|
296
|
+
'relationship' => 'calls', 'line' => c[:line]
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Drop call edges whose target isn't defined in the project — filters
|
|
302
|
+
# out stdlib/gem calls (puts, map, ...) that would swamp the graph.
|
|
303
|
+
def prune_call_edges!
|
|
304
|
+
defined_methods = @nodes.filter_map { |n| n['name'] if n['type'] == 'method' }.to_set
|
|
305
|
+
@edges.reject! { |e| e['relationship'] == 'calls' && !defined_methods.include?(e['to']) }
|
|
306
|
+
end
|
|
307
|
+
|
|
248
308
|
def extract_classes(content, file)
|
|
249
309
|
content.scan(/^\s*(class|module)\s+(\S+)/).each do |type, name|
|
|
250
310
|
node_type = classify_node(file, type)
|
|
@@ -342,7 +402,8 @@ module RubynCode
|
|
|
342
402
|
|
|
343
403
|
def save!
|
|
344
404
|
FileUtils.mkdir_p(File.dirname(@index_path))
|
|
345
|
-
data = { '
|
|
405
|
+
data = { 'format_version' => FORMAT_VERSION, 'nodes' => @nodes, 'edges' => @edges,
|
|
406
|
+
'file_mtimes' => @file_mtimes }
|
|
346
407
|
File.write(@index_path, JSON.generate(data))
|
|
347
408
|
end
|
|
348
409
|
end
|