rails_console_ai 0.22.0 → 0.24.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.
@@ -148,7 +148,7 @@ module RailsConsoleAi
148
148
  rescue SyntaxError => e
149
149
  $stdout = old_stdout if old_stdout
150
150
  @last_error = "SyntaxError: #{e.message}"
151
- display_error(@last_error)
151
+ log_execution_error(@last_error)
152
152
  @last_output = nil
153
153
  nil
154
154
  rescue => e
@@ -166,7 +166,7 @@ module RailsConsoleAi
166
166
  end
167
167
  @last_error = "#{e.class}: #{e.message}"
168
168
  backtrace = e.backtrace.first(3).map { |line| " #{line}" }.join("\n")
169
- display_error("Error: #{@last_error}\n#{backtrace}")
169
+ log_execution_error("Error: #{@last_error}\n#{backtrace}")
170
170
  @last_output = captured_output&.string
171
171
  nil
172
172
  end
@@ -216,7 +216,20 @@ module RailsConsoleAi
216
216
 
217
217
  loop do
218
218
  case answer
219
- when 'y', 'yes', 'a'
219
+ when 'a', 'auto'
220
+ RailsConsoleAi.configuration.auto_execute = true
221
+ if @channel
222
+ @channel.display_status("Auto-execute: ON")
223
+ else
224
+ $stdout.puts colorize("Auto-execute: ON", :cyan)
225
+ end
226
+ result = execute(code)
227
+ if @last_safety_error
228
+ return nil unless danger_allowed?
229
+ return offer_danger_retry(code)
230
+ end
231
+ return result
232
+ when 'y', 'yes'
220
233
  result = execute(code)
221
234
  if @last_safety_error
222
235
  return nil unless danger_allowed?
@@ -310,6 +323,14 @@ module RailsConsoleAi
310
323
  end
311
324
  end
312
325
 
326
+ def execute_unsafe(code)
327
+ guards = RailsConsoleAi.configuration.safety_guards
328
+ guards.disable!
329
+ execute(code)
330
+ ensure
331
+ guards.enable!
332
+ end
333
+
313
334
  private
314
335
 
315
336
  def danger_allowed?
@@ -324,6 +345,15 @@ module RailsConsoleAi
324
345
  end
325
346
  end
326
347
 
348
+ # Log code execution errors — full details to STDOUT, brief summary to Slack
349
+ def log_execution_error(msg)
350
+ if @channel && @channel.mode == 'slack'
351
+ RailsConsoleAi.logger.info("Code execution error: #{msg}")
352
+ else
353
+ display_error(msg)
354
+ end
355
+ end
356
+
327
357
  def allow_description(guard, blocked_key)
328
358
  case guard
329
359
  when :database_writes
@@ -335,20 +365,12 @@ module RailsConsoleAi
335
365
  end
336
366
  end
337
367
 
338
- def execute_unsafe(code)
339
- guards = RailsConsoleAi.configuration.safety_guards
340
- guards.disable!
341
- execute(code)
342
- ensure
343
- guards.enable!
344
- end
345
-
346
368
  def execute_prompt
347
369
  guards = RailsConsoleAi.configuration.safety_guards
348
370
  if !guards.empty? && guards.enabled? && danger_allowed?
349
- "Execute? [y/N/danger] "
371
+ "Execute? [y/N/a/danger] "
350
372
  else
351
- "Execute? [y/N] "
373
+ "Execute? [y/N/a] "
352
374
  end
353
375
  end
354
376
 
@@ -121,8 +121,10 @@ module RailsConsoleAi
121
121
  else
122
122
  [{ text: msg[:content].to_s }]
123
123
  end
124
- # Bedrock rejects empty text blocks in content arrays
125
- content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.empty? }
124
+ # Bedrock rejects empty or whitespace-only text blocks in content arrays
125
+ content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.strip.empty? }
126
+ # Bedrock also rejects messages with completely empty content arrays
127
+ content << { text: '.' } if content.empty?
126
128
  { role: msg[:role].to_s, content: content }
127
129
  end
128
130
 
@@ -58,8 +58,8 @@ module RailsConsoleAi
58
58
  @engine.send(:send_query, query, conversation: conversation)
