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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -26
- data/README.md +33 -11
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +2 -9
- data/app/controllers/rails_console_ai/agents_controller.rb +25 -48
- data/app/controllers/rails_console_ai/memories_controller.rb +35 -38
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +2 -6
- data/app/controllers/rails_console_ai/sessions_controller.rb +1 -1
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +2 -8
- data/app/controllers/rails_console_ai/skills_controller.rb +19 -48
- data/app/models/rails_console_ai/agent.rb +33 -65
- data/app/models/rails_console_ai/agent_version.rb +8 -20
- data/app/models/rails_console_ai/memory.rb +67 -23
- data/app/models/rails_console_ai/memory_version.rb +5 -20
- data/app/models/rails_console_ai/skill.rb +55 -105
- data/app/models/rails_console_ai/skill_version.rb +7 -28
- data/app/views/rails_console_ai/agents/_form.html.erb +9 -34
- data/app/views/rails_console_ai/agents/diff.html.erb +1 -5
- data/app/views/rails_console_ai/agents/new.html.erb +0 -16
- data/app/views/rails_console_ai/memories/_form.html.erb +6 -13
- data/app/views/rails_console_ai/memories/diff.html.erb +1 -5
- data/app/views/rails_console_ai/memories/index.html.erb +13 -1
- data/app/views/rails_console_ai/memories/new.html.erb +0 -15
- data/app/views/rails_console_ai/memories/show.html.erb +23 -0
- data/app/views/rails_console_ai/skills/_form.html.erb +7 -29
- data/app/views/rails_console_ai/skills/diff.html.erb +1 -8
- data/app/views/rails_console_ai/skills/new.html.erb +0 -17
- data/config/routes.rb +3 -3
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -5
- data/lib/rails_console_ai/agent_loader.rb +18 -10
- data/lib/rails_console_ai/agent_runner.rb +66 -4
- data/lib/rails_console_ai/channel/console.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +51 -33
- data/lib/rails_console_ai/conversation_engine.rb +3 -3
- data/lib/rails_console_ai/safety_guards.rb +36 -0
- data/lib/rails_console_ai/session_logger.rb +4 -0
- data/lib/rails_console_ai/skill_loader.rb +18 -9
- data/lib/rails_console_ai/slack_bot.rb +2 -2
- data/lib/rails_console_ai/storage/database_storage.rb +14 -20
- data/lib/rails_console_ai/sub_agent.rb +1 -1
- data/lib/rails_console_ai/tools/memory_tools.rb +37 -6
- data/lib/rails_console_ai/tools/registry.rb +18 -6
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +92 -71
- 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
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
208
|
+
family = self.class.model_family(resolved_model)
|
|
209
|
+
family ? family[:max_tokens] : 4096
|
|
193
210
|
end
|
|
194
211
|
|
|
195
|
-
# Returns nil for
|
|
196
|
-
#
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
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 =
|
|
76
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
353
|
-
|
|
354
|
+
Database / ActiveRecord access is DISABLED in this context — any 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
|
-
|
|
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' => {
|