rails_console_ai 0.28.0 → 0.30.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +48 -0
  4. data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
  5. data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
  6. data/app/controllers/rails_console_ai/application_controller.rb +5 -0
  7. data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
  8. data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
  9. data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
  10. data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
  11. data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
  12. data/app/models/rails_console_ai/agent.rb +175 -0
  13. data/app/models/rails_console_ai/agent_version.rb +46 -0
  14. data/app/models/rails_console_ai/memory.rb +98 -0
  15. data/app/models/rails_console_ai/memory_version.rb +46 -0
  16. data/app/models/rails_console_ai/session.rb +1 -1
  17. data/app/models/rails_console_ai/skill.rb +198 -0
  18. data/app/models/rails_console_ai/skill_version.rb +54 -0
  19. data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
  20. data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
  21. data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
  22. data/app/views/rails_console_ai/agents/_form.html.erb +65 -0
  23. data/app/views/rails_console_ai/agents/diff.html.erb +19 -0
  24. data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
  25. data/app/views/rails_console_ai/agents/index.html.erb +80 -0
  26. data/app/views/rails_console_ai/agents/new.html.erb +24 -0
  27. data/app/views/rails_console_ai/agents/show.html.erb +108 -0
  28. data/app/views/rails_console_ai/memories/_form.html.erb +36 -0
  29. data/app/views/rails_console_ai/memories/diff.html.erb +19 -0
  30. data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
  31. data/app/views/rails_console_ai/memories/index.html.erb +67 -0
  32. data/app/views/rails_console_ai/memories/new.html.erb +23 -0
  33. data/app/views/rails_console_ai/memories/show.html.erb +65 -0
  34. data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
  35. data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
  36. data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
  37. data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
  38. data/app/views/rails_console_ai/skills/_form.html.erb +65 -0
  39. data/app/views/rails_console_ai/skills/diff.html.erb +22 -0
  40. data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
  41. data/app/views/rails_console_ai/skills/index.html.erb +79 -0
  42. data/app/views/rails_console_ai/skills/new.html.erb +25 -0
  43. data/app/views/rails_console_ai/skills/show.html.erb +94 -0
  44. data/config/routes.rb +42 -0
  45. data/lib/rails_console_ai/agent_loader.rb +131 -43
  46. data/lib/rails_console_ai/agent_runner.rb +158 -0
  47. data/lib/rails_console_ai/channel/api.rb +139 -0
  48. data/lib/rails_console_ai/channel/slack.rb +33 -0
  49. data/lib/rails_console_ai/channel/sub_agent.rb +12 -0
  50. data/lib/rails_console_ai/conversation_engine.rb +50 -13
  51. data/lib/rails_console_ai/session_logger.rb +6 -0
  52. data/lib/rails_console_ai/skill_loader.rb +119 -27
  53. data/lib/rails_console_ai/slack_bot.rb +8 -0
  54. data/lib/rails_console_ai/storage/database_storage.rb +201 -0
  55. data/lib/rails_console_ai/sub_agent.rb +25 -0
  56. data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
  57. data/lib/rails_console_ai/tools/registry.rb +99 -8
  58. data/lib/rails_console_ai/version.rb +1 -1
  59. data/lib/rails_console_ai.rb +256 -0
  60. data/lib/tasks/rails_console_ai.rake +7 -0
  61. metadata +55 -1
@@ -11,6 +11,9 @@ module RailsConsoleAi
11
11
  @thread_ts = thread_ts
12
12
  @user_name = user_name
13
13
  @reply_queue = Queue.new
14
+ @guidance_main = []
15
+ @guidance_sub = []
16
+ @guidance_mutex = Mutex.new
14
17
  @cancelled = false
15
18
  @log_prefix = "[#{@channel_id}/#{@thread_ts}] @#{@user_name}"
16
19
  @output_log = StringIO.new
@@ -18,6 +21,36 @@ module RailsConsoleAi
18
21
 
19
22
  def cancel!
20
23
  @cancelled = true
24
+ @guidance_mutex.synchronize do
25
+ @guidance_main.clear
26
+ @guidance_sub.clear
27
+ end
28
+ end
29
+
30
+ # Guidance is broadcast to both the main-engine queue and the sub-agent queue
31
+ # so a steering message arriving during a sub-agent run is seen by both layers
32
+ # (sub-agent reacts immediately; main engine reacts after delegate_task returns).
33
+ def add_guidance(text)
34
+ @guidance_mutex.synchronize do
35
+ @guidance_main << text
36
+ @guidance_sub << text
37
+ end
38
+ end
39
+
40
+ def drain_guidance(scope: :main)
41
+ @guidance_mutex.synchronize do
42
+ arr = scope == :sub ? @guidance_sub : @guidance_main
43
+ pending = arr.dup
44
+ arr.clear
45
+ pending
46
+ end
47
+ end
48
+
49
+ def pending_guidance?(scope: :main)
50
+ @guidance_mutex.synchronize do
51
+ arr = scope == :sub ? @guidance_sub : @guidance_main
52
+ !arr.empty?
53
+ end
21
54
  end
22
55
 
23
56
  def cancelled?
@@ -64,6 +64,18 @@ module RailsConsoleAi
64
64
  @parent.cancelled?
65
65
  end
66
66
 
67
+ def pending_guidance?
68
+ @parent.respond_to?(:pending_guidance?) && @parent.pending_guidance?(scope: :sub)
69
+ end
70
+
71
+ def drain_guidance
72
+ @parent.respond_to?(:drain_guidance) ? @parent.drain_guidance(scope: :sub) : []
73
+ end
74
+
75
+ def add_guidance(text)
76
+ @parent.add_guidance(text) if @parent.respond_to?(:add_guidance)
77
+ end
78
+
67
79
  def supports_danger?
68
80
  false # Sub-agents must never silently bypass safety guards
69
81
  end
@@ -36,7 +36,7 @@ module RailsConsoleAi
36
36
 
37
37
  # --- Public API for channels ---
38
38
 
39
- def one_shot(query)
39
+ def one_shot(query, existing_session_id: nil)
40
40
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
41
  console_capture = StringIO.new
42
42
  exec_result = with_console_capture(console_capture) do
@@ -61,7 +61,8 @@ module RailsConsoleAi
61
61
  code_output: executed ? @executor.last_output : nil,
62
62
  code_result: executed && exec_result ? exec_result.inspect : nil,
63
63
  executed: executed,
64
- start_time: start_time
64
+ start_time: start_time,
65
+ existing_session_id: existing_session_id
65
66
  }
66
67
 
67
68
  exec_result
@@ -803,6 +804,15 @@ module RailsConsoleAi
803
804
  break
804
805
  end
805
806
 
807
+ if round > 0 && @channel.respond_to?(:pending_guidance?) && @channel.pending_guidance?
808
+ pending = @channel.drain_guidance
809
+ guidance_text = format_user_interruption(pending)
810
+ guidance_msg = { role: :user, content: guidance_text }
811
+ messages << guidance_msg
812
+ new_messages << guidance_msg
813
+ @channel.display_status(" Steering: incorporating user guidance.")
814
+ end
815
+
806
816
  if round == 0
807
817
  @channel.display_status(" Thinking...")
808
818
  elsif last_thinking
@@ -1007,18 +1017,23 @@ module RailsConsoleAi
1007
1017
 
1008
1018
  def log_session(attrs)
1009
1019
  require 'rails_console_ai/session_logger'