59
59
  end
60
60
 
61
- def trim_old_outputs(messages)
62
- @engine.send(:trim_old_outputs, messages)
61
+ def trim_large_outputs(messages)
62
+ @engine.send(:trim_large_outputs, messages)
63
63
  end
64
64
  end
65
65
  end
@@ -32,8 +32,57 @@ module RailsConsoleAi
32
32
  skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
33
33
  end
34
34
 
35
+ def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
36
+ key = skill_key(name)
37
+ existing = find_skill(name)
38
+
39
+ frontmatter = {
40
+ 'name' => name,
41
+ 'description' => description,
42
+ 'tags' => Array(tags)
43
+ }
44
+ bypasses = Array(bypass_guards_for_methods)
45
+ frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
46
+
47
+ content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
48
+ @storage.write(key, content)
49
+
50
+ path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
51
+ if existing
52
+ "Skill updated: \"#{name}\" (#{path})"
53
+ else
54
+ "Skill created: \"#{name}\" (#{path})"
55
+ end
56
+ rescue Storage::StorageError => e
57
+ "FAILED to save skill (#{e.message})."
58
+ end
59
+
60
+ def delete_skill(name:)
61
+ key = skill_key(name)
62
+ unless @storage.exists?(key)
63
+ found = load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
64
+ return "No skill found: \"#{name}\"" unless found
65
+ key = skill_key(found['name'])
66
+ end
67
+
68
+ skill = load_skill(key)
69
+ @storage.delete(key)
70
+ "Skill deleted: \"#{skill ? skill['name'] : name}\""
71
+ rescue Storage::StorageError => e
72
+ "FAILED to delete skill (#{e.message})."
73
+ end
74
+
35
75
  private
36
76
 
77
+ def skill_key(name)
78
+ slug = name.downcase.strip
79
+ .gsub(/[^a-z0-9\s-]/, '')
80
+ .gsub(/[\s]+/, '-')
81
+ .gsub(/-+/, '-')
82
+ .sub(/^-/, '').sub(/-$/, '')
83
+ "#{SKILLS_DIR}/#{slug}.md"
84
+ end
85
+
37
86
  def load_skill(key)
38
87
  content = @storage.read(key)
39
88
  return nil if content.nil? || content.strip.empty?
@@ -22,7 +22,9 @@ module RailsConsoleAi
22
22
 
23
23
  raise ConfigurationError, "SLACK_BOT_TOKEN is required" unless @bot_token
24
24
  raise ConfigurationError, "SLACK_APP_TOKEN is required (Socket Mode)" unless @app_token
25
- raise ConfigurationError, "slack_allowed_usernames must be configured (e.g. ['alice'] or 'ALL')" unless RailsConsoleAi.configuration.slack_allowed_usernames
25
+ unless RailsConsoleAi.configuration.channel_setting('slack', 'allowed_usernames')
26
+ raise ConfigurationError, "slack allowed_usernames must be configured — either channels['slack']['allowed_usernames'] or slack_allowed_usernames (e.g. ['alice'] or 'ALL')"
27
+ end
26
28
 
27
29
  @bot_user_id = nil
28
30
  @sessions = {} # thread_ts → { channel:, engine:, thread:, owner_user_id: }
@@ -316,8 +318,7 @@ module RailsConsoleAi
316
318
  unless session[:owner_user_id] == user_id
317
319
  # Non-owner: tell unrecognized users, silently ignore recognized non-owners
318
320
  chk_name = resolve_user_name(user_id)
319
- chk_list = Array(RailsConsoleAi.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
320
- unless chk_list.include?('all') || chk_list.include?(chk_name.to_s.downcase)
321
+ unless RailsConsoleAi.configuration.username_allowed?('slack', 'allowed_usernames', chk_name)
321
322
  puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{chk_name} << (ignored — not in allowed usernames)"
322
323
  post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{chk_name}). Ask an admin to add you to the allowed usernames list.")
323
324
  end
@@ -338,8 +339,7 @@ module RailsConsoleAi
338
339
  # --- Common processing (DMs and channels) ---
339
340
  user_name = resolve_user_name(user_id)
340
341
 
