console_agent 0.0.1 → 0.2.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/LICENSE +21 -0
- data/README.md +335 -0
- data/lib/console_agent/configuration.rb +61 -0
- data/lib/console_agent/console_methods.rb +131 -0
- data/lib/console_agent/context_builder.rb +229 -0
- data/lib/console_agent/executor.rb +193 -0
- data/lib/console_agent/providers/anthropic.rb +112 -0
- data/lib/console_agent/providers/base.rb +106 -0
- data/lib/console_agent/providers/openai.rb +114 -0
- data/lib/console_agent/railtie.rb +30 -0
- data/lib/console_agent/repl.rb +342 -0
- data/lib/console_agent/storage/base.rb +27 -0
- data/lib/console_agent/storage/file_storage.rb +63 -0
- data/lib/console_agent/tools/code_tools.rb +114 -0
- data/lib/console_agent/tools/memory_tools.rb +136 -0
- data/lib/console_agent/tools/model_tools.rb +95 -0
- data/lib/console_agent/tools/registry.rb +253 -0
- data/lib/console_agent/tools/schema_tools.rb +60 -0
- data/lib/console_agent/version.rb +3 -0
- data/lib/console_agent.rb +76 -1
- data/lib/generators/console_agent/install_generator.rb +26 -0
- data/lib/generators/console_agent/templates/initializer.rb +33 -0
- metadata +95 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
module Providers
|
|
3
|
+
class OpenAI < Base
|
|
4
|
+
API_URL = 'https://api.openai.com'.freeze
|
|
5
|
+
|
|
6
|
+
def chat(messages, system_prompt: nil)
|
|
7
|
+
call_api(messages, system_prompt: system_prompt)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def chat_with_tools(messages, tools:, system_prompt: nil)
|
|
11
|
+
call_api(messages, system_prompt: system_prompt, tools: tools)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format_assistant_message(result)
|
|
15
|
+
msg = { role: 'assistant' }
|
|
16
|
+
msg[:content] = result.text if result.text && !result.text.empty?
|
|
17
|
+
if result.tool_calls && !result.tool_calls.empty?
|
|
18
|
+
msg[:tool_calls] = result.tool_calls.map do |tc|
|
|
19
|
+
{
|
|
20
|
+
'id' => tc[:id],
|
|
21
|
+
'type' => 'function',
|
|
22
|
+
'function' => {
|
|
23
|
+
'name' => tc[:name],
|
|
24
|
+
'arguments' => JSON.generate(tc[:arguments] || {})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
msg
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def format_tool_result(tool_call_id, result_string)
|
|
33
|
+
{
|
|
34
|
+
role: 'tool',
|
|
35
|
+
tool_call_id: tool_call_id,
|
|
36
|
+
content: result_string.to_s
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def call_api(messages, system_prompt: nil, tools: nil)
|
|
43
|
+
conn = build_connection(API_URL, {
|
|
44
|
+
'Authorization' => "Bearer #{config.resolved_api_key}"
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
formatted = []
|
|
48
|
+
formatted << { role: 'system', content: system_prompt } if system_prompt
|
|
49
|
+
formatted.concat(format_messages(messages))
|
|
50
|
+
|
|
51
|
+
body = {
|
|
52
|
+
model: config.resolved_model,
|
|
53
|
+
max_tokens: config.max_tokens,
|
|
54
|
+
temperature: config.temperature,
|
|
55
|
+
messages: formatted
|
|
56
|
+
}
|
|
57
|
+
body[:tools] = tools.to_openai_format if tools
|
|
58
|
+
|
|
59
|
+
json_body = JSON.generate(body)
|
|
60
|
+
debug_request("#{API_URL}/v1/chat/completions", body)
|
|
61
|
+
response = conn.post('/v1/chat/completions', json_body)
|
|
62
|
+
debug_response(response.body)
|
|
63
|
+
data = parse_response(response)
|
|
64
|
+
usage = data['usage'] || {}
|
|
65
|
+
|
|
66
|
+
choice = (data['choices'] || []).first || {}
|
|
67
|
+
message = choice['message'] || {}
|
|
68
|
+
finish_reason = choice['finish_reason']
|
|
69
|
+
|
|
70
|
+
tool_calls = extract_tool_calls(message)
|
|
71
|
+
stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
|
|
72
|
+
|
|
73
|
+
ChatResult.new(
|
|
74
|
+
text: message['content'] || '',
|
|
75
|
+
input_tokens: usage['prompt_tokens'],
|
|
76
|
+
output_tokens: usage['completion_tokens'],
|
|
77
|
+
tool_calls: tool_calls,
|
|
78
|
+
stop_reason: stop
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_messages(messages)
|
|
83
|
+
messages.map do |msg|
|
|
84
|
+
base = { role: msg[:role].to_s }
|
|
85
|
+
if msg[:content]
|
|
86
|
+
base[:content] = msg[:content].is_a?(Array) ? JSON.generate(msg[:content]) : msg[:content].to_s
|
|
87
|
+
end
|
|
88
|
+
base[:tool_calls] = msg[:tool_calls] if msg[:tool_calls]
|
|
89
|
+
base[:tool_call_id] = msg[:tool_call_id] if msg[:tool_call_id]
|
|
90
|
+
base
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_tool_calls(message)
|
|
95
|
+
calls = message['tool_calls']
|
|
96
|
+
return [] unless calls.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
calls.map do |tc|
|
|
99
|
+
func = tc['function'] || {}
|
|
100
|
+
args = begin
|
|
101
|
+
JSON.parse(func['arguments'] || '{}')
|
|
102
|
+
rescue
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
{
|
|
106
|
+
id: tc['id'],
|
|
107
|
+
name: func['name'],
|
|
108
|
+
arguments: args
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require 'console_agent'
|
|
2
|
+
|
|
3
|
+
module ConsoleAgent
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
console do
|
|
6
|
+
require 'console_agent/console_methods'
|
|
7
|
+
|
|
8
|
+
# Inject into IRB if available
|
|
9
|
+
if defined?(IRB::ExtendCommandBundle)
|
|
10
|
+
IRB::ExtendCommandBundle.include(ConsoleAgent::ConsoleMethods)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Extend TOPLEVEL_BINDING's receiver as well
|
|
14
|
+
TOPLEVEL_BINDING.eval('self').extend(ConsoleAgent::ConsoleMethods)
|
|
15
|
+
|
|
16
|
+
# Welcome message
|
|
17
|
+
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
18
|
+
$stdout.puts "\e[36m[ConsoleAgent] AI assistant loaded. Try: ai \"show me all tables\"\e[0m"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Pre-build context in background
|
|
22
|
+
Thread.new do
|
|
23
|
+
require 'console_agent/context_builder'
|
|
24
|
+
ConsoleAgent::ContextBuilder.new.build
|
|
25
|
+
rescue => e
|
|
26
|
+
ConsoleAgent.logger.debug("ConsoleAgent: background context build failed: #{e.message}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
require 'readline'
|
|
2
|
+
|
|
3
|
+
module ConsoleAgent
|
|
4
|
+
class Repl
|
|
5
|
+
def initialize(binding_context)
|
|
6
|
+
@binding_context = binding_context
|
|
7
|
+
@executor = Executor.new(binding_context)
|
|
8
|
+
@provider = nil
|
|
9
|
+
@context_builder = nil
|
|
10
|
+
@context = nil
|
|
11
|
+
@history = []
|
|
12
|
+
@total_input_tokens = 0
|
|
13
|
+
@total_output_tokens = 0
|
|
14
|
+
@input_history = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def one_shot(query)
|
|
18
|
+
result = send_query(query)
|
|
19
|
+
track_usage(result)
|
|
20
|
+
code = @executor.display_response(result.text)
|
|
21
|
+
display_usage(result)
|
|
22
|
+
return nil if code.nil? || code.strip.empty?
|
|
23
|
+
|
|
24
|
+
if ConsoleAgent.configuration.auto_execute
|
|
25
|
+
@executor.execute(code)
|
|
26
|
+
else
|
|
27
|
+
@executor.confirm_and_execute(code)
|
|
28
|
+
end
|
|
29
|
+
rescue Providers::ProviderError => e
|
|
30
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
|
|
31
|
+
nil
|
|
32
|
+
rescue => e
|
|
33
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def explain(query)
|
|
38
|
+
result = send_query(query)
|
|
39
|
+
track_usage(result)
|
|
40
|
+
@executor.display_response(result.text)
|
|
41
|
+
display_usage(result)
|
|
42
|
+
nil
|
|
43
|
+
rescue Providers::ProviderError => e
|
|
44
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
|
|
45
|
+
nil
|
|
46
|
+
rescue => e
|
|
47
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def interactive
|
|
52
|
+
$stdout.puts "\e[36mConsoleAgent interactive mode. Type 'exit' or 'quit' to leave.\e[0m"
|
|
53
|
+
@history = []
|
|
54
|
+
@total_input_tokens = 0
|
|
55
|
+
@total_output_tokens = 0
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
input = Readline.readline("\e[33mai> \e[0m", false)
|
|
59
|
+
break if input.nil? # Ctrl-D
|
|
60
|
+
|
|
61
|
+
input = input.strip
|
|
62
|
+
break if input.downcase == 'exit' || input.downcase == 'quit'
|
|
63
|
+
next if input.empty?
|
|
64
|
+
|
|
65
|
+
# Add to Readline history (avoid consecutive duplicates)
|
|
66
|
+
Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
|
|
67
|
+
|
|
68
|
+
@history << { role: :user, content: input }
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
result = send_query(input, conversation: @history)
|
|
72
|
+
rescue Interrupt
|
|
73
|
+
$stdout.puts "\n\e[33m Aborted.\e[0m"
|
|
74
|
+
@history.pop # Remove the user message that never got a response
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
track_usage(result)
|
|
79
|
+
code = @executor.display_response(result.text)
|
|
80
|
+
display_usage(result, show_session: true)
|
|
81
|
+
|
|
82
|
+
@history << { role: :assistant, content: result.text }
|
|
83
|
+
|
|
84
|
+
if code && !code.strip.empty?
|
|
85
|
+
if ConsoleAgent.configuration.auto_execute
|
|
86
|
+
exec_result = @executor.execute(code)
|
|
87
|
+
else
|
|
88
|
+
exec_result = @executor.confirm_and_execute(code)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if @executor.last_cancelled?
|
|
92
|
+
@history << { role: :user, content: "User declined to execute the code." }
|
|
93
|
+
else
|
|
94
|
+
output_parts = []
|
|
95
|
+
|
|
96
|
+
# Capture printed output (puts, print, etc.)
|
|
97
|
+
if @executor.last_output && !@executor.last_output.strip.empty?
|
|
98
|
+
output_parts << "Output:\n#{@executor.last_output.strip}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Capture return value
|
|
102
|
+
if exec_result
|
|
103
|
+
output_parts << "Return value: #{exec_result.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unless output_parts.empty?
|
|
107
|
+
result_str = output_parts.join("\n\n")
|
|
108
|
+
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
109
|
+
@history << { role: :user, content: "Code was executed. #{result_str}" }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
display_session_summary
|
|
116
|
+
$stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
|
|
117
|
+
rescue Interrupt
|
|
118
|
+
# Ctrl-C during Readline input — exit cleanly
|
|
119
|
+
$stdout.puts
|
|
120
|
+
display_session_summary
|
|
121
|
+
$stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
|
|
122
|
+
rescue => e
|
|
123
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def provider
|
|
129
|
+
@provider ||= Providers.build
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def context_builder
|
|
133
|
+
@context_builder ||= ContextBuilder.new
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def context
|
|
137
|
+
@context ||= context_builder.build
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def send_query(query, conversation: nil)
|
|
141
|
+
ConsoleAgent.configuration.validate!
|
|
142
|
+
|
|
143
|
+
messages = if conversation
|
|
144
|
+
conversation.map { |m| { role: m[:role], content: m[:content] } }
|
|
145
|
+
else
|
|
146
|
+
[{ role: :user, content: query }]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if ConsoleAgent.configuration.context_mode == :smart
|
|
150
|
+
send_query_with_tools(messages)
|
|
151
|
+
else
|
|
152
|
+
provider.chat(messages, system_prompt: context)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def send_query_with_tools(messages)
|
|
157
|
+
require 'console_agent/tools/registry'
|
|
158
|
+
tools = Tools::Registry.new
|
|
159
|
+
max_rounds = ConsoleAgent.configuration.max_tool_rounds
|
|
160
|
+
total_input = 0
|
|
161
|
+
total_output = 0
|
|
162
|
+
result = nil
|
|
163
|
+
|
|
164
|
+
exhausted = false
|
|
165
|
+
|
|
166
|
+
max_rounds.times do |round|
|
|
167
|
+
if round == 0
|
|
168
|
+
$stdout.puts "\e[2m Thinking...\e[0m"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
result = provider.chat_with_tools(messages, tools: tools, system_prompt: context)
|
|
172
|
+
total_input += result.input_tokens || 0
|
|
173
|
+
total_output += result.output_tokens || 0
|
|
174
|
+
|
|
175
|
+
break unless result.tool_use?
|
|
176
|
+
|
|
177
|
+
# Show what the LLM is thinking (if it returned text alongside tool calls)
|
|
178
|
+
if result.text && !result.text.strip.empty?
|
|
179
|
+
$stdout.puts "\e[2m #{result.text.strip}\e[0m"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Add assistant message with tool calls to conversation
|
|
183
|
+
messages << provider.format_assistant_message(result)
|
|
184
|
+
|
|
185
|
+
# Execute each tool and show progress
|
|
186
|
+
result.tool_calls.each do |tc|
|
|
187
|
+
# ask_user handles its own display (prompt + input)
|
|
188
|
+
if tc[:name] == 'ask_user'
|
|
189
|
+
tool_result = tools.execute(tc[:name], tc[:arguments])
|
|
190
|
+
else
|
|
191
|
+
args_display = format_tool_args(tc[:name], tc[:arguments])
|
|
192
|
+
$stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
|
|
193
|
+
|
|
194
|
+
tool_result = tools.execute(tc[:name], tc[:arguments])
|
|
195
|
+
|
|
196
|
+
preview = compact_tool_result(tc[:name], tool_result)
|
|
197
|
+
cached_tag = tools.last_cached? ? " (cached)" : ""
|
|
198
|
+
$stdout.puts "\e[2m #{preview}#{cached_tag}\e[0m"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if ConsoleAgent.configuration.debug
|
|
202
|
+
$stderr.puts "\e[35m[debug tool result] #{tool_result}\e[0m"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
messages << provider.format_tool_result(tc[:id], tool_result)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
exhausted = true if round == max_rounds - 1
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# If we hit the tool round limit, force a final response without tools
|
|
212
|
+
if exhausted
|
|
213
|
+
$stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: ConsoleAgent.configure { |c| c.max_tool_rounds = 200 }\e[0m"
|
|
214
|
+
messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
|
|
215
|
+
result = provider.chat(messages, system_prompt: context)
|
|
216
|
+
total_input += result.input_tokens || 0
|
|
217
|
+
total_output += result.output_tokens || 0
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
Providers::ChatResult.new(
|
|
221
|
+
text: result ? result.text : '',
|
|
222
|
+
input_tokens: total_input,
|
|
223
|
+
output_tokens: total_output,
|
|
224
|
+
stop_reason: result ? result.stop_reason : :end_turn
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def format_tool_args(name, args)
|
|
229
|
+
return '' if args.nil? || args.empty?
|
|
230
|
+
|
|
231
|
+
case name
|
|
232
|
+
when 'describe_table'
|
|
233
|
+
"(\"#{args['table_name']}\")"
|
|
234
|
+
when 'describe_model'
|
|
235
|
+
"(\"#{args['model_name']}\")"
|
|
236
|
+
when 'read_file'
|
|
237
|
+
"(\"#{args['path']}\")"
|
|
238
|
+
when 'search_code'
|
|
239
|
+
dir = args['directory'] ? ", dir: \"#{args['directory']}\"" : ''
|
|
240
|
+
"(\"#{args['query']}\"#{dir})"
|
|
241
|
+
when 'list_files'
|
|
242
|
+
args['directory'] ? "(\"#{args['directory']}\")" : ''
|
|
243
|
+
when 'save_memory'
|
|
244
|
+
"(\"#{args['name']}\")"
|
|
245
|
+
when 'delete_memory'
|
|
246
|
+
"(\"#{args['name']}\")"
|
|
247
|
+
when 'recall_memories'
|
|
248
|
+
args['query'] ? "(\"#{args['query']}\")" : ''
|
|
249
|
+
else
|
|
250
|
+
''
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def compact_tool_result(name, result)
|
|
255
|
+
return '(empty)' if result.nil? || result.strip.empty?
|
|
256
|
+
|
|
257
|
+
case name
|
|
258
|
+
when 'list_tables'
|
|
259
|
+
tables = result.split(', ')
|
|
260
|
+
if tables.length > 8
|
|
261
|
+
"#{tables.length} tables: #{tables.first(8).join(', ')}..."
|
|
262
|
+
else
|
|
263
|
+
"#{tables.length} tables: #{result}"
|
|
264
|
+
end
|
|
265
|
+
when 'list_models'
|
|
266
|
+
lines = result.split("\n")
|
|
267
|
+
if lines.length > 6
|
|
268
|
+
"#{lines.length} models: #{lines.first(6).map { |l| l.split(' ').first }.join(', ')}..."
|
|
269
|
+
else
|
|
270
|
+
"#{lines.length} models"
|
|
271
|
+
end
|
|
272
|
+
when 'describe_table'
|
|
273
|
+
col_count = result.scan(/^\s{2}\S/).length
|
|
274
|
+
"#{col_count} columns"
|
|
275
|
+
when 'describe_model'
|
|
276
|
+
parts = []
|
|
277
|
+
assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
|
|
278
|
+
val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
|
|
279
|
+
parts << "#{assoc_count} associations" if assoc_count > 0
|
|
280
|
+
parts << "#{val_count} validations" if val_count > 0
|
|
281
|
+
parts.empty? ? truncate(result, 80) : parts.join(', ')
|
|
282
|
+
when 'list_files'
|
|
283
|
+
lines = result.split("\n")
|
|
284
|
+
"#{lines.length} files"
|
|
285
|
+
when 'read_file'
|
|
286
|
+
lines = result.split("\n")
|
|
287
|
+
"#{lines.length} lines"
|
|
288
|
+
when 'search_code'
|
|
289
|
+
if result.start_with?('Found')
|
|
290
|
+
result.split("\n").first
|
|
291
|
+
elsif result.start_with?('No matches')
|
|
292
|
+
result
|
|
293
|
+
else
|
|
294
|
+
truncate(result, 80)
|
|
295
|
+
end
|
|
296
|
+
when 'save_memory'
|
|
297
|
+
(result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
|
|
298
|
+
when 'delete_memory'
|
|
299
|
+
result.start_with?('Memory deleted') ? result : truncate(result, 80)
|
|
300
|
+
when 'recall_memories'
|
|
301
|
+
chunks = result.split("\n\n")
|
|
302
|
+
chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
|
|
303
|
+
else
|
|
304
|
+
truncate(result, 80)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def truncate(str, max)
|
|
309
|
+
str.length > max ? str[0..max] + '...' : str
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def track_usage(result)
|
|
313
|
+
@total_input_tokens += result.input_tokens || 0
|
|
314
|
+
@total_output_tokens += result.output_tokens || 0
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def display_usage(result, show_session: false)
|
|
318
|
+
input = result.input_tokens
|
|
319
|
+
output = result.output_tokens
|
|
320
|
+
return unless input || output
|
|
321
|
+
|
|
322
|
+
parts = []
|
|
323
|
+
parts << "in: #{input}" if input
|
|
324
|
+
parts << "out: #{output}" if output
|
|
325
|
+
parts << "total: #{result.total_tokens}"
|
|
326
|
+
|
|
327
|
+
line = "\e[2m[tokens #{parts.join(' | ')}]\e[0m"
|
|
328
|
+
|
|
329
|
+
if show_session && (@total_input_tokens + @total_output_tokens) > result.total_tokens
|
|
330
|
+
line += "\e[2m [session: in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
$stdout.puts line
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def display_session_summary
|
|
337
|
+
return if @total_input_tokens == 0 && @total_output_tokens == 0
|
|
338
|
+
|
|
339
|
+
$stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
module Storage
|
|
3
|
+
class StorageError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class Base
|
|
6
|
+
def read(key)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def write(key, content)
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list(pattern)
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exists?(key)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delete(key)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'console_agent/storage/base'
|
|
3
|
+
|
|
4
|
+
module ConsoleAgent
|
|
5
|
+
module Storage
|
|
6
|
+
class FileStorage < Base
|
|
7
|
+
attr_reader :root_path
|
|
8
|
+
|
|
9
|
+
def initialize(root_path = nil)
|
|
10
|
+
@root_path = root_path || default_root
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(key)
|
|
14
|
+
path = full_path(key)
|
|
15
|
+
return nil unless File.exist?(path)
|
|
16
|
+
File.read(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(key, content)
|
|
20
|
+
path = full_path(key)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
22
|
+
File.write(path, content)
|
|
23
|
+
true
|
|
24
|
+
rescue Errno::EACCES, Errno::EROFS, IOError => e
|
|
25
|
+
raise StorageError, "Cannot write #{key}: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list(pattern)
|
|
29
|
+
Dir.glob(File.join(@root_path, pattern)).sort.map do |path|
|
|
30
|
+
path.sub("#{@root_path}/", '')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exists?(key)
|
|
35
|
+
File.exist?(full_path(key))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete(key)
|
|
39
|
+
path = full_path(key)
|
|
40
|
+
return false unless File.exist?(path)
|
|
41
|
+
File.delete(path)
|
|
42
|
+
true
|
|
43
|
+
rescue Errno::EACCES, Errno::EROFS, IOError => e
|
|
44
|
+
raise StorageError, "Cannot delete #{key}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def full_path(key)
|
|
50
|
+
sanitized = key.gsub('..', '').gsub(%r{\A/+}, '')
|
|
51
|
+
File.join(@root_path, sanitized)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_root
|
|
55
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
56
|
+
File.join(Rails.root.to_s, '.console_agent')
|
|
57
|
+
else
|
|
58
|
+
File.join(Dir.pwd, '.console_agent')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
module Tools
|
|
3
|
+
class CodeTools
|
|
4
|
+
MAX_FILE_LINES = 200
|
|
5
|
+
MAX_LIST_ENTRIES = 100
|
|
6
|
+
MAX_SEARCH_RESULTS = 50
|
|
7
|
+
|
|
8
|
+
def list_files(directory = nil)
|
|
9
|
+
directory = sanitize_directory(directory || 'app')
|
|
10
|
+
root = rails_root
|
|
11
|
+
return "Rails.root is not available." unless root
|
|
12
|
+
|
|
13
|
+
full_path = File.join(root, directory)
|
|
14
|
+
return "Directory '#{directory}' not found." unless File.directory?(full_path)
|
|
15
|
+
|
|
16
|
+
files = Dir.glob(File.join(full_path, '**', '*.rb')).sort
|
|
17
|
+
files = files.map { |f| f.sub("#{root}/", '') }
|
|
18
|
+
|
|
19
|
+
if files.length > MAX_LIST_ENTRIES
|
|
20
|
+
truncated = files.first(MAX_LIST_ENTRIES)
|
|
21
|
+
truncated.join("\n") + "\n... and #{files.length - MAX_LIST_ENTRIES} more files"
|
|
22
|
+
elsif files.empty?
|
|
23
|
+
"No Ruby files found in '#{directory}'."
|
|
24
|
+
else
|
|
25
|
+
files.join("\n")
|
|
26
|
+
end
|
|
27
|
+
rescue => e
|
|
28
|
+
"Error listing files: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read_file(path)
|
|
32
|
+
return "Error: path is required." if path.nil? || path.strip.empty?
|
|
33
|
+
|
|
34
|
+
root = rails_root
|
|
35
|
+
return "Rails.root is not available." unless root
|
|
36
|
+
|
|
37
|
+
path = sanitize_path(path)
|
|
38
|
+
full_path = File.expand_path(File.join(root, path))
|
|
39
|
+
|
|
40
|
+
# Security: ensure resolved path is under Rails.root
|
|
41
|
+
unless full_path.start_with?(File.expand_path(root))
|
|
42
|
+
return "Error: path must be within the Rails application."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return "File '#{path}' not found." unless File.exist?(full_path)
|
|
46
|
+
return "Error: '#{path}' is a directory, not a file." if File.directory?(full_path)
|
|
47
|
+
|
|
48
|
+
lines = File.readlines(full_path)
|
|
49
|
+
if lines.length > MAX_FILE_LINES
|
|
50
|
+
numbered = lines.first(MAX_FILE_LINES).each_with_index.map { |line, i| "#{i + 1}: #{line}" }
|
|
51
|
+
numbered.join + "\n... truncated (#{lines.length} total lines, showing first #{MAX_FILE_LINES})"
|
|
52
|
+
else
|
|
53
|
+
lines.each_with_index.map { |line, i| "#{i + 1}: #{line}" }.join
|
|
54
|
+
end
|
|
55
|
+
rescue => e
|
|
56
|
+
"Error reading file '#{path}': #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def search_code(query, directory = nil)
|
|
60
|
+
return "Error: query is required." if query.nil? || query.strip.empty?
|
|
61
|
+
|
|
62
|
+
directory = sanitize_directory(directory || 'app')
|
|
63
|
+
root = rails_root
|
|
64
|
+
return "Rails.root is not available." unless root
|
|
65
|
+
|
|
66
|
+
full_path = File.join(root, directory)
|
|
67
|
+
return "Directory '#{directory}' not found." unless File.directory?(full_path)
|
|
68
|
+
|
|
69
|
+
results = []
|
|
70
|
+
Dir.glob(File.join(full_path, '**', '*.rb')).sort.each do |file|
|
|
71
|
+
break if results.length >= MAX_SEARCH_RESULTS
|
|
72
|
+
|
|
73
|
+
relative = file.sub("#{root}/", '')
|
|
74
|
+
File.readlines(file).each_with_index do |line, idx|
|
|
75
|
+
if line.include?(query)
|
|
76
|
+
results << "#{relative}:#{idx + 1}: #{line.strip}"
|
|
77
|
+
break if results.length >= MAX_SEARCH_RESULTS
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
rescue => e
|
|
81
|
+
# skip unreadable files
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if results.empty?
|
|
85
|
+
"No matches found for '#{query}' in #{directory}/."
|
|
86
|
+
else
|
|
87
|
+
header = "Found #{results.length} match#{'es' if results.length != 1}:\n"
|
|
88
|
+
header + results.join("\n")
|
|
89
|
+
end
|
|
90
|
+
rescue => e
|
|
91
|
+
"Error searching: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def rails_root
|
|
97
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
98
|
+
Rails.root.to_s
|
|
99
|
+
else
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def sanitize_path(path)
|
|
105
|
+
# Remove leading slashes and ../ sequences
|
|
106
|
+
path.strip.gsub(/\A\/+/, '').gsub(/\.\.\//, '').gsub(/\.\.\\/, '')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sanitize_directory(dir)
|
|
110
|
+
sanitize_path(dir || 'app')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|