console_agent 0.0.1 → 0.1.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.
@@ -0,0 +1,131 @@
1
+ module ConsoleAgent
2
+ class Executor
3
+ CODE_REGEX = /```ruby\s*\n(.*?)```/m
4
+
5
+ attr_reader :binding_context
6
+
7
+ def initialize(binding_context)
8
+ @binding_context = binding_context
9
+ end
10
+
11
+ def extract_code(response)
12
+ match = response.match(CODE_REGEX)
13
+ match ? match[1].strip : ''
14
+ end
15
+
16
+ def display_response(response)
17
+ code = extract_code(response)
18
+ explanation = response.gsub(CODE_REGEX, '').strip
19
+
20
+ $stdout.puts
21
+ $stdout.puts colorize(explanation, :cyan) unless explanation.empty?
22
+
23
+ unless code.empty?
24
+ $stdout.puts
25
+ $stdout.puts colorize("# Generated code:", :yellow)
26
+ $stdout.puts highlight_code(code)
27
+ $stdout.puts
28
+ end
29
+
30
+ code
31
+ end
32
+
33
+ def execute(code)
34
+ return nil if code.nil? || code.strip.empty?
35
+
36
+ result = binding_context.eval(code, "(console_agent)", 1)
37
+ $stdout.puts colorize("=> #{result.inspect}", :green)
38
+ result
39
+ rescue SyntaxError => e
40
+ $stderr.puts colorize("SyntaxError: #{e.message}", :red)
41
+ nil
42
+ rescue => e
43
+ $stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
44
+ e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
45
+ nil
46
+ end
47
+
48
+ def confirm_and_execute(code)
49
+ return nil if code.nil? || code.strip.empty?
50
+
51
+ $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
52
+ answer = $stdin.gets.to_s.strip.downcase
53
+
54
+ case answer
55
+ when 'y', 'yes'
56
+ execute(code)
57
+ when 'e', 'edit'
58
+ edited = open_in_editor(code)
59
+ if edited && edited != code
60
+ $stdout.puts colorize("# Edited code:", :yellow)
61
+ $stdout.puts highlight_code(edited)
62
+ $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
63
+ if $stdin.gets.to_s.strip.downcase == 'y'
64
+ execute(edited)
65
+ else
66
+ $stdout.puts colorize("Cancelled.", :yellow)
67
+ nil
68
+ end
69
+ else
70
+ execute(code)
71
+ end
72
+ else
73
+ $stdout.puts colorize("Cancelled.", :yellow)
74
+ nil
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def open_in_editor(code)
81
+ require 'tempfile'
82
+ editor = ENV['EDITOR'] || 'vi'
83
+ tmpfile = Tempfile.new(['console_agent', '.rb'])
84
+ tmpfile.write(code)
85
+ tmpfile.flush
86
+
87
+ system("#{editor} #{tmpfile.path}")
88
+ File.read(tmpfile.path)
89
+ rescue => e
90
+ $stderr.puts colorize("Editor error: #{e.message}", :red)
91
+ code
92
+ ensure
93
+ tmpfile.close! if tmpfile
94
+ end
95
+
96
+ def highlight_code(code)
97
+ if coderay_available?
98
+ CodeRay.scan(code, :ruby).terminal
99
+ else
100
+ colorize(code, :white)
101
+ end
102
+ end
103
+
104
+ def coderay_available?
105
+ return @coderay_available unless @coderay_available.nil?
106
+ @coderay_available = begin
107
+ require 'coderay'
108
+ true
109
+ rescue LoadError
110
+ false
111
+ end
112
+ end
113
+
114
+ COLORS = {
115
+ red: "\e[31m",
116
+ green: "\e[32m",
117
+ yellow: "\e[33m",
118
+ cyan: "\e[36m",
119
+ white: "\e[37m",
120
+ reset: "\e[0m"
121
+ }.freeze
122
+
123
+ def colorize(text, color)
124
+ if $stdout.respond_to?(:tty?) && $stdout.tty?
125
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
126
+ else
127
+ text
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,112 @@
1
+ module ConsoleAgent
2
+ module Providers
3
+ class Anthropic < Base
4
+ API_URL = 'https://api.anthropic.com'.freeze
5
+
6
+ def chat(messages, system_prompt: nil)
7
+ result = call_api(messages, system_prompt: system_prompt)
8
+ result
9
+ end
10
+
11
+ def chat_with_tools(messages, tools:, system_prompt: nil)
12
+ call_api(messages, system_prompt: system_prompt, tools: tools)
13
+ end
14
+
15
+ def format_assistant_message(result)
16
+ # Rebuild the assistant content blocks from the raw response
17
+ content_blocks = []
18
+ content_blocks << { 'type' => 'text', 'text' => result.text } if result.text && !result.text.empty?
19
+ (result.tool_calls || []).each do |tc|
20
+ content_blocks << {
21
+ 'type' => 'tool_use',
22
+ 'id' => tc[:id],
23
+ 'name' => tc[:name],
24
+ 'input' => tc[:arguments]
25
+ }
26
+ end
27
+ { role: 'assistant', content: content_blocks }
28
+ end
29
+
30
+ def format_tool_result(tool_call_id, result_string)
31
+ {
32
+ role: 'user',
33
+ content: [
34
+ {
35
+ 'type' => 'tool_result',
36
+ 'tool_use_id' => tool_call_id,
37
+ 'content' => result_string.to_s
38
+ }
39
+ ]
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def call_api(messages, system_prompt: nil, tools: nil)
46
+ conn = build_connection(API_URL, {
47
+ 'x-api-key' => config.resolved_api_key,
48
+ 'anthropic-version' => '2023-06-01'
49
+ })
50
+
51
+ body = {
52
+ model: config.resolved_model,
53
+ max_tokens: config.max_tokens,
54
+ temperature: config.temperature,
55
+ messages: format_messages(messages)
56
+ }
57
+ body[:system] = system_prompt if system_prompt
58
+ body[:tools] = tools.to_anthropic_format if tools
59
+
60
+ json_body = JSON.generate(body)
61
+ debug_request("#{API_URL}/v1/messages", body)
62
+ response = conn.post('/v1/messages', json_body)
63
+ debug_response(response.body)
64
+ data = parse_response(response)
65
+ usage = data['usage'] || {}
66
+
67
+ tool_calls = extract_tool_calls(data)
68
+ stop = data['stop_reason'] == 'tool_use' ? :tool_use : :end_turn
69
+
70
+ ChatResult.new(
71
+ text: extract_text(data),
72
+ input_tokens: usage['input_tokens'],
73
+ output_tokens: usage['output_tokens'],
74
+ tool_calls: tool_calls,
75
+ stop_reason: stop
76
+ )
77
+ end
78
+
79
+ def format_messages(messages)
80
+ messages.map do |msg|
81
+ if msg[:content].is_a?(Array)
82
+ { role: msg[:role].to_s, content: msg[:content] }
83
+ else
84
+ { role: msg[:role].to_s, content: msg[:content].to_s }
85
+ end
86
+ end
87
+ end
88
+
89
+ def extract_text(data)
90
+ content = data['content']
91
+ return '' unless content.is_a?(Array)
92
+
93
+ content.select { |c| c['type'] == 'text' }
94
+ .map { |c| c['text'] }
95
+ .join("\n")
96
+ end
97
+
98
+ def extract_tool_calls(data)
99
+ content = data['content']
100
+ return [] unless content.is_a?(Array)
101
+
102
+ content.select { |c| c['type'] == 'tool_use' }.map do |c|
103
+ {
104
+ id: c['id'],
105
+ name: c['name'],
106
+ arguments: c['input'] || {}
107
+ }
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,106 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module ConsoleAgent
5
+ module Providers
6
+ class Base
7
+ attr_reader :config
8
+
9
+ def initialize(config = ConsoleAgent.configuration)
10
+ @config = config
11
+ end
12
+
13
+ def chat(messages, system_prompt: nil)
14
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
15
+ end
16
+
17
+ def chat_with_tools(messages, tools:, system_prompt: nil)
18
+ raise NotImplementedError, "#{self.class}#chat_with_tools must be implemented"
19
+ end
20
+
21
+ def format_assistant_message(_result)
22
+ raise NotImplementedError, "#{self.class}#format_assistant_message must be implemented"
23
+ end
24
+
25
+ def format_tool_result(_tool_call_id, _result_string)
26
+ raise NotImplementedError, "#{self.class}#format_tool_result must be implemented"
27
+ end
28
+
29
+ private
30
+
31
+ def build_connection(url, headers = {})
32
+ Faraday.new(url: url) do |f|
33
+ f.options.timeout = config.timeout
34
+ f.options.open_timeout = config.timeout
35
+ f.headers.update(headers)
36
+ f.headers['Content-Type'] = 'application/json'
37
+ f.adapter Faraday.default_adapter
38
+ end
39
+ end
40
+
41
+ def debug_request(url, body)
42
+ return unless config.debug
43
+
44
+ $stderr.puts "\e[33m--- ConsoleAgent DEBUG: REQUEST ---\e[0m"
45
+ $stderr.puts "\e[33mURL: #{url}\e[0m"
46
+ parsed = body.is_a?(String) ? JSON.parse(body) : body
47
+ $stderr.puts "\e[33m#{JSON.pretty_generate(parsed)}\e[0m"
48
+ $stderr.puts "\e[33m--- END REQUEST ---\e[0m"
49
+ rescue => e
50
+ $stderr.puts "\e[33m[debug] #{body}\e[0m"
51
+ end
52
+
53
+ def debug_response(body)
54
+ return unless config.debug
55
+
56
+ $stderr.puts "\e[36m--- ConsoleAgent DEBUG: RESPONSE ---\e[0m"
57
+ parsed = body.is_a?(String) ? JSON.parse(body) : body
58
+ $stderr.puts "\e[36m#{JSON.pretty_generate(parsed)}\e[0m"
59
+ $stderr.puts "\e[36m--- END RESPONSE ---\e[0m"
60
+ rescue => e
61
+ $stderr.puts "\e[36m[debug] #{body}\e[0m"
62
+ end
63
+
64
+ def parse_response(response)
65
+ unless response.success?
66
+ body = begin
67
+ JSON.parse(response.body)
68
+ rescue
69
+ { 'error' => response.body }
70
+ end
71
+ error_msg = body.dig('error', 'message') || body['error'] || response.body
72
+ raise ProviderError, "API error (#{response.status}): #{error_msg}"
73
+ end
74
+
75
+ JSON.parse(response.body)
76
+ rescue JSON::ParserError => e
77
+ raise ProviderError, "Failed to parse response: #{e.message}"
78
+ end
79
+ end
80
+
81
+ class ProviderError < StandardError; end
82
+
83
+ ChatResult = Struct.new(:text, :input_tokens, :output_tokens, :tool_calls, :stop_reason, keyword_init: true) do
84
+ def total_tokens
85
+ (input_tokens || 0) + (output_tokens || 0)
86
+ end
87
+
88
+ def tool_use?
89
+ stop_reason == :tool_use && tool_calls && !tool_calls.empty?
90
+ end
91
+ end
92
+
93
+ def self.build(config = ConsoleAgent.configuration)
94
+ case config.provider
95
+ when :anthropic
96
+ require 'console_agent/providers/anthropic'
97
+ Anthropic.new(config)
98
+ when :openai
99
+ require 'console_agent/providers/openai'
100
+ OpenAI.new(config)
101
+ else
102
+ raise ConfigurationError, "Unknown provider: #{config.provider}"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -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