rails_console_ai 0.31.0 → 0.33.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.
@@ -1,39 +1,77 @@
1
- require 'set'
2
-
3
1
  module RailsConsoleAi
4
2
  class Configuration
5
3
  PROVIDERS = %i[anthropic openai local bedrock].freeze
6
4
 
7
- # cache_read: 0.1x input, cache_write: 1.25x input for Anthropic models
8
- PRICING = {
9
- 'claude-sonnet-4-6' => { input: 3.0 / 1_000_000, output: 15.0 / 1_000_000, cache_read: 0.30 / 1_000_000, cache_write: 3.75 / 1_000_000 },
10
- 'claude-opus-4-6' => { input: 15.0 / 1_000_000, output: 75.0 / 1_000_000, cache_read: 1.50 / 1_000_000, cache_write: 18.75 / 1_000_000 },
11
- 'claude-haiku-4-5-20251001' => { input: 0.80 / 1_000_000, output: 4.0 / 1_000_000, cache_read: 0.08 / 1_000_000, cache_write: 1.0 / 1_000_000 },
12
- # Bedrock model IDs (same pricing as direct API)
13
- 'us.anthropic.claude-sonnet-4-6' => { input: 3.0 / 1_000_000, output: 15.0 / 1_000_000, cache_read: 0.30 / 1_000_000, cache_write: 3.75 / 1_000_000 },
14
- 'us.anthropic.claude-opus-4-6-v1' => { input: 15.0 / 1_000_000, output: 75.0 / 1_000_000, cache_read: 1.50 / 1_000_000, cache_write: 18.75 / 1_000_000 },
5
+ # Per-family model attributes, matched by substring so one entry covers every
6
+ # ID variant of a family: bare Anthropic IDs (claude-sonnet-5), dated
7
+ # snapshots (claude-haiku-4-5-20251001), and Bedrock inference profiles
8
+ # (us.anthropic.claude-sonnet-5, global.anthropic.claude-opus-4-6-v1).
9
+ #
10
+ # input/output are $ per MTok (converted to per-token in .pricing_for).
11
+ # Cache pricing is derived: read = 0.1x input, write = 1.25x input.
12
+ # temperature: false marks families that reject the `temperature` parameter
13
+ # (removed on opus-4-7+, sonnet-5, and fable-5).
14
+ MODEL_FAMILIES = {
15
+ 'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, temperature: false },
16
+ 'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
17
+ 'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
18
+ 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
19
+ 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
20
+ 'claude-sonnet-5' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: false },
21
+ 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: true },
22
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, temperature: true },
15
23
  }.freeze
16
24
 
17
- DEFAULT_MAX_TOKENS = {
18
- 'claude-sonnet-4-6' => 16_000,
19
- 'claude-haiku-4-5-20251001' => 16_000,
20
- 'claude-opus-4-6' => 4_096,
21
- }.freeze
25
+ # Family keys sorted longest-first so a more specific family always wins
26
+ # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
27
+ # before 'claude-sonnet-5').
28
+ MODEL_FAMILY_KEYS = MODEL_FAMILIES.keys.sort_by { |k| -k.length }.freeze
29
+
30
+ # Returns the family attributes for a model ID, or nil for unknown models.
31
+ def self.model_family(model_id)
32
+ return nil unless model_id
33
+ key = MODEL_FAMILY_KEYS.find { |k| model_id.include?(k) }
34
+ key && MODEL_FAMILIES[key]
35
+ end
36
+
37
+ # Per-token pricing for a model ID, matched by family. Returns
38
+ # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
39
+ def self.pricing_for(model_id)
40
+ family = model_family(model_id)
41
+ return nil unless family
42
+ input = family[:input] / 1_000_000
43
+ {
44
+ input: input,
45
+ output: family[:output] / 1_000_000,
46
+ cache_read: input * 0.1,
47
+ cache_write: input * 1.25,
48
+ }
49
+ end
22
50
 
