rubyn-code 0.7.0 → 0.9.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  3. data/lib/rubyn_code/agent/conversation.rb +11 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +5 -1
  6. data/lib/rubyn_code/agent/loop.rb +57 -3
  7. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +10 -0
  10. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  11. data/lib/rubyn_code/cli/commands/context.rb +27 -0
  12. data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
  13. data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
  14. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  15. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  16. data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
  17. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  18. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  19. data/lib/rubyn_code/cli/first_run.rb +1 -1
  20. data/lib/rubyn_code/cli/mention_expander.rb +19 -0
  21. data/lib/rubyn_code/cli/repl.rb +31 -1
  22. data/lib/rubyn_code/cli/repl_commands.rb +1 -1
  23. data/lib/rubyn_code/cli/repl_setup.rb +8 -6
  24. data/lib/rubyn_code/config/defaults.rb +2 -1
  25. data/lib/rubyn_code/config/schema.json +5 -0
  26. data/lib/rubyn_code/config/settings.rb +4 -2
  27. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  28. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  29. data/lib/rubyn_code/index/codebase_index.rb +64 -3
  30. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  31. data/lib/rubyn_code/learning/injector.rb +1 -2
  32. data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
  33. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  34. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  35. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  36. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  37. data/lib/rubyn_code/llm/client.rb +16 -3
  38. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  39. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  40. data/lib/rubyn_code/llm/model_router.rb +4 -4
  41. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  42. data/lib/rubyn_code/memory/session_persistence.rb +1 -1
  43. data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
  44. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  45. data/lib/rubyn_code/tasks/models.rb +0 -16
  46. data/lib/rubyn_code/teams/teammate.rb +0 -15
  47. data/lib/rubyn_code/tools/RUBYN.md +3 -3
  48. data/lib/rubyn_code/tools/bash.rb +3 -3
  49. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  50. data/lib/rubyn_code/tools/executor.rb +6 -1
  51. data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
  52. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  53. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  54. data/lib/rubyn_code/version.rb +1 -1
  55. data/lib/rubyn_code.rb +14 -8
  56. data/skills/rubyn_self_test.md +140 -0
  57. metadata +11 -7
  58. data/lib/rubyn_code/context/context_budget.rb +0 -183
  59. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  60. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  61. data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
  62. data/lib/rubyn_code/llm/streaming.rb +0 -10
  63. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module RubynCode
7
+ module CLI
8
+ module Commands
9
+ # Export the current conversation transcript to a file.
10
+ #
11
+ # /export path/to/transcript.md # markdown (default)
12
+ # /export --format jsonl path/to/transcript.jsonl
13
+ # /export path/to/transcript.md --force # overwrite existing
14
+ class Export < Base
15
+ def self.command_name = '/export'
16
+ def self.description = 'Export conversation transcript to markdown or JSONL'
17
+
18
+ MAX_LINE_LENGTH = 100
19
+
20
+ # -- CLI arg parsing is intrinsically procedural
21
+ def execute(args, ctx)
22
+ opts = parse_args(args)
23
+ return unless opts
24
+
25
+ messages = ctx.conversation.to_a
26
+ if messages.empty?
27
+ ctx.renderer.warning('No messages to export.')
28
+ return
29
+ end
30
+
31
+ path = File.expand_path(opts[:path])
32
+ dir = File.dirname(path)
33
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
34
+
35
+ if File.exist?(path) && !opts[:force] && !tty_yes?(ctx, "File exists at #{path}. Overwrite? [y/N]")
36
+ ctx.renderer.info('Export cancelled.')
37
+ return
38
+ end
39
+
40
+ body = opts[:format] == 'jsonl' ? render_jsonl(messages) : render_markdown(messages)
41
+ File.write(path, body)
42
+ plural = messages.size == 1 ? '' : 's'
43
+ ctx.renderer.info("Exported #{messages.size} message#{plural} to #{path}")
44
+ end
45
+
46
+ private
47
+
48
+ # @return [Hash{Symbol=>Object}, nil] nil on bad input
49
+ # -- arg parsing is intrinsically branchy
50
+ def parse_args(args) # rubocop:disable Metrics/MethodLength -- arg parsing is intrinsically procedural
51
+ format = 'markdown'
52
+ force = false
53
+ path = nil
54
+
55
+ if (idx = args.index('--format') || args.index('-f'))
56
+ format = args[idx + 1].to_s
57
+ end
58
+ format = case format
59
+ when 'md' then 'markdown'
60
+ else format
61
+ end
62
+
63
+ args.each do |a|
64
+ case a
65
+ when '--jsonl'
66
+ format = 'jsonl'
67
+ when '--markdown', '--md'
68
+ format = 'markdown'
69
+ when '--format', '-f'
70
+ # already handled above
71
+ when '--force', '-y'
72
+ force = true
73
+ when /\A--.+/
74
+ next
75
+ else
76
+ path ||= a
77
+ end
78
+ end
79
+
80
+ return nil if path.to_s.empty?
81
+
82
+ { path: path, format: format, force: force }
83
+ end
84
+
85
+ def render_markdown(messages)
86
+ title = 'Rubyn transcript'
87
+ buffer = String.new
88
+ buffer << "# #{title}\n\n"
89
+ buffer << "_Exported at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n"
90
+ messages.each { |msg| render_markdown_message(buffer, msg) }
91
+ buffer
92
+ end
93
+
94
+ def render_markdown_message(buffer, msg)
95
+ role = msg[:role] || msg['role']
96
+ case role
97
+ when 'user'
98
+ text = render_user_text(msg[:content] || msg['content'])
99
+ buffer << "## User\n\n#{text}\n\n"
100
+ when 'assistant'
101
+ render_assistant_blocks(buffer, msg[:content] || msg['content'])
102
+ when 'tool'
103
+ buffer << "### tool result\n\n```\n#{msg[:content] || msg['content']}\n```\n\n"
104
+ end
105
+ end
106
+
107
+ def render_user_text(content)
108
+ blocks = normalize_content(content)
109
+ if blocks.any? { |b| block_type(b) == 'image' }
110
+ blocks.map { |b| block_type(b) == 'image' ? '_(image attachment)_' : text_of(b) }.join("\n")
111
+ else
112
+ blocks.map { |b| text_of(b) }.join("\n")
113
+ end
114
+ end
115
+
116
+ def render_assistant_blocks(buffer, content)
117
+ blocks = normalize_content(content)
118
+ if blocks.empty?
119
+ buffer << "## Assistant\n\n_(empty response)_\n\n"
120
+ return
121
+ end
122
+ blocks.each do |b|
123
+ render_assistant_block(buffer, b)
124
+ end
125
+ end
126
+
127
+ def render_assistant_block(buffer, b) # rubocop:disable Naming/MethodParameterName -- short names are clearer here than "block"
128
+ case block_type(b)
129
+ when 'text'
130
+ buffer << "## Assistant\n\n#{text_of(b)}\n\n"
131
+ when 'thinking'
132
+ buffer << "<details><summary>thinking</summary>\n\n#{text_of(b)}\n\n</details>\n\n"
133
+ when 'tool_use'
134
+ buffer << "[tool: #{b[:name] || b['name']}]\n\n```json\n#{JSON.pretty_generate(input_of(b))}\n```\n\n"
135
+ end
136
+ end
137
+
138
+ def render_jsonl(messages)
139
+ lines = messages.map do |msg|
140
+ role = msg[:role] || msg['role']
141
+ raw = msg[:content] || msg['content']
142
+ content = raw.is_a?(String) ? raw : normalize_content(raw)
143
+ "#{JSON.generate(role: role, content: content, timestamp: Time.now.utc.iso8601)}\n"
144
+ end
145
+ lines.join
146
+ end
147
+
148
+ # Wrap a String content into a single text block so we can render uniformly.
149
+ def normalize_content(content)
150
+ return [{ type: 'text', text: content.to_s }] if content.is_a?(String)
151
+ return [{ type: 'text', text: '' }] if content.nil?
152
+
153
+ content
154
+ end
155
+
156
+ def block_type(block)
157
+ block[:type] || block['type']
158
+ end
159
+
160
+ def text_of(block)
161
+ (block[:text] || block['text']).to_s
162
+ end
163
+
164
+ def input_of(block)
165
+ (block[:input] || block['input']) || {}
166
+ end
167
+
168
+ def tty_yes?(ctx, prompt)
169
+ ctx.renderer.respond_to?(:ask) ? ctx.renderer.ask(prompt, default: false) : true
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -8,37 +8,61 @@ module RubynCode
8
8
  def self.description = 'MCP server status'