341
- allowed_list = Array(RailsConsoleAi.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
342
- unless allowed_list.include?('all') || allowed_list.include?(user_name.to_s.downcase)
342
+ unless RailsConsoleAi.configuration.username_allowed?('slack', 'allowed_usernames', user_name)
343
343
  puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name} << (ignored — not in allowed usernames)"
344
344
  post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{user_name}). Ask an admin to add you to the allowed usernames list.")
345
345
  return
@@ -443,14 +443,14 @@ module RailsConsoleAi
443
443
  Thread.current.report_on_exception = false
444
444
  Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
445
445
  begin
446
- channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
446
+ channel.display_status("_session: #{channel_id}/#{thread_ts}_")
447
447
  if restored
448
448
  puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
449
- channel.display_dim("_(session restored — continuing from previous conversation)_")
449
+ channel.display_status("_(session restored — continuing from previous conversation)_")
450
450
  end
451
451
  engine.process_message(text)
452
452
  rescue => e
453
- channel.display_error("Error: #{e.class}: #{e.message}")
453
+ channel.display_error("Something went wrong. Please try again.")
454
454
  RailsConsoleAi.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
455
455
  ensure
456
456
  ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
@@ -488,7 +488,7 @@ module RailsConsoleAi
488
488
  begin
489
489
  engine.process_message(text)
490
490
  rescue => e
491
- channel.display_error("Error: #{e.class}: #{e.message}")
491
+ channel.display_error("Something went wrong. Please try again.")
492
492
  RailsConsoleAi.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
493
493
  ensure
494
494
  ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
@@ -497,6 +497,14 @@ module RailsConsoleAi
497
497
  end
498
498
 
499
499
  def handle_direct_code(session, channel_id, thread_ts, raw_code, user_name, user_id)
500
+ # Check code execution permission
501
+ unless RailsConsoleAi.configuration.username_allowed?('slack', 'allow_code_execution', user_name)
502
+ puts "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name} << (blocked — not in allow_code_execution)"
503
+ post_message(channel: channel_id, thread_ts: thread_ts,
504
+ text: "Sorry, you don't have permission to execute code directly. Ask an admin to add you to the `allow_code_execution` list.")
505
+ return
506
+ end
507
+
500
508
  # Ensure a session exists for this thread
501
509
  unless session
502
510
  start_direct_session(channel_id, thread_ts, user_name, user_id)
@@ -521,7 +529,7 @@ module RailsConsoleAi
521
529
  end
522
530
  engine.send(:log_interactive_turn)
523
531
  rescue => e
524
- channel.display_error("Error: #{e.class}: #{e.message}")
532
+ channel.display_error("Something went wrong. Please try again.")
525
533
  RailsConsoleAi.logger.error("SlackBot direct code error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
526
534
  ensure
527
535
  ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
@@ -646,7 +654,7 @@ module RailsConsoleAi
646
654
  engine.send(:log_interactive_turn)
647
655
  rescue => e
648
656
  post_message(channel: channel_id, thread_ts: thread_ts,
649
- text: "Retry failed: #{e.message}")
657
+ text: ":x: Retry didn't work. Please try again.")
650
658
  ensure
651
659
  ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
652
660
  end
@@ -744,13 +752,9 @@ module RailsConsoleAi
744
752
  history = engine.history
745
753
  return "No conversation history yet." if history.empty?
746
754
 
747
- msg_count = history.length
748
- chars = history.sum { |m| m[:content].to_s.length }
749
- user_msgs = history.count { |m| m[:role].to_s == 'user' }
750
- asst_msgs = history.count { |m| m[:role].to_s == 'assistant' }
751
- name_str = engine.session_name ? " (*#{engine.session_name}*)" : ""
752
-
753
- "Context#{name_str}: #{msg_count} messages (#{user_msgs} user, #{asst_msgs} assistant), ~#{chars} chars"
755
+ buf = StringIO.new
756
+ engine.send(:display_conversation_to, buf)
757
+ "```\n#{buf.string}```"
754
758
  end
755
759
 
756
760
  def count_bot_messages(channel_id, thread_ts)
@@ -50,6 +50,20 @@ module RailsConsoleAi
50
50
  "FAILED to delete memory (#{e.message})."
51
51
  end
52
52
 
