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.
@@ -0,0 +1,229 @@
1
+ module ConsoleAgent
2
+ class ContextBuilder
3
+ def initialize(config = ConsoleAgent.configuration)
4
+ @config = config
5
+ end
6
+
7
+ def build
8
+ case @config.context_mode
9
+ when :smart
10
+ build_smart
11
+ else
12
+ build_full
13
+ end
14
+ rescue => e
15
+ ConsoleAgent.logger.warn("ConsoleAgent: context build error: #{e.message}")
16
+ system_instructions + "\n\n" + environment_context
17
+ end
18
+
19
+ def build_full
20
+ parts = []
21
+ parts << system_instructions
22
+ parts << environment_context
23
+ parts << schema_context
24
+ parts << models_context
25
+ parts << routes_context
26
+ parts.compact.join("\n\n")
27
+ end
28
+
29
+ def build_smart
30
+ parts = []
31
+ parts << smart_system_instructions
32
+ parts << environment_context
33
+ parts << memory_context
34
+ parts.compact.join("\n\n")
35
+ end
36
+
37
+ private
38
+
39
+ def smart_system_instructions
40
+ <<~PROMPT.strip
41
+ You are a Ruby on Rails console assistant. The user is in a `rails console` session.
42
+ You help them query data, debug issues, and understand their application.
43
+
44
+ You have tools available to introspect the app's database schema, models, and source code.
45
+ Use them as needed to write accurate queries. For example, call list_tables to see what
46
+ tables exist, then describe_table to get column details for the ones you need.
47
+
48
+ You also have an ask_user tool to ask the console user clarifying questions. Use it when
49
+ you need specific information to write accurate code — such as which user they are, which
50
+ record to target, or what value to use.
51
+
52
+ You have memory tools to persist what you learn across sessions:
53
+ - save_memory: persist facts or procedures you learn about this codebase.
54
+ If a memory with the same name already exists, it will be updated in place.
55
+ - delete_memory: remove a memory by name
56
+ - recall_memories: search your saved memories for details
57
+
58
+ IMPORTANT: Check the Memories section below BEFORE answering. If a memory is relevant,
59
+ use recall_memories to get full details and apply that knowledge to your answer.
60
+ When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
61
+ When you discover important patterns about this app, save them as memories.
62
+
63
+ RULES:
64
+ - Give ONE concise answer. Do not offer multiple alternatives or variations.
65
+ - Respond with a single ```ruby code block that directly answers the question.
66
+ - Include a brief one-line explanation before the code block.
67
+ - Use the app's actual model names, associations, and schema.
68
+ - Prefer ActiveRecord query interface over raw SQL.
69
+ - For destructive operations, add a comment warning.
70
+ - NEVER use placeholder values like YOUR_USER_ID or YOUR_EMAIL in code. If you need
71
+ a specific value from the user, call the ask_user tool to get it first.
72
+ - Keep code concise and idiomatic.
73
+ - Use tools to look up schema/model details rather than guessing column names.
74
+ PROMPT
75
+ end
76
+
77
+ def system_instructions
78
+ <<~PROMPT.strip
79
+ You are a Ruby on Rails console assistant. The user is in a `rails console` session.
80
+ You help them query data, debug issues, and understand their application.
81
+
82
+ RULES:
83
+ - Give ONE concise answer. Do not offer multiple alternatives or variations.
84
+ - Respond with a single ```ruby code block that directly answers the question.
85
+ - Include a brief one-line explanation before the code block.
86
+ - Use the app's actual model names, associations, and schema.
87
+ - Prefer ActiveRecord query interface over raw SQL.
88
+ - For destructive operations, add a comment warning.
89
+ - If the request is ambiguous, ask a clarifying question instead of guessing.
90
+ - Keep code concise and idiomatic.
91
+ PROMPT
92
+ end
93
+
94
+ def environment_context
95
+ lines = ["## Environment"]
96
+ lines << "- Ruby #{RUBY_VERSION}"
97
+ lines << "- Rails #{Rails.version}" if defined?(Rails) && Rails.respond_to?(:version)
98
+
99
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
100
+ adapter = ActiveRecord::Base.connection.adapter_name rescue 'unknown'
101
+ lines << "- Database adapter: #{adapter}"
102
+ end
103
+
104
+ if defined?(Bundler)
105
+ key_gems = %w[devise cancancan pundit sidekiq delayed_job resque
106
+ paperclip carrierwave activestorage shrine
107
+ pg mysql2 sqlite3 mongoid]
108
+ loaded = key_gems.select { |g| Gem.loaded_specs.key?(g) }
109
+ lines << "- Key gems: #{loaded.join(', ')}" unless loaded.empty?
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ def schema_context
116
+ return nil unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
117
+
118
+ conn = ActiveRecord::Base.connection
119
+ tables = conn.tables.sort
120
+ return nil if tables.empty?
121
+
122
+ lines = ["## Database Schema"]
123
+ tables.each do |table|
124
+ next if table == 'schema_migrations' || table == 'ar_internal_metadata'
125
+
126
+ cols = conn.columns(table).map { |c| "#{c.name}:#{c.type}" }
127
+ lines << "- #{table} (#{cols.join(', ')})"
128
+ end
129
+ lines.join("\n")
130
+ rescue => e
131
+ ConsoleAgent.logger.debug("ConsoleAgent: schema introspection failed: #{e.message}")
132
+ nil
133
+ end
134
+
135
+ def models_context
136
+ return nil unless defined?(ActiveRecord::Base)
137
+
138
+ eager_load_app!
139
+ base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
140
+ models = ObjectSpace.each_object(Class).select { |c|
141
+ c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
142
+ }.sort_by(&:name)
143
+
144
+ return nil if models.empty?
145
+
146
+ lines = ["## Models"]
147
+ models.each do |model|
148
+ parts = [model.name]
149
+
150
+ assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
151
+ parts << " associations: #{assocs.join(', ')}" unless assocs.empty?
152
+
153
+ scopes = model.methods.grep(/^_scope_/).map { |m| m.to_s.sub('_scope_', '') } rescue []
154
+ if scopes.empty?
155
+ # Alternative: check singleton methods that return ActiveRecord::Relation
156
+ scope_names = (model.singleton_methods - base_class.singleton_methods).select { |m|
157
+ m != :all && !m.to_s.start_with?('_') && !m.to_s.start_with?('find')
158
+ }.first(10)
159
+ # We won't list these to avoid noise — scopes are hard to detect reliably
160
+ end
161
+
162
+ validations = model.validators.map { |v|
163
+ attrs = v.attributes.join(', ')
164
+ "#{v.class.name.demodulize.underscore.sub('_validator', '')} on #{attrs}"
165
+ }.uniq.first(5) rescue []
166
+ parts << " validations: #{validations.join('; ')}" unless validations.empty?
167
+
168
+ lines << "- #{parts.join("\n")}"
169
+ end
170
+ lines.join("\n")
171
+ rescue => e
172
+ ConsoleAgent.logger.debug("ConsoleAgent: model introspection failed: #{e.message}")
173
+ nil
174
+ end
175
+
176
+ def routes_context
177
+ return nil unless defined?(Rails) && Rails.respond_to?(:application)
178
+
179
+ routes = Rails.application.routes.routes
180
+ return nil if routes.empty?
181
+
182
+ lines = ["## Routes (summary)"]
183
+ count = 0
184
+ routes.each do |route|
185
+ next if route.internal?
186
+ path = route.path.spec.to_s.sub('(.:format)', '')
187
+ verb = route.verb.to_s
188
+ next if verb.empty?
189
+ action = route.defaults[:controller].to_s + '#' + route.defaults[:action].to_s
190
+ lines << "- #{verb} #{path} -> #{action}"
191
+ count += 1
192
+ break if count >= 50
193
+ end
194
+ lines << "- ... (#{routes.size - count} more routes)" if routes.size > count
195
+
196
+ lines.join("\n")
197
+ rescue => e
198
+ ConsoleAgent.logger.debug("ConsoleAgent: route introspection failed: #{e.message}")
199
+ nil
200
+ end
201
+
202
+ def memory_context
203
+ return nil unless @config.memories_enabled
204
+
205
+ require 'console_agent/tools/memory_tools'
206
+ summaries = Tools::MemoryTools.new.memory_summaries
207
+ return nil if summaries.nil? || summaries.empty?
208
+
209
+ lines = ["## Memories"]
210
+ lines.concat(summaries)
211
+ lines << ""
212
+ lines << "Call recall_memories to get details before answering. Do NOT guess from the name alone."
213
+ lines.join("\n")
214
+ rescue => e
215
+ ConsoleAgent.logger.debug("ConsoleAgent: memory context failed: #{e.message}")
216
+ nil
217
+ end
218
+
219
+ def eager_load_app!
220
+ return unless defined?(Rails) && Rails.respond_to?(:application)
221
+
222
+ if Rails.application.respond_to?(:eager_load!)
223
+ Rails.application.eager_load!
224
+ end
225
+ rescue => e
226
+ ConsoleAgent.logger.debug("ConsoleAgent: eager_load failed: #{e.message}")
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,193 @@
1
+ require 'stringio'
2
+
3
+ module ConsoleAgent
4
+ # Writes to two IO streams simultaneously
5
+ class TeeIO
6
+ def initialize(primary, secondary)
7
+ @primary = primary
8
+ @secondary = secondary
9
+ end
10
+
11
+ def write(str)
12
+ @primary.write(str)
13
+ @secondary.write(str)
14
+ end
15
+
16
+ def puts(*args)
17
+ @primary.puts(*args)
18
+ # Capture what puts would output
19
+ args.each { |a| @secondary.write("#{a}\n") }
20
+ @secondary.write("\n") if args.empty?
21
+ end
22
+
23
+ def print(*args)
24
+ @primary.print(*args)
25
+ args.each { |a| @secondary.write(a.to_s) }
26
+ end
27
+
28
+ def flush
29
+ @primary.flush if @primary.respond_to?(:flush)
30
+ end
31
+
32
+ def respond_to_missing?(method, include_private = false)
33
+ @primary.respond_to?(method, include_private) || super
34
+ end
35
+
36
+ def method_missing(method, *args, &block)
37
+ @primary.send(method, *args, &block)
38
+ end
39
+ end
40
+
41
+ class Executor
42
+ CODE_REGEX = /```ruby\s*\n(.*?)```/m
43
+
44
+ attr_reader :binding_context
45
+
46
+ def initialize(binding_context)
47
+ @binding_context = binding_context
48
+ end
49
+
50
+ def extract_code(response)
51
+ match = response.match(CODE_REGEX)
52
+ match ? match[1].strip : ''
53
+ end
54
+
55
+ def display_response(response)
56
+ code = extract_code(response)
57
+ explanation = response.gsub(CODE_REGEX, '').strip
58
+
59
+ $stdout.puts
60
+ $stdout.puts colorize(explanation, :cyan) unless explanation.empty?
61
+
62
+ unless code.empty?
63
+ $stdout.puts
64
+ $stdout.puts colorize("# Generated code:", :yellow)
65
+ $stdout.puts highlight_code(code)
66
+ $stdout.puts
67
+ end
68
+
69
+ code
70
+ end
71
+
72
+ def execute(code)
73
+ return nil if code.nil? || code.strip.empty?
74
+
75
+ captured_output = StringIO.new
76
+ old_stdout = $stdout
77
+ # Tee output: capture it and also print to the real stdout
78
+ $stdout = TeeIO.new(old_stdout, captured_output)
79
+
80
+ result = binding_context.eval(code, "(console_agent)", 1)
81
+
82
+ $stdout = old_stdout
83
+ $stdout.puts colorize("=> #{result.inspect}", :green)
84
+
85
+ @last_output = captured_output.string
86
+ result
87
+ rescue SyntaxError => e
88
+ $stdout = old_stdout if old_stdout
89
+ $stderr.puts colorize("SyntaxError: #{e.message}", :red)
90
+ @last_output = nil
91
+ nil
92
+ rescue => e
93
+ $stdout = old_stdout if old_stdout
94
+ $stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
95
+ e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
96
+ @last_output = captured_output&.string
97
+ nil
98
+ end
99
+
100
+ def last_output
101
+ @last_output
102
+ end
103
+
104
+ def last_cancelled?
105
+ @last_cancelled
106
+ end
107
+
108
+ def confirm_and_execute(code)
109
+ return nil if code.nil? || code.strip.empty?
110
+
111
+ @last_cancelled = false
112
+ $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
113
+ answer = $stdin.gets.to_s.strip.downcase
114
+
115
+ case answer
116
+ when 'y', 'yes'
117
+ execute(code)
118
+ when 'e', 'edit'
119
+ edited = open_in_editor(code)
120
+ if edited && edited != code
121
+ $stdout.puts colorize("# Edited code:", :yellow)
122
+ $stdout.puts highlight_code(edited)
123
+ $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
124
+ if $stdin.gets.to_s.strip.downcase == 'y'
125
+ execute(edited)
126
+ else
127
+ $stdout.puts colorize("Cancelled.", :yellow)
128
+ nil
129
+ end
130
+ else
131
+ execute(code)
132
+ end
133
+ else
134
+ $stdout.puts colorize("Cancelled.", :yellow)
135
+ @last_cancelled = true
136
+ nil
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def open_in_editor(code)
143
+ require 'tempfile'
144
+ editor = ENV['EDITOR'] || 'vi'
145
+ tmpfile = Tempfile.new(['console_agent', '.rb'])
146
+ tmpfile.write(code)
147
+ tmpfile.flush
148
+
149
+ system("#{editor} #{tmpfile.path}")
150
+ File.read(tmpfile.path)
151
+ rescue => e
152
+ $stderr.puts colorize("Editor error: #{e.message}", :red)
153
+ code
154
+ ensure
155
+ tmpfile.close! if tmpfile
156
+ end
157
+
158
+ def highlight_code(code)
159
+ if coderay_available?
160
+ CodeRay.scan(code, :ruby).terminal
161
+ else
162
+ colorize(code, :white)
163
+ end
164
+ end
165
+
166
+ def coderay_available?
167
+ return @coderay_available unless @coderay_available.nil?
168
+ @coderay_available = begin
169
+ require 'coderay'
170
+ true
171
+ rescue LoadError
172
+ false
173
+ end
174
+ end
175
+
176
+ COLORS = {
177
+ red: "\e[31m",
178
+ green: "\e[32m",
179
+ yellow: "\e[33m",
180
+ cyan: "\e[36m",
181
+ white: "\e[37m",
182
+ reset: "\e[0m"
183
+ }.freeze
184
+
185
+ def colorize(text, color)
186
+ if $stdout.respond_to?(:tty?) && $stdout.tty?
187
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
188
+ else
189
+ text
190
+ end
191
+ end
192
+ end
193
+ 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