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,126 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module Tools
|
|
3
|
+
class CodeTools
|
|
4
|
+
MAX_FILE_LINES = 500
|
|
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, start_line: nil, end_line: nil)
|
|
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
|
+
all_lines = File.readlines(full_path)
|
|
49
|
+
total = all_lines.length
|
|
50
|
+
|
|
51
|
+
# Apply line range if specified (1-based, inclusive)
|
|
52
|
+
if start_line || end_line
|
|
53
|
+
s = [(start_line || 1).to_i, 1].max
|
|
54
|
+
e = [(end_line || total).to_i, total].min
|
|
55
|
+
return "Error: start_line (#{s}) is beyond end of file (#{total} lines)." if s > total
|
|
56
|
+
lines = all_lines[(s - 1)..(e - 1)] || []
|
|
57
|
+
offset = s - 1
|
|
58
|
+
numbered = lines.each_with_index.map { |line, i| "#{offset + i + 1}: #{line}" }
|
|
59
|
+
header = "Lines #{s}-#{[e, s + lines.length - 1].min} of #{total}:\n"
|
|
60
|
+
header + numbered.join
|
|
61
|
+
elsif total > MAX_FILE_LINES
|
|
62
|
+
numbered = all_lines.first(MAX_FILE_LINES).each_with_index.map { |line, i| "#{i + 1}: #{line}" }
|
|
63
|
+
numbered.join + "\n... truncated (#{total} total lines, showing first #{MAX_FILE_LINES}). Use start_line/end_line to read specific sections."
|
|
64
|
+
else
|
|
65
|
+
all_lines.each_with_index.map { |line, i| "#{i + 1}: #{line}" }.join
|
|
66
|
+
end
|
|
67
|
+
rescue => e
|
|
68
|
+
"Error reading file '#{path}': #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def search_code(query, directory = nil)
|
|
72
|
+
return "Error: query is required." if query.nil? || query.strip.empty?
|
|
73
|
+
|
|
74
|
+
directory = sanitize_directory(directory || 'app')
|
|
75
|
+
root = rails_root
|
|
76
|
+
return "Rails.root is not available." unless root
|
|
77
|
+
|
|
78
|
+
full_path = File.join(root, directory)
|
|
79
|
+
return "Directory '#{directory}' not found." unless File.directory?(full_path)
|
|
80
|
+
|
|
81
|
+
results = []
|
|
82
|
+
Dir.glob(File.join(full_path, '**', '*.rb')).sort.each do |file|
|
|
83
|
+
break if results.length >= MAX_SEARCH_RESULTS
|
|
84
|
+
|
|
85
|
+
relative = file.sub("#{root}/", '')
|
|
86
|
+
File.readlines(file).each_with_index do |line, idx|
|
|
87
|
+
if line.include?(query)
|
|
88
|
+
results << "#{relative}:#{idx + 1}: #{line.strip}"
|
|
89
|
+
break if results.length >= MAX_SEARCH_RESULTS
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
rescue => e
|
|
93
|
+
# skip unreadable files
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if results.empty?
|
|
97
|
+
"No matches found for '#{query}' in #{directory}/."
|
|
98
|
+
else
|
|
99
|
+
header = "Found #{results.length} match#{'es' if results.length != 1}:\n"
|
|
100
|
+
header + results.join("\n")
|
|
101
|
+
end
|
|
102
|
+
rescue => e
|
|
103
|
+
"Error searching: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def rails_root
|
|
109
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
110
|
+
Rails.root.to_s
|
|
111
|
+
else
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def sanitize_path(path)
|
|
117
|
+
# Remove leading slashes and ../ sequences
|
|
118
|
+
path.strip.gsub(/\A\/+/, '').gsub(/\.\.\//, '').gsub(/\.\.\\/, '')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def sanitize_directory(dir)
|
|
122
|
+
sanitize_path(dir || 'app')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAI
|
|
4
|
+
module Tools
|
|
5
|
+
class MemoryTools
|
|
6
|
+
MEMORIES_DIR = 'memories'
|
|
7
|
+
|
|
8
|
+
def initialize(storage = nil)
|
|
9
|
+
@storage = storage || RailsConsoleAI.storage
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def save_memory(name:, description:, tags: [])
|
|
13
|
+
key = memory_key(name)
|
|
14
|
+
existing = load_memory(key)
|
|
15
|
+
|
|
16
|
+
frontmatter = {
|
|
17
|
+
'name' => name,
|
|
18
|
+
'tags' => Array(tags).empty? && existing ? (existing['tags'] || []) : Array(tags),
|
|
19
|
+
'created_at' => existing ? existing['created_at'] : Time.now.utc.iso8601
|
|
20
|
+
}
|
|
21
|
+
frontmatter['updated_at'] = Time.now.utc.iso8601 if existing
|
|
22
|
+
|
|
23
|
+
content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
|
|
24
|
+
@storage.write(key, content)
|
|
25
|
+
|
|
26
|
+
path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
|
|
27
|
+
if existing
|
|
28
|
+
"Memory updated: \"#{name}\" (#{path})"
|
|
29
|
+
else
|
|
30
|
+
"Memory saved: \"#{name}\" (#{path})"
|
|
31
|
+
end
|
|
32
|
+
rescue Storage::StorageError => e
|
|
33
|
+
"FAILED to save (#{e.message}). Add this manually to .rails_console_ai/#{key}:\n" \
|
|
34
|
+
"---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete_memory(name:)
|
|
38
|
+
key = memory_key(name)
|
|
39
|
+
unless @storage.exists?(key)
|
|
40
|
+
# Try to find by name match across all memory files
|
|
41
|
+
found_key = find_memory_key_by_name(name)
|
|
42
|
+
return "No memory found: \"#{name}\"" unless found_key
|
|
43
|
+
key = found_key
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
memory = load_memory(key)
|
|
47
|
+
@storage.delete(key)
|
|
48
|
+
"Memory deleted: \"#{memory ? memory['name'] : name}\""
|
|
49
|
+
rescue Storage::StorageError => e
|
|
50
|
+
"FAILED to delete memory (#{e.message})."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def recall_memories(query: nil, tag: nil)
|
|
54
|
+
memories = load_all_memories
|
|
55
|
+
return "No memories stored yet." if memories.empty?
|
|
56
|
+
|
|
57
|
+
results = memories
|
|
58
|
+
if tag && !tag.empty?
|
|
59
|
+
results = results.select { |m|
|
|
60
|
+
Array(m['tags']).any? { |t| t.downcase.include?(tag.downcase) }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
if query && !query.empty?
|
|
64
|
+
q = query.downcase
|
|
65
|
+
results = results.select { |m|
|
|
66
|
+
m['name'].to_s.downcase.include?(q) ||
|
|
67
|
+
m['description'].to_s.downcase.include?(q) ||
|
|
68
|
+
Array(m['tags']).any? { |t| t.downcase.include?(q) }
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return "No memories matching your search." if results.empty?
|
|
73
|
+
|
|
74
|
+
results.map { |m|
|
|
75
|
+
line = "**#{m['name']}**\n#{m['description']}"
|
|
76
|
+
line += "\nTags: #{m['tags'].join(', ')}" if m['tags'] && !m['tags'].empty?
|
|
77
|
+
line
|
|
78
|
+
}.join("\n\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def memory_summaries
|
|
82
|
+
memories = load_all_memories
|
|
83
|
+
return nil if memories.empty?
|
|
84
|
+
|
|
85
|
+
memories.map { |m|
|
|
86
|
+
tags = Array(m['tags'])
|
|
87
|
+
tag_str = tags.empty? ? '' : " [#{tags.join(', ')}]"
|
|
88
|
+
"- #{m['name']}#{tag_str}"
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def memory_key(name)
|
|
95
|
+
slug = name.downcase.strip
|
|
96
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
97
|
+
.gsub(/[\s]+/, '-')
|
|
98
|
+
.gsub(/-+/, '-')
|
|
99
|
+
.sub(/^-/, '').sub(/-$/, '')
|
|
100
|
+
"#{MEMORIES_DIR}/#{slug}.md"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def load_memory(key)
|
|
104
|
+
content = @storage.read(key)
|
|
105
|
+
return nil if content.nil? || content.strip.empty?
|
|
106
|
+
parse_memory(content)
|
|
107
|
+
rescue => e
|
|
108
|
+
RailsConsoleAI.logger.warn("RailsConsoleAI: failed to load memory #{key}: #{e.message}")
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def load_all_memories
|
|
113
|
+
keys = @storage.list("#{MEMORIES_DIR}/*.md")
|
|
114
|
+
keys.map { |key| load_memory(key) }.compact
|
|
115
|
+
rescue => e
|
|
116
|
+
RailsConsoleAI.logger.warn("RailsConsoleAI: failed to load memories: #{e.message}")
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_memory(content)
|
|
121
|
+
return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
122
|
+
frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
|
|
123
|
+
description = $2.strip
|
|
124
|
+
frontmatter.merge('description' => description)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def find_memory_key_by_name(name)
|
|
128
|
+
keys = @storage.list("#{MEMORIES_DIR}/*.md")
|
|
129
|
+
keys.find do |key|
|
|
130
|
+
memory = load_memory(key)
|
|
131
|
+
memory && memory['name'].to_s.downcase == name.downcase
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module Tools
|
|
3
|
+
class ModelTools
|
|
4
|
+
def list_models
|
|
5
|
+
return "ActiveRecord is not available." unless defined?(ActiveRecord::Base)
|
|
6
|
+
|
|
7
|
+
eager_load_app!
|
|
8
|
+
models = find_models
|
|
9
|
+
return "No models found." if models.empty?
|
|
10
|
+
|
|
11
|
+
lines = models.map do |model|
|
|
12
|
+
assoc_names = model.reflect_on_all_associations.map { |a| a.name.to_s }
|
|
13
|
+
if assoc_names.empty?
|
|
14
|
+
model.name
|
|
15
|
+
else
|
|
16
|
+
"#{model.name} (#{assoc_names.join(', ')})"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
lines.join("\n")
|
|
21
|
+
rescue => e
|
|
22
|
+
"Error listing models: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def describe_model(model_name)
|
|
26
|
+
return "ActiveRecord is not available." unless defined?(ActiveRecord::Base)
|
|
27
|
+
return "Error: model_name is required." if model_name.nil? || model_name.strip.empty?
|
|
28
|
+
|
|
29
|
+
eager_load_app!
|
|
30
|
+
model_name = model_name.strip
|
|
31
|
+
|
|
32
|
+
model = find_models.detect { |m| m.name == model_name || m.name.underscore == model_name.underscore }
|
|
33
|
+
return "Model '#{model_name}' not found. Use list_models to see available models." unless model
|
|
34
|
+
|
|
35
|
+
result = "Model: #{model.name}\n"
|
|
36
|
+
result += "Table: #{model.table_name}\n"
|
|
37
|
+
|
|
38
|
+
assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
|
|
39
|
+
unless assocs.empty?
|
|
40
|
+
result += "Associations:\n"
|
|
41
|
+
assocs.each { |a| result += " #{a}\n" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
validators = model.validators.map { |v|
|
|
46
|
+
attrs = v.attributes.join(', ')
|
|
47
|
+
kind = v.class.name.split('::').last.sub('Validator', '').downcase
|
|
48
|
+
"#{kind} on #{attrs}"
|
|
49
|
+
}.uniq
|
|
50
|
+
unless validators.empty?
|
|
51
|
+
result += "Validations:\n"
|
|
52
|
+
validators.each { |v| result += " #{v}\n" }
|
|
53
|
+
end
|
|
54
|
+
rescue => e
|
|
55
|
+
# validations may not be accessible
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Scopes - detect via singleton methods that aren't inherited
|
|
59
|
+
begin
|
|
60
|
+
base = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
61
|
+
scope_candidates = (model.singleton_methods - base.singleton_methods)
|
|
62
|
+
.reject { |m| m.to_s.start_with?('_') || m.to_s.start_with?('find') }
|
|
63
|
+
.sort
|
|
64
|
+
.first(20)
|
|
65
|
+
unless scope_candidates.empty?
|
|
66
|
+
result += "Possible scopes/class methods:\n"
|
|
67
|
+
scope_candidates.each { |s| result += " #{s}\n" }
|
|
68
|
+
end
|
|
69
|
+
rescue => e
|
|
70
|
+
# ignore
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
rescue => e
|
|
75
|
+
"Error describing model '#{model_name}': #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def find_models
|
|
81
|
+
base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
82
|
+
ObjectSpace.each_object(Class).select { |c|
|
|
83
|
+
c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
|
|
84
|
+
}.sort_by(&:name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def eager_load_app!
|
|
88
|
+
return unless defined?(Rails) && Rails.respond_to?(:application)
|
|
89
|
+
Rails.application.eager_load! if Rails.application.respond_to?(:eager_load!)
|
|
90
|
+
rescue => e
|
|
91
|
+
# ignore
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|