console_agent 0.1.0 → 0.3.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/README.md +129 -143
- data/app/controllers/console_agent/application_controller.rb +21 -0
- data/app/controllers/console_agent/sessions_controller.rb +16 -0
- data/app/helpers/console_agent/sessions_helper.rb +42 -0
- data/app/models/console_agent/session.rb +23 -0
- data/app/views/console_agent/sessions/index.html.erb +57 -0
- data/app/views/console_agent/sessions/show.html.erb +56 -0
- data/app/views/layouts/console_agent/application.html.erb +83 -0
- data/config/routes.rb +4 -0
- data/lib/console_agent/configuration.rb +12 -5
- data/lib/console_agent/console_methods.rb +167 -4
- data/lib/console_agent/context_builder.rb +34 -125
- data/lib/console_agent/engine.rb +5 -0
- data/lib/console_agent/executor.rb +81 -1
- data/lib/console_agent/repl.rb +363 -42
- data/lib/console_agent/session_logger.rb +79 -0
- data/lib/console_agent/storage/base.rb +27 -0
- data/lib/console_agent/storage/file_storage.rb +63 -0
- data/lib/console_agent/tools/memory_tools.rb +136 -0
- data/lib/console_agent/tools/registry.rb +228 -2
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +143 -3
- data/lib/generators/console_agent/templates/initializer.rb +14 -6
- metadata +14 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>ConsoleAgent Admin</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
10
|
+
background: #f5f5f5;
|
|
11
|
+
color: #333;
|
|
12
|
+
line-height: 1.6;
|
|
13
|
+
}
|
|
14
|
+
.header {
|
|
15
|
+
background: #1a1a2e;
|
|
16
|
+
color: #fff;
|
|
17
|
+
padding: 16px 24px;
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: space-between;
|
|
21
|
+
}
|
|
22
|
+
.header h1 { font-size: 18px; font-weight: 600; }
|
|
23
|
+
.header a { color: #8be9fd; text-decoration: none; font-size: 14px; }
|
|
24
|
+
.container { max-width: 1200px; margin: 24px auto; padding: 0 24px; }
|
|
25
|
+
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
26
|
+
th { background: #f8f9fa; text-align: left; padding: 12px 16px; font-size: 13px; font-weight: 600; color: #555; border-bottom: 2px solid #e9ecef; }
|
|
27
|
+
td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
|
28
|
+
tr:hover td { background: #f8f9fa; }
|
|
29
|
+
a { color: #4a6fa5; text-decoration: none; }
|
|
30
|
+
a:hover { text-decoration: underline; }
|
|
31
|
+
.badge {
|
|
32
|
+
display: inline-block; padding: 2px 8px; border-radius: 12px;
|
|
33
|
+
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
34
|
+
}
|
|
35
|
+
.badge-one_shot { background: #d4edda; color: #155724; }
|
|
36
|
+
.badge-interactive { background: #cce5ff; color: #004085; }
|
|
37
|
+
.badge-explain { background: #fff3cd; color: #856404; }
|
|
38
|
+
.meta-card {
|
|
39
|
+
background: #fff; border-radius: 8px; padding: 20px;
|
|
40
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
|
|
41
|
+
}
|
|
42
|
+
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
|
43
|
+
.meta-item label { font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; display: block; }
|
|
44
|
+
.meta-item span { font-size: 14px; color: #333; }
|
|
45
|
+
.terminal {
|
|
46
|
+
background: #1e1e1e; border-radius: 8px; overflow: hidden;
|
|
47
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
48
|
+
}
|
|
49
|
+
.terminal pre {
|
|
50
|
+
background: transparent; color: #d4d4d4;
|
|
51
|
+
padding: 16px 20px; margin: 0;
|
|
52
|
+
font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
|
|
53
|
+
font-size: 13px; line-height: 1.5;
|
|
54
|
+
overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
|
|
55
|
+
}
|
|
56
|
+
.mono { font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace; font-size: 13px; }
|
|
57
|
+
.text-muted { color: #888; }
|
|
58
|
+
.back-link { margin-bottom: 16px; display: inline-block; }
|
|
59
|
+
.check { color: #28a745; }
|
|
60
|
+
.cross { color: #dc3545; }
|
|
61
|
+
.pagination {
|
|
62
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
63
|
+
margin-top: 16px; padding: 12px 0;
|
|
64
|
+
}
|
|
65
|
+
.pagination a {
|
|
66
|
+
padding: 6px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px;
|
|
67
|
+
font-size: 13px; color: #4a6fa5;
|
|
68
|
+
}
|
|
69
|
+
.pagination a:hover { background: #f8f9fa; text-decoration: none; }
|
|
70
|
+
.pagination .disabled { padding: 6px 14px; font-size: 13px; color: #ccc; }
|
|
71
|
+
.pagination .page-info { font-size: 13px; color: #888; }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="header">
|
|
76
|
+
<h1>ConsoleAgent Admin</h1>
|
|
77
|
+
<a href="<%= console_agent.root_path %>">Sessions</a>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="container">
|
|
80
|
+
<%= yield %>
|
|
81
|
+
</div>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
module ConsoleAgent
|
|
2
2
|
class Configuration
|
|
3
3
|
PROVIDERS = %i[anthropic openai].freeze
|
|
4
|
-
CONTEXT_MODES = %i[full smart].freeze
|
|
5
4
|
|
|
6
5
|
attr_accessor :provider, :api_key, :model, :max_tokens,
|
|
7
|
-
:auto_execute, :
|
|
8
|
-
:timeout, :debug, :max_tool_rounds
|
|
6
|
+
:auto_execute, :temperature,
|
|
7
|
+
:timeout, :debug, :max_tool_rounds,
|
|
8
|
+
:storage_adapter, :memories_enabled,
|
|
9
|
+
:session_logging, :connection_class,
|
|
10
|
+
:admin_username, :admin_password
|
|
9
11
|
|
|
10
12
|
def initialize
|
|
11
13
|
@provider = :anthropic
|
|
@@ -13,11 +15,16 @@ module ConsoleAgent
|
|
|
13
15
|
@model = nil
|
|
14
16
|
@max_tokens = 4096
|
|
15
17
|
@auto_execute = false
|
|
16
|
-
@context_mode = :smart
|
|
17
18
|
@temperature = 0.2
|
|
18
19
|
@timeout = 30
|
|
19
20
|
@debug = false
|
|
20
|
-
@max_tool_rounds =
|
|
21
|
+
@max_tool_rounds = 100
|
|
22
|
+
@storage_adapter = nil
|
|
23
|
+
@memories_enabled = true
|
|
24
|
+
@session_logging = true
|
|
25
|
+
@connection_class = nil
|
|
26
|
+
@admin_username = nil
|
|
27
|
+
@admin_password = nil
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
def resolved_api_key
|
|
@@ -4,16 +4,141 @@ module ConsoleAgent
|
|
|
4
4
|
ConsoleAgent.status
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
+
def ai_memories(n = nil)
|
|
8
|
+
require 'yaml'
|
|
9
|
+
require 'console_agent/tools/memory_tools'
|
|
10
|
+
storage = ConsoleAgent.storage
|
|
11
|
+
keys = storage.list('memories/*.md').sort
|
|
12
|
+
|
|
13
|
+
if keys.empty?
|
|
14
|
+
$stdout.puts "\e[2mNo memories stored yet.\e[0m"
|
|
15
|
+
return nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
memories = keys.filter_map do |key|
|
|
19
|
+
content = storage.read(key)
|
|
20
|
+
next if content.nil? || content.strip.empty?
|
|
21
|
+
next unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
22
|
+
fm = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
|
|
23
|
+
fm.merge('description' => $2.strip, 'file' => key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if memories.empty?
|
|
27
|
+
$stdout.puts "\e[2mNo memories stored yet.\e[0m"
|
|
28
|
+
return nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
shown = n ? memories.last(n) : memories.last(5)
|
|
32
|
+
total = memories.length
|
|
33
|
+
|
|
34
|
+
$stdout.puts "\e[36m[Memories — showing last #{shown.length} of #{total}]\e[0m"
|
|
35
|
+
shown.each do |m|
|
|
36
|
+
$stdout.puts "\e[33m #{m['name']}\e[0m"
|
|
37
|
+
$stdout.puts "\e[2m #{m['description']}\e[0m"
|
|
38
|
+
tags = Array(m['tags'])
|
|
39
|
+
$stdout.puts "\e[2m tags: #{tags.join(', ')}\e[0m" unless tags.empty?
|
|
40
|
+
$stdout.puts
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
path = storage.respond_to?(:root_path) ? File.join(storage.root_path, 'memories') : 'memories/'
|
|
44
|
+
$stdout.puts "\e[2mStored in: #{path}/\e[0m"
|
|
45
|
+
$stdout.puts "\e[2mUse ai_memories(n) to show last n.\e[0m"
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ai_sessions(n = 10, search: nil)
|
|
50
|
+
require 'console_agent/session_logger'
|
|
51
|
+
session_class = Object.const_get('ConsoleAgent::Session')
|
|
52
|
+
|
|
53
|
+
scope = session_class.recent
|
|
54
|
+
scope = scope.search(search) if search
|
|
55
|
+
sessions = scope.limit(n)
|
|
56
|
+
|
|
57
|
+
if sessions.empty?
|
|
58
|
+
$stdout.puts "\e[2mNo sessions found.\e[0m"
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
$stdout.puts "\e[36m[Sessions — showing #{sessions.length}#{search ? " matching \"#{search}\"" : ''}]\e[0m"
|
|
63
|
+
$stdout.puts
|
|
64
|
+
|
|
65
|
+
sessions.each do |s|
|
|
66
|
+
id_str = "\e[2m##{s.id}\e[0m"
|
|
67
|
+
name_str = s.name ? "\e[33m#{s.name}\e[0m " : ""
|
|
68
|
+
query_str = s.name ? "\e[2m#{truncate_str(s.query, 50)}\e[0m" : truncate_str(s.query, 50)
|
|
69
|
+
mode_str = "\e[2m[#{s.mode}]\e[0m"
|
|
70
|
+
time_str = "\e[2m#{time_ago(s.created_at)}\e[0m"
|
|
71
|
+
tokens = (s.input_tokens || 0) + (s.output_tokens || 0)
|
|
72
|
+
token_str = tokens > 0 ? "\e[2m#{tokens} tokens\e[0m" : ""
|
|
73
|
+
|
|
74
|
+
$stdout.puts " #{id_str} #{name_str}#{query_str}"
|
|
75
|
+
$stdout.puts " #{mode_str} #{time_str} #{token_str}"
|
|
76
|
+
$stdout.puts
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
$stdout.puts "\e[2mUse ai_resume(id_or_name) to resume a session.\e[0m"
|
|
80
|
+
$stdout.puts "\e[2mUse ai_sessions(n, search: \"term\") to filter.\e[0m"
|
|
81
|
+
nil
|
|
82
|
+
rescue => e
|
|
83
|
+
$stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ai_resume(identifier)
|
|
88
|
+
__ensure_console_agent_user
|
|
89
|
+
|
|
90
|
+
require 'console_agent/context_builder'
|
|
91
|
+
require 'console_agent/providers/base'
|
|
92
|
+
require 'console_agent/executor'
|
|
93
|
+
require 'console_agent/repl'
|
|
94
|
+
require 'console_agent/session_logger'
|
|
95
|
+
|
|
96
|
+
session = __find_session(identifier)
|
|
97
|
+
unless session
|
|
98
|
+
$stderr.puts "\e[31mSession not found: #{identifier}\e[0m"
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
repl = Repl.new(__console_agent_binding)
|
|
103
|
+
repl.resume(session)
|
|
104
|
+
rescue => e
|
|
105
|
+
$stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ai_name(identifier, new_name)
|
|
110
|
+
require 'console_agent/session_logger'
|
|
111
|
+
|
|
112
|
+
session = __find_session(identifier)
|
|
113
|
+
unless session
|
|
114
|
+
$stderr.puts "\e[31mSession not found: #{identifier}\e[0m"
|
|
115
|
+
return nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
ConsoleAgent::SessionLogger.update(session.id, name: new_name)
|
|
119
|
+
$stdout.puts "\e[36mSession ##{session.id} named: #{new_name}\e[0m"
|
|
120
|
+
nil
|
|
121
|
+
rescue => e
|
|
122
|
+
$stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
7
126
|
def ai(query = nil)
|
|
8
127
|
if query.nil?
|
|
9
128
|
$stderr.puts "\e[33mUsage: ai \"your question here\"\e[0m"
|
|
10
|
-
$stderr.puts "\e[33m ai \"query\"
|
|
11
|
-
$stderr.puts "\e[33m ai! \"query\"
|
|
12
|
-
$stderr.puts "\e[33m ai? \"query\"
|
|
13
|
-
$stderr.puts "\e[33m
|
|
129
|
+
$stderr.puts "\e[33m ai \"query\" - ask + confirm execution\e[0m"
|
|
130
|
+
$stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
|
|
131
|
+
$stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
|
|
132
|
+
$stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
|
|
133
|
+
$stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
|
|
134
|
+
$stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
|
|
135
|
+
$stderr.puts "\e[33m ai_status - show current configuration\e[0m"
|
|
136
|
+
$stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
|
|
14
137
|
return nil
|
|
15
138
|
end
|
|
16
139
|
|
|
140
|
+
__ensure_console_agent_user
|
|
141
|
+
|
|
17
142
|
require 'console_agent/context_builder'
|
|
18
143
|
require 'console_agent/providers/base'
|
|
19
144
|
require 'console_agent/executor'
|
|
@@ -27,6 +152,8 @@ module ConsoleAgent
|
|
|
27
152
|
end
|
|
28
153
|
|
|
29
154
|
def ai!(query = nil)
|
|
155
|
+
__ensure_console_agent_user
|
|
156
|
+
|
|
30
157
|
require 'console_agent/context_builder'
|
|
31
158
|
require 'console_agent/providers/base'
|
|
32
159
|
require 'console_agent/executor'
|
|
@@ -50,6 +177,8 @@ module ConsoleAgent
|
|
|
50
177
|
return nil
|
|
51
178
|
end
|
|
52
179
|
|
|
180
|
+
__ensure_console_agent_user
|
|
181
|
+
|
|
53
182
|
require 'console_agent/context_builder'
|
|
54
183
|
require 'console_agent/providers/base'
|
|
55
184
|
require 'console_agent/executor'
|
|
@@ -64,6 +193,40 @@ module ConsoleAgent
|
|
|
64
193
|
|
|
65
194
|
private
|
|
66
195
|
|
|
196
|
+
def __find_session(identifier)
|
|
197
|
+
session_class = Object.const_get('ConsoleAgent::Session')
|
|
198
|
+
if identifier.is_a?(Integer)
|
|
199
|
+
session_class.find_by(id: identifier)
|
|
200
|
+
else
|
|
201
|
+
session_class.where(name: identifier.to_s).recent.first ||
|
|
202
|
+
session_class.find_by(id: identifier.to_i)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def truncate_str(str, max)
|
|
207
|
+
return '' if str.nil?
|
|
208
|
+
str.length > max ? str[0...max] + '...' : str
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def time_ago(time)
|
|
212
|
+
return '' unless time
|
|
213
|
+
seconds = Time.now - time
|
|
214
|
+
case seconds
|
|
215
|
+
when 0...60 then "just now"
|
|
216
|
+
when 60...3600 then "#{(seconds / 60).to_i}m ago"
|
|
217
|
+
when 3600...86400 then "#{(seconds / 3600).to_i}h ago"
|
|
218
|
+
else "#{(seconds / 86400).to_i}d ago"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def __ensure_console_agent_user
|
|
223
|
+
return if ConsoleAgent.current_user
|
|
224
|
+
$stdout.puts "\e[36mConsoleAgent logs all AI sessions for audit purposes.\e[0m"
|
|
225
|
+
$stdout.print "\e[36mPlease enter your name: \e[0m"
|
|
226
|
+
name = $stdin.gets.to_s.strip
|
|
227
|
+
ConsoleAgent.current_user = name.empty? ? ENV['USER'] : name
|
|
228
|
+
end
|
|
229
|
+
|
|
67
230
|
def __console_agent_binding
|
|
68
231
|
# Try IRB workspace binding
|
|
69
232
|
if defined?(IRB) && IRB.respond_to?(:CurrentContext)
|
|
@@ -5,31 +5,17 @@ module ConsoleAgent
|
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def build
|
|
8
|
-
|
|
9
|
-
when :smart
|
|
10
|
-
build_smart
|
|
11
|
-
else
|
|
12
|
-
build_full
|
|
13
|
-
end
|
|
8
|
+
build_smart
|
|
14
9
|
rescue => e
|
|
15
10
|
ConsoleAgent.logger.warn("ConsoleAgent: context build error: #{e.message}")
|
|
16
|
-
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def build_full
|
|
20
|
-
parts = []
|
|
21
|
-
parts << system_instructions
|
|
22
|
-
parts << environment_context
|
|
23
|
-
parts << schema_context
|
|
24
|
-
parts << models_context
|
|
25
|
-
parts << routes_context
|
|
26
|
-
parts.compact.join("\n\n")
|
|
11
|
+
smart_system_instructions + "\n\n" + environment_context
|
|
27
12
|
end
|
|
28
13
|
|
|
29
14
|
def build_smart
|
|
30
15
|
parts = []
|
|
31
16
|
parts << smart_system_instructions
|
|
32
17
|
parts << environment_context
|
|
18
|
+
parts << memory_context
|
|
33
19
|
parts.compact.join("\n\n")
|
|
34
20
|
end
|
|
35
21
|
|
|
@@ -48,10 +34,29 @@ module ConsoleAgent
|
|
|
48
34
|
you need specific information to write accurate code — such as which user they are, which
|
|
49
35
|
record to target, or what value to use.
|
|
50
36
|
|
|
37
|
+
You have memory tools to persist what you learn across sessions:
|
|
38
|
+
- save_memory: persist facts or procedures you learn about this codebase.
|
|
39
|
+
If a memory with the same name already exists, it will be updated in place.
|
|
40
|
+
- delete_memory: remove a memory by name
|
|
41
|
+
- recall_memories: search your saved memories for details
|
|
42
|
+
|
|
43
|
+
IMPORTANT: Check the Memories section below BEFORE answering. If a memory is relevant,
|
|
44
|
+
use recall_memories to get full details and apply that knowledge to your answer.
|
|
45
|
+
When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
|
|
46
|
+
When you discover important patterns about this app, save them as memories.
|
|
47
|
+
|
|
48
|
+
You have an execute_plan tool to run multi-step code. When a task requires multiple
|
|
49
|
+
sequential operations, use execute_plan with an array of steps (each with a description
|
|
50
|
+
and Ruby code). The plan is shown to the user for review before execution begins.
|
|
51
|
+
After each step runs, its return value is stored as step1, step2, etc. — use these
|
|
52
|
+
variables in later steps to reference earlier results (e.g. `api = SalesforceApi.new(step1)`).
|
|
53
|
+
For simple single-expression answers, you may respond with a ```ruby code block instead.
|
|
54
|
+
|
|
51
55
|
RULES:
|
|
52
56
|
- Give ONE concise answer. Do not offer multiple alternatives or variations.
|
|
53
|
-
-
|
|
54
|
-
-
|
|
57
|
+
- For multi-step tasks, use execute_plan to break the work into small, clear steps.
|
|
58
|
+
- For simple queries, respond with a single ```ruby code block.
|
|
59
|
+
- Include a brief one-line explanation before any code block.
|
|
55
60
|
- Use the app's actual model names, associations, and schema.
|
|
56
61
|
- Prefer ActiveRecord query interface over raw SQL.
|
|
57
62
|
- For destructive operations, add a comment warning.
|
|
@@ -62,23 +67,6 @@ module ConsoleAgent
|
|
|
62
67
|
PROMPT
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
def system_instructions
|
|
66
|
-
<<~PROMPT.strip
|
|
67
|
-
You are a Ruby on Rails console assistant. The user is in a `rails console` session.
|
|
68
|
-
You help them query data, debug issues, and understand their application.
|
|
69
|
-
|
|
70
|
-
RULES:
|
|
71
|
-
- Give ONE concise answer. Do not offer multiple alternatives or variations.
|
|
72
|
-
- Respond with a single ```ruby code block that directly answers the question.
|
|
73
|
-
- Include a brief one-line explanation before the code block.
|
|
74
|
-
- Use the app's actual model names, associations, and schema.
|
|
75
|
-
- Prefer ActiveRecord query interface over raw SQL.
|
|
76
|
-
- For destructive operations, add a comment warning.
|
|
77
|
-
- If the request is ambiguous, ask a clarifying question instead of guessing.
|
|
78
|
-
- Keep code concise and idiomatic.
|
|
79
|
-
PROMPT
|
|
80
|
-
end
|
|
81
|
-
|
|
82
70
|
def environment_context
|
|
83
71
|
lines = ["## Environment"]
|
|
84
72
|
lines << "- Ruby #{RUBY_VERSION}"
|
|
@@ -100,101 +88,22 @@ module ConsoleAgent
|
|
|
100
88
|
lines.join("\n")
|
|
101
89
|
end
|
|
102
90
|
|
|
103
|
-
def
|
|
104
|
-
return nil unless
|
|
105
|
-
|
|
106
|
-
conn = ActiveRecord::Base.connection
|
|
107
|
-
tables = conn.tables.sort
|
|
108
|
-
return nil if tables.empty?
|
|
109
|
-
|
|
110
|
-
lines = ["## Database Schema"]
|
|
111
|
-
tables.each do |table|
|
|
112
|
-
next if table == 'schema_migrations' || table == 'ar_internal_metadata'
|
|
113
|
-
|
|
114
|
-
cols = conn.columns(table).map { |c| "#{c.name}:#{c.type}" }
|
|
115
|
-
lines << "- #{table} (#{cols.join(', ')})"
|
|
116
|
-
end
|
|
117
|
-
lines.join("\n")
|
|
118
|
-
rescue => e
|
|
119
|
-
ConsoleAgent.logger.debug("ConsoleAgent: schema introspection failed: #{e.message}")
|
|
120
|
-
nil
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def models_context
|
|
124
|
-
return nil unless defined?(ActiveRecord::Base)
|
|
125
|
-
|
|
126
|
-
eager_load_app!
|
|
127
|
-
base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
128
|
-
models = ObjectSpace.each_object(Class).select { |c|
|
|
129
|
-
c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
|
|
130
|
-
}.sort_by(&:name)
|
|
131
|
-
|
|
132
|
-
return nil if models.empty?
|
|
133
|
-
|
|
134
|
-
lines = ["## Models"]
|
|
135
|
-
models.each do |model|
|
|
136
|
-
parts = [model.name]
|
|
137
|
-
|
|
138
|
-
assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
|
|
139
|
-
parts << " associations: #{assocs.join(', ')}" unless assocs.empty?
|
|
140
|
-
|
|
141
|
-
scopes = model.methods.grep(/^_scope_/).map { |m| m.to_s.sub('_scope_', '') } rescue []
|
|
142
|
-
if scopes.empty?
|
|
143
|
-
# Alternative: check singleton methods that return ActiveRecord::Relation
|
|
144
|
-
scope_names = (model.singleton_methods - base_class.singleton_methods).select { |m|
|
|
145
|
-
m != :all && !m.to_s.start_with?('_') && !m.to_s.start_with?('find')
|
|
146
|
-
}.first(10)
|
|
147
|
-
# We won't list these to avoid noise — scopes are hard to detect reliably
|
|
148
|
-
end
|
|
91
|
+
def memory_context
|
|
92
|
+
return nil unless @config.memories_enabled
|
|
149
93
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}.uniq.first(5) rescue []
|
|
154
|
-
parts << " validations: #{validations.join('; ')}" unless validations.empty?
|
|
155
|
-
|
|
156
|
-
lines << "- #{parts.join("\n")}"
|
|
157
|
-
end
|
|
158
|
-
lines.join("\n")
|
|
159
|
-
rescue => e
|
|
160
|
-
ConsoleAgent.logger.debug("ConsoleAgent: model introspection failed: #{e.message}")
|
|
161
|
-
nil
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def routes_context
|
|
165
|
-
return nil unless defined?(Rails) && Rails.respond_to?(:application)
|
|
166
|
-
|
|
167
|
-
routes = Rails.application.routes.routes
|
|
168
|
-
return nil if routes.empty?
|
|
169
|
-
|
|
170
|
-
lines = ["## Routes (summary)"]
|
|
171
|
-
count = 0
|
|
172
|
-
routes.each do |route|
|
|
173
|
-
next if route.internal?
|
|
174
|
-
path = route.path.spec.to_s.sub('(.:format)', '')
|
|
175
|
-
verb = route.verb.to_s
|
|
176
|
-
next if verb.empty?
|
|
177
|
-
action = route.defaults[:controller].to_s + '#' + route.defaults[:action].to_s
|
|
178
|
-
lines << "- #{verb} #{path} -> #{action}"
|
|
179
|
-
count += 1
|
|
180
|
-
break if count >= 50
|
|
181
|
-
end
|
|
182
|
-
lines << "- ... (#{routes.size - count} more routes)" if routes.size > count
|
|
94
|
+
require 'console_agent/tools/memory_tools'
|
|
95
|
+
summaries = Tools::MemoryTools.new.memory_summaries
|
|
96
|
+
return nil if summaries.nil? || summaries.empty?
|
|
183
97
|
|
|
98
|
+
lines = ["## Memories"]
|
|
99
|
+
lines.concat(summaries)
|
|
100
|
+
lines << ""
|
|
101
|
+
lines << "Call recall_memories to get details before answering. Do NOT guess from the name alone."
|
|
184
102
|
lines.join("\n")
|
|
185
103
|
rescue => e
|
|
186
|
-
ConsoleAgent.logger.debug("ConsoleAgent:
|
|
104
|
+
ConsoleAgent.logger.debug("ConsoleAgent: memory context failed: #{e.message}")
|
|
187
105
|
nil
|
|
188
106
|
end
|
|
189
107
|
|
|
190
|
-
def eager_load_app!
|
|
191
|
-
return unless defined?(Rails) && Rails.respond_to?(:application)
|
|
192
|
-
|
|
193
|
-
if Rails.application.respond_to?(:eager_load!)
|
|
194
|
-
Rails.application.eager_load!
|
|
195
|
-
end
|
|
196
|
-
rescue => e
|
|
197
|
-
ConsoleAgent.logger.debug("ConsoleAgent: eager_load failed: #{e.message}")
|
|
198
|
-
end
|
|
199
108
|
end
|
|
200
109
|
end
|
|
@@ -1,8 +1,50 @@
|
|
|
1
|
+
require 'stringio'
|
|
2
|
+
|
|
1
3
|
module ConsoleAgent
|
|
4
|
+
# Writes to two IO streams simultaneously
|
|
5
|
+
class TeeIO
|
|
6
|
+
attr_reader :secondary
|
|
7
|
+
|
|
8
|
+
def initialize(primary, secondary)
|
|
9
|
+
@primary = primary
|
|
10
|
+
@secondary = secondary
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write(str)
|
|
14
|
+
@primary.write(str)
|
|
15
|
+
@secondary.write(str)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def puts(*args)
|
|
19
|
+
@primary.puts(*args)
|
|
20
|
+
# Capture what puts would output
|
|
21
|
+
args.each { |a| @secondary.write("#{a}\n") }
|
|
22
|
+
@secondary.write("\n") if args.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def print(*args)
|
|
26
|
+
@primary.print(*args)
|
|
27
|
+
args.each { |a| @secondary.write(a.to_s) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flush
|
|
31
|
+
@primary.flush if @primary.respond_to?(:flush)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def respond_to_missing?(method, include_private = false)
|
|
35
|
+
@primary.respond_to?(method, include_private) || super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def method_missing(method, *args, &block)
|
|
39
|
+
@primary.send(method, *args, &block)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
2
43
|
class Executor
|
|
3
44
|
CODE_REGEX = /```ruby\s*\n(.*?)```/m
|
|
4
45
|
|
|
5
46
|
attr_reader :binding_context
|
|
47
|
+
attr_accessor :on_prompt
|
|
6
48
|
|
|
7
49
|
def initialize(binding_context)
|
|
8
50
|
@binding_context = binding_context
|
|
@@ -33,23 +75,53 @@ module ConsoleAgent
|
|
|
33
75
|
def execute(code)
|
|
34
76
|
return nil if code.nil? || code.strip.empty?
|
|
35
77
|
|
|
78
|
+
captured_output = StringIO.new
|
|
79
|
+
old_stdout = $stdout
|
|
80
|
+
# Tee output: capture it and also print to the real stdout
|
|
81
|
+
$stdout = TeeIO.new(old_stdout, captured_output)
|
|
82
|
+
|
|
36
83
|
result = binding_context.eval(code, "(console_agent)", 1)
|
|
84
|
+
|
|
85
|
+
$stdout = old_stdout
|
|
37
86
|
$stdout.puts colorize("=> #{result.inspect}", :green)
|
|
87
|
+
|
|
88
|
+
@last_output = captured_output.string
|
|
38
89
|
result
|
|
39
90
|
rescue SyntaxError => e
|
|
91
|
+
$stdout = old_stdout if old_stdout
|
|
40
92
|
$stderr.puts colorize("SyntaxError: #{e.message}", :red)
|
|
93
|
+
@last_output = nil
|
|
41
94
|
nil
|
|
42
95
|
rescue => e
|
|
96
|
+
$stdout = old_stdout if old_stdout
|
|
43
97
|
$stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
|
|
44
98
|
e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
|
|
99
|
+
@last_output = captured_output&.string
|
|
45
100
|
nil
|
|
46
101
|
end
|
|
47
102
|
|
|
103
|
+
def last_output
|
|
104
|
+
@last_output
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def last_answer
|
|
108
|
+
@last_answer
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def last_cancelled?
|
|
112
|
+
@last_cancelled
|
|
113
|
+
end
|
|
114
|
+
|
|
48
115
|
def confirm_and_execute(code)
|
|
49
116
|
return nil if code.nil? || code.strip.empty?
|
|
50
117
|
|
|
118
|
+
@last_cancelled = false
|
|
119
|
+
@last_answer = nil
|
|
51
120
|
$stdout.print colorize("Execute? [y/N/edit] ", :yellow)
|
|
121
|
+
@on_prompt&.call
|
|
52
122
|
answer = $stdin.gets.to_s.strip.downcase
|
|
123
|
+
@last_answer = answer
|
|
124
|
+
echo_stdin(answer)
|
|
53
125
|
|
|
54
126
|
case answer
|
|
55
127
|
when 'y', 'yes'
|
|
@@ -60,7 +132,9 @@ module ConsoleAgent
|
|
|
60
132
|
$stdout.puts colorize("# Edited code:", :yellow)
|
|
61
133
|
$stdout.puts highlight_code(edited)
|
|
62
134
|
$stdout.print colorize("Execute edited code? [y/N] ", :yellow)
|
|
63
|
-
|
|
135
|
+
edit_answer = $stdin.gets.to_s.strip.downcase
|
|
136
|
+
echo_stdin(edit_answer)
|
|
137
|
+
if edit_answer == 'y'
|
|
64
138
|
execute(edited)
|
|
65
139
|
else
|
|
66
140
|
$stdout.puts colorize("Cancelled.", :yellow)
|
|
@@ -71,12 +145,18 @@ module ConsoleAgent
|
|
|
71
145
|
end
|
|
72
146
|
else
|
|
73
147
|
$stdout.puts colorize("Cancelled.", :yellow)
|
|
148
|
+
@last_cancelled = true
|
|
74
149
|
nil
|
|
75
150
|
end
|
|
76
151
|
end
|
|
77
152
|
|
|
78
153
|
private
|
|
79
154
|
|
|
155
|
+
# Write stdin input to the capture IO only (avoids double-echo on terminal)
|
|
156
|
+
def echo_stdin(text)
|
|
157
|
+
$stdout.secondary.write("#{text}\n") if $stdout.respond_to?(:secondary)
|
|
158
|
+
end
|
|
159
|
+
|
|
80
160
|
def open_in_editor(code)
|
|
81
161
|
require 'tempfile'
|
|
82
162
|
editor = ENV['EDITOR'] || 'vi'
|