1010
- start_time = attrs.delete(:start_time)
1011
- duration_ms = if start_time
1012
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1013
- end
1014
- SessionLogger.log(
1015
- attrs.merge(
1016
- input_tokens: @total_input_tokens,
1017
- output_tokens: @total_output_tokens,
1018
- duration_ms: duration_ms,
1019
- model: effective_model
1020
- )
1020
+ start_time = attrs.delete(:start_time)
1021
+ existing_id = attrs.delete(:existing_session_id)
1022
+ duration_ms = if start_time
1023
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
1024
+ end
1025
+ merged = attrs.merge(
1026
+ input_tokens: @total_input_tokens,
1027
+ output_tokens: @total_output_tokens,
1028
+ duration_ms: duration_ms,
1029
+ model: effective_model
1021
1030
  )
1031
+ if existing_id
1032
+ SessionLogger.update(existing_id, merged)
1033
+ existing_id
1034
+ else
1035
+ SessionLogger.log(merged)
1036
+ end
1022
1037
  end
1023
1038
 
1024
1039
  # --- Formatting helpers ---
@@ -1152,6 +1167,28 @@ module RailsConsoleAi
1152
1167
  str.length > max ? str[0..max] + '...' : str
1153
1168
  end
1154
1169
 
1170
+ # Wraps mid-task user messages with explicit framing so the model treats them
1171
+ # as a real-time interruption that supersedes the prior task, rather than as
1172
+ # a reply to the most recent tool result.
1173
+ def format_user_interruption(messages)
1174
+ joined = messages.map { |t| t.to_s.strip }.reject(&:empty?).join("\n\n")
1175
+ <<~MSG.strip
1176
+ [INTERRUPTION FROM USER — REAL-TIME MESSAGE]
1177
+
1178
+ The user sent the following message while you were working on the previous step.
1179
+ They sent it before seeing the result of your last tool call, so it is NOT a
1180
+ reply to that result. It is your most recent direction from the user and
1181
+ supersedes the prior task.
1182
+
1183
+ If they are telling you to stop, halt completely and acknowledge — do not
1184
+ autonomously switch to a different method to accomplish the original task.
1185
+ If their instruction is unclear, ask them what they want before continuing.
1186
+
1187
+ User message:
1188
+ "#{joined}"
1189
+ MSG
1190
+ end
1191
+
1155
1192
  def llm_status(round, messages, req_tokens, total_billed, last_thinking = nil, last_tool_names = [])
1156
1193
  status = "Calling LLM (round #{round + 1}, #{messages.length} msgs"
1157
1194
  status += ", ~#{format_tokens(req_tokens)} ctx" if req_tokens > 0
@@ -25,6 +25,9 @@ module RailsConsoleAi
25
25
  }
26
26
  create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
27
  create_attrs[:slack_channel_name] = attrs[:slack_channel_name] if attrs[:slack_channel_name]
