rails_console_ai 0.22.0 → 0.23.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
@@ -310,6 +310,14 @@ module RailsConsoleAi
310
310
  end
311
311
  end
312
312
 
313
+ def execute_unsafe(code)
314
+ guards = RailsConsoleAi.configuration.safety_guards
315
+ guards.disable!
316
+ execute(code)
317
+ ensure
318
+ guards.enable!
319
+ end
320
+
313
321
  private
314
322
 
315
323
  def danger_allowed?
@@ -324,6 +332,15 @@ module RailsConsoleAi
324
332
  end
325
333
  end
326
334
 
335
+ # Log code execution errors — full details to STDOUT, brief summary to Slack
336
+ def log_execution_error(msg)
337
+ if @channel && @channel.mode == 'slack'
338
+ RailsConsoleAi.logger.info("Code execution error: #{msg}")
339
+ else
340
+ display_error(msg)
341
+ end
342
+ end
343
+
327
344
  def allow_description(guard, blocked_key)
328
345
  case guard
329
346
  when :database_writes
@@ -335,14 +352,6 @@ module RailsConsoleAi
335
352
  end
336
353
  end
337
354
 
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
355
  def execute_prompt
347
356
  guards = RailsConsoleAi.configuration.safety_guards
348
357
  if !guards.empty? && guards.enabled? && danger_allowed?
@@ -123,6 +123,8 @@ module RailsConsoleAi
123
123
  end
124
124
  # Bedrock rejects empty text blocks in content arrays
125
125
  content.reject! { |block| block.is_a?(Hash) && block.key?(:text) && !block.key?(:tool_use) && !block.key?(:tool_result) && block[:text].to_s.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
@@ -450,7 +450,7 @@ module RailsConsoleAi
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,11 +325,56 @@ 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
318
369
  return unless @executor
319
370
 
371
+ # Check per-channel code execution permission
372
+ if @channel
373
+ unless RailsConsoleAi.configuration.username_allowed?(@channel.mode, 'allow_code_execution', @channel.user_identity)
374
+ return
375
+ end
376
+ end
377
+
320
378
  register(
321
379
  name: 'execute_code',
322
380
  description: 'Execute Ruby code in the Rails console and return the result. Use this for all code execution — simple queries, data lookups, reports, etc. The output of puts/print statements is automatically shown to the user. The return value is sent back to you so you can summarize the findings.',
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.22.0'.freeze
2
+ VERSION = '0.23.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.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr