rails_console_ai 0.30.0 → 0.32.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -26
  3. data/README.md +33 -11
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +2 -9
  5. data/app/controllers/rails_console_ai/agents_controller.rb +25 -48
  6. data/app/controllers/rails_console_ai/memories_controller.rb +35 -38
  7. data/app/controllers/rails_console_ai/memory_versions_controller.rb +2 -6
  8. data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +2 -8
  10. data/app/controllers/rails_console_ai/skills_controller.rb +19 -48
  11. data/app/models/rails_console_ai/agent.rb +33 -65
  12. data/app/models/rails_console_ai/agent_version.rb +8 -20
  13. data/app/models/rails_console_ai/memory.rb +67 -23
  14. data/app/models/rails_console_ai/memory_version.rb +5 -20
  15. data/app/models/rails_console_ai/skill.rb +55 -105
  16. data/app/models/rails_console_ai/skill_version.rb +7 -28
  17. data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
  18. data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
  19. data/app/views/rails_console_ai/agents/new.html.erb +0 -16
  20. data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
  21. data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
  22. data/app/views/rails_console_ai/memories/index.html.erb +13 -1
  23. data/app/views/rails_console_ai/memories/new.html.erb +0 -15
  24. data/app/views/rails_console_ai/memories/show.html.erb +23 -0
  25. data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
  26. data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
  27. data/app/views/rails_console_ai/skills/new.html.erb +0 -17
  28. data/config/routes.rb +3 -3
  29. data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
  30. data/lib/rails_console_ai/agent_loader.rb +18 -10
  31. data/lib/rails_console_ai/agent_runner.rb +66 -4
  32. data/lib/rails_console_ai/channel/console.rb +1 -1
  33. data/lib/rails_console_ai/configuration.rb +51 -33
  34. data/lib/rails_console_ai/conversation_engine.rb +3 -3
  35. data/lib/rails_console_ai/safety_guards.rb +36 -0
  36. data/lib/rails_console_ai/session_logger.rb +4 -0
  37. data/lib/rails_console_ai/skill_loader.rb +18 -9
  38. data/lib/rails_console_ai/slack_bot.rb +2 -2
  39. data/lib/rails_console_ai/storage/database_storage.rb +14 -20
  40. data/lib/rails_console_ai/sub_agent.rb +1 -1
  41. data/lib/rails_console_ai/tools/memory_tools.rb +37 -6
  42. data/lib/rails_console_ai/tools/registry.rb +18 -6
  43. data/lib/rails_console_ai/version.rb +1 -1
  44. data/lib/rails_console_ai.rb +92 -71
  45. metadata +1 -1
@@ -1,35 +1,51 @@
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-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
17
+ 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
18
+ 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
19
+ 'claude-sonnet-5' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: false },
20
+ 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: true },
21
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, temperature: true },
15
22
  }.freeze
16
23
 
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
24
+ # Family keys sorted longest-first so a more specific family always wins
25
+ # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
26
+ # before 'claude-sonnet-5').
27
+ MODEL_FAMILY_KEYS = MODEL_FAMILIES.keys.sort_by { |k| -k.length }.freeze
22
28
 
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
29
+ # Returns the family attributes for a model ID, or nil for unknown models.
30
+ def self.model_family(model_id)
31
+ return nil unless model_id
32
+ key = MODEL_FAMILY_KEYS.find { |k| model_id.include?(k) }
33
+ key && MODEL_FAMILIES[key]
34
+ end
35
+
36
+ # Per-token pricing for a model ID, matched by family. Returns
37
+ # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
38
+ def self.pricing_for(model_id)
39
+ family = model_family(model_id)
40
+ return nil unless family
41
+ input = family[:input] / 1_000_000
42
+ {
43
+ input: input,
44
+ output: family[:output] / 1_000_000,
45
+ cache_read: input * 0.1,
46
+ cache_write: input * 1.25,
47
+ }
48
+ end
33
49
 
34
50
  attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
35
51
  :auto_execute, :temperature,
@@ -176,26 +192,28 @@ module RailsConsoleAi
176
192
 
177
193
  case @provider
178
194
  when :anthropic
179
- 'claude-sonnet-4-6'
195
+ 'claude-sonnet-5'
180
196
  when :openai
181
197
  'gpt-5.3-codex'
182
198
  when :local
183
199
  @local_model
184
200
  when :bedrock
185
- 'us.anthropic.claude-sonnet-4-6'
201
+ 'us.anthropic.claude-sonnet-5'
186
202
  end
187
203
  end
188
204
 
189
205
  def resolved_max_tokens
190
206
  return @max_tokens if @max_tokens
191
207
 
192
- DEFAULT_MAX_TOKENS.fetch(resolved_model, 4096)
208
+ family = self.class.model_family(resolved_model)
209
+ family ? family[:max_tokens] : 4096
193
210
  end
194
211
 
195
- # Returns nil for models that reject the `temperature` parameter (e.g. opus-4-7).
196
- # Providers should use this in place of @temperature.
212
+ # Returns nil for model families that reject the `temperature` parameter
213
+ # (opus-4-7+, sonnet-5, fable-5) so providers omit the field from the request.
197
214
  def resolved_temperature
198
- return nil if MODELS_WITHOUT_TEMPERATURE.include?(resolved_model)
215
+ family = self.class.model_family(resolved_model)
216
+ return nil if family && family[:temperature] == false
199
217
  @temperature
200
218
  end
201
219
 
@@ -204,13 +222,13 @@ module RailsConsoleAi
204
222
 
205
223
  case @provider
206
224
  when :anthropic
207
- 'claude-opus-4-6'
225
+ 'claude-opus-4-8'
208
226
  when :openai
209
227
  'gpt-5.3-codex'
210
228
  when :local
211
229
  @local_model
212
230
  when :bedrock
213
- 'us.anthropic.claude-opus-4-6-v1'
231
+ 'us.anthropic.claude-opus-4-8'
214
232
  end
215
233
  end
216
234
 
@@ -390,7 +390,7 @@ module RailsConsoleAi
390
390
  $stdout.puts "\e[36m Cost estimate:\e[0m"
391
391
 
392
392
  @token_usage.each do |model, usage|
393
- pricing = Configuration::PRICING[model]
393
+ pricing = Configuration.pricing_for(model)
394
394
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
395
395
  input_str = "in: #{format_tokens(usage[:input])}"
396
396
  output_str = "out: #{format_tokens(usage[:output])}"
@@ -1386,7 +1386,7 @@ module RailsConsoleAi
1386
1386
  cache_w = result.cache_write_input_tokens || 0
1387
1387
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1388
1388
  model = effective_model
1389
- pricing = Configuration::PRICING[model]
1389
+ pricing = Configuration.pricing_for(model)
1390
1390
  if pricing
1391
1391
  cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1392
1392
  if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
@@ -1419,7 +1419,7 @@ module RailsConsoleAi
1419
1419
  input_t = result.input_tokens || 0
1420
1420
  output_t = result.output_tokens || 0
1421
1421
  model = effective_model
1422
- pricing = Configuration::PRICING[model]
1422
+ pricing = Configuration.pricing_for(model)
1423
1423
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1424
1424
 
1425
1425
  cache_r = result.cache_read_input_tokens || 0
@@ -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
@@ -28,6 +28,10 @@ module RailsConsoleAi
28
28
  create_attrs[:status] = attrs[:status] if attrs.key?(:status)
29
29
  create_attrs[:result] = attrs[:result] if attrs.key?(:result)
30
30
  create_attrs[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
31
+ if attrs.key?(:options)
32
+ opts = attrs[:options]
33
+ create_attrs[:options] = opts.is_a?(String) ? opts : opts.to_json
34
+ end
31
35
  record = session_class.create!(create_attrs)
32
36
  record.id
33
37
  rescue => e
@@ -122,15 +122,10 @@ module RailsConsoleAi
122
122
  key = skill_key(name)
123
123
  existing = load_skill_file(key)
124
124
 
125
- frontmatter = {
126
- 'name' => name,
127
- 'description' => description,
128
- 'tags' => Array(tags)
129
- }
130
- bypasses = Array(bypass_guards_for_methods)
131
- frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
132
-
133
- content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
125
+ content = self.class.dump(
126
+ name: name, description: description, body: body,
127
+ tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
128
+ )
134
129
  @storage.write(key, content)
135
130
 
136
131
  path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
@@ -190,5 +185,19 @@ module RailsConsoleAi
190
185
  rescue Psych::SyntaxError
191
186
  nil
192
187
  end
188
+
189
+ # Inverse of parse: emit a canonical .md (frontmatter + body) string from
190
+ # structured attrs. Used by AI tool callers and file-fallback writers so
191
+ # the on-disk and DB representations stay byte-identical.
192
+ def self.dump(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
193
+ frontmatter = {
194
+ 'name' => name,
195
+ 'description' => description,
196
+ 'tags' => Array(tags)
197
+ }
198
+ bypasses = Array(bypass_guards_for_methods)
199
+ frontmatter['bypass_guards_for_methods'] = bypasses unless bypasses.empty?
200
+ "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{body}\n"
201
+ end
193
202
  end
194
203
  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]}"
@@ -68,14 +68,12 @@ module RailsConsoleAi
68
68
  record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
69
69
  record ||= RailsConsoleAi::Skill.new
70
70
  was_new = record.new_record?
71
+ content = RailsConsoleAi::SkillLoader.dump(
72
+ name: name, description: description, body: body,
73
+ tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
74
+ )
71
75
  record.update_with_version!(
72
- {
73
- name: name,
74
- description: description,
75
- body: body,
76
- tags: Array(tags),
77
- bypass_guards_for_methods: Array(bypass_guards_for_methods)
78
- },
76
+ { content: content },
79
77
  edited_by: edited_by,
80
78
  change_note: change_note
81
79
  )
@@ -114,12 +112,11 @@ module RailsConsoleAi
114
112
  record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
115
113
  record ||= RailsConsoleAi::Memory.new
116
114
  was_new = record.new_record?