28
+ create_attrs[:status] = attrs[:status] if attrs.key?(:status)
29
+ create_attrs[:result] = attrs[:result] if attrs.key?(:result)
30
+ create_attrs[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
28
31
  record = session_class.create!(create_attrs)
29
32
  record.id
30
33
  rescue => e
@@ -59,6 +62,9 @@ module RailsConsoleAi
59
62
  updates[:executed] = attrs[:executed] if attrs.key?(:executed)
60
63
  updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
61
64
  updates[:name] = attrs[:name] if attrs.key?(:name)
65
+ updates[:status] = attrs[:status] if attrs.key?(:status)
66
+ updates[:result] = attrs[:result] if attrs.key?(:result)
67
+ updates[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
62
68
 
63
69
  session_class.where(id: id).update_all(updates) unless updates.empty?
64
70
  rescue => e
@@ -1,4 +1,5 @@
1
1
  require 'yaml'
2
+ require 'rails_console_ai/storage/database_storage'
2
3
 
3
4
  module RailsConsoleAi
4
5
  class SkillLoader
@@ -8,16 +9,33 @@ module RailsConsoleAi
8
9
  @storage = storage || RailsConsoleAi.storage
9
10
  end
10
11
 
12
+ # Returns the union of DB-backed skills and file-backed skills.
13
+ # When the same name appears in both, the DB record wins and the file
14
+ # record is shadowed (but the file isn't touched).
15
+ #
16
+ # Includes proposed (unapproved) DB skills — they show up in the admin UI
17
+ # with a "PROPOSED" badge. The AI-facing surface (#skill_summaries, #find_skill)
18
+ # filters them out, so an unapproved skill can never be activated.
11
19
  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
- []
20
+ db = safe_load_db_skills
21
+ file = safe_load_file_skills
22
+
23
+ names = db.map { |s| s['name'].to_s.downcase }
24
+ file.reject! { |s| names.include?(s['name'].to_s.downcase) }
25
+
26
+ (db + file).sort_by { |s| s['name'].to_s.downcase }
27
+ end
28
+
29
+ # Skills the AI is allowed to see / activate: approved DB skills + all file skills.
30
+ # File skills are considered pre-approved because they're git-tracked.
31
+ def load_activatable_skills
32
+ # Use string literal so this doesn't require the AR model to be autoloaded
33
+ # in environments that don't reference it.
34
+ load_all_skills.reject { |s| s['source'] == :db && s['status'] != 'approved' }
17
35
  end
18
36
 
19
37
  def skill_summaries
20
- skills = load_all_skills
38
+ skills = load_activatable_skills
21
39
  return nil if skills.empty?
22
40
 
23
41
  skills.map { |s|
@@ -28,13 +46,81 @@ module RailsConsoleAi
28
46
  end
29
47
 
30
48
  def find_skill(name)
31
- skills = load_all_skills
32
- skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
49
+ load_activatable_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
50
+ end
51
+
52
+ # UI-facing: includes proposed skills too, with name-collision DB wins.
53
+ def find_any_skill(name)
54
+ load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
55
+ end
56
+
57
+ # target: :db (default) | :file
58
+ # Falls back to :file (with a notice in the return string) if DB tables aren't set up.
59
+ def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [], target: :db, edited_by: nil, change_note: nil)
60
+ target = (target || :db).to_sym
61
+ db_fell_back = false
62
+ if target == :db && !Storage::DatabaseStorage.available?
63
+ target = :file
64
+ db_fell_back = true
65
+ end
66
+
67
+ if target == :file
68
+ result = save_skill_to_file(
69
+ name: name, description: description, body: body,
70
+ tags: tags, bypass_guards_for_methods: bypass_guards_for_methods
71
+ )
72
+ if db_fell_back
73
+ result += "\nNOTE: DB storage was requested but the rails_console_ai_skills table does not exist. " \
74
+ "Run `ai_db_setup` in your Rails console to enable the versioned DB store. " \
75
+ "Saved to a file instead."
76
+ end
77
+ result
78
+ else
79
+ record, was_new = Storage::DatabaseStorage.save_skill(
80
+ name: name, description: description, body: body,
81
+ tags: tags, bypass_guards_for_methods: bypass_guards_for_methods,
82
+ edited_by: edited_by || 'ai', change_note: change_note
83
+ )
84
+ status_note = if record.respond_to?(:proposed?) && record.proposed?
85
+ ' — status: PROPOSED. A human must approve it at /rails_console_ai/skills before it can be activated.'
86
+ else
87
+ ''
88
+ end
89
+ if was_new
90
+ "Skill created (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
91
+ else
92
+ "Skill updated (db): \"#{record.name}\" (id=#{record.id})#{status_note}"
93
+ end
94
+ end
95
+ rescue Storage::StorageError, ::ActiveRecord::RecordInvalid => e
96
+ "FAILED to save skill (#{e.message})."
33
97
  end
34
98
 
35
- def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [])
99
+ # Tries DB first, falls back to file. Reports which source it removed from.
100
+ def delete_skill(name:)
101
+ if Storage::DatabaseStorage.delete_skill_by_name(name)
102
+ return "Skill deleted (db): \"#{name}\""
103
+ end
104
+
36
105
  key = skill_key(name)
37
- existing = find_skill(name)
106
+ unless @storage.exists?(key)
107
+ found = safe_load_file_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
108
+ return "No skill found: \"#{name}\"" unless found
109
+ key = skill_key(found['name'])
110
+ end
111
+
112
+ skill = load_skill_file(key)
113
+ @storage.delete(key)
114
+ "Skill deleted: \"#{skill ? skill['name'] : name}\""
115
+ rescue Storage::StorageError => e
116
+ "FAILED to delete skill (#{e.message})."
117
+ end
118
+
119
+ private
120
+
121
+ def save_skill_to_file(name:, description:, body:, tags:, bypass_guards_for_methods:)
122
+ key = skill_key(name)
123
+ existing = load_skill_file(key)
38
124
 
39
125
  frontmatter = {
40
126
  'name' => name,
@@ -53,26 +139,23 @@ module RailsConsoleAi
53
139
  else
54
140
  "Skill created: \"#{name}\" (#{path})"
55
141
  end
56
- rescue Storage::StorageError => e
57
- "FAILED to save skill (#{e.message})."
58
142
  end
59
143
 
60
- def delete_skill(name:)
61
- key = skill_key(name)
62
- unless @storage.exists?(key)
63
- found = load_all_skills.find { |s| s['name'].to_s.downcase == name.to_s.downcase }
64
- return "No skill found: \"#{name}\"" unless found
65
- key = skill_key(found['name'])
66
- end
67
-
68
- skill = load_skill(key)
69
- @storage.delete(key)
70
- "Skill deleted: \"#{skill ? skill['name'] : name}\""
71
- rescue Storage::StorageError => e
72
- "FAILED to delete skill (#{e.message})."
144
+ def safe_load_db_skills
145
+ Storage::DatabaseStorage.all_skills
73
146
  end
74
147
 
75
- private
148
+ def safe_load_file_skills
149
+ keys = @storage.list("#{SKILLS_DIR}/*.md")
150
+ keys.filter_map { |key|
151
+ skill = load_skill_file(key)
152
+ next nil unless skill
153
+ skill.merge('source' => :file, 'file_key' => key)
154
+ }
155
+ rescue => e
156
+ RailsConsoleAi.logger.warn("RailsConsoleAi: failed to load file skills: #{e.message}")
157
+ []
158
+ end
76
159
 
77
160
  def skill_key(name)
78
161
  slug = name.downcase.strip
@@ -83,7 +166,7 @@ module RailsConsoleAi
83
166
  "#{SKILLS_DIR}/#{slug}.md"
84
167
  end
85
168
 
86
- def load_skill(key)
169
+ def load_skill_file(key)
87
170
  content = @storage.read(key)
88
171
  return nil if content.nil? || content.strip.empty?
89
172
  parse_skill(content)
@@ -93,10 +176,19 @@ module RailsConsoleAi
93
176
  end
94
177
 
95
178
  def parse_skill(content)
179
+ self.class.parse(content)
180
+ end
181
+
182
+ # Public: parse a raw .md (YAML frontmatter + body) string into a hash.
183
+ # Returns nil for content that doesn't have valid frontmatter so the caller
184
+ # can show a clear error instead of producing a half-formed record.
185
+ def self.parse(content)
96
186
  return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
97
187
  frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
98
188
  body = $2.strip
99
189
  frontmatter.merge('body' => body)
190
+ rescue Psych::SyntaxError
191
+ nil
100
192
  end
101
193
  end
102
194
  end
@@ -491,6 +491,14 @@ module RailsConsoleAi
491
491
  return
492
492
  end
493
493
 
494
+ # If the engine is mid-run, treat the message as steering guidance to be
495
+ # folded in at the next tool-loop boundary instead of restarting.
496
+ if session[:thread]&.alive? && channel.respond_to?(:add_guidance)
497
+ channel.add_guidance(text)
498
+ channel.display("Got it. One moment.")
499
+ return
500
+ end
501
+
494
502
  # Otherwise treat as a new message in the conversation
495
503
  replace_session_thread(session) do
496
504
  Thread.current.report_on_exception = false
@@ -0,0 +1,201 @@
1
+ module RailsConsoleAi
2
+ module Storage
3
+ # Thin facade over the AR-backed Skill / Memory tables.
4
+ #
5
+ # Not a drop-in Storage::Base adapter: the loaders read & write structured
6
+ # records, not opaque Markdown blobs, so we expose a small typed API instead.
7
+ # All methods are safe to call before `ai_db_setup` has run — they detect
8
+ # missing tables and return empty results / nil rather than raising.
9
+ module DatabaseStorage
10
+ module_function
11
+
12
+ def available?
13
+ table_exists?('rails_console_ai_skills')
14
+ end
15
+
16
+ def memories_available?
17
+ table_exists?('rails_console_ai_memories')
18
+ end
19
+
20
+ def agents_available?
21
+ table_exists?('rails_console_ai_agents')
22
+ end
23
+
24
+ # Ask the connection directly so we don't depend on the AR model being
25
+ # autoloaded yet. In a Rails console, the models in app/models are
26
+ # autoloaded lazily — the constant is `defined?`-false until first
27
+ # reference. Going through the connection avoids that timing trap.
28
+ def table_exists?(table_name)
29
+ return false unless defined?(::ActiveRecord)
30
+ conn = active_record_connection
31
+ return false unless conn
32
+ conn.table_exists?(table_name)
33
+ rescue ::ActiveRecord::ActiveRecordError, ::ActiveRecord::NoDatabaseError, NoMethodError
34
+ false
35
+ end
36
+
37
+ def active_record_connection
38
+ klass = RailsConsoleAi.configuration.connection_class
39
+ if klass
40
+ klass = Object.const_get(klass) if klass.is_a?(String)
41
+ klass.connection
42
+ else
43
+ ::ActiveRecord::Base.connection
44
+ end
45
+ end
46
+
47
+ # --- Skills ---
48
+
49
+ def all_skills
50
+ return [] unless available?
51
+ RailsConsoleAi::Skill.alphabetical.map(&:to_hash)
52
+ rescue => e
53
+ warn_failure(:all_skills, e)
54
+ []
55
+ end
56
+
57
+ def find_skill_by_name(name)
58
+ return nil unless available?
59
+ record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
60
+ record&.to_hash
61
+ rescue => e
62
+ warn_failure(:find_skill_by_name, e)
63
+ nil
64
+ end
65
+
66
+ def save_skill(name:, description:, body:, tags: [], bypass_guards_for_methods: [], edited_by: nil, change_note: nil)
67
+ ensure_tables!(:skills)
68
+ record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
69
+ record ||= RailsConsoleAi::Skill.new
70
+ was_new = record.new_record?
71
+ 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
+ },
79
+ edited_by: edited_by,
80
+ change_note: change_note
81
+ )
82
+ [record, was_new]
83
+ end
84
+
85
+ def delete_skill_by_name(name)
86
+ return false unless available?
87
+ record = RailsConsoleAi::Skill.where('LOWER(name) = ?', name.to_s.downcase).first
88
+ return false unless record
89
+ record.destroy
90
+ true
91
+ end
92
+
93
+ # --- Memories ---
94
+
95
+ def all_memories
96
+ return [] unless memories_available?
97
+ RailsConsoleAi::Memory.alphabetical.map(&:to_hash)
98
+ rescue => e
99
+ warn_failure(:all_memories, e)
100
+ []
101
+ end
102
+
103
+ def find_memory_by_name(name)
104
+ return nil unless memories_available?
105
+ record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
106
+ record&.to_hash
107
+ rescue => e
108
+ warn_failure(:find_memory_by_name, e)
109
+ nil
110
+ end
111
+
112
+ def save_memory(name:, description:, tags: [], edited_by: nil, change_note: nil)
113
+ ensure_tables!(:memories)
114
+ record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
115
+ record ||= RailsConsoleAi::Memory.new
116
+ was_new = record.new_record?
117
+ record.update_with_version!(
118
+ {
119
+ name: name,
120
+ description: description,
121
+ tags: Array(tags)
122
+ },
123
+ edited_by: edited_by,
124
+ change_note: change_note
125
+ )
126
+ [record, was_new]
127
+ end
128
+
129
+ def delete_memory_by_name(name)
130
+ return false unless memories_available?
131
+ record = RailsConsoleAi::Memory.where('LOWER(name) = ?', name.to_s.downcase).first
132
+ return false unless record
133
+ record.destroy
134
+ true
135
+ end
136
+
137
+ # --- Agents ---
138
+
139
+ def all_agents
140
+ return [] unless agents_available?
141
+ RailsConsoleAi::Agent.alphabetical.map(&:to_hash)
142
+ rescue => e
143
+ warn_failure(:all_agents, e)
144
+ []
145
+ end
146
+
147
+ def find_agent_by_name(name)
148
+ return nil unless agents_available?
149
+ record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
150
+ record&.to_hash
151
+ rescue => e
152
+ warn_failure(:find_agent_by_name, e)
153
+ nil
154
+ end
155
+
156
+ def save_agent(name:, description:, body:, max_rounds: nil, model: nil, tools: [], edited_by: nil, change_note: nil)
157
+ ensure_tables!(:agents)
158
+ record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
159
+ record ||= RailsConsoleAi::Agent.new
160
+ was_new = record.new_record?
161
+ 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
+ },
170
+ edited_by: edited_by,
171
+ change_note: change_note
172
+ )
173
+ [record, was_new]
174
+ end
175
+
176
+ def delete_agent_by_name(name)
177
+ return false unless agents_available?
178
+ record = RailsConsoleAi::Agent.where('LOWER(name) = ?', name.to_s.downcase).first
179
+ return false unless record
180
+ record.destroy
181
+ true
182
+ end
183
+
184
+ def ensure_tables!(kind)
185
+ ready = case kind
186
+ when :skills then available?
187
+ when :memories then memories_available?
188
+ when :agents then agents_available?
189
+ else false
190
+ end
191
+ return if ready
192
+ raise StorageError, "rails_console_ai_#{kind} table does not exist. Run `ai_db_setup` in your console."
193
+ end
194
+
195
+ def warn_failure(method, error)
196
+ return unless defined?(RailsConsoleAi.logger) && RailsConsoleAi.logger
197
+ RailsConsoleAi.logger.warn("RailsConsoleAi::Storage::DatabaseStorage##{method} failed: #{error.class}: #{error.message}")
198
+ end
199
+ end
200
+ end
201
+ end
@@ -66,6 +66,12 @@ module RailsConsoleAi
66
66
  max_rounds.times do |round|
