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,478 @@
1
+ require 'json'
2
+
3
+ module RailsConsoleAI
4
+ module Tools
5
+ class Registry
6
+ attr_reader :definitions
7
+
8
+ # Tools that should never be cached (side effects or user interaction)
9
+ NO_CACHE = %w[ask_user save_memory delete_memory execute_plan].freeze
10
+
11
+ def initialize(executor: nil, mode: :default, channel: nil)
12
+ @executor = executor
13
+ @mode = mode
14
+ @channel = channel
15
+ @definitions = []
16
+ @handlers = {}
17
+ @cache = {}
18
+ @last_cached = false
19
+ register_all
20
+ end
21
+
22
+ def last_cached?
23
+ @last_cached
24
+ end
25
+
26
+ def execute(tool_name, arguments = {})
27
+ handler = @handlers[tool_name]
28
+ unless handler
29
+ return "Error: unknown tool '#{tool_name}'"
30
+ end
31
+
32
+ args = if arguments.is_a?(String)
33
+ begin
34
+ JSON.parse(arguments)
35
+ rescue
36
+ {}
37
+ end
38
+ else
39
+ arguments || {}
40
+ end
41
+
42
+ unless NO_CACHE.include?(tool_name)
43
+ cache_key = [tool_name, args].hash
44
+ if @cache.key?(cache_key)
45
+ @last_cached = true
46
+ return @cache[cache_key]
47
+ end
48
+ end
49
+
50
+ @last_cached = false
51
+ result = handler.call(args)
52
+ @cache[[tool_name, args].hash] = result unless NO_CACHE.include?(tool_name)
53
+ result
54
+ rescue => e
55
+ "Error executing #{tool_name}: #{e.message}"
56
+ end
57
+
58
+ def to_anthropic_format
59
+ definitions.map do |d|
60
+ tool = {
61
+ 'name' => d[:name],
62
+ 'description' => d[:description],
63
+ 'input_schema' => d[:parameters]
64
+ }
65
+ tool
66
+ end
67
+ end
68
+
69
+ def to_bedrock_format
70
+ definitions.map do |d|
71
+ {
72
+ tool_spec: {
73
+ name: d[:name],
74
+ description: d[:description],
75
+ input_schema: { json: d[:parameters] }
76
+ }
77
+ }
78
+ end
79
+ end
80
+
81
+ def to_openai_format
82
+ definitions.map do |d|
83
+ {
84
+ 'type' => 'function',
85
+ 'function' => {
86
+ 'name' => d[:name],
87
+ 'description' => d[:description],
88
+ 'parameters' => d[:parameters]
89
+ }
90
+ }
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def register_all
97
+ require 'rails_console_ai/tools/schema_tools'
98
+ require 'rails_console_ai/tools/model_tools'
99
+ require 'rails_console_ai/tools/code_tools'
100
+
101
+ schema = SchemaTools.new
102
+ models = ModelTools.new
103
+ code = CodeTools.new
104
+
105
+ register(
106
+ name: 'list_tables',
107
+ description: 'List all database table names in this Rails app.',
108
+ parameters: { 'type' => 'object', 'properties' => {} },
109
+ handler: ->(_args) { schema.list_tables }
110
+ )
111
+
112
+ register(
113
+ name: 'describe_table',
114
+ description: 'Get column names and types for a specific database table.',
115
+ parameters: {
116
+ 'type' => 'object',
117
+ 'properties' => {
118
+ 'table_name' => { 'type' => 'string', 'description' => 'The database table name (e.g. "users")' }
119
+ },
120
+ 'required' => ['table_name']
121
+ },
122
+ handler: ->(args) { schema.describe_table(args['table_name']) }
123
+ )
124
+
125
+ register(
126
+ name: 'list_models',
127
+ description: 'List all ActiveRecord model names with their association names.',
128
+ parameters: { 'type' => 'object', 'properties' => {} },
129
+ handler: ->(_args) { models.list_models }
130
+ )
131
+
132
+ register(
133
+ name: 'describe_model',
134
+ description: 'Get detailed info about a specific model: associations, validations, table name.',
135
+ parameters: {
136
+ 'type' => 'object',
137
+ 'properties' => {
138
+ 'model_name' => { 'type' => 'string', 'description' => 'The model class name (e.g. "User")' }
139
+ },
140
+ 'required' => ['model_name']
141
+ },
142
+ handler: ->(args) { models.describe_model(args['model_name']) }
143
+ )
144
+
145
+ register(
146
+ name: 'list_files',
147
+ description: 'List Ruby files in a directory of this Rails app. Defaults to app/ directory.',
148
+ parameters: {
149
+ 'type' => 'object',
150
+ 'properties' => {
151
+ 'directory' => { 'type' => 'string', 'description' => 'Relative directory path (e.g. "app/models", "lib"). Defaults to "app".' }
152
+ }
153
+ },
154
+ handler: ->(args) { code.list_files(args['directory']) }
155
+ )
156
+
157
+ register(
158
+ name: 'read_file',
159
+ description: 'Read the contents of a file in this Rails app. Returns up to 500 lines by default. Use start_line/end_line to read specific sections of large files.',
160
+ parameters: {
161
+ 'type' => 'object',
162
+ 'properties' => {
163
+ 'path' => { 'type' => 'string', 'description' => 'Relative file path (e.g. "app/models/user.rb")' },
164
+ 'start_line' => { 'type' => 'integer', 'description' => 'First line to read (1-based). Optional — omit to start from beginning.' },
165
+ 'end_line' => { 'type' => 'integer', 'description' => 'Last line to read (1-based, inclusive). Optional — omit to read to end.' }
166
+ },
167
+ 'required' => ['path']
168
+ },
169
+ handler: ->(args) { code.read_file(args['path'], start_line: args['start_line'], end_line: args['end_line']) }
170
+ )
171
+
172
+ register(
173
+ name: 'search_code',
174
+ description: 'Search for a pattern in Ruby files. Returns matching lines with file paths.',
175
+ parameters: {
176
+ 'type' => 'object',
177
+ 'properties' => {
178
+ 'query' => { 'type' => 'string', 'description' => 'Search pattern (substring match)' },
179
+ 'directory' => { 'type' => 'string', 'description' => 'Relative directory to search in. Defaults to "app".' }
180
+ },
181
+ 'required' => ['query']
182
+ },
183
+ handler: ->(args) { code.search_code(args['query'], args['directory']) }
184
+ )
185
+
186
+ if @executor
187
+ register(
188
+ name: 'recall_output',
189
+ description: 'Retrieve a previous code execution output that was omitted from the conversation to save context. Use the output id shown in the "[Output omitted]" placeholder.',
190
+ parameters: {
191
+ 'type' => 'object',
192
+ 'properties' => {
193
+ 'id' => { 'type' => 'integer', 'description' => 'The output id to retrieve' }
194
+ },
195
+ 'required' => ['id']
196
+ },
197
+ handler: ->(args) {
198
+ result = @executor.recall_output(args['id'].to_i)
199
+ result || "No output found with id #{args['id']}"
200
+ }
201
+ )
202
+ end
203
+
204
+ unless @mode == :init
205
+ register(
206
+ name: 'ask_user',
207
+ description: 'Ask the console user a clarifying question. Use this when you need specific information to write accurate code (e.g. which user they are, which record to target, what value to use). Do NOT generate placeholder values like YOUR_USER_ID — ask instead.',
208
+ parameters: {
209
+ 'type' => 'object',
210
+ 'properties' => {
211
+ 'question' => { 'type' => 'string', 'description' => 'The question to ask the user' }
212
+ },
213
+ 'required' => ['question']
214
+ },
215
+ handler: ->(args) { ask_user(args['question']) }
216
+ )
217
+
218
+ register_memory_tools
219
+ register_execute_plan
220
+ end
221
+ end
222
+
223
+ def register_memory_tools
224
+ return unless RailsConsoleAI.configuration.memories_enabled
225
+
226
+ require 'rails_console_ai/tools/memory_tools'
227
+ memory = MemoryTools.new
228
+
229
+ register(
230
+ name: 'save_memory',
231
+ description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic).',
232
+ parameters: {
233
+ 'type' => 'object',
234
+ 'properties' => {
235
+ 'name' => { 'type' => 'string', 'description' => 'Short name for this memory (e.g. "Sharding architecture")' },
236
+ 'description' => { 'type' => 'string', 'description' => 'Detailed description of what you learned' },
237
+ 'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags (e.g. ["database", "sharding"])' }
238
+ },
239
+ 'required' => ['name', 'description']
240
+ },
241
+ handler: ->(args) {
242
+ memory.save_memory(name: args['name'], description: args['description'], tags: args['tags'] || [])
243
+ }
244
+ )
245
+
246
+ register(
247
+ name: 'delete_memory',
248
+ description: 'Delete a memory by name.',
249
+ parameters: {
250
+ 'type' => 'object',
251
+ 'properties' => {
252
+ 'name' => { 'type' => 'string', 'description' => 'The memory name to delete (e.g. "Sharding architecture")' }
253
+ },
254
+ 'required' => ['name']
255
+ },
256
+ handler: ->(args) { memory.delete_memory(name: args['name']) }
257
+ )
258
+
259
+ register(
260
+ name: 'recall_memories',
261
+ description: 'Search your saved memories about this codebase. Call with no args to list all, or pass a query/tag to filter.',
262
+ parameters: {
263
+ 'type' => 'object',
264
+ 'properties' => {
265
+ 'query' => { 'type' => 'string', 'description' => 'Search term to filter by name, description, or tags' },
266
+ 'tag' => { 'type' => 'string', 'description' => 'Filter by a specific tag' }
267
+ }
268
+ },
269
+ handler: ->(args) { memory.recall_memories(query: args['query'], tag: args['tag']) }
270
+ )
271
+ end
272
+
273
+ def register_execute_plan
274
+ return unless @executor
275
+
276
+ register(
277
+ name: 'execute_plan',
278
+ description: 'Execute a multi-step plan. Each step has a description and Ruby code. The plan is shown to the user for approval, then each step is executed in order. After each step executes, its return value is stored as step1, step2, etc. Use these variables in later steps to reference earlier results (e.g. `token = step1`).',
279
+ parameters: {
280
+ 'type' => 'object',
281
+ 'properties' => {
282
+ 'steps' => {
283
+ 'type' => 'array',
284
+ 'description' => 'Ordered list of steps to execute',
285
+ 'items' => {
286
+ 'type' => 'object',
287
+ 'properties' => {
288
+ 'description' => { 'type' => 'string', 'description' => 'What this step does' },
289
+ 'code' => { 'type' => 'string', 'description' => 'Ruby code to execute' }
290
+ },
291
+ 'required' => %w[description code]
292
+ }
293
+ }
294
+ },
295
+ 'required' => ['steps']
296
+ },
297
+ handler: ->(args) { execute_plan(args['steps'] || []) }
298
+ )
299
+ end
300
+
301
+ def execute_plan(steps)
302
+ return 'No steps provided.' if steps.nil? || steps.empty?
303
+
304
+ auto = RailsConsoleAI.configuration.auto_execute
305
+
306
+ # Display full plan
307
+ $stdout.puts
308
+ $stdout.puts "\e[36m Plan (#{steps.length} steps):\e[0m"
309
+ steps.each_with_index do |step, i|
310
+ $stdout.puts "\e[36m #{i + 1}. #{step['description']}\e[0m"
311
+ $stdout.puts highlight_plan_code(step['code'])
312
+ end
313
+ $stdout.puts
314
+
315
+ # Ask for plan approval (unless auto-execute)
316
+ skip_confirmations = auto
317
+ unless auto
318
+ if @channel
319
+ answer = @channel.confirm(" Accept plan? [y/N/a(uto)] ")
320
+ else
321
+ $stdout.print "\e[33m Accept plan? [y/N/a(uto)] \e[0m"
322
+ answer = $stdin.gets.to_s.strip.downcase
323
+ end
324
+ case answer
325
+ when 'a', 'auto'
326
+ skip_confirmations = true
327
+ when 'y', 'yes'
328
+ skip_confirmations = true if steps.length == 1
329
+ else
330
+ $stdout.puts "\e[33m Plan declined.\e[0m"
331
+ feedback = ask_feedback("What would you like changed?")
332
+ return "User declined the plan. Feedback: #{feedback}"
333
+ end
334
+ end
335
+
336
+ # Execute steps one by one
337
+ results = []
338
+ steps.each_with_index do |step, i|
339
+ $stdout.puts
340
+ $stdout.puts "\e[36m Step #{i + 1}/#{steps.length}: #{step['description']}\e[0m"
341
+ $stdout.puts "\e[33m # Code:\e[0m"
342
+ $stdout.puts highlight_plan_code(step['code'])
343
+
344
+ # Per-step confirmation (unless auto-execute or plan-level auto)
345
+ unless skip_confirmations
346
+ if @channel
347
+ step_answer = @channel.confirm(" Run? [y/N/edit] ")
348
+ else
349
+ $stdout.print "\e[33m Run? [y/N/edit] \e[0m"
350
+ step_answer = $stdin.gets.to_s.strip.downcase
351
+ end
352
+
353
+ case step_answer
354
+ when 'e', 'edit'
355
+ edited = edit_step_code(step['code'])
356
+ if edited && edited != step['code']
357
+ $stdout.puts "\e[33m # Edited code:\e[0m"
358
+ $stdout.puts highlight_plan_code(edited)
359
+ if @channel
360
+ confirm = @channel.confirm(" Run edited code? [y/N] ")
361
+ else
362
+ $stdout.print "\e[33m Run edited code? [y/N] \e[0m"
363
+ confirm = $stdin.gets.to_s.strip.downcase
364
+ end
365
+ unless confirm == 'y' || confirm == 'yes'
366
+ feedback = ask_feedback("What would you like changed?")
367
+ results << "Step #{i + 1}: User declined after edit. Feedback: #{feedback}"
368
+ break
369
+ end
370
+ step['code'] = edited
371
+ end
372
+ when 'y', 'yes'
373
+ # proceed
374
+ else
375
+ feedback = ask_feedback("What would you like changed?")
376
+ results << "Step #{i + 1}: User declined. Feedback: #{feedback}"
377
+ break
378
+ end
379
+ end
380
+
381
+ exec_result = @executor.execute(step['code'])
382
+
383
+ # On safety error, offer to re-run with guards disabled (console only)
384
+ if @executor.last_safety_error
385
+ if @channel && !@channel.supports_danger?
386
+ results << "Step #{i + 1} (#{step['description']}):\nBLOCKED by safety guard: #{@executor.last_error}. Write operations are not permitted in this channel."
387
+ break
388
+ else
389
+ exec_result = @executor.offer_danger_retry(step['code'])
390
+ end
391
+ end
392
+
393
+ # Make result available as step1, step2, etc. for subsequent steps
394
+ @executor.binding_context.local_variable_set(:"step#{i + 1}", exec_result)
395
+ output = @executor.last_output
396
+ error = @executor.last_error
397
+
398
+ step_report = "Step #{i + 1} (#{step['description']}):\n"
399
+ if error
400
+ step_report += "ERROR: #{error}\n"
401
+ end
402
+ if output && !output.strip.empty?
403
+ step_report += "Output: #{output.strip}\n"
404
+ end
405
+ step_report += "Return value: #{exec_result.inspect}"
406
+ results << step_report
407
+ end
408
+
409
+ results.join("\n\n")
410
+ end
411
+
412
+ def highlight_plan_code(code)
413
+ if coderay_available?
414
+ CodeRay.scan(code, :ruby).terminal.gsub(/^/, ' ')
415
+ else
416
+ code.split("\n").map { |l| " \e[37m#{l}\e[0m" }.join("\n")
417
+ end
418
+ end
419
+
420
+ def edit_step_code(code)
421
+ require 'tempfile'
422
+ editor = ENV['EDITOR'] || 'vi'
423
+ tmpfile = Tempfile.new(['rails_console_ai_step', '.rb'])
424
+ tmpfile.write(code)
425
+ tmpfile.flush
426
+ system("#{editor} #{tmpfile.path}")
427
+ File.read(tmpfile.path).strip
428
+ rescue => e
429
+ $stderr.puts "\e[31m Editor error: #{e.message}\e[0m"
430
+ code
431
+ ensure
432
+ tmpfile.close! if tmpfile
433
+ end
434
+
435
+ def coderay_available?
436
+ return @coderay_available unless @coderay_available.nil?
437
+ @coderay_available = begin
438
+ require 'coderay'
439
+ true
440
+ rescue LoadError
441
+ false
442
+ end
443
+ end
444
+
445
+ def ask_feedback(prompt)
446
+ if @channel
447
+ @channel.prompt(" #{prompt} > ")
448
+ else
449
+ $stdout.print "\e[36m #{prompt} > \e[0m"
450
+ feedback = $stdin.gets
451
+ return '(no feedback provided)' if feedback.nil?
452
+ feedback.strip.empty? ? '(no feedback provided)' : feedback.strip
453
+ end
454
+ end
455
+
456
+ def ask_user(question)
457
+ if @channel
458
+ @channel.prompt(" ? #{question}\n > ")
459
+ else
460
+ $stdout.puts "\e[36m ? #{question}\e[0m"
461
+ $stdout.print "\e[36m > \e[0m"
462
+ answer = $stdin.gets
463
+ return '(no answer provided)' if answer.nil?
464
+ answer.strip.empty? ? '(no answer provided)' : answer.strip
465
+ end
466
+ end
467
+
468
+ def register(name:, description:, parameters:, handler:)
469
+ @definitions << {
470
+ name: name,
471
+ description: description,
472
+ parameters: parameters
473
+ }
474
+ @handlers[name] = handler
475
+ end
476
+ end
477
+ end
478
+ end
@@ -0,0 +1,60 @@
1
+ module RailsConsoleAI
2
+ module Tools
3
+ class SchemaTools
4
+ def list_tables
5
+ return "ActiveRecord is not connected." unless ar_connected?
6
+
7
+ tables = connection.tables.sort
8
+ tables.reject! { |t| t == 'schema_migrations' || t == 'ar_internal_metadata' }
9
+ return "No tables found." if tables.empty?
10
+
11
+ tables.join(", ")
12
+ rescue => e
13
+ "Error listing tables: #{e.message}"
14
+ end
15
+
16
+ def describe_table(table_name)
17
+ return "ActiveRecord is not connected." unless ar_connected?
18
+ return "Error: table_name is required." if table_name.nil? || table_name.strip.empty?
19
+
20
+ table_name = table_name.strip
21
+ unless connection.tables.include?(table_name)
22
+ return "Table '#{table_name}' not found. Use list_tables to see available tables."
23
+ end
24
+
25
+ cols = connection.columns(table_name).map do |c|
26
+ parts = ["#{c.name}:#{c.type}"]
27
+ parts << "nullable" if c.null
28
+ parts << "default=#{c.default}" unless c.default.nil?
29
+ parts.join(" ")
30
+ end
31
+
32
+ indexes = connection.indexes(table_name).map do |idx|
33
+ unique = idx.unique ? "UNIQUE " : ""
34
+ "#{unique}INDEX on (#{idx.columns.join(', ')})"
35
+ end
36
+
37
+ result = "Table: #{table_name}\n"
38
+ result += "Columns:\n"
39
+ cols.each { |c| result += " #{c}\n" }
40
+ unless indexes.empty?
41
+ result += "Indexes:\n"
42
+ indexes.each { |i| result += " #{i}\n" }
43
+ end
44
+ result
45
+ rescue => e
46
+ "Error describing table '#{table_name}': #{e.message}"
47
+ end
48
+
49
+ private
50
+
51
+ def ar_connected?
52
+ defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
53
+ end
54
+
55
+ def connection
56
+ ActiveRecord::Base.connection
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module RailsConsoleAI
2
+ VERSION = '0.13.0'.freeze
3
+ end