23
- # Models that reject the `temperature` parameter. Configuration#resolved_temperature
24
- # returns nil for these so providers can omit the field from the request.
25
- MODELS_WITHOUT_TEMPERATURE = Set.new(%w[
26
- claude-opus-4-7
27
- anthropic.claude-opus-4-7
28
- us.anthropic.claude-opus-4-7
29
- eu.anthropic.claude-opus-4-7
30
- jp.anthropic.claude-opus-4-7
31
- global.anthropic.claude-opus-4-7
32
- ]).freeze
51
+ # Known environment-level failures the executor recognizes and explains to the
52
+ # LLM on the FIRST occurrence, so it doesn't burn rounds rediscovering them
53
+ # through trial and error. Each entry: { name:, pattern:, hint: }.
54
+ # The pattern is matched against the execution error AND the captured output
55
+ # (to catch errors rescued and printed by the generated code itself).
56
+ DEFAULT_ERROR_HINTS = [
57
+ {
58
+ name: :decryption_failure,
59
+ pattern: /OpenSSL::Cipher::CipherError|bad decrypt|ActiveRecord::Encryption::Errors/i,
60
+ hint: "Decryption failed — this console process's encryption key (e.g. ENV['ENCRYPTION_KEY']) " \
61
+ "appears to be missing, invalid, or a placeholder for the data you are reading. This is an " \
62
+ "environment configuration issue, not a data issue, and it affects EVERY encrypted field in " \
63
+ "this session. Retrying with different code (reloading records, toggling encryption flags, " \
64
+ "decrypting other fields or records) will fail the same way. Do NOT retry. Report this " \
65
+ "limitation to the user, tell them the encryption key needs to be configured for this " \
66
+ "environment, and answer using only what you can determine without decrypting."
67
+ }
68
+ ].freeze
33
69
 
34
70
  attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
35
71
  :auto_execute, :temperature,
36
72
  :timeout, :debug, :max_tool_rounds,
73
+ :error_hints,
74
+ :token_nudge_threshold, :token_stop_threshold,
37
75
  :storage_adapter, :memories_enabled,
38
76
  :session_logging, :connection_class,
39
77
  :admin_username, :admin_password,
@@ -59,6 +97,9 @@ module RailsConsoleAi
59
97
  @timeout = 30
60
98
  @debug = false
61
99
  @max_tool_rounds = 200
100
+ @error_hints = DEFAULT_ERROR_HINTS.dup
101
+ @token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
102
+ @token_stop_threshold = 1_000_000 # input tokens in one tool loop → force a final answer (nil disables)
62
103
  @storage_adapter = nil
63
104
  @memories_enabled = true
64
105
  @session_logging = true
@@ -176,26 +217,28 @@ module RailsConsoleAi
176
217
 
177
218
  case @provider
178
219
  when :anthropic
179
- 'claude-sonnet-4-6'
220
+ 'claude-sonnet-5'
180
221
  when :openai
181
222
  'gpt-5.3-codex'
182
223
  when :local
183
224
  @local_model
184
225
  when :bedrock
185
- 'us.anthropic.claude-sonnet-4-6'
226
+ 'us.anthropic.claude-sonnet-5'
186
227
  end
187
228
  end
188
229
 
189
230
  def resolved_max_tokens
190
231
  return @max_tokens if @max_tokens
191
232
 
192
- DEFAULT_MAX_TOKENS.fetch(resolved_model, 4096)
233
+ family = self.class.model_family(resolved_model)
234
+ family ? family[:max_tokens] : 4096
193
235
  end
194
236
 
195
- # Returns nil for models that reject the `temperature` parameter (e.g. opus-4-7).
196
- # Providers should use this in place of @temperature.
237
+ # Returns nil for model families that reject the `temperature` parameter
238
+ # (opus-4-7+, sonnet-5, fable-5) so providers omit the field from the request.
197
239
  def resolved_temperature
198
- return nil if MODELS_WITHOUT_TEMPERATURE.include?(resolved_model)
240
+ family = self.class.model_family(resolved_model)
241
+ return nil if family && family[:temperature] == false
199
242
  @temperature
200
243
  end
201
244
 
@@ -204,13 +247,13 @@ module RailsConsoleAi
204
247
 
205
248
  case @provider
206
249
  when :anthropic
207
- 'claude-opus-4-6'
250
+ 'claude-opus-5'
208
251
  when :openai
209
252
  'gpt-5.3-codex'
210
253
  when :local
