console_agent 0.0.1 → 0.2.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/LICENSE +21 -0
- data/README.md +335 -0
- data/lib/console_agent/configuration.rb +61 -0
- data/lib/console_agent/console_methods.rb +131 -0
- data/lib/console_agent/context_builder.rb +229 -0
- data/lib/console_agent/executor.rb +193 -0
- data/lib/console_agent/providers/anthropic.rb +112 -0
- data/lib/console_agent/providers/base.rb +106 -0
- data/lib/console_agent/providers/openai.rb +114 -0
- data/lib/console_agent/railtie.rb +30 -0
- data/lib/console_agent/repl.rb +342 -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/code_tools.rb +114 -0
- data/lib/console_agent/tools/memory_tools.rb +136 -0
- data/lib/console_agent/tools/model_tools.rb +95 -0
- data/lib/console_agent/tools/registry.rb +253 -0
- data/lib/console_agent/tools/schema_tools.rb +60 -0
- data/lib/console_agent/version.rb +3 -0
- data/lib/console_agent.rb +76 -1
- data/lib/generators/console_agent/install_generator.rb +26 -0
- data/lib/generators/console_agent/templates/initializer.rb +33 -0
- metadata +95 -3
|
@@ -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
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
module Tools
|
|
3
|
+
class ModelTools
|
|
4
|
+
def list_models
|
|
5
|
+
return "ActiveRecord is not available." unless defined?(ActiveRecord::Base)
|
|
6
|
+
|
|
7
|
+
eager_load_app!
|
|
8
|
+
models = find_models
|
|
9
|
+
return "No models found." if models.empty?
|
|
10
|
+
|
|
11
|
+
lines = models.map do |model|
|
|
12
|
+
assoc_names = model.reflect_on_all_associations.map { |a| a.name.to_s }
|
|
13
|
+
if assoc_names.empty?
|
|
14
|
+
model.name
|
|
15
|
+
else
|
|
16
|
+
"#{model.name} (#{assoc_names.join(', ')})"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
lines.join("\n")
|
|
21
|
+
rescue => e
|
|
22
|
+
"Error listing models: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def describe_model(model_name)
|
|
26
|
+
return "ActiveRecord is not available." unless defined?(ActiveRecord::Base)
|
|
27
|
+
return "Error: model_name is required." if model_name.nil? || model_name.strip.empty?
|
|
28
|
+
|
|
29
|
+
eager_load_app!
|
|
30
|
+
model_name = model_name.strip
|
|
31
|
+
|
|
32
|
+
model = find_models.detect { |m| m.name == model_name || m.name.underscore == model_name.underscore }
|
|
33
|
+
return "Model '#{model_name}' not found. Use list_models to see available models." unless model
|
|
34
|
+
|
|
35
|
+
result = "Model: #{model.name}\n"
|
|
36
|
+
result += "Table: #{model.table_name}\n"
|
|
37
|
+
|
|
38
|
+
assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
|
|
39
|
+
unless assocs.empty?
|
|
40
|
+
result += "Associations:\n"
|
|
41
|
+
assocs.each { |a| result += " #{a}\n" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
validators = model.validators.map { |v|
|
|
46
|
+
attrs = v.attributes.join(', ')
|
|
47
|
+
kind = v.class.name.split('::').last.sub('Validator', '').downcase
|
|
48
|
+
"#{kind} on #{attrs}"
|
|
49
|
+
}.uniq
|
|
50
|
+
unless validators.empty?
|
|
51
|
+
result += "Validations:\n"
|
|
52
|
+
validators.each { |v| result += " #{v}\n" }
|
|
53
|
+
end
|
|
54
|
+
rescue => e
|
|
55
|
+
# validations may not be accessible
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Scopes - detect via singleton methods that aren't inherited
|
|
59
|
+
begin
|
|
60
|
+
base = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
61
|
+
scope_candidates = (model.singleton_methods - base.singleton_methods)
|
|
62
|
+
.reject { |m| m.to_s.start_with?('_') || m.to_s.start_with?('find') }
|
|
63
|
+
.sort
|
|
64
|
+
.first(20)
|
|
65
|
+
unless scope_candidates.empty?
|
|
66
|
+
result += "Possible scopes/class methods:\n"
|
|
67
|
+
scope_candidates.each { |s| result += " #{s}\n" }
|
|
68
|
+
end
|
|
69
|
+
rescue => e
|
|
70
|
+
# ignore
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
rescue => e
|
|
75
|
+
"Error describing model '#{model_name}': #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def find_models
|
|
81
|
+
base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
|
|
82
|
+
ObjectSpace.each_object(Class).select { |c|
|
|
83
|
+
c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
|
|
84
|
+
}.sort_by(&:name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def eager_load_app!
|
|
88
|
+
return unless defined?(Rails) && Rails.respond_to?(:application)
|
|
89
|
+
Rails.application.eager_load! if Rails.application.respond_to?(:eager_load!)
|
|
90
|
+
rescue => e
|
|
91
|
+
# ignore
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module ConsoleAgent
|
|
4
|
+
module Tools
|
|
5
|
+
class Registry
|
|
6
|
+
attr_reader :definitions
|
|
7
|
+
|
|
8
|
+
# Tools that should never be cached (side effects or user interaction)
|
|
9
|
+
NO_CACHE = %w[ask_user save_memory delete_memory].freeze
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@definitions = []
|
|
13
|
+
@handlers = {}
|
|
14
|
+
@cache = {}
|
|
15
|
+
@last_cached = false
|
|
16
|
+
register_all
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def last_cached?
|
|
20
|
+
@last_cached
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute(tool_name, arguments = {})
|
|
24
|
+
handler = @handlers[tool_name]
|
|
25
|
+
unless handler
|
|
26
|
+
return "Error: unknown tool '#{tool_name}'"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
args = if arguments.is_a?(String)
|
|
30
|
+
begin
|
|
31
|
+
JSON.parse(arguments)
|
|
32
|
+
rescue
|
|
33
|
+
{}
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
arguments || {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless NO_CACHE.include?(tool_name)
|
|
40
|
+
cache_key = [tool_name, args].hash
|
|
41
|
+
if @cache.key?(cache_key)
|
|
42
|
+
@last_cached = true
|
|
43
|
+
return @cache[cache_key]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@last_cached = false
|
|
48
|
+
result = handler.call(args)
|
|
49
|
+
@cache[[tool_name, args].hash] = result unless NO_CACHE.include?(tool_name)
|
|
50
|
+
result
|
|
51
|
+
rescue => e
|
|
52
|
+
"Error executing #{tool_name}: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_anthropic_format
|
|
56
|
+
definitions.map do |d|
|
|
57
|
+
tool = {
|
|
58
|
+
'name' => d[:name],
|
|
59
|
+
'description' => d[:description],
|
|
60
|
+
'input_schema' => d[:parameters]
|
|
61
|
+
}
|
|
62
|
+
tool
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_openai_format
|
|
67
|
+
definitions.map do |d|
|
|
68
|
+
{
|
|
69
|
+
'type' => 'function',
|
|
70
|
+
'function' => {
|
|
71
|
+
'name' => d[:name],
|
|
72
|
+
'description' => d[:description],
|
|
73
|
+
'parameters' => d[:parameters]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def register_all
|
|
82
|
+
require 'console_agent/tools/schema_tools'
|
|
83
|
+
require 'console_agent/tools/model_tools'
|
|
84
|
+
require 'console_agent/tools/code_tools'
|
|
85
|
+
|
|
86
|
+
schema = SchemaTools.new
|
|
87
|
+
models = ModelTools.new
|
|
88
|
+
code = CodeTools.new
|
|
89
|
+
|
|
90
|
+
register(
|
|
91
|
+
name: 'list_tables',
|
|
92
|
+
description: 'List all database table names in this Rails app.',
|
|
93
|
+
parameters: { 'type' => 'object', 'properties' => {} },
|
|
94
|
+
handler: ->(_args) { schema.list_tables }
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
register(
|
|
98
|
+
name: 'describe_table',
|
|
99
|
+
description: 'Get column names and types for a specific database table.',
|
|
100
|
+
parameters: {
|
|
101
|
+
'type' => 'object',
|
|
102
|
+
'properties' => {
|
|
103
|
+
'table_name' => { 'type' => 'string', 'description' => 'The database table name (e.g. "users")' }
|
|
104
|
+
},
|
|
105
|
+
'required' => ['table_name']
|
|
106
|
+
},
|
|
107
|
+
handler: ->(args) { schema.describe_table(args['table_name']) }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
register(
|
|
111
|
+
name: 'list_models',
|
|
112
|
+
description: 'List all ActiveRecord model names with their association names.',
|
|
113
|
+
parameters: { 'type' => 'object', 'properties' => {} },
|
|
114
|
+
handler: ->(_args) { models.list_models }
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
register(
|
|
118
|
+
name: 'describe_model',
|
|
119
|
+
description: 'Get detailed info about a specific model: associations, validations, table name.',
|
|
120
|
+
parameters: {
|
|
121
|
+
'type' => 'object',
|
|
122
|
+
'properties' => {
|
|
123
|
+
'model_name' => { 'type' => 'string', 'description' => 'The model class name (e.g. "User")' }
|
|
124
|
+
},
|
|
125
|
+
'required' => ['model_name']
|
|
126
|
+
},
|
|
127
|
+
handler: ->(args) { models.describe_model(args['model_name']) }
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
register(
|
|
131
|
+
name: 'list_files',
|
|
132
|
+
description: 'List Ruby files in a directory of this Rails app. Defaults to app/ directory.',
|
|
133
|
+
parameters: {
|
|
134
|
+
'type' => 'object',
|
|
135
|
+
'properties' => {
|
|
136
|
+
'directory' => { 'type' => 'string', 'description' => 'Relative directory path (e.g. "app/models", "lib"). Defaults to "app".' }
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
handler: ->(args) { code.list_files(args['directory']) }
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
register(
|
|
143
|
+
name: 'read_file',
|
|
144
|
+
description: 'Read the contents of a file in this Rails app. Capped at 200 lines.',
|
|
145
|
+
parameters: {
|
|
146
|
+
'type' => 'object',
|
|
147
|
+
'properties' => {
|
|
148
|
+
'path' => { 'type' => 'string', 'description' => 'Relative file path (e.g. "app/models/user.rb")' }
|
|
149
|
+
},
|
|
150
|
+
'required' => ['path']
|
|
151
|
+
},
|
|
152
|
+
handler: ->(args) { code.read_file(args['path']) }
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
register(
|
|
156
|
+
name: 'search_code',
|
|
157
|
+
description: 'Search for a pattern in Ruby files. Returns matching lines with file paths.',
|
|
158
|
+
parameters: {
|
|
159
|
+
'type' => 'object',
|
|
160
|
+
'properties' => {
|
|
161
|
+
'query' => { 'type' => 'string', 'description' => 'Search pattern (substring match)' },
|
|
162
|
+
'directory' => { 'type' => 'string', 'description' => 'Relative directory to search in. Defaults to "app".' }
|
|
163
|
+
},
|
|
164
|
+
'required' => ['query']
|
|
165
|
+
},
|
|
166
|
+
handler: ->(args) { code.search_code(args['query'], args['directory']) }
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
register(
|
|
170
|
+
name: 'ask_user',
|
|
171
|
+
description: 'Ask the console user a clarifying question. Use this when you need specific information to write accurate code (e.g. which user they are, which record to target, what value to use). Do NOT generate placeholder values like YOUR_USER_ID — ask instead.',
|
|
172
|
+
parameters: {
|
|
173
|
+
'type' => 'object',
|
|
174
|
+
'properties' => {
|
|
175
|
+
'question' => { 'type' => 'string', 'description' => 'The question to ask the user' }
|
|
176
|
+
},
|
|
177
|
+
'required' => ['question']
|
|
178
|
+
},
|
|
179
|
+
handler: ->(args) { ask_user(args['question']) }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
register_memory_tools
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def register_memory_tools
|
|
186
|
+
return unless ConsoleAgent.configuration.memories_enabled
|
|
187
|
+
|
|
188
|
+
require 'console_agent/tools/memory_tools'
|
|
189
|
+
memory = MemoryTools.new
|
|
190
|
+
|
|
191
|
+
register(
|
|
192
|
+
name: 'save_memory',
|
|
193
|
+
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).',
|
|
194
|
+
parameters: {
|
|
195
|
+
'type' => 'object',
|
|
196
|
+
'properties' => {
|
|
197
|
+
'name' => { 'type' => 'string', 'description' => 'Short name for this memory (e.g. "Sharding architecture")' },
|
|
198
|
+
'description' => { 'type' => 'string', 'description' => 'Detailed description of what you learned' },
|
|
199
|
+
'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Optional tags (e.g. ["database", "sharding"])' }
|
|
200
|
+
},
|
|
201
|
+
'required' => ['name', 'description']
|
|
202
|
+
},
|
|
203
|
+
handler: ->(args) {
|
|
204
|
+
memory.save_memory(name: args['name'], description: args['description'], tags: args['tags'] || [])
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
register(
|
|
209
|
+
name: 'delete_memory',
|
|
210
|
+
description: 'Delete a memory by name.',
|
|
211
|
+
parameters: {
|
|
212
|
+
'type' => 'object',
|
|
213
|
+
'properties' => {
|
|
214
|
+
'name' => { 'type' => 'string', 'description' => 'The memory name to delete (e.g. "Sharding architecture")' }
|
|
215
|
+
},
|
|
216
|
+
'required' => ['name']
|
|
217
|
+
},
|
|
218
|
+
handler: ->(args) { memory.delete_memory(name: args['name']) }
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
register(
|
|
222
|
+
name: 'recall_memories',
|
|
223
|
+
description: 'Search your saved memories about this codebase. Call with no args to list all, or pass a query/tag to filter.',
|
|
224
|
+
parameters: {
|
|
225
|
+
'type' => 'object',
|
|
226
|
+
'properties' => {
|
|
227
|
+
'query' => { 'type' => 'string', 'description' => 'Search term to filter by name, description, or tags' },
|
|
228
|
+
'tag' => { 'type' => 'string', 'description' => 'Filter by a specific tag' }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
handler: ->(args) { memory.recall_memories(query: args['query'], tag: args['tag']) }
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def ask_user(question)
|
|
236
|
+
$stdout.puts "\e[36m ? #{question}\e[0m"
|
|
237
|
+
$stdout.print "\e[36m > \e[0m"
|
|
238
|
+
answer = $stdin.gets
|
|
239
|
+
return '(no answer provided)' if answer.nil?
|
|
240
|
+
answer.strip.empty? ? '(no answer provided)' : answer.strip
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def register(name:, description:, parameters:, handler:)
|
|
244
|
+
@definitions << {
|
|
245
|
+
name: name,
|
|
246
|
+
description: description,
|
|
247
|
+
parameters: parameters
|
|
248
|
+
}
|
|
249
|
+
@handlers[name] = handler
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module ConsoleAgent
|
|
2
|
+
module Tools
|
|
3
|
+
class SchemaTools
|
|
4
|
+
def list_tables
|
|
5
|
+
return "ActiveRecord is not connected." unless ar_connected?
|
|
6
|
+
|
|
7
|
+
tables = connection.tables.sort
|
|
8
|
+
tables.reject! { |t| t == 'schema_migrations' || t == 'ar_internal_metadata' }
|
|
9
|
+
return "No tables found." if tables.empty?
|
|
10
|
+
|
|
11
|
+
tables.join(", ")
|
|
12
|
+
rescue => e
|
|
13
|
+
"Error listing tables: #{e.message}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def describe_table(table_name)
|
|
17
|
+
return "ActiveRecord is not connected." unless ar_connected?
|
|
18
|
+
return "Error: table_name is required." if table_name.nil? || table_name.strip.empty?
|
|
19
|
+
|
|
20
|
+
table_name = table_name.strip
|
|
21
|
+
unless connection.tables.include?(table_name)
|
|
22
|
+
return "Table '#{table_name}' not found. Use list_tables to see available tables."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
cols = connection.columns(table_name).map do |c|
|
|
26
|
+
parts = ["#{c.name}:#{c.type}"]
|
|
27
|
+
parts << "nullable" if c.null
|
|
28
|
+
parts << "default=#{c.default}" unless c.default.nil?
|
|
29
|
+
parts.join(" ")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
indexes = connection.indexes(table_name).map do |idx|
|
|
33
|
+
unique = idx.unique ? "UNIQUE " : ""
|
|
34
|
+
"#{unique}INDEX on (#{idx.columns.join(', ')})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result = "Table: #{table_name}\n"
|
|
38
|
+
result += "Columns:\n"
|
|
39
|
+
cols.each { |c| result += " #{c}\n" }
|
|
40
|
+
unless indexes.empty?
|
|
41
|
+
result += "Indexes:\n"
|
|
42
|
+
indexes.each { |i| result += " #{i}\n" }
|
|
43
|
+
end
|
|
44
|
+
result
|
|
45
|
+
rescue => e
|
|
46
|
+
"Error describing table '#{table_name}': #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def ar_connected?
|
|
52
|
+
defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def connection
|
|
56
|
+
ActiveRecord::Base.connection
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/console_agent.rb
CHANGED
|
@@ -1 +1,76 @@
|
|
|
1
|
-
|
|
1
|
+
require 'console_agent/version'
|
|
2
|
+
require 'console_agent/configuration'
|
|
3
|
+
|
|
4
|
+
module ConsoleAgent
|
|
5
|
+
class << self
|
|
6
|
+
def configuration
|
|
7
|
+
@configuration ||= Configuration.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def configure
|
|
11
|
+
yield(configuration) if block_given?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def reset_configuration!
|
|
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
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def logger
|
|
36
|
+
@logger ||= if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
37
|
+
Rails.logger
|
|
38
|
+
else
|
|
39
|
+
require 'logger'
|
|
40
|
+
Logger.new($stderr, progname: 'ConsoleAgent')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def logger=(log)
|
|
45
|
+
@logger = log
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def status
|
|
49
|
+
c = configuration
|
|
50
|
+
key = c.resolved_api_key
|
|
51
|
+
masked_key = if key.nil? || key.empty?
|
|
52
|
+
"\e[31m(not set)\e[0m"
|
|
53
|
+
else
|
|
54
|
+
key[0..6] + '...' + key[-4..-1]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
lines = []
|
|
58
|
+
lines << "\e[36m[ConsoleAgent v#{VERSION}]\e[0m"
|
|
59
|
+
lines << " Provider: #{c.provider}"
|
|
60
|
+
lines << " Model: #{c.resolved_model}"
|
|
61
|
+
lines << " API key: #{masked_key}"
|
|
62
|
+
lines << " Context mode: #{c.context_mode}"
|
|
63
|
+
lines << " Max tokens: #{c.max_tokens}"
|
|
64
|
+
lines << " Temperature: #{c.temperature}"
|
|
65
|
+
lines << " Timeout: #{c.timeout}s"
|
|
66
|
+
lines << " Max tool rounds:#{c.max_tool_rounds}" if c.context_mode == :smart
|
|
67
|
+
lines << " Auto-execute: #{c.auto_execute}"
|
|
68
|
+
lines << " Memories: #{c.memories_enabled}"
|
|
69
|
+
lines << " Debug: #{c.debug}"
|
|
70
|
+
$stdout.puts lines.join("\n")
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
require 'console_agent/railtie' if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'rails/generators'
|
|
2
|
+
|
|
3
|
+
module ConsoleAgent
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
|
7
|
+
desc 'Creates a ConsoleAgent initializer in config/initializers/'
|
|
8
|
+
|
|
9
|
+
def copy_initializer
|
|
10
|
+
template 'initializer.rb', 'config/initializers/console_agent.rb'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show_readme
|
|
14
|
+
say ''
|
|
15
|
+
say 'ConsoleAgent installed!', :green
|
|
16
|
+
say ''
|
|
17
|
+
say 'Next steps:'
|
|
18
|
+
say ' 1. Set your API key: export ANTHROPIC_API_KEY=sk-...'
|
|
19
|
+
say ' 2. Edit config/initializers/console_agent.rb if needed'
|
|
20
|
+
say ' 3. Run: rails console'
|
|
21
|
+
say ' 4. Try: ai "show me all tables"'
|
|
22
|
+
say ''
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
ConsoleAgent.configure do |config|
|
|
2
|
+
# LLM provider: :anthropic or :openai
|
|
3
|
+
config.provider = :anthropic
|
|
4
|
+
|
|
5
|
+
# API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
|
|
6
|
+
# config.api_key = 'sk-...'
|
|
7
|
+
|
|
8
|
+
# Model override (defaults: claude-opus-4-6 for Anthropic, gpt-5.3-codex for OpenAI)
|
|
9
|
+
# config.model = 'claude-opus-4-6'
|
|
10
|
+
|
|
11
|
+
# Max tokens for LLM response
|
|
12
|
+
config.max_tokens = 4096
|
|
13
|
+
|
|
14
|
+
# Temperature (0.0 - 1.0)
|
|
15
|
+
config.temperature = 0.2
|
|
16
|
+
|
|
17
|
+
# Auto-execute generated code without confirmation (use with caution!)
|
|
18
|
+
config.auto_execute = false
|
|
19
|
+
|
|
20
|
+
# Context mode:
|
|
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)
|
|
26
|
+
config.max_tool_rounds = 10
|
|
27
|
+
|
|
28
|
+
# HTTP timeout in seconds
|
|
29
|
+
config.timeout = 30
|
|
30
|
+
|
|
31
|
+
# Debug mode: prints full API requests/responses and tool calls to stderr
|
|
32
|
+
# config.debug = true
|
|
33
|
+
end
|