rails_console_ai 0.13.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 +7 -0
- data/CHANGELOG.md +95 -0
- data/LICENSE +21 -0
- data/README.md +328 -0
- data/app/controllers/rails_console_ai/application_controller.rb +28 -0
- data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
- data/app/models/rails_console_ai/session.rb +23 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
- data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
- data/config/routes.rb +4 -0
- data/lib/generators/rails_console_ai/install_generator.rb +26 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
- data/lib/rails_console_ai/channel/base.rb +23 -0
- data/lib/rails_console_ai/channel/console.rb +457 -0
- data/lib/rails_console_ai/channel/slack.rb +182 -0
- data/lib/rails_console_ai/configuration.rb +185 -0
- data/lib/rails_console_ai/console_methods.rb +277 -0
- data/lib/rails_console_ai/context_builder.rb +120 -0
- data/lib/rails_console_ai/conversation_engine.rb +1142 -0
- data/lib/rails_console_ai/engine.rb +5 -0
- data/lib/rails_console_ai/executor.rb +461 -0
- data/lib/rails_console_ai/providers/anthropic.rb +122 -0
- data/lib/rails_console_ai/providers/base.rb +118 -0
- data/lib/rails_console_ai/providers/bedrock.rb +171 -0
- data/lib/rails_console_ai/providers/local.rb +112 -0
- data/lib/rails_console_ai/providers/openai.rb +114 -0
- data/lib/rails_console_ai/railtie.rb +34 -0
- data/lib/rails_console_ai/repl.rb +65 -0
- data/lib/rails_console_ai/safety_guards.rb +207 -0
- data/lib/rails_console_ai/session_logger.rb +90 -0
- data/lib/rails_console_ai/slack_bot.rb +473 -0
- data/lib/rails_console_ai/storage/base.rb +27 -0
- data/lib/rails_console_ai/storage/file_storage.rb +63 -0
- data/lib/rails_console_ai/tools/code_tools.rb +126 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
- data/lib/rails_console_ai/tools/model_tools.rb +95 -0
- data/lib/rails_console_ai/tools/registry.rb +478 -0
- data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
- data/lib/rails_console_ai/version.rb +3 -0
- data/lib/rails_console_ai.rb +214 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +152 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module Providers
|
|
3
|
+
class Bedrock < Base
|
|
4
|
+
def chat(messages, system_prompt: nil)
|
|
5
|
+
call_api(messages, system_prompt: system_prompt)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def chat_with_tools(messages, tools:, system_prompt: nil)
|
|
9
|
+
call_api(messages, system_prompt: system_prompt, tools: tools)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def format_assistant_message(result)
|
|
13
|
+
content = []
|
|
14
|
+
content << { text: result.text } if result.text && !result.text.empty?
|
|
15
|
+
(result.tool_calls || []).each do |tc|
|
|
16
|
+
content << {
|
|
17
|
+
tool_use: {
|
|
18
|
+
tool_use_id: tc[:id],
|
|
19
|
+
name: tc[:name],
|
|
20
|
+
input: tc[:arguments]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
{ role: 'assistant', content: content }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def format_tool_result(tool_call_id, result_string)
|
|
28
|
+
{
|
|
29
|
+
role: 'user',
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
tool_result: {
|
|
33
|
+
tool_use_id: tool_call_id,
|
|
34
|
+
content: [{ text: result_string.to_s }]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def call_api(messages, system_prompt: nil, tools: nil)
|
|
44
|
+
params = {
|
|
45
|
+
model_id: config.resolved_model,
|
|
46
|
+
messages: format_messages(messages),
|
|
47
|
+
inference_config: {
|
|
48
|
+
max_tokens: config.resolved_max_tokens,
|
|
49
|
+
temperature: config.temperature
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if system_prompt
|
|
53
|
+
sys_blocks = [{ text: system_prompt }]
|
|
54
|
+
sys_blocks << { cache_point: { type: 'default' } } if cache_supported?
|
|
55
|
+
params[:system] = sys_blocks
|
|
56
|
+
end
|
|
57
|
+
if tools
|
|
58
|
+
bedrock_tools = tools.to_bedrock_format
|
|
59
|
+
bedrock_tools << { cache_point: { type: 'default' } } if bedrock_tools.any? && cache_supported?
|
|
60
|
+
params[:tool_config] = { tools: bedrock_tools }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
debug_bedrock_request(params)
|
|
64
|
+
response = client.converse(params)
|
|
65
|
+
debug_bedrock_response(response)
|
|
66
|
+
|
|
67
|
+
tool_calls = extract_tool_calls(response)
|
|
68
|
+
stop = response.stop_reason == 'tool_use' ? :tool_use : :end_turn
|
|
69
|
+
|
|
70
|
+
usage = response.usage
|
|
71
|
+
ChatResult.new(
|
|
72
|
+
text: extract_text(response),
|
|
73
|
+
input_tokens: usage&.input_tokens,
|
|
74
|
+
output_tokens: usage&.output_tokens,
|
|
75
|
+
cache_read_input_tokens: usage.respond_to?(:cache_read_input_token_count) ? usage.cache_read_input_token_count : nil,
|
|
76
|
+
cache_write_input_tokens: usage.respond_to?(:cache_write_input_token_count) ? usage.cache_write_input_token_count : nil,
|
|
77
|
+
tool_calls: tool_calls,
|
|
78
|
+
stop_reason: stop
|
|
79
|
+
)
|
|
80
|
+
rescue aws_error_class => e
|
|
81
|
+
raise ProviderError, "AWS Bedrock error: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def client
|
|
85
|
+
@client ||= begin
|
|
86
|
+
unless defined?(Aws::BedrockRuntime::Client)
|
|
87
|
+
begin
|
|
88
|
+
require 'aws-sdk-bedrockruntime'
|
|
89
|
+
rescue LoadError
|
|
90
|
+
raise ProviderError,
|
|
91
|
+
"aws-sdk-bedrockruntime gem is required for the :bedrock provider. Add it to your Gemfile."
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
client_opts = {}
|
|
95
|
+
region = config.respond_to?(:bedrock_region) && config.bedrock_region
|
|
96
|
+
client_opts[:region] = region if region && !region.empty?
|
|
97
|
+
t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
|
|
98
|
+
client_opts[:http_read_timeout] = t
|
|
99
|
+
Aws::BedrockRuntime::Client.new(client_opts)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def cache_supported?
|
|
104
|
+
model = config.resolved_model
|
|
105
|
+
model.include?('anthropic')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def aws_error_class
|
|
109
|
+
if defined?(Aws::BedrockRuntime::Errors::ServiceError)
|
|
110
|
+
Aws::BedrockRuntime::Errors::ServiceError
|
|
111
|
+
else
|
|
112
|
+
# Fallback if the gem isn't loaded yet (shouldn't happen after client init)
|
|
113
|
+
StandardError
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def format_messages(messages)
|
|
118
|
+
messages.map do |msg|
|
|
119
|
+
content = if msg[:content].is_a?(Array)
|
|
120
|
+
msg[:content]
|
|
121
|
+
else
|
|
122
|
+
[{ text: msg[:content].to_s }]
|
|
123
|
+
end
|
|
124
|
+
{ role: msg[:role].to_s, content: content }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def extract_text(response)
|
|
129
|
+
content = response.output&.message&.content
|
|
130
|
+
return '' unless content.is_a?(Array)
|
|
131
|
+
|
|
132
|
+
content.select { |c| c.respond_to?(:text) && c.text }
|
|
133
|
+
.map(&:text)
|
|
134
|
+
.join("\n")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract_tool_calls(response)
|
|
138
|
+
content = response.output&.message&.content
|
|
139
|
+
return [] unless content.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
content.select { |c| c.respond_to?(:tool_use) && c.tool_use }
|
|
142
|
+
.map do |c|
|
|
143
|
+
tu = c.tool_use
|
|
144
|
+
{
|
|
145
|
+
id: tu.tool_use_id,
|
|
146
|
+
name: tu.name,
|
|
147
|
+
arguments: tu.input || {}
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def debug_bedrock_request(params)
|
|
153
|
+
return unless config.debug
|
|
154
|
+
|
|
155
|
+
msg_count = params[:messages]&.length || 0
|
|
156
|
+
sys_len = params.dig(:system, 0, :text).to_s.length
|
|
157
|
+
tool_count = params.dig(:tool_config, :tools)&.length || 0
|
|
158
|
+
$stderr.puts "\e[33m[debug] Bedrock converse | model: #{params[:model_id]} | #{msg_count} msgs | system: #{sys_len} chars | #{tool_count} tools\e[0m"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def debug_bedrock_response(response)
|
|
162
|
+
return unless config.debug
|
|
163
|
+
|
|
164
|
+
usage = response.usage
|
|
165
|
+
if usage
|
|
166
|
+
$stderr.puts "\e[36m[debug] response: #{response.stop_reason} | in: #{usage.input_tokens} out: #{usage.output_tokens}\e[0m"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module Providers
|
|
3
|
+
class Local < OpenAI
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def call_api(messages, system_prompt: nil, tools: nil)
|
|
7
|
+
base_url = config.local_url
|
|
8
|
+
|
|
9
|
+
headers = { 'Content-Type' => 'application/json' }
|
|
10
|
+
api_key = config.local_api_key
|
|
11
|
+
if api_key && api_key != 'no-key' && !api_key.empty?
|
|
12
|
+
headers['Authorization'] = "Bearer #{api_key}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
conn = build_connection(base_url, headers)
|
|
16
|
+
|
|
17
|
+
formatted = []
|
|
18
|
+
formatted << { role: 'system', content: system_prompt } if system_prompt
|
|
19
|
+
formatted.concat(format_messages(messages))
|
|
20
|
+
|
|
21
|
+
body = {
|
|
22
|
+
model: config.resolved_model,
|
|
23
|
+
max_tokens: config.resolved_max_tokens,
|
|
24
|
+
temperature: config.temperature,
|
|
25
|
+
messages: formatted
|
|
26
|
+
}
|
|
27
|
+
body[:tools] = tools.to_openai_format if tools
|
|
28
|
+
|
|
29
|
+
estimated_input_tokens = estimate_tokens(formatted, system_prompt, tools)
|
|
30
|
+
|
|
31
|
+
json_body = JSON.generate(body)
|
|
32
|
+
debug_request("#{base_url}/v1/chat/completions", body)
|
|
33
|
+
response = conn.post('/v1/chat/completions', json_body)
|
|
34
|
+
debug_response(response.body)
|
|
35
|
+
data = parse_response(response)
|
|
36
|
+
usage = data['usage'] || {}
|
|
37
|
+
|
|
38
|
+
prompt_tokens = usage['prompt_tokens']
|
|
39
|
+
if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
|
|
40
|
+
raise ProviderError,
|
|
41
|
+
"Context truncated by local server: sent ~#{estimated_input_tokens} estimated tokens " \
|
|
42
|
+
"but server only used #{prompt_tokens}. Increase the model's context window " \
|
|
43
|
+
"(e.g. num_ctx for Ollama) or reduce conversation length."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
choice = (data['choices'] || []).first || {}
|
|
47
|
+
message = choice['message'] || {}
|
|
48
|
+
finish_reason = choice['finish_reason']
|
|
49
|
+
|
|
50
|
+
tool_calls = extract_tool_calls(message)
|
|
51
|
+
|
|
52
|
+
# Fallback: some local models (e.g. Ollama) emit tool calls as JSON
|
|
53
|
+
# in the content field instead of using the structured tool_calls format.
|
|
54
|
+
# Only match when the JSON "name" is a known tool name to avoid false positives.
|
|
55
|
+
if tool_calls.empty? && tools
|
|
56
|
+
tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
|
|
57
|
+
text_calls = extract_tool_calls_from_text(message['content'], tool_names)
|
|
58
|
+
if text_calls.any?
|
|
59
|
+
tool_calls = text_calls
|
|
60
|
+
finish_reason = 'tool_calls'
|
|
61
|
+
message['content'] = ''
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
|
|
66
|
+
|
|
67
|
+
ChatResult.new(
|
|
68
|
+
text: message['content'] || '',
|
|
69
|
+
input_tokens: usage['prompt_tokens'],
|
|
70
|
+
output_tokens: usage['completion_tokens'],
|
|
71
|
+
tool_calls: tool_calls,
|
|
72
|
+
stop_reason: stop
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def estimate_tokens(messages, system_prompt, tools)
|
|
77
|
+
chars = system_prompt.to_s.length
|
|
78
|
+
messages.each { |m| chars += m[:content].to_s.length + (m[:tool_calls].to_s.length) }
|
|
79
|
+
chars += tools.to_openai_format.to_s.length if tools
|
|
80
|
+
chars / 4
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse tool calls emitted as JSON text in the content field.
|
|
84
|
+
# Only recognizes calls whose "name" matches a known tool name.
|
|
85
|
+
def extract_tool_calls_from_text(content, tool_names)
|
|
86
|
+
return [] if content.nil? || content.strip.empty?
|
|
87
|
+
|
|
88
|
+
text = content.strip
|
|
89
|
+
parsed = begin
|
|
90
|
+
JSON.parse(text)
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
match = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
|
|
93
|
+
match ? (JSON.parse(match[1]) rescue nil) : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return [] unless parsed
|
|
97
|
+
|
|
98
|
+
calls = parsed.is_a?(Array) ? parsed : [parsed]
|
|
99
|
+
calls.filter_map do |call|
|
|
100
|
+
next unless call.is_a?(Hash) && tool_names.include?(call['name'])
|
|
101
|
+
{
|
|
102
|
+
id: "local_#{SecureRandom.hex(4)}",
|
|
103
|
+
name: call['name'],
|
|
104
|
+
arguments: call['arguments'] || {}
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
rescue
|
|
108
|
+
[]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
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.resolved_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,34 @@
|
|
|
1
|
+
require 'rails_console_ai'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAI
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
rake_tasks do
|
|
6
|
+
load File.expand_path('../tasks/rails_console_ai.rake', __dir__)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
console do
|
|
10
|
+
require 'rails_console_ai/console_methods'
|
|
11
|
+
|
|
12
|
+
# Inject into IRB if available
|
|
13
|
+
if defined?(IRB::ExtendCommandBundle)
|
|
14
|
+
IRB::ExtendCommandBundle.include(RailsConsoleAI::ConsoleMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Extend TOPLEVEL_BINDING's receiver as well
|
|
18
|
+
TOPLEVEL_BINDING.eval('self').extend(RailsConsoleAI::ConsoleMethods)
|
|
19
|
+
|
|
20
|
+
# Welcome message
|
|
21
|
+
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
22
|
+
$stdout.puts "\e[36m[RailsConsoleAI v#{RailsConsoleAI::VERSION}] AI assistant loaded. Try: ai \"show me all tables\"\e[0m"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Pre-build context in background
|
|
26
|
+
Thread.new do
|
|
27
|
+
require 'rails_console_ai/context_builder'
|
|
28
|
+
RailsConsoleAI::ContextBuilder.new.build
|
|
29
|
+
rescue => e
|
|
30
|
+
RailsConsoleAI.logger.debug("RailsConsoleAI: background context build failed: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require 'rails_console_ai/channel/console'
|
|
2
|
+
require 'rails_console_ai/conversation_engine'
|
|
3
|
+
|
|
4
|
+
module RailsConsoleAI
|
|
5
|
+
class Repl
|
|
6
|
+
def initialize(binding_context)
|
|
7
|
+
@binding_context = binding_context
|
|
8
|
+
@channel = Channel::Console.new
|
|
9
|
+
@engine = ConversationEngine.new(binding_context: binding_context, channel: @channel)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def one_shot(query)
|
|
13
|
+
@engine.one_shot(query)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def explain(query)
|
|
17
|
+
@engine.explain(query)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def init_guide
|
|
21
|
+
@engine.init_guide
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def interactive
|
|
25
|
+
@channel.interactive_loop(@engine)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resume(session)
|
|
29
|
+
@channel.resume_interactive(@engine, session)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Expose engine internals for specs that inspect state
|
|
33
|
+
def instance_variable_get(name)
|
|
34
|
+
case name
|
|
35
|
+
when :@history
|
|
36
|
+
@engine.history
|
|
37
|
+
when :@executor
|
|
38
|
+
@engine.instance_variable_get(:@executor)
|
|
39
|
+
else
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Allow specs to set internal state
|
|
45
|
+
def instance_variable_set(name, value)
|
|
46
|
+
case name
|
|
47
|
+
when :@history
|
|
48
|
+
@engine.instance_variable_set(:@history, value)
|
|
49
|
+
else
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Expose send methods for spec compatibility
|
|
57
|
+
def send_query(query, conversation: nil)
|
|
58
|
+
@engine.send(:send_query, query, conversation: conversation)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def trim_old_outputs(messages)
|
|
62
|
+
@engine.send(:trim_old_outputs, messages)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
# Raised by safety guards to block dangerous operations.
|
|
3
|
+
# Host apps should raise this error in their custom guards.
|
|
4
|
+
# RailsConsoleAI will catch it and guide the user to use 'd' or /danger.
|
|
5
|
+
class SafetyError < StandardError
|
|
6
|
+
attr_reader :guard, :blocked_key
|
|
7
|
+
|
|
8
|
+
def initialize(message, guard: nil, blocked_key: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@guard = guard
|
|
11
|
+
@blocked_key = blocked_key
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class SafetyGuards
|
|
16
|
+
attr_reader :guards
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@guards = {}
|
|
20
|
+
@enabled = true
|
|
21
|
+
@allowlist = {} # { guard_name => [String or Regexp, ...] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add(name, &block)
|
|
25
|
+
@guards[name.to_sym] = block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def remove(name)
|
|
29
|
+
@guards.delete(name.to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
@enabled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enable!
|
|
37
|
+
@enabled = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def disable!
|
|
41
|
+
@enabled = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def empty?
|
|
45
|
+
@guards.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def names
|
|
49
|
+
@guards.keys
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def allow(guard_name, key)
|
|
53
|
+
guard_name = guard_name.to_sym
|
|
54
|
+
@allowlist[guard_name] ||= []
|
|
55
|
+
@allowlist[guard_name] << key unless @allowlist[guard_name].include?(key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def allowed?(guard_name, key)
|
|
59
|
+
entries = @allowlist[guard_name.to_sym]
|
|
60
|
+
return false unless entries
|
|
61
|
+
|
|
62
|
+
entries.any? do |entry|
|
|
63
|
+
case entry
|
|
64
|
+
when Regexp then key.match?(entry)
|
|
65
|
+
else entry.to_s == key.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def allowlist
|
|
71
|
+
@allowlist
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Compose all guards around a block of code.
|
|
75
|
+
# Each guard is an around-block: guard.call { inner }
|
|
76
|
+
# Result: guard_1 { guard_2 { guard_3 { yield } } }
|
|
77
|
+
def wrap(&block)
|
|
78
|
+
return yield unless @enabled && !@guards.empty?
|
|
79
|
+
|
|
80
|
+
@guards.values.reduce(block) { |inner, guard|
|
|
81
|
+
-> { guard.call(&inner) }
|
|
82
|
+
}.call
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Built-in guard: database write prevention
|
|
87
|
+
# Works on all Rails versions (5+) and all database adapters.
|
|
88
|
+
# Prepends a write-intercepting module once, controlled by a thread-local flag.
|
|
89
|
+
module BuiltinGuards
|
|
90
|
+
# Blocks INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE
|
|
91
|
+
module WriteBlocker
|
|
92
|
+
WRITE_PATTERN = /\A\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)\b/i
|
|
93
|
+
TABLE_PATTERN = /\b(?:INTO|FROM|UPDATE|TABLE|TRUNCATE)\s+[`"]?(\w+)[`"]?/i
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def rails_console_ai_check_write!(sql)
|
|
98
|
+
return unless Thread.current[:rails_console_ai_block_writes] && sql.match?(WRITE_PATTERN)
|
|
99
|
+
|
|
100
|
+
table = sql.match(TABLE_PATTERN)&.captures&.first
|
|
101
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
102
|
+
return if table && guards.allowed?(:database_writes, table)
|
|
103
|
+
|
|
104
|
+
raise RailsConsoleAI::SafetyError.new(
|
|
105
|
+
"Database write blocked: #{sql.strip.split(/\s+/).first(3).join(' ')}...",
|
|
106
|
+
guard: :database_writes,
|
|
107
|
+
blocked_key: table
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
public
|
|
112
|
+
|
|
113
|
+
def execute(sql, *args, **kwargs)
|
|
114
|
+
rails_console_ai_check_write!(sql)
|
|
115
|
+
super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def exec_delete(sql, *args, **kwargs)
|
|
119
|
+
rails_console_ai_check_write!(sql)
|
|
120
|
+
super
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def exec_update(sql, *args, **kwargs)
|
|
124
|
+
rails_console_ai_check_write!(sql)
|
|
125
|
+
super
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.database_writes
|
|
130
|
+
->(& block) {
|
|
131
|
+
ensure_write_blocker_installed!
|
|
132
|
+
Thread.current[:rails_console_ai_block_writes] = true
|
|
133
|
+
begin
|
|
134
|
+
block.call
|
|
135
|
+
ensure
|
|
136
|
+
Thread.current[:rails_console_ai_block_writes] = false
|
|
137
|
+
end
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.ensure_write_blocker_installed!
|
|
142
|
+
return if @write_blocker_installed
|
|
143
|
+
|
|
144
|
+
connection = ActiveRecord::Base.connection
|
|
145
|
+
unless connection.class.ancestors.include?(WriteBlocker)
|
|
146
|
+
connection.class.prepend(WriteBlocker)
|
|
147
|
+
end
|
|
148
|
+
@write_blocker_installed = true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Blocks non-safe HTTP requests (POST, PUT, PATCH, DELETE, etc.) via Net::HTTP.
|
|
152
|
+
# Since most Ruby HTTP libraries (HTTParty, RestClient, Faraday) use Net::HTTP
|
|
153
|
+
# under the hood, this covers them all.
|
|
154
|
+
module HttpBlocker
|
|
155
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
156
|
+
|
|
157
|
+
def request(req, *args, &block)
|
|
158
|
+
if Thread.current[:rails_console_ai_block_http] && !SAFE_METHODS.include?(req.method)
|
|
159
|
+
host = @address.to_s
|
|
160
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
161
|
+
unless guards.allowed?(:http_mutations, host)
|
|
162
|
+
raise RailsConsoleAI::SafetyError.new(
|
|
163
|
+
"HTTP #{req.method} blocked (#{host}#{req.path})",
|
|
164
|
+
guard: :http_mutations,
|
|
165
|
+
blocked_key: host
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
super
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.http_mutations
|
|
174
|
+
->(&block) {
|
|
175
|
+
ensure_http_blocker_installed!
|
|
176
|
+
Thread.current[:rails_console_ai_block_http] = true
|
|
177
|
+
begin
|
|
178
|
+
block.call
|
|
179
|
+
ensure
|
|
180
|
+
Thread.current[:rails_console_ai_block_http] = false
|
|
181
|
+
end
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.mailers
|
|
186
|
+
->(&block) {
|
|
187
|
+
old_value = ActionMailer::Base.perform_deliveries
|
|
188
|
+
ActionMailer::Base.perform_deliveries = false
|
|
189
|
+
begin
|
|
190
|
+
block.call
|
|
191
|
+
ensure
|
|
192
|
+
ActionMailer::Base.perform_deliveries = old_value
|
|
193
|
+
end
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.ensure_http_blocker_installed!
|
|
198
|
+
return if @http_blocker_installed
|
|
199
|
+
|
|
200
|
+
require 'net/http'
|
|
201
|
+
unless Net::HTTP.ancestors.include?(HttpBlocker)
|
|
202
|
+
Net::HTTP.prepend(HttpBlocker)
|
|
203
|
+
end
|
|
204
|
+
@http_blocker_installed = true
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|