115
+ content = RailsConsoleAi::Tools::MemoryTools.dump(
116
+ name: name, description: description, tags: tags
117
+ )
117
118
  record.update_with_version!(
118
- {
119
- name: name,
120
- description: description,
121
- tags: Array(tags)
122
- },
119
+ { content: content },
123
120
  edited_by: edited_by,
124
121
  change_note: change_note
125
122
  )
@@ -158,15 +155,12 @@ module RailsConsoleAi
158
155
  record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
159
156
  record ||= RailsConsoleAi::Agent.new
160
157
  was_new = record.new_record?
158
+ content = RailsConsoleAi::AgentLoader.dump(
159
+ name: name, description: description, body: body,
160
+ max_rounds: max_rounds, model: model, tools: tools
161
+ )
161
162
  record.update_with_version!(
162
- {
163
- name: name,
164
- description: description,
165
- body: body,
166
- max_rounds: max_rounds,
167
- model: model,
168
- tools: Array(tools)
169
- },
163
+ { content: content },
170
164
  edited_by: edited_by,
171
165
  change_note: change_note
172
166
  )
@@ -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.
@@ -211,6 +234,14 @@ module RailsConsoleAi
211
234
  nil
212
235
  end
213
236
 
237
+ # Inverse of parse: emit a canonical .md string. The DB store uses this
238
+ # minimal form (name + tags only in frontmatter); the file store layers
239
+ # created_at/updated_at on top via save_memory_to_file.
240
+ def self.dump(name:, description:, tags: [])
241
+ frontmatter = { 'name' => name, 'tags' => Array(tags) }
242
+ "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
243
+ end
244
+
214
245
  def find_memory_key_by_name(name)
215
246
  keys = @storage.list("#{MEMORIES_DIR}/*.md")
216
247
  keys.find do |key|
@@ -338,19 +338,27 @@ module RailsConsoleAi
338
338
  'name' => 'output-explorer',
339
339
  'tools' => ['execute_code'],
340
340
  'max_rounds' => 8,
341
+ 'skip_base_instructions' => true,
341
342
  'body' => <<~PROMPT.freeze
342
- You are exploring a single chunk of captured tool output on behalf of the main assistant.
343
+ You are exploring a single chunk of already-captured tool output on behalf of the
344
+ main assistant. Your ONLY job is to answer a question about that captured text.
343
345
 
344
346
  The full output is bound to the local variable `output` (a String). You do NOT see it
345
- directly — it lives in Ruby memory. Use `execute_code` with Ruby to query it:
347
+ directly — it lives in Ruby memory. Use `execute_code` with Ruby to examine it:
346
348
  - `output.length`, `output.lines.count`
347
349
  - `output[start, len]`, `output.lines[n]`
348
350
  - `output.scan(/pattern/)`, `output.include?("...")`
349
351
  - `JSON.parse(output)` if it looks like JSON, then drill in
350
352
  - any other Ruby string/collection methods
351
353
 
352
- Print only the specific slice or summary the task requires never dump the whole `output`.
353
- Return a concise factual answer. No preamble.
354
+ Database / ActiveRecord access is DISABLED in this contextany DB query (e.g.
355
+ `SomeModel.where(...)`, `.find`, raw SQL) will be blocked and raise an error. The data
356
+ you need exists ONLY in the `output` String already in memory. Do not load models, do
357
+ not run queries, and do not try to "look up" anything — every fact must come from
358
+ examining `output`.
359
+
360
+ Print only the specific slice or summary the task requires — never dump the whole
361
+ `output`. Return a concise factual answer. No preamble.
354
362
  PROMPT
355
363
  }.freeze
356
364
 
@@ -368,7 +376,11 @@ module RailsConsoleAi
368
376
  executor: @executor,
369
377
  output_payload: payload.dup
370
378
  )
371
- result = sub.run
379
+ # Hard sandbox: the output-explorer only examines the in-memory `output` string.
380
+ # Block all DB access for the duration of its run so a stray query can't escape to
381
+ # the live database. Scoped via thread-local (explore_output runs synchronously),
382
+ # so the parent session's guard state is untouched.
383
+ result = RailsConsoleAi.configuration.safety_guards.with_database_blocked { sub.run }
372
384
  @last_sub_agent_usage = { input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used }
373
385
  "Exploration result (#{sub.input_tokens + sub.output_tokens} tokens used, #{payload.length} chars explored):\n#{result}"
374
386
  end
@@ -418,7 +430,7 @@ module RailsConsoleAi
418
430
 
419
431
  register(
420
432
  name: 'save_memory',
421
- description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic). Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/memories directory instead.',
433
+ description: 'Save a fact or pattern you learned about this codebase for future sessions. Use after discovering how something works (e.g. sharding, auth, custom business logic). Defaults to the versioned DB store; pass target: "file" to write to the on-disk .rails_console_ai/memories directory instead. IMPORTANT: memories saved to the DB start in "proposed" state and must be approved by a human in the web UI before you can recall them. Edits to an approved memory also revert it to proposed. Tell the user to visit /rails_console_ai/memories to approve.',
422
434
  parameters: {
423
435
  'type' => 'object',
424
436
  'properties' => {
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.30.0'.freeze
2
+ VERSION = '0.32.0'.freeze
3
3
  end