211
254
  @local_model
212
255
  when :bedrock
213
- 'us.anthropic.claude-opus-4-6-v1'
256
+ 'us.anthropic.claude-opus-5'
214
257
  end
215
258
  end
216
259
 
@@ -7,6 +7,8 @@ module RailsConsoleAi
7
7
  LARGE_OUTPUT_PREVIEW_CHARS = 16_000 # chars — how much of the output the LLM sees upfront
8
8
  LOOP_WARN_THRESHOLD = 3 # same tool+args repeated → inject warning
9
9
  LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
10
+ REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
11
+ REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
10
12
 
11
13
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
12
14
  @binding_context = binding_context
@@ -46,6 +48,7 @@ module RailsConsoleAi
46
48
  if executed && @executor.last_error && !@executor.last_safety_error
47
49
  error_msg = "Code execution failed with error: #{@executor.last_error}"
48
50
  error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
51
+ error_msg += "\n\n#{@executor.last_error_hint}" if @executor.last_error_hint
49
52
  conversation << { role: :assistant, content: @_last_result_text }
50
53
  conversation << { role: :user, content: error_msg }
51
54
 
@@ -347,6 +350,7 @@ module RailsConsoleAi
347
350
  elsif @executor.last_error
348
351
  error_msg = "Code execution failed with error: #{@executor.last_error}"
349
352
  error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
353
+ error_msg += "\n\n#{@executor.last_error_hint}" if @executor.last_error_hint
350
354
  @history << { role: :user, content: error_msg }
351
355
  :error
352
356
  else
@@ -390,7 +394,7 @@ module RailsConsoleAi
390
394
  $stdout.puts "\e[36m Cost estimate:\e[0m"
391
395
 
392
396
  @token_usage.each do |model, usage|
393
- pricing = Configuration::PRICING[model]
397
+ pricing = Configuration.pricing_for(model)
394
398
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
395
399
  input_str = "in: #{format_tokens(usage[:input])}"
396
400
  output_str = "out: #{format_tokens(usage[:output])}"
@@ -796,7 +800,11 @@ module RailsConsoleAi
796
800
  last_tool_names = []
797
801
 
798
802
  exhausted = false
803
+ wrap_up_reason = nil
799
804
  tool_call_counts = Hash.new(0)
805
+ error_sig_counts = Hash.new(0)
806
+ warned_error_sigs = Set.new
807
+ token_nudge_sent = false
800
808
 
801
809
  max_rounds.times do |round|
802
810
  if @channel.cancelled?
@@ -918,6 +926,12 @@ module RailsConsoleAi
918
926
  $stderr.puts "\e[35m[debug] tool result (#{tool_result.to_s.length} chars)\e[0m"
919
927
  end
920
928
 
929
+ # Track repeated error signatures across DIFFERENT tool calls — the
930
+ # identical-call loop detection below misses "same dead end, new code".
931
+ if (sig = error_signature(tool_result))
932
+ error_sig_counts[sig] += 1
933
+ end
934
+
921
935
  tool_msg = provider.format_tool_result(tc[:id], tool_result)
922
936
  full_text = tool_result.to_s
923
937
  output_id = @executor.store_output(full_text)
@@ -942,23 +956,68 @@ module RailsConsoleAi
942
956
  if tool_call_counts[key] >= LOOP_BREAK_THRESHOLD
943
957
  @channel.display_status(" Loop detected: #{tc[:name]} called #{tool_call_counts[key]} times with same args — stopping.")
944
958
  exhausted = true
959
+ wrap_up_reason ||= :tool_loop
945
960
  elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
946
961
  @channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
947
962
  messages << { role: :user, content: "You are repeating the same tool call (#{tc[:name]}) with the same arguments. This is not making progress. Try a different approach or provide your answer now." }
948
963
  end
949
964
  end
