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,63 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'console_agent/storage/base'
|
|
3
|
+
|
|
4
|
+
module ConsoleAgent
|
|
5
|
+
module Storage
|
|
6
|
+
class FileStorage < Base
|
|
7
|
+
attr_reader :root_path
|
|
8
|
+
|
|
9
|
+
def initialize(root_path = nil)
|
|
10
|
+
@root_path = root_path || default_root
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(key)
|
|
14
|
+
path = full_path(key)
|
|
15
|
+
return nil unless File.exist?(path)
|
|
16
|
+
File.read(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(key, content)
|
|
20
|
+
path = full_path(key)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
22
|
+
File.write(path, content)
|
|
23
|
+
true
|
|
24
|
+
rescue Errno::EACCES, Errno::EROFS, IOError => e
|
|
25
|
+
raise StorageError, "Cannot write #{key}: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list(pattern)
|
|
29
|
+
Dir.glob(File.join(@root_path, pattern)).sort.map do |path|
|
|
30
|
+
path.sub("#{@root_path}/", '')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exists?(key)
|
|
35
|
+
File.exist?(full_path(key))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete(key)
|
|
39
|
+
path = full_path(key)
|
|
40
|
+
return false unless File.exist?(path)
|
|
41
|
+
File.delete(path)
|
|
42
|
+
true
|
|
43
|
+
rescue Errno::EACCES, Errno::EROFS, IOError => e
|
|
44
|
+
raise StorageError, "Cannot delete #{key}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def full_path(key)
|
|
50
|
+
sanitized = key.gsub('..', '').gsub(%r{\A/+}, '')
|
|
51
|
+
File.join(@root_path, sanitized)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_root
|
|
55
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
56
|
+
File.join(Rails.root.to_s, '.console_agent')
|
|
57
|
+
else
|
|
58
|
+
File.join(Dir.pwd, '.console_agent')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
3
|
+
module ConsoleAgent
|
|
4
|
+
module Tools
|
|
5
|
+
class MemoryTools
|
|
6
|
+
MEMORIES_DIR = 'memories'
|
|
7
|
+
|
|
8
|
+
def initialize(storage = nil)
|
|
9
|
+
@storage = storage || ConsoleAgent.storage
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def save_memory(name:, description:, tags: [])
|
|
13
|
+
key = memory_key(name)
|
|
14
|
+
existing = load_memory(key)
|
|
15
|
+
|
|
16
|
+
frontmatter = {
|
|
17
|
+
'name' => name,
|
|
18
|
+
'tags' => Array(tags).empty? && existing ? (existing['tags'] || []) : Array(tags),
|
|
19
|
+
'created_at' => existing ? existing['created_at'] : Time.now.utc.iso8601
|
|
20
|
+
}
|
|
21
|
+
frontmatter['updated_at'] = Time.now.utc.iso8601 if existing
|
|
22
|
+
|
|
23
|
+
content = "---\n#{YAML.dump(frontmatter).sub("---\n", '').strip}\n---\n\n#{description}\n"
|
|
24
|
+
@storage.write(key, content)
|
|
25
|
+
|
|
26
|
+
path = @storage.respond_to?(:root_path) ? File.join(@storage.root_path, key) : key
|
|
27
|
+
if existing
|
|
28
|
+
"Memory updated: \"#{name}\" (#{path})"
|
|
29
|
+
else
|
|
30
|
+
"Memory saved: \"#{name}\" (#{path})"
|
|
31
|
+
end
|
|
32
|
+
rescue Storage::StorageError => e
|
|
33
|
+
"FAILED to save (#{e.message}). Add this manually to .console_agent/#{key}:\n" \
|
|
34
|
+
"---\nname: #{name}\ntags: #{Array(tags).inspect}\n---\n\n#{description}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete_memory(name:)
|
|
38
|
+
key = memory_key(name)
|
|
39
|
+
unless @storage.exists?(key)
|
|
40
|
+
# Try to find by name match across all memory files
|
|
41
|
+
found_key = find_memory_key_by_name(name)
|
|
42
|
+
return "No memory found: \"#{name}\"" unless found_key
|
|
43
|
+
key = found_key
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
memory = load_memory(key)
|
|
47
|
+
@storage.delete(key)
|
|
48
|
+
"Memory deleted: \"#{memory ? memory['name'] : name}\""
|
|
49
|
+
rescue Storage::StorageError => e
|
|
50
|
+
"FAILED to delete memory (#{e.message})."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def recall_memories(query: nil, tag: nil)
|
|
54
|
+
memories = load_all_memories
|
|
55
|
+
return "No memories stored yet." if memories.empty?
|
|
56
|
+
|
|
57
|
+
results = memories
|
|
58
|
+
if tag && !tag.empty?
|
|
59
|
+
results = results.select { |m|
|
|
60
|
+
Array(m['tags']).any? { |t| t.downcase.include?(tag.downcase) }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
if query && !query.empty?
|
|
64
|
+
q = query.downcase
|
|
65
|
+
results = results.select { |m|
|
|
66
|
+
m['name'].to_s.downcase.include?(q) ||
|
|
67
|
+
m['description'].to_s.downcase.include?(q) ||
|
|
68
|
+
Array(m['tags']).any? { |t| t.downcase.include?(q) }
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return "No memories matching your search." if results.empty?
|
|
73
|
+
|
|
74
|
+
results.map { |m|
|
|
75
|
+
line = "**#{m['name']}**\n#{m['description']}"
|
|
76
|
+
line += "\nTags: #{m['tags'].join(', ')}" if m['tags'] && !m['tags'].empty?
|
|
77
|
+
line
|
|
78
|
+
}.join("\n\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def memory_summaries
|
|
82
|
+
memories = load_all_memories
|
|
83
|
+
return nil if memories.empty?
|
|
84
|
+
|
|
85
|
+
memories.map { |m|
|
|
86
|
+
tags = Array(m['tags'])
|
|
87
|
+
tag_str = tags.empty? ? '' : " [#{tags.join(', ')}]"
|
|
88
|
+
"- #{m['name']}#{tag_str}"
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def memory_key(name)
|
|
95
|
+
slug = name.downcase.strip
|
|
96
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
97
|
+
.gsub(/[\s]+/, '-')
|
|
98
|
+
.gsub(/-+/, '-')
|
|
99
|
+
.sub(/^-/, '').sub(/-$/, '')
|
|
100
|
+
"#{MEMORIES_DIR}/#{slug}.md"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def load_memory(key)
|
|
104
|
+
content = @storage.read(key)
|
|
105
|
+
return nil if content.nil? || content.strip.empty?
|
|
106
|
+
parse_memory(content)
|
|
107
|
+
rescue => e
|
|
108
|
+
ConsoleAgent.logger.warn("ConsoleAgent: failed to load memory #{key}: #{e.message}")
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def load_all_memories
|
|
113
|
+
keys = @storage.list("#{MEMORIES_DIR}/*.md")
|
|
114
|
+
keys.map { |key| load_memory(key) }.compact
|
|
115
|
+
rescue => e
|
|
116
|
+
ConsoleAgent.logger.warn("ConsoleAgent: failed to load memories: #{e.message}")
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_memory(content)
|
|
121
|
+
return nil unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
122
|
+
frontmatter = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
|
|
123
|
+
description = $2.strip
|
|
124
|
+
frontmatter.merge('description' => description)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def find_memory_key_by_name(name)
|
|
128
|
+
keys = @storage.list("#{MEMORIES_DIR}/*.md")
|
|
129
|
+
keys.find do |key|
|
|
130
|
+
memory = load_memory(key)
|
|
131
|
+
memory && memory['name'].to_s.downcase == name.downcase
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -5,12 +5,22 @@ module ConsoleAgent
|
|
|
5
5
|
class Registry
|
|
6
6
|
attr_reader :definitions
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# Tools that should never be cached (side effects or user interaction)
|
|
9
|
+
NO_CACHE = %w[ask_user save_memory delete_memory execute_plan].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(executor: nil)
|
|
12
|
+
@executor = executor
|
|
9
13
|
@definitions = []
|
|
10
14
|
@handlers = {}
|
|
15
|
+
@cache = {}
|
|
16
|
+
@last_cached = false
|
|
11
17
|
register_all
|
|
12
18
|
end
|
|
13
19
|
|
|
20
|
+
def last_cached?
|
|
21
|
+
@last_cached
|
|
22
|
+
end
|
|
23
|
+
|
|
14
24
|
def execute(tool_name, arguments = {})
|
|
15
25
|
handler = @handlers[tool_name]
|
|
16
26
|
unless handler
|
|
@@ -27,7 +37,18 @@ module ConsoleAgent
|
|
|
27
37
|
arguments || {}
|
|
28
38
|
end
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
unless NO_CACHE.include?(tool_name)
|
|
41
|
+
cache_key = [tool_name, args].hash
|
|
42
|
+
if @cache.key?(cache_key)
|
|
43
|
+
@last_cached = true
|
|
44
|
+
return @cache[cache_key]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@last_cached = false
|
|
49
|
+
result = handler.call(args)
|
|
50
|
+
@cache[[tool_name, args].hash] = result unless NO_CACHE.include?(tool_name)
|
|
51
|
+
result
|
|
31
52
|
rescue => e
|
|
32
53
|
"Error executing #{tool_name}: #{e.message}"
|
|
33
54
|
end
|
|
@@ -158,6 +179,211 @@ module ConsoleAgent
|
|
|
158
179
|
},
|
|
159
180
|
handler: ->(args) { ask_user(args['question']) }
|
|
160
181
|
)
|
|
182
|
+
|
|
183
|
+
register_memory_tools
|
|
184
|
+
register_execute_plan
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def register_memory_tools
|
|
188
|
+
return unless ConsoleAgent.configuration.memories_enabled
|
|
189
|
+
|
|
190
|
+
require 'console_agent/tools/memory_tools'
|
|
191
|
+
memory = MemoryTools.new
|
|
192
|
+
|
|
193
|
+
register(
|
|
194
|
+
name: 'save_memory',
|
|
195
|
+
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).',
|
|
196
|
+
parameters: {
|
|
197
|
+
'type' => 'object',
|
|
198
|
+
'properties' => {
|
|
199
|
+
'name' => { 'type' => 'string', 'description' => 'Short name for this memory (e.g. "Sharding architecture")' },
|
|
200
|
+
'description' => { 'type' => 'string', 'description' => 'Detailed description of what you learned' },
|
|
201
|
+
'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags (e.g. ["database", "sharding"])' }
|
|
202
|
+
},
|
|
203
|
+
'required' => ['name', 'description']
|
|
204
|
+
},
|
|
205
|
+
handler: ->(args) {
|
|
206
|
+
memory.save_memory(name: args['name'], description: args['description'], tags: args['tags'] || [])
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
register(
|
|
211
|
+
name: 'delete_memory',
|
|
212
|
+
description: 'Delete a memory by name.',
|
|
213
|
+
parameters: {
|
|
214
|
+
'type' => 'object',
|
|
215
|
+
'properties' => {
|
|
216
|
+
'name' => { 'type' => 'string', 'description' => 'The memory name to delete (e.g. "Sharding architecture")' }
|
|
217
|
+
},
|
|
218
|
+
'required' => ['name']
|
|
219
|
+
},
|
|
220
|
+
handler: ->(args) { memory.delete_memory(name: args['name']) }
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
register(
|
|
224
|
+
name: 'recall_memories',
|
|
225
|
+
description: 'Search your saved memories about this codebase. Call with no args to list all, or pass a query/tag to filter.',
|
|
226
|
+
parameters: {
|
|
227
|
+
'type' => 'object',
|
|
228
|
+
'properties' => {
|
|
229
|
+
'query' => { 'type' => 'string', 'description' => 'Search term to filter by name, description, or tags' },
|
|
230
|
+
'tag' => { 'type' => 'string', 'description' => 'Filter by a specific tag' }
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
handler: ->(args) { memory.recall_memories(query: args['query'], tag: args['tag']) }
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def register_execute_plan
|
|
238
|
+
return unless @executor
|
|
239
|
+
|
|
240
|
+
register(
|
|
241
|
+
name: 'execute_plan',
|
|
242
|
+
description: 'Execute a multi-step plan. Each step has a description and Ruby code. The plan is shown to the user for approval, then each step is executed in order. After each step executes, its return value is stored as step1, step2, etc. Use these variables in later steps to reference earlier results (e.g. `token = step1`).',
|
|
243
|
+
parameters: {
|
|
244
|
+
'type' => 'object',
|
|
245
|
+
'properties' => {
|
|
246
|
+
'steps' => {
|
|
247
|
+
'type' => 'array',
|
|
248
|
+
'description' => 'Ordered list of steps to execute',
|
|
249
|
+
'items' => {
|
|
250
|
+
'type' => 'object',
|
|
251
|
+
'properties' => {
|
|
252
|
+
'description' => { 'type' => 'string', 'description' => 'What this step does' },
|
|
253
|
+
'code' => { 'type' => 'string', 'description' => 'Ruby code to execute' }
|
|
254
|
+
},
|
|
255
|
+
'required' => %w[description code]
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
'required' => ['steps']
|
|
260
|
+
},
|
|
261
|
+
handler: ->(args) { execute_plan(args['steps'] || []) }
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def execute_plan(steps)
|
|
266
|
+
return 'No steps provided.' if steps.nil? || steps.empty?
|
|
267
|
+
|
|
268
|
+
auto = ConsoleAgent.configuration.auto_execute
|
|
269
|
+
|
|
270
|
+
# Display full plan
|
|
271
|
+
$stdout.puts
|
|
272
|
+
$stdout.puts "\e[36m Plan (#{steps.length} steps):\e[0m"
|
|
273
|
+
steps.each_with_index do |step, i|
|
|
274
|
+
$stdout.puts "\e[36m #{i + 1}. #{step['description']}\e[0m"
|
|
275
|
+
$stdout.puts highlight_plan_code(step['code'])
|
|
276
|
+
end
|
|
277
|
+
$stdout.puts
|
|
278
|
+
|
|
279
|
+
# Ask for plan approval (unless auto-execute)
|
|
280
|
+
skip_confirmations = auto
|
|
281
|
+
unless auto
|
|
282
|
+
$stdout.print "\e[33m Accept plan? [y/N/a(uto)] \e[0m"
|
|
283
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
284
|
+
case answer
|
|
285
|
+
when 'a', 'auto'
|
|
286
|
+
skip_confirmations = true
|
|
287
|
+
when 'y', 'yes'
|
|
288
|
+
# proceed with per-step confirmation
|
|
289
|
+
else
|
|
290
|
+
$stdout.puts "\e[33m Plan declined.\e[0m"
|
|
291
|
+
feedback = ask_feedback("What would you like changed?")
|
|
292
|
+
return "User declined the plan. Feedback: #{feedback}"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Execute steps one by one
|
|
297
|
+
results = []
|
|
298
|
+
steps.each_with_index do |step, i|
|
|
299
|
+
$stdout.puts
|
|
300
|
+
$stdout.puts "\e[36m Step #{i + 1}/#{steps.length}: #{step['description']}\e[0m"
|
|
301
|
+
$stdout.puts "\e[33m # Code:\e[0m"
|
|
302
|
+
$stdout.puts highlight_plan_code(step['code'])
|
|
303
|
+
|
|
304
|
+
# Per-step confirmation (unless auto-execute or plan-level auto)
|
|
305
|
+
unless skip_confirmations
|
|
306
|
+
$stdout.print "\e[33m Run? [y/N/edit] \e[0m"
|
|
307
|
+
step_answer = $stdin.gets.to_s.strip.downcase
|
|
308
|
+
|
|
309
|
+
case step_answer
|
|
310
|
+
when 'e', 'edit'
|
|
311
|
+
edited = edit_step_code(step['code'])
|
|
312
|
+
if edited && edited != step['code']
|
|
313
|
+
$stdout.puts "\e[33m # Edited code:\e[0m"
|
|
314
|
+
$stdout.puts highlight_plan_code(edited)
|
|
315
|
+
$stdout.print "\e[33m Run edited code? [y/N] \e[0m"
|
|
316
|
+
confirm = $stdin.gets.to_s.strip.downcase
|
|
317
|
+
unless confirm == 'y' || confirm == 'yes'
|
|
318
|
+
feedback = ask_feedback("What would you like changed?")
|
|
319
|
+
results << "Step #{i + 1}: User declined after edit. Feedback: #{feedback}"
|
|
320
|
+
break
|
|
321
|
+
end
|
|
322
|
+
step['code'] = edited
|
|
323
|
+
end
|
|
324
|
+
when 'y', 'yes'
|
|
325
|
+
# proceed
|
|
326
|
+
else
|
|
327
|
+
feedback = ask_feedback("What would you like changed?")
|
|
328
|
+
results << "Step #{i + 1}: User declined. Feedback: #{feedback}"
|
|
329
|
+
break
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
exec_result = @executor.execute(step['code'])
|
|
334
|
+
# Make result available as step1, step2, etc. for subsequent steps
|
|
335
|
+
@executor.binding_context.local_variable_set(:"step#{i + 1}", exec_result)
|
|
336
|
+
output = @executor.last_output
|
|
337
|
+
|
|
338
|
+
step_report = "Step #{i + 1} (#{step['description']}):\n"
|
|
339
|
+
if output && !output.strip.empty?
|
|
340
|
+
step_report += "Output: #{output.strip}\n"
|
|
341
|
+
end
|
|
342
|
+
step_report += "Return value: #{exec_result.inspect}"
|
|
343
|
+
results << step_report
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
results.join("\n\n")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def highlight_plan_code(code)
|
|
350
|
+
if coderay_available?
|
|
351
|
+
CodeRay.scan(code, :ruby).terminal.gsub(/^/, ' ')
|
|
352
|
+
else
|
|
353
|
+
code.split("\n").map { |l| " \e[37m#{l}\e[0m" }.join("\n")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def edit_step_code(code)
|
|
358
|
+
require 'tempfile'
|
|
359
|
+
editor = ENV['EDITOR'] || 'vi'
|
|
360
|
+
tmpfile = Tempfile.new(['console_agent_step', '.rb'])
|
|
361
|
+
tmpfile.write(code)
|
|
362
|
+
tmpfile.flush
|
|
363
|
+
system("#{editor} #{tmpfile.path}")
|
|
364
|
+
File.read(tmpfile.path).strip
|
|
365
|
+
rescue => e
|
|
366
|
+
$stderr.puts "\e[31m Editor error: #{e.message}\e[0m"
|
|
367
|
+
code
|
|
368
|
+
ensure
|
|
369
|
+
tmpfile.close! if tmpfile
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def coderay_available?
|
|
373
|
+
return @coderay_available unless @coderay_available.nil?
|
|
374
|
+
@coderay_available = begin
|
|
375
|
+
require 'coderay'
|
|
376
|
+
true
|
|
377
|
+
rescue LoadError
|
|
378
|
+
false
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def ask_feedback(prompt)
|
|
383
|
+
$stdout.print "\e[36m #{prompt} > \e[0m"
|
|
384
|
+
feedback = $stdin.gets
|
|
385
|
+
return '(no feedback provided)' if feedback.nil?
|
|
386
|
+
feedback.strip.empty? ? '(no feedback provided)' : feedback.strip
|
|
161
387
|
end
|
|
162
388
|
|
|
163
389
|
def ask_user(question)
|
data/lib/console_agent.rb
CHANGED
|
@@ -13,6 +13,23 @@ module ConsoleAgent
|
|
|
13
13
|
|
|
14
14
|
def reset_configuration!
|
|
15
15
|
@configuration = Configuration.new
|
|
16
|
+
reset_storage!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def storage
|
|
20
|
+
@storage ||= begin
|
|
21
|
+
adapter = configuration.storage_adapter
|
|
22
|
+
if adapter
|
|
23
|
+
adapter
|
|
24
|
+
else
|
|
25
|
+
require 'console_agent/storage/file_storage'
|
|
26
|
+
Storage::FileStorage.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_storage!
|
|
32
|
+
@storage = nil
|
|
16
33
|
end
|
|
17
34
|
|
|
18
35
|
def logger
|
|
@@ -28,6 +45,14 @@ module ConsoleAgent
|
|
|
28
45
|
@logger = log
|
|
29
46
|
end
|
|
30
47
|
|
|
48
|
+
def current_user
|
|
49
|
+
@current_user
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def current_user=(name)
|
|
53
|
+
@current_user = name
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
def status
|
|
32
57
|
c = configuration
|
|
33
58
|
key = c.resolved_api_key
|
|
@@ -42,17 +67,132 @@ module ConsoleAgent
|
|
|
42
67
|
lines << " Provider: #{c.provider}"
|
|
43
68
|
lines << " Model: #{c.resolved_model}"
|
|
44
69
|
lines << " API key: #{masked_key}"
|
|
45
|
-
lines << " Context mode: #{c.context_mode}"
|
|
46
70
|
lines << " Max tokens: #{c.max_tokens}"
|
|
47
71
|
lines << " Temperature: #{c.temperature}"
|
|
48
72
|
lines << " Timeout: #{c.timeout}s"
|
|
49
|
-
lines << " Max tool rounds:#{c.max_tool_rounds}"
|
|
73
|
+
lines << " Max tool rounds:#{c.max_tool_rounds}"
|
|
50
74
|
lines << " Auto-execute: #{c.auto_execute}"
|
|
75
|
+
lines << " Memories: #{c.memories_enabled}"
|
|
76
|
+
lines << " Session logging:#{session_table_status}"
|
|
51
77
|
lines << " Debug: #{c.debug}"
|
|
52
78
|
$stdout.puts lines.join("\n")
|
|
53
79
|
nil
|
|
54
80
|
end
|
|
81
|
+
|
|
82
|
+
def setup!
|
|
83
|
+
conn = session_connection
|
|
84
|
+
table = 'console_agent_sessions'
|
|
85
|
+
|
|
86
|
+
if conn.table_exists?(table)
|
|
87
|
+
$stdout.puts "\e[32mConsoleAgent: #{table} already exists. Run ConsoleAgent.teardown! first to recreate.\e[0m"
|
|
88
|
+
else
|
|
89
|
+
conn.create_table(table) do |t|
|
|
90
|
+
t.text :query, null: false
|
|
91
|
+
t.text :conversation, null: false
|
|
92
|
+
t.integer :input_tokens, default: 0
|
|
93
|
+
t.integer :output_tokens, default: 0
|
|
94
|
+
t.string :user_name, limit: 255
|
|
95
|
+
t.string :mode, limit: 20, null: false
|
|
96
|
+
t.text :code_executed
|
|
97
|
+
t.text :code_output
|
|
98
|
+
t.text :code_result
|
|
99
|
+
t.text :console_output
|
|
100
|
+
t.boolean :executed, default: false
|
|
101
|
+
t.string :provider, limit: 50
|
|
102
|
+
t.string :model, limit: 100
|
|
103
|
+
t.string :name, limit: 255
|
|
104
|
+
t.integer :duration_ms
|
|
105
|
+
t.datetime :created_at, null: false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
conn.add_index(table, :created_at)
|
|
109
|
+
conn.add_index(table, :user_name)
|
|
110
|
+
conn.add_index(table, :name)
|
|
111
|
+
|
|
112
|
+
$stdout.puts "\e[32mConsoleAgent: created #{table} table.\e[0m"
|
|
113
|
+
end
|
|
114
|
+
rescue => e
|
|
115
|
+
$stderr.puts "\e[31mConsoleAgent setup failed: #{e.class}: #{e.message}\e[0m"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def migrate!
|
|
119
|
+
conn = session_connection
|
|
120
|
+
table = 'console_agent_sessions'
|
|
121
|
+
|
|
122
|
+
unless conn.table_exists?(table)
|
|
123
|
+
$stderr.puts "\e[33mConsoleAgent: #{table} does not exist. Run ConsoleAgent.setup! first.\e[0m"
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
migrations = []
|
|
128
|
+
|
|
129
|
+
unless conn.column_exists?(table, :name)
|
|
130
|
+
conn.add_column(table, :name, :string, limit: 255)
|
|
131
|
+
conn.add_index(table, :name) unless conn.index_exists?(table, :name)
|
|
132
|
+
migrations << 'name'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if migrations.empty?
|
|
136
|
+
$stdout.puts "\e[32mConsoleAgent: #{table} is up to date.\e[0m"
|
|
137
|
+
else
|
|
138
|
+
$stdout.puts "\e[32mConsoleAgent: added columns: #{migrations.join(', ')}.\e[0m"
|
|
139
|
+
end
|
|
140
|
+
rescue => e
|
|
141
|
+
$stderr.puts "\e[31mConsoleAgent migrate failed: #{e.class}: #{e.message}\e[0m"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def teardown!
|
|
145
|
+
conn = session_connection
|
|
146
|
+
table = 'console_agent_sessions'
|
|
147
|
+
|
|
148
|
+
unless conn.table_exists?(table)
|
|
149
|
+
$stdout.puts "\e[33mConsoleAgent: #{table} does not exist, nothing to remove.\e[0m"
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
count = conn.select_value("SELECT COUNT(*) FROM #{conn.quote_table_name(table)}")
|
|
154
|
+
$stdout.print "\e[33mDrop #{table} (#{count} sessions)? [y/N] \e[0m"
|
|
155
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
156
|
+
|
|
157
|
+
unless answer == 'y' || answer == 'yes'
|
|
158
|
+
$stdout.puts "\e[33mCancelled.\e[0m"
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
conn.drop_table(table)
|
|
163
|
+
$stdout.puts "\e[32mConsoleAgent: dropped #{table}.\e[0m"
|
|
164
|
+
rescue => e
|
|
165
|
+
$stderr.puts "\e[31mConsoleAgent teardown failed: #{e.class}: #{e.message}\e[0m"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def session_table_status
|
|
171
|
+
return 'disabled' unless configuration.session_logging
|
|
172
|
+
conn = session_connection
|
|
173
|
+
if conn.table_exists?('console_agent_sessions')
|
|
174
|
+
count = conn.select_value("SELECT COUNT(*) FROM #{conn.quote_table_name('console_agent_sessions')}")
|
|
175
|
+
"\e[32m#{count} sessions\e[0m"
|
|
176
|
+
else
|
|
177
|
+
"\e[33mtable missing (run ConsoleAgent.setup!)\e[0m"
|
|
178
|
+
end
|
|
179
|
+
rescue
|
|
180
|
+
"\e[33munavailable\e[0m"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def session_connection
|
|
184
|
+
klass = configuration.connection_class
|
|
185
|
+
if klass
|
|
186
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
187
|
+
klass.connection
|
|
188
|
+
else
|
|
189
|
+
ActiveRecord::Base.connection
|
|
190
|
+
end
|
|
191
|
+
end
|
|
55
192
|
end
|
|
56
193
|
end
|
|
57
194
|
|
|
58
|
-
|
|
195
|
+
if defined?(Rails::Railtie)
|
|
196
|
+
require 'console_agent/railtie'
|
|
197
|
+
require 'console_agent/engine'
|
|
198
|
+
end
|
|
@@ -17,12 +17,7 @@ ConsoleAgent.configure do |config|
|
|
|
17
17
|
# Auto-execute generated code without confirmation (use with caution!)
|
|
18
18
|
config.auto_execute = false
|
|
19
19
|
|
|
20
|
-
#
|
|
21
|
-
# :smart - (default) minimal system prompt, LLM uses tools to fetch schema/model/code details on demand
|
|
22
|
-
# :full - sends all schema, models, and routes in the system prompt every time
|
|
23
|
-
config.context_mode = :smart
|
|
24
|
-
|
|
25
|
-
# Max tool-use rounds per query in :smart mode (safety cap)
|
|
20
|
+
# Max tool-use rounds per query (safety cap)
|
|
26
21
|
config.max_tool_rounds = 10
|
|
27
22
|
|
|
28
23
|
# HTTP timeout in seconds
|
|
@@ -30,4 +25,17 @@ ConsoleAgent.configure do |config|
|
|
|
30
25
|
|
|
31
26
|
# Debug mode: prints full API requests/responses and tool calls to stderr
|
|
32
27
|
# config.debug = true
|
|
28
|
+
|
|
29
|
+
# Session logging: persist AI sessions to the database
|
|
30
|
+
# Run ConsoleAgent.setup! in the Rails console to create the table
|
|
31
|
+
config.session_logging = true
|
|
32
|
+
|
|
33
|
+
# Database connection for ConsoleAgent tables (default: ActiveRecord::Base)
|
|
34
|
+
# Set to a class that responds to .connection if tables live on a different DB
|
|
35
|
+
# config.connection_class = Sharding::CentralizedModel
|
|
36
|
+
|
|
37
|
+
# Admin UI credentials (mount ConsoleAgent::Engine => '/console_agent' in routes.rb)
|
|
38
|
+
# When nil, no authentication is required (convenient for development)
|
|
39
|
+
# config.admin_username = 'admin'
|
|
40
|
+
# config.admin_password = 'changeme'
|
|
33
41
|
end
|