67
67
  break if channel.cancelled?
68
68
 
69
+ if round > 0 && channel.respond_to?(:pending_guidance?) && channel.pending_guidance?
70
+ pending = channel.drain_guidance
71
+ messages << { role: :user, content: format_user_interruption(pending) }
72
+ channel.display_status(" Steering: incorporating user guidance.")
73
+ end
74
+
69
75
  if round == 0
70
76
  channel.display_status("Thinking...")
71
77
  end
@@ -147,6 +153,25 @@ module RailsConsoleAi
147
153
  result&.text || '(sub-agent returned no result)'
148
154
  end
149
155
 
156
+ def format_user_interruption(messages)
157
+ joined = messages.map { |t| t.to_s.strip }.reject(&:empty?).join("\n\n")
158
+ <<~MSG.strip
159
+ [INTERRUPTION FROM USER — REAL-TIME MESSAGE]
160
+
161
+ The user sent the following message while you were working. They sent it
162
+ before seeing your latest tool result, so it is NOT a reply to that result.
163
+ It is your most recent direction from the user and supersedes the prior task.
164
+
165
+ If they are telling you to stop, halt immediately and finish with a brief
166
+ acknowledgement — do not switch to a different method to accomplish the
167
+ original task on your own. If unclear, return what you have so far and let
168
+ the parent agent ask the user.
169
+
170
+ User message:
171
+ "#{joined}"
172
+ MSG
173
+ end
174
+
150
175
  def build_provider
151
176
  config = RailsConsoleAi.configuration
152
177
  model_override = @agent_config['model'] || config.sub_agent_model