965
+
966
+ # Circuit breaker: the same error signature recurring across different
967
+ # tool calls means no new information is being gained (e.g. every
968
+ # decryption attempt failing with "bad decrypt" regardless of the code).
969
+ error_sig_counts.each do |sig, count|
970
+ if count >= REPEAT_ERROR_BREAK_THRESHOLD
971
+ @channel.display_status(" Circuit breaker: same error hit #{count} times (#{sig}) — stopping.")
972
+ exhausted = true
973
+ wrap_up_reason ||= :repeated_errors
974
+ elsif count >= REPEAT_ERROR_WARN_THRESHOLD && !warned_error_sigs.include?(sig)
975
+ warned_error_sigs << sig
976
+ @channel.display_status(" Warning: same error hit #{count} times — nudging model to change strategy.")
977
+ messages << { role: :user, content: "You have now hit the same error #{count} times (#{sig}). Trying variations of the same approach is not producing new information. If this error cannot be resolved from this session, stop investigating it: summarize what you have established, state what you could not determine and why, and give the user your best answer." }
978
+ end
979
+ end
980
+
981
+ # Circuit breaker: token budget for a single tool loop.
982
+ config = RailsConsoleAi.configuration
983
+ if config.token_stop_threshold && total_input >= config.token_stop_threshold
984
+ @channel.display_status(" Token budget exceeded (#{format_tokens(total_input)} input tokens this request) — forcing wrap-up.")
985
+ exhausted = true
986
+ wrap_up_reason ||= :token_budget
987
+ elsif config.token_nudge_threshold && total_input >= config.token_nudge_threshold && !token_nudge_sent
988
+ token_nudge_sent = true
989
+ @channel.display_status(" High token usage (#{format_tokens(total_input)} input tokens this request) — nudging model to wrap up.")
990
+ messages << { role: :user, content: "This investigation has consumed #{format_tokens(total_input)} input tokens without reaching a conclusion. Wrap up now: stop opening new lines of investigation, summarize what you have established, state what you could not determine and why, and give the user your best answer. Only make another tool call if you are confident a single call will resolve the question." }
991
+ end
992
+
950
993
  break if exhausted
951
994
 
952
995
  # If the user declined execution, don't call the LLM again —
953
996
  # just return to the prompt so they can correct their request.
954
997
  break if @executor.last_cancelled?
955
998
 
956
- exhausted = true if round == max_rounds - 1
999
+ if round == max_rounds - 1
1000
+ exhausted = true
1001
+ wrap_up_reason ||= :round_cap
1002
+ end
957
1003
  end
958
1004
 
959
1005
  if exhausted
960
- $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
961
- messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
1006
+ final_nudge =
1007
+ case wrap_up_reason
1008
+ when :repeated_errors
1009
+ "You have hit the same error repeatedly without gaining new information. Do not attempt any further tool calls. Give the user a final answer now: (1) what you established, (2) what you could not determine and the blocking error, and (3) what a human should do next."
1010
+ when :token_budget
1011
+ "This session has exceeded its token budget. Do not attempt any further tool calls. Give the user a final answer now: (1) what you established, (2) what you could not determine and why, and (3) what a human should do next."
1012
+ when :tool_loop
1013
+ "You kept repeating the same tool call with the same arguments. Do not attempt any further tool calls. Provide your best answer now based on what you've learned so far."
1014
+ else
1015
+ "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far."
1016
+ end
1017
+ if wrap_up_reason.nil? || wrap_up_reason == :round_cap
1018
+ $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
1019
+ end
1020
+ messages << { role: :user, content: final_nudge }
962
1021
  result = provider.chat(messages, system_prompt: active_system_prompt)
963
1022
  total_input += result.input_tokens || 0
964
1023
  total_output += result.output_tokens || 0
@@ -1167,6 +1226,15 @@ module RailsConsoleAi
1167
1226
  str.length > max ? str[0..max] + '...' : str
1168
1227
  end
1169
1228
 
1229
+ # Extract a normalized error signature from a tool result so we can detect
1230
+ # "same failure, different code" loops. Digits are collapsed so record IDs,
1231
+ # counts, and offsets don't make identical failures look distinct.
1232
+ def error_signature(tool_result)
1233
+ line = tool_result.to_s[/^ERROR: [^\n]+/]
1234
+ return nil unless line
1235
+ line.gsub(/\d+/, 'N')[0, 160]
1236
+ end
1237
+
1170
1238
  # Wraps mid-task user messages with explicit framing so the model treats them
1171
1239
  # as a real-time interruption that supersedes the prior task, rather than as
