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.
- checksums.yaml +4 -4
- data/README.md +45 -17
- 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 +3 -3
- 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 +6 -1
- data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
- 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 +14 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +11 -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
|
@@ -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
|
-
|
|
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
|
|