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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/app/controllers/rails_console_ai/application_controller.rb +28 -0
  6. data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
  7. data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
  8. data/app/models/rails_console_ai/session.rb +23 -0
  9. data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
  10. data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
  11. data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/generators/rails_console_ai/install_generator.rb +26 -0
  14. data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
  15. data/lib/rails_console_ai/channel/base.rb +23 -0
  16. data/lib/rails_console_ai/channel/console.rb +457 -0
  17. data/lib/rails_console_ai/channel/slack.rb +182 -0
  18. data/lib/rails_console_ai/configuration.rb +185 -0
  19. data/lib/rails_console_ai/console_methods.rb +277 -0
  20. data/lib/rails_console_ai/context_builder.rb +120 -0
  21. data/lib/rails_console_ai/conversation_engine.rb +1142 -0
  22. data/lib/rails_console_ai/engine.rb +5 -0
  23. data/lib/rails_console_ai/executor.rb +461 -0
  24. data/lib/rails_console_ai/providers/anthropic.rb +122 -0
  25. data/lib/rails_console_ai/providers/base.rb +118 -0
  26. data/lib/rails_console_ai/providers/bedrock.rb +171 -0
  27. data/lib/rails_console_ai/providers/local.rb +112 -0
  28. data/lib/rails_console_ai/providers/openai.rb +114 -0
  29. data/lib/rails_console_ai/railtie.rb +34 -0
  30. data/lib/rails_console_ai/repl.rb +65 -0
  31. data/lib/rails_console_ai/safety_guards.rb +207 -0
  32. data/lib/rails_console_ai/session_logger.rb +90 -0
  33. data/lib/rails_console_ai/slack_bot.rb +473 -0
  34. data/lib/rails_console_ai/storage/base.rb +27 -0
  35. data/lib/rails_console_ai/storage/file_storage.rb +63 -0
  36. data/lib/rails_console_ai/tools/code_tools.rb +126 -0
  37. data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
  38. data/lib/rails_console_ai/tools/model_tools.rb +95 -0
  39. data/lib/rails_console_ai/tools/registry.rb +478 -0
  40. data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
  41. data/lib/rails_console_ai/version.rb +3 -0
  42. data/lib/rails_console_ai.rb +214 -0
  43. data/lib/tasks/rails_console_ai.rake +7 -0
  44. 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