1172
1240
  # a reply to the most recent tool result.
@@ -1386,7 +1454,7 @@ module RailsConsoleAi
1386
1454
  cache_w = result.cache_write_input_tokens || 0
1387
1455
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1388
1456
  model = effective_model
1389
- pricing = Configuration::PRICING[model]
1457
+ pricing = Configuration.pricing_for(model)
1390
1458
  if pricing
1391
1459
  cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1392
1460
  if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
@@ -1419,7 +1487,7 @@ module RailsConsoleAi
1419
1487
  input_t = result.input_tokens || 0
1420
1488
  output_t = result.output_tokens || 0
1421
1489
  model = effective_model
1422
- pricing = Configuration::PRICING[model]
1490
+ pricing = Configuration.pricing_for(model)
1423
1491
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1424
1492
 
1425
1493
  cache_r = result.cache_read_input_tokens || 0
@@ -44,7 +44,8 @@ module RailsConsoleAi
44
44
  class Executor
45
45
  CODE_REGEX = /```ruby\s*\n(.*?)```/m
46
46
 
47
- attr_reader :binding_context, :last_error, :last_safety_error, :last_safety_exception
47
+ attr_reader :binding_context, :last_error, :last_safety_error, :last_safety_exception,
48
+ :last_error_hint
48
49
  attr_accessor :on_prompt
49
50
 
50
51
  def initialize(binding_context, channel: nil)
@@ -55,6 +56,7 @@ module RailsConsoleAi
55
56
  @output_store = {}
56
57
  @output_counter = 0
57
58
  @active_skill_bypass_methods = Set.new
59
+ @error_hint_counts = Hash.new(0)
58
60
  end
59
61
 
60
62
  def extract_code(response)
@@ -98,6 +100,7 @@ module RailsConsoleAi
98
100
  @last_error = nil
99
101
  @last_safety_error = false
100
102
  @last_safety_exception = nil
103
+ @last_error_hint = nil
101
104
  captured_output = StringIO.new
102
105
  old_stdout = $stdout
103
106
  # Three capture strategies:
@@ -140,6 +143,8 @@ module RailsConsoleAi
140
143
  display_result(result) if display
141
144
 
142
145
  @last_output = captured_output.string
146
+ # Code may have rescued a known failure and printed it — still surface the hint.
147
+ @last_error_hint = build_error_hint(@last_output)
143
148
  result
144
149
  rescue Interrupt
145
150
  restore_stdout(use_thread_local, old_stdout)
@@ -174,6 +179,7 @@ module RailsConsoleAi
174
179
  return nil
175
180
  end
176
181
  @last_error = "#{e.class}: #{e.message}"
182
+ @last_error_hint = build_error_hint(@last_error, captured_output&.string)
177
183
  backtrace = e.backtrace.first(3).map { |line| " #{line}" }.join("\n")
178
184
  log_execution_error("Error: #{@last_error}\n#{backtrace}")
179
185
  @last_output = captured_output&.string
@@ -346,6 +352,29 @@ module RailsConsoleAi
346
352
 
347
353
  private
348
354
 
355
+ # Match the error/output against configured known-issue patterns
356
+ # (Configuration#error_hints). Returns a guidance string for the LLM, or nil.
357
+ # The first occurrence explains the issue; repeats escalate so the model
358
+ # stops probing the same dead end.
359
+ def build_error_hint(*texts)
360
+ text = texts.compact.join("\n")
361
+ return nil if text.strip.empty?
362
+
363
+ hint_def = Array(RailsConsoleAi.configuration.error_hints).find do |h|
364
+ h[:pattern] && text.match?(h[:pattern])
365
+ end
366
+ return nil unless hint_def
367
+
368
+ count = (@error_hint_counts[hint_def[:name]] += 1)
369
+ if count == 1
370
+ "KNOWN ISSUE DETECTED (#{hint_def[:name]}): #{hint_def[:hint]}"
371
+ else
372
+ "KNOWN ISSUE (#{hint_def[:name]}) — this is failure ##{count} for the same underlying problem. " \
373
+ "You were already told this cannot succeed from this session. Stop retrying now and report the " \
374
+ "limitation to the user. Reminder: #{hint_def[:hint]}"
375
+ end
376
+ end
377
+
349
378
  def restore_stdout(use_thread_local, old_stdout)