9
9
 
10
10
  def execute(_args, ctx)
11
- configs = load_configs(ctx.project_root)
11
+ entries = load_entries(ctx.project_root)
12
12
 
13
- if configs.empty?
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 (#{configs.size}):")
19
+ ctx.renderer.info("MCP servers (#{entries.size}):")
20
20
  puts
21
21
 
22
- configs.each { |cfg| render_server(cfg) }
22
+ entries.each { |entry| render_server(entry) }
23
23
  end
24
24
 
25
25
  private
26
26
 
27
- def load_configs(project_root)
28
- MCP::Config.load(project_root)
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 render_server(cfg)
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} #{cfg[:name]} [#{status}]#{capability_label(counts)}"
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 = 'Resume a session or list recent sessions'
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
- session_id = args.first
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
- if session_id
14
- resume_session(session_id, ctx)
15
- else
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 resume_session(session_id, ctx)
23
- data = ctx.session_persistence.load_session(session_id)
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
- if data
26
- ctx.conversation.replace!(data[:messages])
27
- ctx.renderer.info("Resumed session #{session_id[0..7]}")
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 list_sessions(ctx)
35
- sessions = ctx.session_persistence.list_sessions(
36
- project_path: ctx.project_root,
37
- limit: 10
38
- )
39
-
40
- if sessions.empty?
41
- ctx.renderer.info('No previous sessions.')
42
- else
43
- sessions.each do |s|
44
- puts " #{s[:id][0..7]} | #{s[:title] || 'untitled'} | #{s[:created_at]}"
45
- end
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
@@ -136,7 +136,7 @@ module RubynCode
136
136
  def default_model(provider)
137
137
  return 'gpt-5.4' if provider == 'openai'
138
138
 
139
- 'claude-opus-4-8'
139
+ 'claude-opus-5'
140
140
  end
141
141
 
142
142
  def display_summary
@@ -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
@@ -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
- server_configs = MCP::Config.load(@project_root)
195
- return if server_configs.empty?
195
+ entries = MCP::Discovery.discover(@project_root)
196
+ return if entries.empty?
196
197
 
197
- server_configs.each do |config|
198
- connect_mcp_server(config)
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
- @renderer.info("MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
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[:name]}': #{e.message}"
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-4-8'
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
@@ -47,6 +47,11 @@
47
47
  "chisel_mode": {
48
48
  "type": "string",
49
49
  "enum": ["off", "lite", "full", "ultra"]
50
+ },
51
+ "thinking_budget_tokens": {
52
+ "type": "integer",
53
+ "minimum": 0,
54
+ "maximum": 200000
50
55
  }
51
56
  },
52
57
  "additionalProperties": true
@@ -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-4-6', 'top' => 'claude-opus-4-8' }
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-4-6' if llm_client.respond_to?(:chat)
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-4-6' if llm_client.respond_to?(:chat)
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