rails_console_ai 0.19.0 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fac547626547b2b99702978a38365e9877c11c3e5892be2d91004395ddfb92a4
4
- data.tar.gz: f89b097e49fb3fb766bc618082ace286658dce54a496decf98251c1f3a8c6eff
3
+ metadata.gz: ee0daa4b9926c6df19c9e2946f019b5d17dd3bd64d4e8998e92d86ed16dff8e1
4
+ data.tar.gz: f336fe3abf34a513a5de12b138d69f4941e3cd65eeecaa8b548658d3da8b846e
5
5
  SHA512:
6
- metadata.gz: 7e9b2488043e925762da5b442412f27b92c57facf2fd880b27bbc74314c41cc4c421e091af690b262b0b6ff965aa4ad66cd5efd0bdde6e1de08737d18e7a0411
7
- data.tar.gz: fc44b20a37d2bc56254f6b4dc966225f8d7442139ae4f7270194939b5d17291e34adf4c8376edbcdbd9f6078915cdef87a293f255cb57c666213c3ad19f0d02a
6
+ metadata.gz: 35d266cac783a3e43a6712380085215b7ba7503874b4f7a97ce6fac30875bded29e658d52dc4d1196bcb2e907e555255538ec6563e190212ea0bbcc6c02179d4
7
+ data.tar.gz: 64e365d8d18a9a559bf9c8ec5b982f7ecff03d1faed9d731bc3734b6493cdeb7cab3055de5ae56da3e5d5112ccfb7b6ecf47b652a7a570c8fdb8401e0eddcf40
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.20.0]
6
+
7
+ - Add per-user system prompt seeding
8
+ - Improve explicit code execution through Slack bot
9
+ - Show last thinking output before prompting
10
+ - Add Skills system
11
+ - Add `bypass_guards_for_methods` to allow specific methods to skip safety guards
12
+ - Add `execute_code` tool for simple query execution without code fences
13
+ - Support `<code>` tags in Slack responses
14
+ - Improve Slack bot server logging and keepalive visibility
15
+ - Improve database safety guards in Rails 5
16
+
5
17
  ## [0.19.0]
6
18
 
7
19
  - Fix duplicate tool result IDs in AWS Bedrock provider
data/README.md CHANGED
@@ -97,6 +97,7 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
97
97
  - **Multi-step plans** — complex tasks are broken into steps, executed sequentially with `step1`/`step2` references
98
98
  - **Two-tier models** — defaults to Sonnet for speed/cost; `/think` upgrades to Opus when you need it
99
99
  - **Cost tracking** — `/cost` shows per-model token usage and estimated spend
100
+ - **Skills** — predefined procedures with guard bypasses that the AI activates on demand
100
101
  - **Memories** — AI saves what it learns about your app across sessions
101
102
  - **App guide** — `ai_init` generates a guide injected into every system prompt
102
103
  - **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
@@ -141,6 +142,74 @@ Raise `RailsConsoleAi::SafetyError` in your app code to trigger the safe mode pr
141
142
  raise RailsConsoleAi::SafetyError, "Stripe charge blocked"
142
143
  ```
143
144
 
145
+ ### Allowing Specific Methods
146
+
147
+ Some operations (like admin approvals) need to write to the database even when guards are active. Use `bypass_guards_for_methods` to declare methods that should bypass all safety guards when called during an AI session:
148
+
149
+ ```ruby
150
+ RailsConsoleAi.configure do |config|
151
+ # Global — applies to all channels
152
+ config.bypass_guards_for_methods = [
153
+ 'ChangeApproval#approve_by!',
154
+ 'ChangeApproval#reject_by!'
155
+ ]
156
+
157
+ # Per-channel — only active in the specified channel
158
+ config.channels = {
159
+ 'slack' => { 'bypass_guards_for_methods' => ['Deployment#promote!'] },
160
+ 'console' => {}
161
+ }
162
+ end
163
+ ```
164
+
165
+ Global and channel-specific methods are merged for the active channel. These method shims are installed lazily on the first AI execution (not at boot) and are session-scoped — they only bypass guards inside `SafetyGuards#wrap`. Outside of an AI session (e.g. in normal web requests), the methods behave normally with zero overhead beyond a single thread-local read.
166
+
167
+ The AI is told about these trusted methods in its system prompt and will use them directly without triggering safety errors.
168
+
169
+ ### Skills
170
+
171
+ Skills bundle a step-by-step recipe with guard bypass declarations into a single file. Unlike `bypass_guards_for_methods` (which is always-on), skill bypasses are only active after the AI explicitly activates the skill.
172
+
173
+ Create markdown files in `.rails_console_ai/skills/`:
174
+
175
+ ```markdown
176
+ ---
177
+ name: Approve/Reject ChangeApprovals
178
+ description: Approve or reject change approval records on behalf of an admin
179
+ tags:
180
+ - change-approval
181
+ - admin
182
+ bypass_guards_for_methods:
183
+ - "ChangeApproval#approve_by!"
184
+ - "ChangeApproval#reject_by!"
185
+ ---
186
+
187
+ ## When to use
188
+ Use when the user asks to approve or reject a change approval.
189
+
190
+ ## Recipe
191
+ 1. Find the ChangeApproval by ID or search
192
+ 2. Confirm approve or reject
193
+ 3. Get optional review notes
194
+ 4. Determine which admin user is acting
195
+ 5. Call approve_by! or reject_by!
196
+
197
+ ## Code Examples
198
+
199
+ ca = ChangeApproval.find(id)
200
+ admin = User.find_by!(email: "admin@example.com")
201
+ ca.approve_by!(admin, "Approved per request")
202
+ ```
203
+
204
+ **How it works:**
205
+
206
+ 1. Skill summaries (name + description) appear in the AI's system prompt
207
+ 2. When the user's request matches a skill, the AI calls `activate_skill` to load the full recipe
208
+ 3. The skill's `bypass_guards_for_methods` are added to the active bypass set
209
+ 4. The AI follows the recipe, executing code with the declared methods bypassing safety guards
210
+
211
+ Skills and global `bypass_guards_for_methods` coexist — use config-level bypasses for simple trusted methods, and skills for operations that benefit from a documented procedure.
212
+
144
213
  ### Toggling Safe Mode