350
379
  if use_thread_local
351
380
  Thread.current[:capture_io] = nil
@@ -52,6 +52,22 @@ module RailsConsoleAi
52
52
  Thread.current[:rails_console_ai_guards_disabled] = true
53
53
  end
54
54
 
55
+ # Hard sandbox: block ALL database access (reads and writes) for the duration of the
56
+ # block. Used to fence in sub-agents that must never touch the DB (e.g. the
57
+ # output-explorer, which only examines an in-memory string). Scoped via thread-local,
58
+ # so it covers synchronous work in the calling thread and is restored afterward —
59
+ # nestable, and independent of enable!/disable! and the registered guard set.
60
+ def with_database_blocked
61
+ BuiltinGuards.ensure_write_blocker_installed!
62
+ prev = Thread.current[:rails_console_ai_block_all_db]
63
+ Thread.current[:rails_console_ai_block_all_db] = true
64
+ begin
65
+ yield
66
+ ensure
67
+ Thread.current[:rails_console_ai_block_all_db] = prev
68
+ end
69
+ end
70
+
55
71
  def empty?
56
72
  @guards.empty?
57
73
  end
@@ -213,6 +229,21 @@ module RailsConsoleAi
213
229
 
214
230
  private
215
231
 
232
+ # Hard sandbox: when active, block ALL database access (reads and writes), not just
233
+ # mutations. Used to fence in sub-agents that must never touch the DB (e.g. the
234
+ # output-explorer, which only examines an in-memory string). Deliberately does NOT
235
+ # honor the bypass flag — it is a true wall, not a safe-mode toggle.
236
+ def rails_console_ai_check_db_blocked!(_sql)
237
+ return unless Thread.current[:rails_console_ai_block_all_db]
238
+
239
+ raise RailsConsoleAi::SafetyError.new(
240
+ "Database access is disabled here. The captured data is in the `output` " \
241
+ "variable — examine that instead of querying the database.",
242
+ guard: :database_access,
243
+ blocked_key: nil
244
+ )
245
+ end
246
+
216
247
  def rails_console_ai_check_write!(sql)
217
248
  return if Thread.current[:rails_console_ai_bypass_guards]
218
249
  return unless Thread.current[:rails_console_ai_block_writes] && sql.match?(WRITE_PATTERN)
@@ -231,26 +262,31 @@ module RailsConsoleAi
231
262
  public
232
263
 
233
264
  def execute(sql, *args, **kwargs)
265
+ rails_console_ai_check_db_blocked!(sql)
234
266
  rails_console_ai_check_write!(sql)
235
267
  super
236
268
  end
237
269
 
238
270
  def exec_query(sql, *args, **kwargs)
271
+ rails_console_ai_check_db_blocked!(sql)
239
272
  rails_console_ai_check_write!(sql)
240
273
  super
241
274
  end
242
275
 
243
276
  def exec_insert(sql, *args, **kwargs)
277
+ rails_console_ai_check_db_blocked!(sql)
244
278
  rails_console_ai_check_write!(sql)
245
279
  super
246
280
  end
247
281
 
248
282
  def exec_delete(sql, *args, **kwargs)
283
+ rails_console_ai_check_db_blocked!(sql)
249
284
  rails_console_ai_check_write!(sql)
250
285
  super
251
286
  end
252
287
 
253
288
  def exec_update(sql, *args, **kwargs)
289
+ rails_console_ai_check_db_blocked!(sql)
254
290
  rails_console_ai_check_write!(sql)
255
291
  super
256
292
  end
@@ -706,7 +706,7 @@ module RailsConsoleAi
706
706
  config = RailsConsoleAi.configuration
707
707
  model = engine ? engine.effective_model : config.resolved_model
708
708
  thinking = config.resolved_thinking_model
709
- pricing = Configuration::PRICING[model]
709
+ pricing = Configuration.pricing_for(model)
710
710
 
711
711
  lines = ["*Model info:*"]
712
712
  lines << " Provider: `#{config.provider}`"