53
+ def recall_memory(name:)
54
+ key = memory_key(name)
55
+ memory = load_memory(key)
56
+ # Fall back to case-insensitive name search
57
+ unless memory
58
+ memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.downcase }
59
+ end
60
+ return "No memory found: \"#{name}\"" unless memory
61
+
62
+ line = "**#{memory['name']}**\n#{memory['description']}"
63
+ line += "\nTags: #{memory['tags'].join(', ')}" if memory['tags'] && !memory['tags'].empty?
64
+ line
65
+ end
66
+
53
67
  def recall_memories(query: nil, tag: nil)
54
68
  memories = load_all_memories
55
69
  return "No memories stored yet." if memories.empty?
@@ -61,11 +75,14 @@ module RailsConsoleAi
61
75
  }
62
76
  end
63
77
  if query && !query.empty?
64
- q = query.downcase
78
+ words = query.downcase.split(/\s+/)
65
79
  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) }
80
+ searchable = [
81
+ m['name'].to_s.downcase,
82
+ m['description'].to_s.downcase,
83
+ Array(m['tags']).map(&:downcase).join(' ')
84
+ ].join(' ')
85
+ words.all? { |w| searchable.include?(w) }
69
86
  }
70
87
  end
71
88
 
@@ -75,7 +92,7 @@ module RailsConsoleAi
75
92
  line = "**#{m['name']}**\n#{m['description']}"
76
93
  line += "\nTags: #{m['tags'].join(', ')}" if m['tags'] && !m['tags'].empty?
77
94
  line
78
- }.join("\n\n")
95
+ }.join("\n\n---\n\n")
79
96
  end
80
97
 
81
98
  def memory_summaries
@@ -35,6 +35,34 @@ module RailsConsoleAi
35
35
  result = "Model: #{model.name}\n"
36
36
  result += "Table: #{model.table_name}\n"
37
37
 
38
+ # Columns and indexes from the database table
39
+ begin
40
+ if ActiveRecord::Base.connected?
41
+ conn = ActiveRecord::Base.connection
42
+ if conn.tables.include?(model.table_name)
43
+ cols = conn.columns(model.table_name).map do |c|
44
+ parts = ["#{c.name}:#{c.type}"]
45
+ parts << "nullable" if c.null
46
+ parts << "default=#{c.default}" unless c.default.nil?
47
+ parts.join(" ")
48
+ end
49
+ result += "Columns:\n"
50
+ cols.each { |c| result += " #{c}\n" }
51
+
52
+ indexes = conn.indexes(model.table_name).map do |idx|
53
+ unique = idx.unique ? "UNIQUE " : ""
54
+ "#{unique}INDEX on (#{idx.columns.join(', ')})"
55
+ end
56
+ unless indexes.empty?
57
+ result += "Indexes:\n"
58
+ indexes.each { |i| result += " #{i}\n" }
59
+ end
60
+ end
61
+ end
62
+ rescue => e
63
+ # table introspection may fail
64
+ end
65
+
38
66
  assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
39
67
  unless assocs.empty?
40
68
  result += "Associations:\n"
@@ -6,7 +6,7 @@ module RailsConsoleAi
6
6
  attr_reader :definitions
7
7
 
8
8
  # Tools that should never be cached (side effects or user interaction)
9
- NO_CACHE = %w[ask_user save_memory delete_memory execute_code execute_plan activate_skill].freeze
9
+ NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill].freeze
10
10
 
11
11
  def initialize(executor: nil, mode: :default, channel: nil)
12
12
  @executor = executor
@@ -111,7 +111,7 @@ module RailsConsoleAi
111
111
 
