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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +48 -0
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
- data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
- data/app/controllers/rails_console_ai/application_controller.rb +5 -0
- data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
- data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
- data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
- data/app/models/rails_console_ai/agent.rb +175 -0
- data/app/models/rails_console_ai/agent_version.rb +46 -0
- data/app/models/rails_console_ai/memory.rb +98 -0
- data/app/models/rails_console_ai/memory_version.rb +46 -0
- data/app/models/rails_console_ai/session.rb +1 -1
- data/app/models/rails_console_ai/skill.rb +198 -0
- data/app/models/rails_console_ai/skill_version.rb +54 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
- data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
- data/app/views/rails_console_ai/agents/_form.html.erb +65 -0
- data/app/views/rails_console_ai/agents/diff.html.erb +19 -0
- data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
- data/app/views/rails_console_ai/agents/index.html.erb +80 -0
- data/app/views/rails_console_ai/agents/new.html.erb +24 -0
- data/app/views/rails_console_ai/agents/show.html.erb +108 -0
- data/app/views/rails_console_ai/memories/_form.html.erb +36 -0
- data/app/views/rails_console_ai/memories/diff.html.erb +19 -0
- data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
- data/app/views/rails_console_ai/memories/index.html.erb +67 -0
- data/app/views/rails_console_ai/memories/new.html.erb +23 -0
- data/app/views/rails_console_ai/memories/show.html.erb +65 -0
- data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
- data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
- data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
- data/app/views/rails_console_ai/skills/_form.html.erb +65 -0
- data/app/views/rails_console_ai/skills/diff.html.erb +22 -0
- data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
- data/app/views/rails_console_ai/skills/index.html.erb +79 -0
- data/app/views/rails_console_ai/skills/new.html.erb +25 -0
- data/app/views/rails_console_ai/skills/show.html.erb +94 -0
- data/config/routes.rb +42 -0
- data/lib/rails_console_ai/agent_loader.rb +131 -43
- data/lib/rails_console_ai/agent_runner.rb +158 -0
- data/lib/rails_console_ai/channel/api.rb +139 -0
- data/lib/rails_console_ai/channel/slack.rb +33 -0
- data/lib/rails_console_ai/channel/sub_agent.rb +12 -0
- data/lib/rails_console_ai/conversation_engine.rb +50 -13
- data/lib/rails_console_ai/session_logger.rb +6 -0
- data/lib/rails_console_ai/skill_loader.rb +119 -27
- data/lib/rails_console_ai/slack_bot.rb +8 -0
- data/lib/rails_console_ai/storage/database_storage.rb +201 -0
- data/lib/rails_console_ai/sub_agent.rb +25 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
- data/lib/rails_console_ai/tools/registry.rb +99 -8
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +256 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- 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
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
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
|