@@ -739,7 +739,7 @@ module RailsConsoleAi
739
739
  total_cost = 0.0
740
740
 
741
741
  token_usage.each do |model, usage|
742
- pricing = Configuration::PRICING[model]
742
+ pricing = Configuration.pricing_for(model)
743
743
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
744
744
  input_str = "in: #{usage[:input]}"
745
745
  output_str = "out: #{usage[:output]}"
@@ -189,7 +189,7 @@ module RailsConsoleAi
189
189
 
190
190
  def build_system_prompt
191
191
  parts = []
192
- parts << base_instructions
192
+ parts << base_instructions unless @agent_config['skip_base_instructions']
193
193
  parts << guide_context
194
194
  parts << pinned_memory_context
195
195
  parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
@@ -33,10 +33,15 @@ module RailsConsoleAi
33
33
  name: name, description: description, tags: tags,
34
34
  edited_by: edited_by || 'ai', change_note: change_note
35
35
  )
36
+ status_note = if record.respond_to?(:proposed?) && record.proposed?
37
+ ' — status: PROPOSED. A human must approve it at /rails_console_ai/memories before you can recall it.'
38
+ else
39
+ ''
40
+ end
36
41
  if was_new
37
- "Memory saved (db): \"#{record.name}\" (id=#{record.id})"
42
+ "Memory saved (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
38
43
  else
39
- "Memory updated (db): \"#{record.name}\" (id=#{record.id})"
44
+ "Memory updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
40
45
  end
41
46
  end
42
47
  rescue Storage::StorageError => e
@@ -72,8 +77,16 @@ module RailsConsoleAi
72
77
  end
73
78
 
74
79
  def recall_memory(name:)
75
- memory = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
76
- return "No memory found: \"#{name}\"" unless memory
80
+ memory = load_activatable_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
81
+ unless memory
82
+ # Distinguish "doesn't exist" from "exists but isn't approved yet".
83
+ proposed = load_all_memories.find { |m| m['name'].to_s.downcase == name.to_s.downcase }
84
+ if proposed && proposed['source'] == :db && proposed['status'] != 'approved'
85
+ return "Memory \"#{name}\" exists but is awaiting human approval and cannot be recalled yet. " \
86
+ "Ask the user to approve it in the web UI at /rails_console_ai/memories."
87
+ end
88
+ return "No memory found: \"#{name}\""
89
+ end
77
90
 
78
91
  record_use(memory)
79
92
 
@@ -83,7 +96,7 @@ module RailsConsoleAi
83
96
  end
84
97
 
85
98
  def recall_memories(query: nil, tag: nil)
86
- memories = load_all_memories
99
+ memories = load_activatable_memories
87
100
  return "No memories stored yet." if memories.empty?
88
101
 
89
102
  results = memories
@@ -117,7 +130,7 @@ module RailsConsoleAi
117
130
  end
118
131
 
119
132
  def memory_summaries
120
- memories = load_all_memories
133
+ memories = load_activatable_memories
121
134
  return nil if memories.empty?
122
135
 
123
136
  memories.map { |m|
@@ -127,6 +140,10 @@ module RailsConsoleAi
127
140
  }
128
141
  end
129
142
 
143
+ # Includes proposed (unapproved) DB memories — they show up in the admin UI
144
+ # with a "PROPOSED" badge. The AI-facing surface (#memory_summaries,
145
+ # #recall_memory, #recall_memories) filters them out via
146
+ # #load_activatable_memories, so an unapproved memory can never be recalled.
130
147
  def load_all_memories
131
148
  db = Storage::DatabaseStorage.all_memories
132
149
  file = load_all_file_memories
@@ -135,6 +152,12 @@ module RailsConsoleAi
135
152
  (db + file).sort_by { |m| m['name'].to_s.downcase }
136
153
  end
137
154
 
155
+ # Memories the AI is allowed to see / recall: approved DB memories + all file
156
+ # memories. File memories are considered pre-approved because they're git-tracked.
157
+ def load_activatable_memories
158
+ load_all_memories.reject { |m| m['source'] == :db && m['status'] != 'approved' }
159
+ end
160
+
138
161
  private
139
162
 
140
163
  # DB-backed memories only — file memories have no row to update.