112
112
  register(
113
113
  name: 'describe_table',
114
- description: 'Get column names and types for a specific database table.',
114
+ description: 'Get column names, types, and indexes for a database table. Use this only for tables that have no corresponding ActiveRecord model — for tables with models, use describe_model instead (it includes columns).',
115
115
  parameters: {
116
116
  'type' => 'object',
117
117
  'properties' => {
@@ -131,7 +131,7 @@ module RailsConsoleAi
131
131
 
132
132
  register(
133
133
  name: 'describe_model',
134
- description: 'Get detailed info about a specific model: associations, validations, table name.',
134
+ description: 'Get full details about a model: columns, indexes, associations, validations, and scopes. This is the primary tool for understanding a model — it includes the table schema.',
135
135
  parameters: {
136
136
  'type' => 'object',
137
137
  'properties' => {
@@ -270,6 +270,19 @@ module RailsConsoleAi
270
270
  handler: ->(args) { memory.delete_memory(name: args['name']) }
271
271
  )
272
272
 
273
+ register(
274
+ name: 'recall_memory',
275
+ description: 'Retrieve a specific memory by name. Use this when you know which memory you need (e.g. from the Memories list in the system prompt). For searching across memories, use recall_memories instead.',
276
+ parameters: {
277
+ 'type' => 'object',
278
+ 'properties' => {
279
+ 'name' => { 'type' => 'string', 'description' => 'The exact memory name (e.g. "Sharding architecture")' }
280
+ },
281
+ 'required' => ['name']
282
+ },
283
+ handler: ->(args) { memory.recall_memory(name: args['name']) }
284
+ )
285
+
273
286
  register(
274
287
  name: 'recall_memories',
275
288
  description: 'Search your saved memories about this codebase. Call with no args to list all, or pass a query/tag to filter.',
@@ -312,6 +325,44 @@ module RailsConsoleAi
312
325
  skill['body']
313
326
  }
314
327
  )
328
+
329
+ register(
330
+ name: 'save_skill',
331
+ description: 'Create or update a skill — a reusable procedure for a specific operation. Use when the user asks you to create a skill, recipe, or runbook. Skills differ from memories: a skill is a step-by-step procedure to follow, while a memory is a fact or pattern you learned.',
332
+ parameters: {
333
+ 'type' => 'object',
334
+ 'properties' => {
335
+ 'name' => { 'type' => 'string', 'description' => 'Skill name (e.g. "Resurrect deleted BookingPage")' },
336
+ 'description' => { 'type' => 'string', 'description' => 'One-line description of when to use this skill' },
337
+ 'body' => { 'type' => 'string', 'description' => 'The full skill recipe in markdown. Include: ## When to use, ## Recipe (numbered steps with code blocks), ## Notes (optional).' },
338
+ 'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags for categorization (e.g. ["booking-page", "admin"])' },
339
+ 'bypass_guards_for_methods' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Methods that should bypass safety guards when this skill is active (e.g. ["BookingPage#save!", "BookingPage#ensure_subdomain_set!"])' }
340
+ },
341
+ 'required' => %w[name description body]
342
+ },
343
+ handler: ->(args) {
344
+ loader.save_skill(
345
+ name: args['name'],
346
+ description: args['description'],
347
+ body: args['body'],
348
+ tags: args['tags'] || [],
349
+ bypass_guards_for_methods: args['bypass_guards_for_methods'] || []
350
+ )
351
+ }
352
+ )
353
+
354
+ register(
355
+ name: 'delete_skill',
356
+ description: 'Delete a skill by name.',
357
+ parameters: {
358
+ 'type' => 'object',
359
+ 'properties' => {
360
+ 'name' => { 'type' => 'string', 'description' => 'The skill name to delete' }
361
+ },
362
+ 'required' => ['name']
363
+ },
364
+ handler: ->(args) { loader.delete_skill(name: args['name']) }
365
+ )
315
366
  end
316
367
 
317
368
  def register_execute_plan
@@ -361,12 +412,8 @@ module RailsConsoleAi
361
412
  # Show the code to the user
362
413
  @executor.display_code_block(code)
363
414
 
364
- # Slack: execute directly, suppress display (output goes back to LLM as tool result).
365
- # Console: show code and confirm before executing, display output directly.
366
- exec_result = if @channel&.mode == 'slack'
367
- @executor.execute(code, display: false)
368
- elsif RailsConsoleAi.configuration.auto_execute
369
- @executor.execute(code, display: false)
415
+ exec_result = if @channel&.mode == 'slack' || RailsConsoleAi.configuration.auto_execute
416
+ @executor.execute(code)
370
417
  else
371
418
  @executor.confirm_and_execute(code)
372
419
  end
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.22.0'.freeze
2
+ VERSION = '0.24.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr