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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +69 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -0
- data/lib/rails_console_ai/channel/base.rb +1 -0
- data/lib/rails_console_ai/channel/console.rb +5 -1
- data/lib/rails_console_ai/channel/slack.rb +17 -2
- data/lib/rails_console_ai/configuration.rb +12 -1
- data/lib/rails_console_ai/context_builder.rb +79 -9
- data/lib/rails_console_ai/conversation_engine.rb +7 -2
- data/lib/rails_console_ai/executor.rb +36 -19
- data/lib/rails_console_ai/safety_guards.rb +94 -4
- data/lib/rails_console_ai/skill_loader.rb +53 -0
- data/lib/rails_console_ai/slack_bot.rb +54 -15
- data/lib/rails_console_ai/tools/registry.rb +84 -1
- data/lib/rails_console_ai/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee0daa4b9926c6df19c9e2946f019b5d17dd3bd64d4e8998e92d86ed16dff8e1
|
|
4
|
+
data.tar.gz: f336fe3abf34a513a5de12b138d69f4941e3cd65eeecaa8b548658d3da8b846e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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,
|
|
81
|
-
- Include a brief one-line explanation before
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
#
|
|
327
|
-
if
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
576
|
+
text
|
|
577
|
+
.gsub(/<((?:https?|mailto):[^|>]+)\|([^>]+)>/, '\2') # <http://url|label> → label
|
|
578
|
+
.gsub(/<((?:https?|mailto):[^>]+)>/, '\1') # <http://url> → url
|
|
579
|
+
.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|
|
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.
|
|
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
|