145
214
 
146
215
  - **`/danger`** in interactive mode toggles all guards off/on for the session
@@ -76,4 +76,16 @@ RailsConsoleAi.configure do |config|
76
76
  # config.safety_guard :jobs do |&execute|
77
77
  # Sidekiq::Testing.fake! { execute.call }
78
78
  # end
79
+ #
80
+ # Bypass ALL safety guards when specific methods are called (e.g. trusted admin actions):
81
+ # config.bypass_guards_for_methods = [
82
+ # 'ChangeApproval#approve_by!',
83
+ # 'ChangeApproval#reject_by!'
84
+ # ]
85
+
86
+ # Per-channel settings (channel mode keys: 'slack', 'console'):
87
+ # config.channels = {
88
+ # 'slack' => { 'pinned_memory_tags' => ['sharding'], 'bypass_guards_for_methods' => ['ChangeApproval#approve_by!'] },
89
+ # 'console' => { 'pinned_memory_tags' => [] }
90
+ # }
79
91
  end
@@ -7,6 +7,7 @@ module RailsConsoleAi
7
7
  def display_error(text); raise NotImplementedError; end
8
8
  def display_code(code); raise NotImplementedError; end
9
9
  def display_result(text); raise NotImplementedError; end
10
+ def display_tool_call(text); end # tool call: "-> name(args)"
10
11
  def display_result_output(text); end # stdout output from code execution
11
12
  def prompt(text); raise NotImplementedError; end
12
13
  def confirm(text); raise NotImplementedError; end
@@ -18,6 +18,10 @@ module RailsConsoleAi
18
18
  $stdout.puts "\e[2m#{text}\e[0m"
19
19
  end
20
20
 
21
+ def display_tool_call(text)
22
+ $stdout.puts "\e[33m -> #{text}\e[0m"
23
+ end
24
+
21
25
  def display_warning(text)
22
26
  $stdout.puts colorize(text, :yellow)
23
27
  end
@@ -75,7 +79,7 @@ module RailsConsoleAi
75
79
  end
76
80
 
77
81
  def mode
78
- 'interactive'
82
+ 'console'
79
83
  end
80
84
 
81
85
  def supports_editing?
@@ -56,9 +56,16 @@ module RailsConsoleAi
56
56
  post(":x: #{strip_ansi(text)}")
57
57
  end
58
58
 
59
- def display_code(_code)
59
+ def display_tool_call(text)
60
+ @output_log.write("-> #{text}\n")
61
+ STDOUT.puts "#{@log_prefix} -> #{text}"
62
+ end
63
+
64
+ def display_code(code)
60
65
  # Don't post raw code/plan steps to Slack — non-technical users don't need to see Ruby
61
- nil
66
+ # But do log to STDOUT so server logs show what was generated/executed
67
+ @output_log.write("# Generated code:\n#{code}\n")
68
+ STDOUT.puts "#{@log_prefix} (code)\n# Generated code:\n#{code}"
62
69
  end
63
70
 
64
71
  def display_result_output(output)
@@ -108,6 +115,14 @@ module RailsConsoleAi
108
115
 
109
116
  You are responding to non-technical users in Slack. Follow these rules:
110
117
 
118
+ ## Code Execution
119
+ - ALWAYS use the `execute_code` tool to run Ruby code. Do NOT put code in markdown
120
+ code fences expecting it to be executed — code fences are display-only in Slack.
121
+ - Use `execute_code` for simple queries, and `execute_plan` for multi-step operations.
122
+ - If the user asks you to provide code they can run later, put it in a code fence
123
+ in your text response (it will be displayed but not executed).
124
+
125
+ ## Formatting
111
126
  - Slack does NOT support markdown tables. For tabular data, use `puts` to print
112
127
  a plain-text table inside a code block. Use fixed-width columns with padding so
113
128
  columns align. Example format:
@@ -28,7 +28,10 @@ module RailsConsoleAi
28
28
  :slack_bot_token, :slack_app_token, :slack_channel_ids, :slack_allowed_usernames,
29
29
  :local_url, :local_model, :local_api_key,
30
30
  :bedrock_region,
31
- :code_search_paths
31
+ :code_search_paths,
32
+ :channels,
33
+ :bypass_guards_for_methods,
34
+ :user_extra_info
32
35
 
33
36
  def initialize
34
37
  @provider = :anthropic
@@ -58,6 +61,14 @@ module RailsConsoleAi
58
61
  @local_api_key = nil
59
62
  @bedrock_region = nil
60
63
  @code_search_paths = %w[app]
64
+ @channels = {}
65
+ @bypass_guards_for_methods = []
66
+ @user_extra_info = {}
67
+ end
68
+
69
+ def resolve_user_extra_info(username)
70
+ return nil if @user_extra_info.nil? || @user_extra_info.empty? || username.nil?
71
+ @user_extra_info[username.to_s.downcase]
61
72
  end
62
73
 
63
74
  def safety_guards
@@ -1,7 +1,9 @@
1
1
  module RailsConsoleAi
2
2
  class ContextBuilder
3
- def initialize(config = RailsConsoleAi.configuration)
3
+ def initialize(config = RailsConsoleAi.configuration, channel_mode: nil, user_name: nil)
4
4
  @config = config
5
+ @channel_mode = channel_mode
6
+ @user_name = user_name
5
7
  end
6
8
 
7
9
  def build
@@ -16,6 +18,10 @@ module RailsConsoleAi
16
18
  parts << smart_system_instructions
17
19
  parts << environment_context
18
20
  parts << guide_context
21
+ parts << trusted_methods_context
22
+ parts << skills_context
23
+ parts << user_extra_info_context
24
+ parts << pinned_memory_context
19
25
  parts << memory_context
20
26
  parts.compact.join("\n\n")
21
27
  end
@@ -67,18 +73,27 @@ module RailsConsoleAi
67
73
  When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
68
74
  When you discover important patterns about this app, save them as memories.
69
75
 
70
- You have an execute_plan tool to run multi-step code. When a task requires multiple
71
- sequential operations, use execute_plan with an array of steps (each with a description
72
- and Ruby code). The plan is shown to the user for review before execution begins.
73
- After each step runs, its return value is stored as step1, step2, etc. use these
74
- variables in later steps to reference earlier results (e.g. `api = SalesforceApi.new(step1)`).
75
- For simple single-expression answers, you may respond with a ```ruby code block instead.
76
+ You have tools for executing Ruby code:
77
+ - Use execute_code for simple queries and single operations.
78
+ - Use execute_plan for multi-step tasks that require sequential operations. Each step
79
+ has a description and Ruby code. The plan is shown to the user for review before
80
+ execution begins. After each step runs, its return value is stored as step1, step2,
81
+ etc. use these variables in later steps to reference earlier results
82
+ (e.g. `api = SalesforceApi.new(step1)`).
83
+ - If the user asks you to provide code for them to run later (not execute now), put it
84
+ in a ```ruby code block in your text response.
85
+ - Use `RailsConsoleAi.configuration.safety_guards.without_guards { }` to wrap any
86
+ operation that should bypass safety guards (e.g. calling a known-safe admin method).
87
+
88
+ You have skills — predefined procedures for specific operations. When a user's request
89
+ matches a skill, call activate_skill first to load the recipe and enable its guard
90
+ bypasses, then follow the recipe.
76
91
 
77
92
  RULES:
78
93
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
79
94
  - For multi-step tasks, use execute_plan to break the work into small, clear steps.
80
- - For simple queries, respond with a single ```ruby code block.
81
- - Include a brief one-line explanation before any code block.
95
+ - For simple queries, use the execute_code tool.
96
+ - Include a brief one-line explanation before or after executing code.
82
97
  - Use the app's actual model names, associations, and schema.
83
98
  - Prefer ActiveRecord query interface over raw SQL.
84
99
  - For destructive operations, add a comment warning.
@@ -89,6 +104,19 @@ module RailsConsoleAi
89
104
  PROMPT
90
105
  end
91
106
 
107
+ def trusted_methods_context
108
+ methods = Array(@config.bypass_guards_for_methods)
109
+ if @channel_mode
110
+ channel_cfg = @config.channels[@channel_mode] || {}
111
+ methods = methods | Array(channel_cfg['bypass_guards_for_methods'])
112
+ end
113
+ return nil if methods.empty?
114
+
115
+ lines = ["## Trusted Methods (safety guards bypassed automatically)"]
116
+ methods.each { |m| lines << "- #{m}" }
117
+ lines.join("\n")
118
+ end
119
+
92
120
  def guide_context
93
121
  content = RailsConsoleAi.storage.read(RailsConsoleAi::GUIDE_KEY)
94
122
  return nil if content.nil? || content.strip.empty?
@@ -99,6 +127,48 @@ module RailsConsoleAi
99
127
  nil
100
128
  end
101
129
 
130
+ def skills_context
131
+ require 'rails_console_ai/skill_loader'
132
+ summaries = RailsConsoleAi::SkillLoader.new.skill_summaries
133
+ return nil if summaries.nil? || summaries.empty?
134
+
135
+ lines = ["## Skills (call activate_skill to use)"]
136
+ lines.concat(summaries)
137
+ lines.join("\n")
138
+ rescue => e
139
+ RailsConsoleAi.logger.debug("RailsConsoleAi: skills context failed: #{e.message}")
140
+ nil
141
+ end
142
+
143
+ def user_extra_info_context
144
+ info = @config.resolve_user_extra_info(@user_name)
145
+ return nil if info.nil? || info.strip.empty?
146
+
147
+ "## Current User\n\nUser: #{@user_name}\n#{info}"
148
+ end
149
+
150
+ def pinned_memory_context
151
+ return nil unless @channel_mode
152
+
153
+ channel_cfg = @config.channels[@channel_mode] || {}
154
+ pinned_tags = channel_cfg['pinned_memory_tags'] || []
155
+ return nil if pinned_tags.empty?
156
+
157
+ require 'rails_console_ai/tools/memory_tools'
158
+ sections = pinned_tags.filter_map do |tag|
159
+ content = Tools::MemoryTools.new.recall_memories(tag: tag)
160
+ next if content.nil? || content.include?("No memories")
161
+ content
162
+ end
163
+ return nil if sections.empty?
164
+
165
+ "## Pinned Memories (always available — no need to recall_memories for these)\n\n" \
166
+ + sections.join("\n\n")
167
+ rescue => e
168
+ RailsConsoleAi.logger.debug("RailsConsoleAi: pinned memory context failed: #{e.message}")
169
+ nil
170
+ end
171
+
102
172
  def memory_context
103
173
  return nil unless @config.memories_enabled
104
174
 
@@ -623,7 +623,7 @@ module RailsConsoleAi
623
623
  end
624
624
 
625
625
  def context_builder
626
- @context_builder ||= ContextBuilder.new
626
+ @context_builder ||= ContextBuilder.new(channel_mode: @channel.mode, user_name: @channel.user_identity)
627
627
  end
628
628
 
629
629
  def binding_variable_summary
@@ -768,10 +768,15 @@ module RailsConsoleAi
768
768
  result.tool_calls.each do |tc|
769
769
  break if @channel.cancelled?
770
770
  if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
771
+ # Display any pending LLM text before prompting the user
772
+ if last_thinking
773
+ last_thinking.split("\n").each { |line| @channel.display_dim(" #{line}") }
774
+ last_thinking = nil
775
+ end
771
776
  tool_result = tools.execute(tc[:name], tc[:arguments])
772
777
  else
773
778
  args_display = format_tool_args(tc[:name], tc[:arguments])
774
- $stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
779
+ @channel.display_tool_call("#{tc[:name]}#{args_display}")
775
780
 
776
781
  tool_result = tools.execute(tc[:name], tc[:arguments])
777
782
 
@@ -54,6 +54,7 @@ module RailsConsoleAi
54
54
  @omitted_counter = 0
55
55
  @output_store = {}
56
56
  @output_counter = 0
57
+ @active_skill_bypass_methods = Set.new
57
58
  end
58
59
 
59
60
  def extract_code(response)
@@ -65,29 +66,33 @@ module RailsConsoleAi
65
66
  ANY_CODE_FENCE_REGEX = /```\w*\s*\n.*?```/m
66
67
 
67
68
  def display_response(response)
68
- code = extract_code(response)
69
- explanation = response.gsub(ANY_CODE_FENCE_REGEX, '').strip
69
+ # Code execution now happens via the execute_code tool, not code-fence extraction.
70
+ # Just display the full response text as-is.
71
+ text = response.to_s.strip
72
+ return '' if text.empty?
70
73
 
74
+ $stdout.puts
71
75
  if @channel
72
- $stdout.puts
73
- @channel.display(explanation) unless explanation.empty?
74
- @channel.display_code(code) unless code.empty?
76
+ @channel.display(text)
75
77
  else
76
- $stdout.puts
77
- $stdout.puts colorize(explanation, :cyan) unless explanation.empty?
78
-
79
- unless code.empty?
80
- $stdout.puts
81
- $stdout.puts colorize("# Generated code:", :yellow)
82
- $stdout.puts highlight_code(code)
83
- $stdout.puts
84
- end
78
+ $stdout.puts colorize(text, :cyan)
85
79
  end
86
80
 
87
- code
81
+ '' # No code to extract — the LLM uses execute_code tool instead
82
+ end
83
+
84
+ def display_code_block(code)
85
+ if @channel
86
+ @channel.display_code(code)
87
+ else
88
+ $stdout.puts
89
+ $stdout.puts colorize("# Generated code:", :yellow)
90
+ $stdout.puts highlight_code(code)
91
+ $stdout.puts
92
+ end
88
93
  end
89
94
 
90
- def execute(code)
95
+ def execute(code, display: true)
91
96
  return nil if code.nil? || code.strip.empty?
92
97
 
93
98
  @last_error = nil
@@ -118,11 +123,11 @@ module RailsConsoleAi
118
123
  end
119
124
 
120
125
  # Send captured puts output through channel before the return value
121
- if @channel && !captured_output.string.empty?
126
+ if display && @channel && !captured_output.string.empty?
122
127
  @channel.display_result_output(captured_output.string)
123
128
  end
124
129
 
125
- display_result(result)
130
+ display_result(result) if display
126
131
 
127
132
  @last_output = captured_output.string
128
133
  result
@@ -317,6 +322,14 @@ module RailsConsoleAi
317
322
  nil
318
323
  end
319
324
 
325
+ def activate_skill_bypasses(methods)
326
+ guards = RailsConsoleAi.configuration.safety_guards
327
+ Array(methods).each do |spec|
328
+ @active_skill_bypass_methods << spec
329
+ guards.install_bypass_method!(spec)
330
+ end
331
+ end
332
+
320
333
  private
321
334
 
322
335
  def danger_allowed?
@@ -360,7 +373,11 @@ module RailsConsoleAi
360
373
  end
361
374
 
362
375
  def with_safety_guards(&block)
363
- RailsConsoleAi.configuration.safety_guards.wrap(&block)
376
+ RailsConsoleAi.configuration.safety_guards.wrap(
377
+ channel_mode: @channel&.mode,
378
+ additional_bypass_methods: @active_skill_bypass_methods,
379
+ &block
380
+ )
364
381
  end
365
382
 
366
383
  # Check if an exception is or wraps a SafetyError (e.g. AR::StatementInvalid wrapping it)
@@ -85,12 +85,87 @@ module RailsConsoleAi
85
85
  # Compose all guards around a block of code.
86
86
  # Each guard is an around-block: guard.call { inner }
87
87
  # Result: guard_1 { guard_2 { guard_3 { yield } } }
88
- def wrap(&block)
88
+ def wrap(channel_mode: nil, additional_bypass_methods: nil, &block)
89
89
  return yield unless @enabled && !@guards.empty?
90
90
 
91
- @guards.values.reduce(block) { |inner, guard|
92
- -> { guard.call(&inner) }
93
- }.call
91
+ install_skills_once!
92
+ bypass_set = resolve_bypass_methods(channel_mode)
93
+ Array(additional_bypass_methods).each { |m| bypass_set << m }
94
+
95
+ prev_active = Thread.current[:rails_console_ai_session_active]
96
+ prev_bypass = Thread.current[:rails_console_ai_bypass_methods]
97
+ Thread.current[:rails_console_ai_session_active] = true
98
+ Thread.current[:rails_console_ai_bypass_methods] = bypass_set
99
+ begin
100
+ @guards.values.reduce(block) { |inner, guard|
101
+ -> { guard.call(&inner) }
102
+ }.call
103
+ ensure
104
+ Thread.current[:rails_console_ai_session_active] = prev_active
105
+ Thread.current[:rails_console_ai_bypass_methods] = prev_bypass
106
+ end
107
+ end
108
+
109
+ # Install a bypass shim for a single method spec (e.g. "ChangeApproval#approve_by!").
110
+ # Prepends a module that checks the thread-local bypass set at runtime.
111
+ # Idempotent: tracks which specs have been installed to avoid double-prepending.
112
+ def install_bypass_method!(spec)
113
+ @installed_bypass_specs ||= Set.new
114
+ return if @installed_bypass_specs.include?(spec)
115
+
116
+ class_name, method_name = spec.split('#')
117
+ klass = Object.const_get(class_name) rescue return
118
+ method_sym = method_name.to_sym
119
+
120
+ bypass_mod = Module.new do
121
+ define_method(method_sym) do |*args, &blk|
122
+ if Thread.current[:rails_console_ai_bypass_methods]&.include?(spec)
123
+ RailsConsoleAi.configuration.safety_guards.without_guards { super(*args, &blk) }
124
+ else
125
+ super(*args, &blk)
126
+ end
127
+ end
128
+ end
129
+ klass.prepend(bypass_mod)
130
+ @installed_bypass_specs << spec
131
+ end
132
+
133
+ private
134
+
135
+ def resolve_bypass_methods(channel_mode)
136
+ config = RailsConsoleAi.configuration
137
+ methods = Set.new(config.bypass_guards_for_methods)
138
+ if channel_mode
139
+ channel_cfg = config.channels[channel_mode] || {}
140
+ (channel_cfg['bypass_guards_for_methods'] || []).each { |m| methods << m }
141
+ end
142
+ methods
143
+ end
144
+
145
+ def install_skills_once!
146
+ return if @skills_installed
147
+ (@skills_mutex ||= Mutex.new).synchronize do
148
+ return if @skills_installed
149
+ all_methods = Set.new(RailsConsoleAi.configuration.bypass_guards_for_methods)
150
+ RailsConsoleAi.configuration.channels.each_value do |cfg|
151
+ (cfg['bypass_guards_for_methods'] || []).each { |m| all_methods << m }
152
+ end
153
+ all_methods.each { |spec| install_bypass_method!(spec) }
154
+ @skills_installed = true
155
+ end
156
+ end
157
+
158
+ public
159
+
160
+ # Bypass all safety guards for the duration of the block.
161
+ # Thread-safe: uses a thread-local flag that is restored after the block,
162
+ # even if the block raises an exception.
163
+ def without_guards
164
+ prev = Thread.current[:rails_console_ai_bypass_guards]
165
+ Thread.current[:rails_console_ai_bypass_guards] = true
166
+ yield
167
+ ensure
168
+ Thread.current[:rails_console_ai_bypass_guards] = prev
94
169
  end
95
170
  end
96
171
 
@@ -106,6 +181,7 @@ module RailsConsoleAi
106
181
  private
107
182
 
108
183
  def rails_console_ai_check_write!(sql)
184
+ return if Thread.current[:rails_console_ai_bypass_guards]
109
185
  return unless Thread.current[:rails_console_ai_block_writes] && sql.match?(WRITE_PATTERN)
110
186
 
111
187
  table = sql.match(TABLE_PATTERN)&.captures&.first
@@ -126,6 +202,16 @@ module RailsConsoleAi
126
202
  super
127
203
  end
128
204
 
205
+ def exec_query(sql, *args, **kwargs)
206
+ rails_console_ai_check_write!(sql)
207
+ super
208
+ end
209
+
210
+ def exec_insert(sql, *args, **kwargs)
211
+ rails_console_ai_check_write!(sql)
212
+ super
213
+ end
214
+
129
215
  def exec_delete(sql, *args, **kwargs)
130
216
  rails_console_ai_check_write!(sql)
131
217
  super
@@ -167,6 +253,8 @@ module RailsConsoleAi
167
253
 
168
254
  def request(req, *args, &block)
169
255
  if Thread.current[:rails_console_ai_block_http] && !SAFE_METHODS.include?(req.method)
256
+ return super if Thread.current[:rails_console_ai_bypass_guards]
257
+
170
258
  host = @address.to_s
171
259
  guards = RailsConsoleAi.configuration.safety_guards
172
260
  unless guards.allowed?(:http_mutations, host)
@@ -195,6 +283,8 @@ module RailsConsoleAi
195
283
 
196
284
  def self.mailers
197
285
  ->(&block) {
286
+ return block.call if Thread.current[:rails_console_ai_bypass_guards]
287
+
198
288
  old_value = ActionMailer::Base.perform_deliveries
199
289
  ActionMailer::Base.perform_deliveries = false
200
290
  begin
@@ -0,0 +1,53 @@
1
+ require 'yaml'
2
+
3
+ module RailsConsoleAi
4
+ class SkillLoader
5
+ SKILLS_DIR = 'skills'
6
+
7
+ def initialize(storage = nil)
8
+ @storage = storage || RailsConsoleAi.storage
9
+ end
10
+
11
+ def load_all_skills
12
+ keys = @storage.list("#{SKILLS_DIR}/*.md")
13
+ keys.filter_map { |key| load_skill(key) }
14
+ rescue => e
15
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load skills: #{e.message}")
16
+ []
17
+ end
18
+
19
+ def skill_summaries
20
+ skills = load_all_skills
21
+ return nil if skills.empty?
22
+
23
+ skills.map { |s|
24
+ tags = Array(s['tags'])
25
+ tag_str = tags.empty? ? '' : " [#{tags.join(', ')}]"
26
+ "- **#{s['name']}**#{tag_str}: #{s['description']}"
27
+ }
28
+ end
29
+
30
+ def find_skill(name)
31
+ skills = load_all_skills
32
+ skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
33
+ end
34
+
35
+ private
36
+
37
+ def load_skill(key)
38
+ content = @storage.read(key)
39
+ return nil if content.nil? || content.strip.empty?
40
+ parse_skill(content)
41
+ rescue => e
42
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load skill #{key}: #{e.message}")
43
+ nil
44
+ end
45
+
46
+ def parse_skill(content)
47
+ return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
48
+ frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
49
+ body = $2.strip
50
+ frontmatter.merge('body' => body)
51
+ end
52
+ end
53
+ end
@@ -13,6 +13,7 @@ module RailsConsoleAi
13
13
  class SlackBot
14
14
  PING_INTERVAL = 30 # seconds — send ping if no data received
15
15
  PONG_TIMEOUT = 60 # seconds — reconnect if no pong after ping
16
+ GREETINGS = %w[hi hey hello howdy yo sup hola].to_set.freeze
16
17
 
17
18
  def initialize
18
19
  @bot_token = RailsConsoleAi.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
@@ -96,25 +97,32 @@ module RailsConsoleAi
96
97
 
97
98
  # Main read loop with keepalive
98
99
  last_activity = Time.now
100
+ last_ping_sent_at = Time.now
99
101
  ping_sent = false
100
102
 
101
103
  loop do
102
104
  ready = IO.select([ssl.to_io], nil, nil, PING_INTERVAL)
103
105
 
104
- if ready.nil?
105
- # Timeout no data received
106
+ # Send outbound ping on a fixed schedule, regardless of inbound activity
107
+ if Time.now - last_ping_sent_at >= PING_INTERVAL
106
108
  if ping_sent && (Time.now - last_activity) > PONG_TIMEOUT
107
- puts "Slack connection timed out (no pong received). Reconnecting..."
109
+ puts "Slack connection timed out (no pong received after #{(Time.now - last_activity).round}s). Reconnecting..."
108
110
  break
109
111
  end
112
+ puts "Sending WebSocket ping (last activity #{(Time.now - last_activity).round}s ago)"
110
113
  send_ws_ping(ssl)
114
+ last_ping_sent_at = Time.now
111
115
  ping_sent = true
112
- next
113
116
  end
114
117
 
118
+ next if ready.nil?
119
+
115
120
  data = read_ws_frame(ssl)
116
121
  last_activity = Time.now
117
- ping_sent = false
122
+ if ping_sent
123
+ puts "Received data after ping — connection alive"
124
+ ping_sent = false
125
+ end
118
126
  next unless data
119
127
 
120
128
  begin
@@ -161,12 +169,14 @@ module RailsConsoleAi
161
169
  # Handle ping (opcode 9) → send pong (opcode 10)
162
170
  if opcode == 9
163
171
  payload = read_ws_payload(ssl)
172
+ puts "Received server ping, sending pong"
164
173
  send_ws_pong(ssl, payload)
165
174
  return nil
166
175
  end
167
176
  # Handle pong (opcode 10) — response to our keepalive ping
168
177
  if opcode == 0xA
169
178
  read_ws_payload(ssl) # consume payload
179
+ puts "Received WebSocket pong"
170
180
  return nil
171
181
  end
172
182
  # Close frame (opcode 8)
@@ -323,13 +333,17 @@ module RailsConsoleAi
323
333
  return
324
334
  end
325
335
 
326
- # Direct code execution: "> User.count" runs code without LLM
327
- if text.strip.start_with?('>')
328
- raw_code = text.strip.sub(/\A>\s*/, '')
329
- unless raw_code.empty?
330
- handle_direct_code(session, channel_id, thread_ts, raw_code, user_name)
331
- return
332
- end
336
+ # Quick greeting no need to hit the LLM
337
+ if !session && GREETINGS.include?(command)
338
+ post_message(channel: channel_id, thread_ts: thread_ts, text: ":wave: Hey @#{user_name}! What would you like to look into today?")
339
+ return
340
+ end
341
+
342
+ # Direct code execution: code blocks or "> User.count" run code without LLM
343
+ raw_code = extract_direct_code(text.strip)
344
+ if raw_code
345
+ handle_direct_code(session, channel_id, thread_ts, raw_code, user_name)
346
+ return
333
347
  end
334
348
 
335
349
  if session
@@ -440,6 +454,7 @@ module RailsConsoleAi
440
454
  unless result_value.nil?
441
455
  display_text = "=> #{result_value}"
442
456
  display_text = display_text[0, 3000] + "\n... (truncated)" if display_text.length > 3000
457
+ puts "#{channel.instance_variable_get(:@log_prefix)} >> #{display_text}"
443
458
  post_message(channel: channel_id, thread_ts: thread_ts, text: "```#{display_text}```")
444
459
  end
445
460
  engine.send(:log_interactive_turn)
@@ -535,11 +550,35 @@ module RailsConsoleAi
535
550
  { "ok" => false, "error" => e.message }
536
551
  end
537
552
 
553
+ # Extract code for direct execution from code blocks or > prefix.
554
+ # Returns the code string, or nil if the message is not direct code.
555
+ def extract_direct_code(text)
556
+ # ```code``` or ```ruby\ncode```
557
+ if text.match?(/\A```/)
558
+ code = text.sub(/\A```/, '').sub(/```\z/, '')
559
+ # Strip optional language hint only when followed by a newline (e.g. ```ruby\n)
560
+ code = code.sub(/\A\w+\n/, '')
561
+ code = code.strip
562
+ return code unless code.empty?
563
+ end
564
+
565
+ # > code
566
+ if text.start_with?('>')
567
+ code = text.sub(/\A>\s*/, '')
568
+ return code unless code.empty?
569
+ end
570
+
571
+ nil
572
+ end
573
+
538
574
  def unescape_slack(text)
539
575
  return text unless text
540
- text.gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">")
541
- .gsub("\u2018", "'").gsub("\u2019", "'") # smart single quotes straight
542
- .gsub("\u201C", '"').gsub("\u201D", '"') # smart double quotes straight
576
+ text
577
+ .gsub(/<((?:https?|mailto):[^|>]+)\|([^>]+)>/, '\2') # <http://url|label>label
578
+ .gsub(/<((?:https?|mailto):[^>]+)>/, '\1') # <http://url>url
579
+ .gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">")
580
+ .gsub("\u2018", "'").gsub("\u2019", "'") # smart single quotes → straight
581
+ .gsub("\u201C", '"').gsub("\u201D", '"') # smart double quotes → straight
543
582
  end
544
583
 
545
584
  def waiting_for_reply?(channel)
@@ -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_plan].freeze
9
+ NO_CACHE = %w[ask_user save_memory delete_memory execute_code execute_plan activate_skill].freeze
10
10
 
11
11
  def initialize(executor: nil, mode: :default, channel: nil)
12
12
  @executor = executor
@@ -216,6 +216,7 @@ module RailsConsoleAi
216
216
  )
217
217
 
218
218
  register_memory_tools
219
+ register_skill_tools
219
220
  register_execute_plan
220
221
  end
221
222
  end
@@ -270,9 +271,52 @@ module RailsConsoleAi
270
271
  )
271
272
  end
272
273
 
274
+ def register_skill_tools
275
+ return unless @executor
276
+
277
+ require 'rails_console_ai/skill_loader'
278
+ loader = RailsConsoleAi::SkillLoader.new
279
+
280
+ register(
281
+ name: 'activate_skill',
282
+ description: 'Activate a skill to load its recipe and enable its guard bypasses. Call this before following a skill\'s procedure.',
283
+ parameters: {
284
+ 'type' => 'object',
285
+ 'properties' => {
286
+ 'name' => { 'type' => 'string', 'description' => 'The skill name to activate' }
287
+ },
288
+ 'required' => ['name']
289
+ },
290
+ handler: ->(args) {
291
+ skill = loader.find_skill(args['name'])
292
+ unless skill
293
+ return "Skill not found: \"#{args['name']}\". Use the skills listed in the system prompt."
294
+ end
295
+
296
+ bypass_methods = Array(skill['bypass_guards_for_methods'])
297
+ @executor.activate_skill_bypasses(bypass_methods) unless bypass_methods.empty?
298
+
299
+ skill['body']
300
+ }
301
+ )
302
+ end
303
+
273
304
  def register_execute_plan
274
305
  return unless @executor
275
306
 
307
+ register(
308
+ name: 'execute_code',
309
+ 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.',
310
+ parameters: {
311
+ 'type' => 'object',
312
+ 'properties' => {
313
+ 'code' => { 'type' => 'string', 'description' => 'Ruby code to execute' }
314
+ },
315
+ 'required' => ['code']
316
+ },
317
+ handler: ->(args) { execute_code(args['code']) }
318
+ )
319
+
276
320
  register(
277
321
  name: 'execute_plan',
278
322
  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`).',
@@ -298,6 +342,45 @@ module RailsConsoleAi
298
342
  )
299
343
  end
300
344
 
345
+ def execute_code(code)
346
+ return 'No code provided.' if code.nil? || code.strip.empty?
347
+
348
+ # Show the code to the user
349
+ @executor.display_code_block(code)
350
+
351
+ # Slack: execute directly, suppress display (output goes back to LLM as tool result).
352
+ # Console: show code and confirm before executing, display output directly.
353
+ exec_result = if @channel&.mode == 'slack'
354
+ @executor.execute(code, display: false)
355
+ elsif RailsConsoleAi.configuration.auto_execute
356
+ @executor.execute(code, display: false)
357
+ else
358
+ @executor.confirm_and_execute(code)
359
+ end
360
+
361
+ if @executor.last_cancelled?
362
+ return "User declined to execute the code."
363
+ end
364
+
365
+ if @executor.last_safety_error
366
+ if @channel && !@channel.supports_danger?
367
+ return "BLOCKED by safety guard: #{@executor.last_error}. Write operations are not permitted in this channel."
368
+ else
369
+ exec_result = @executor.offer_danger_retry(code)
370
+ end
371
+ end
372
+
373
+ if @executor.last_error
374
+ return "ERROR: #{@executor.last_error}"
375
+ end
376
+
377
+ output = @executor.last_output
378
+ parts = []
379
+ parts << "Output:\n#{output.strip}" if output && !output.strip.empty?
380
+ parts << "Return value: #{exec_result.inspect}"
381
+ parts.join("\n\n")
382
+ end
383
+
301
384
  def execute_plan(steps)
302
385
  return 'No steps provided.' if steps.nil? || steps.empty?
303
386
 
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.19.0'.freeze
2
+ VERSION = '0.20.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.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -119,6 +119,7 @@ files:
119
119
  - lib/rails_console_ai/repl.rb
120
120
  - lib/rails_console_ai/safety_guards.rb
121
121
  - lib/rails_console_ai/session_logger.rb
122
+ - lib/rails_console_ai/skill_loader.rb
122
123
  - lib/rails_console_ai/slack_bot.rb
123
124
  - lib/rails_console_ai/storage/base.rb
124
125
  - lib/rails_console_ai/storage/file_storage.rb