console_agent 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09b1eab5b0fd84fb27fe9af623c234da290779f84d5972d4a67bfe000d581ab2'
4
- data.tar.gz: 344f3afbc67adc4d87c5c2fb4e11ccc902fca071776a4a3aa6cb160783e22998
3
+ metadata.gz: 53925e32cd4919e550bfdc081ede866613ce3ea18a585aca6cb81d1687ce3901
4
+ data.tar.gz: ae601be1b81ff2fcb89addda1c008c0646118f2af21c9009a1792556ffd9b4ed
5
5
  SHA512:
6
- metadata.gz: 73a5c9bd000e30b3cfa63c06efbb0f9619849ec56ca8b4b8f1c71cd7eae29439f9b4ef8d10725950aa8fb813159674820706fa5883784060464f8c9959c914aa
7
- data.tar.gz: 4498692aab0860b717b7d309e252cb1b2130b7946a55421f0edd76c3b3469da27c6f8426667e23cf0a2fcab66a356896573f19b819c8ba603f08f50a5e60647d
6
+ metadata.gz: 54ec67e116e4a05de7a5d915f93dc7babf2d64d2b008bc507718dce92d58ad032beeaa1d7710d8190943454f937f91780ff5614e44f5ef173ab5feff6dddb339
7
+ data.tar.gz: f12af224368e44b149d740f8d0b2dee4a38ce86564ee9337c5a8ed569a83bb387d3edcf2ae76142d4b510f507c5e6d64d82aab70c16eb94f66d83e3811eb0cd6
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  An AI-powered assistant for your Rails console. Ask questions in plain English, get executable Ruby code.
4
4
 
5
+ It's like Claude Code embedded in your Rails Console.
6
+
5
7
  ConsoleAgent connects your `rails console` to an LLM (Claude or GPT) and gives it tools to introspect your app's database schema, models, and source code. It figures out what it needs on its own, so you don't pay for 50K tokens of context on every query.
6
8
 
7
9
  ## Quick Start
@@ -19,19 +21,29 @@ bundle install
19
21
  rails generate console_agent:install
20
22
  ```
21
23
 
22
- Set your API key and open a console:
24
+ Set your API key (pick one):
23
25
 
24
26
  ```bash
27
+ # Option A: environment variable
25
28
  export ANTHROPIC_API_KEY=sk-ant-...
26
- rails console
29
+
30
+ # Option B: in the initializer
31
+ # config.api_key = 'sk-ant-...'
27
32
  ```
28
33
 
29
- Then:
34
+ Open a console and go:
30
35
 
31
36
  ```ruby
37
+ rails console
32
38
  ai "show me all users who signed up this week"
33
39
  ```
34
40
 
41
+ You can also set or change your API key at runtime in the console:
42
+
43
+ ```ruby
44
+ ConsoleAgent.configure { |c| c.api_key = 'sk-ant-...' }
45
+ ```
46
+
35
47
  ## Console Commands
36
48
 
37
49
  | Command | Description |
@@ -137,12 +149,12 @@ ConsoleAgent.configure do |config|
137
149
  end
138
150
  ```
139
151
 
140
- You can also change settings at runtime in the console:
152
+ All settings can be changed at runtime in the console:
141
153
 
142
154
  ```ruby
155
+ ConsoleAgent.configure { |c| c.api_key = 'sk-ant-...' }
143
156
  ConsoleAgent.configure { |c| c.debug = true }
144
- ConsoleAgent.configure { |c| c.provider = :openai }
145
- ENV['OPENAI_API_KEY'] = 'sk-...'
157
+ ConsoleAgent.configure { |c| c.provider = :openai; c.api_key = 'sk-...' }
146
158
  ```
147
159
 
148
160
  ## Context Modes
@@ -176,9 +188,111 @@ ConsoleAgent.configure { |c| c.context_mode = :full }
176
188
  | `list_files` | Ruby files in a directory |
177
189
  | `read_file` | Read a source file (capped at 200 lines) |
178
190
  | `search_code` | Grep for a pattern across Ruby files |
191
+ | `ask_user` | Ask the console user a clarifying question |
192
+ | `save_memory` | Persist a learned fact for future sessions |
193
+ | `recall_memories` | Search saved memories |
194
+ | `load_skill` | Load full instructions for a skill |
179
195
 
180
196
  The LLM decides which tools to call based on your question. You can see the tool calls happening in real time.
181
197
 
198
+ ## Memories & Skills
199
+
200
+ ConsoleAgent can remember what it learns about your codebase across sessions and load reusable skill instructions on demand.
201
+
202
+ ### Memories
203
+
204
+ When the AI investigates something complex (like how sharding works in your app), it can save what it learned for next time. Memories are stored in `.console_agent/memories.yml` in your Rails app.
205
+
206
+ ```
207
+ ai> how many users are on this shard?
208
+ Thinking...
209
+ -> search_code("shard")
210
+ Found 50 matches
211
+ -> read_file("config/initializers/sharding.rb")
212
+ 202 lines
213
+ -> save_memory("Sharding architecture")
214
+ Memory saved: "Sharding architecture"
215
+
216
+ Each shard is a separate database. User.count returns the count for the current shard only.
217
+
218
+ User.count
219
+
220
+ [tokens in: 2,340 | out: 120 | total: 2,460]
221
+ ```
222
+
223
+ Next session, the AI already knows:
224
+
225
+ ```
226
+ ai> count users on this shard
227
+ Thinking...
228
+
229
+ User.count
230
+
231
+ [tokens in: 580 | out: 45 | total: 625]
232
+ ```
233
+
234
+ The memory file is human-editable YAML:
235
+
236
+ ```yaml
237
+ # .console_agent/memories.yml
238
+ memories:
239
+ - id: mem_1709123456_42
240
+ name: Sharding architecture
241
+ description: "App uses database-per-shard. Each shard is a separate DB. User.count only counts the current shard."
242
+ tags: [database, sharding]
243
+ created_at: "2026-02-27T10:30:00Z"
244
+ ```
245
+
246
+ You can commit this file to your repo so the whole team benefits, or gitignore it for personal use.
247
+
248
+ ### Skills
249
+
250
+ Skills are reusable instruction files that teach the AI how to handle specific tasks. Store them in `.console_agent/skills/` as markdown files with YAML frontmatter:
251
+
252
+ ```markdown
253
+ # .console_agent/skills/sharding.md
254
+ ---
255
+ name: Sharded database queries
256
+ description: How to query across database shards. Use when user asks about counting records across shards or shard-specific operations.
257
+ ---
258
+
259
+ ## Instructions
260
+
261
+ This app uses Apartment with one database per shard.
262
+ - `User.count` only counts the current shard
263
+ - To count across all shards: `Shard.all.sum { |s| s.switch { User.count } }`
264
+ - Current shard: `Apartment::Tenant.current`
265
+ ```
266
+
267
+ Only the skill name and description are sent to the AI on every request (~100 tokens per skill). The full instructions are loaded on demand via the `load_skill` tool only when relevant.
268
+
269
+ ### Storage
270
+
271
+ By default, memories and skills are stored on the filesystem at `Rails.root/.console_agent/`. This works for development and for production environments with a persistent filesystem.
272
+
273
+ For containers or other environments where the filesystem is ephemeral, you have two options:
274
+
275
+ **Option A: Commit to your repo.** Create `.console_agent/memories.yml` and `.console_agent/skills/*.md` in your codebase. They'll be baked into your Docker image.
276
+
277
+ **Option B: Custom storage adapter.** Implement the storage interface and plug it in:
278
+
279
+ ```ruby
280
+ ConsoleAgent.configure do |config|
281
+ config.storage_adapter = MyS3Storage.new(bucket: 'my-bucket', prefix: 'console_agent/')
282
+ end
283
+ ```
284
+
285
+ A storage adapter just needs four methods: `read(key)`, `write(key, content)`, `list(pattern)`, and `exists?(key)`.
286
+
287
+ To disable memories or skills:
288
+
289
+ ```ruby
290
+ ConsoleAgent.configure do |config|
291
+ config.memories_enabled = false
292
+ config.skills_enabled = false
293
+ end
294
+ ```
295
+
182
296
  ## Providers
183
297
 
184
298
  ### Anthropic (default)
@@ -196,34 +310,20 @@ ConsoleAgent.configure do |config|
196
310
  end
197
311
  ```
198
312
 
199
- ## Docker Setup
313
+ ## Local Development
200
314
 
201
- If your Rails app runs in Docker, mount the gem source as a volume.
202
-
203
- In `docker-compose.yml`:
204
-
205
- ```yaml
206
- volumes:
207
- - /path/to/console_agent:/console_agent
208
- ```
209
-
210
- In `Gemfile`:
315
+ To develop the gem locally against a Rails app, use a path reference in your Gemfile:
211
316
 
212
317
  ```ruby
213
- gem 'console_agent', path: '/console_agent', group: :development
318
+ gem 'console_agent', path: '/path/to/console_agent'
214
319
  ```
215
320
 
216
- For Docker builds (where the gem source isn't available yet), add a stub to your Dockerfile before `bundle install`:
321
+ Switch back to the published gem when you're done:
217
322
 
218
- ```dockerfile
219
- RUN mkdir -p /console_agent/lib/console_agent && \
220
- echo "module ConsoleAgent; VERSION = '0.1.0'; end" > /console_agent/lib/console_agent/version.rb && \
221
- echo "require 'console_agent/version'" > /console_agent/lib/console_agent.rb && \
222
- printf "require_relative 'lib/console_agent/version'\nGem::Specification.new do |s|\n s.name = 'console_agent'\n s.version = ConsoleAgent::VERSION\n s.summary = 'stub'\n s.authors = ['x']\n s.files = ['lib/console_agent.rb']\n s.add_dependency 'rails', '>= 5.0'\n s.add_dependency 'faraday', '>= 1.0'\nend\n" > /console_agent/console_agent.gemspec
323
+ ```ruby
324
+ gem 'console_agent'
223
325
  ```
224
326
 
225
- The volume mount overwrites the stub at runtime with the real source.
226
-
227
327
  ## Requirements
228
328
 
229
329
  - Ruby >= 2.5
@@ -5,7 +5,8 @@ module ConsoleAgent
5
5
 
6
6
  attr_accessor :provider, :api_key, :model, :max_tokens,
7
7
  :auto_execute, :context_mode, :temperature,
8
- :timeout, :debug, :max_tool_rounds
8
+ :timeout, :debug, :max_tool_rounds,
9
+ :storage_adapter, :memories_enabled
9
10
 
10
11
  def initialize
11
12
  @provider = :anthropic
@@ -17,7 +18,9 @@ module ConsoleAgent
17
18
  @temperature = 0.2
18
19
  @timeout = 30
19
20
  @debug = false
20
- @max_tool_rounds = 10
21
+ @max_tool_rounds = 100
22
+ @storage_adapter = nil
23
+ @memories_enabled = true
21
24
  end
22
25
 
23
26
  def resolved_api_key
@@ -4,6 +4,48 @@ 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
+
7
49
  def ai(query = nil)
8
50
  if query.nil?
9
51
  $stderr.puts "\e[33mUsage: ai \"your question here\"\e[0m"
@@ -11,6 +53,7 @@ module ConsoleAgent
11
53
  $stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
12
54
  $stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
13
55
  $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
56
+ $stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
14
57
  return nil
15
58
  end
16
59
 
@@ -30,6 +30,7 @@ module ConsoleAgent
30
30
  parts = []
31
31
  parts << smart_system_instructions
32
32
  parts << environment_context
33
+ parts << memory_context
33
34
  parts.compact.join("\n\n")
34
35
  end
35
36
 
@@ -48,6 +49,17 @@ module ConsoleAgent
48
49
  you need specific information to write accurate code — such as which user they are, which
49
50
  record to target, or what value to use.
50
51
 
52
+ You have memory tools to persist what you learn across sessions:
53
+ - save_memory: persist facts or procedures you learn about this codebase.
54
+ If a memory with the same name already exists, it will be updated in place.
55
+ - delete_memory: remove a memory by name
56
+ - recall_memories: search your saved memories for details
57
+
58
+ IMPORTANT: Check the Memories section below BEFORE answering. If a memory is relevant,
59
+ use recall_memories to get full details and apply that knowledge to your answer.
60
+ When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
61
+ When you discover important patterns about this app, save them as memories.
62
+
51
63
  RULES:
52
64
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
53
65
  - Respond with a single ```ruby code block that directly answers the question.
@@ -187,6 +199,23 @@ module ConsoleAgent
187
199
  nil
188
200
  end
189
201
 
202
+ def memory_context
203
+ return nil unless @config.memories_enabled
204
+
205
+ require 'console_agent/tools/memory_tools'
206
+ summaries = Tools::MemoryTools.new.memory_summaries
207
+ return nil if summaries.nil? || summaries.empty?
208
+
209
+ lines = ["## Memories"]
210
+ lines.concat(summaries)
211
+ lines << ""
212
+ lines << "Call recall_memories to get details before answering. Do NOT guess from the name alone."
213
+ lines.join("\n")
214
+ rescue => e
215
+ ConsoleAgent.logger.debug("ConsoleAgent: memory context failed: #{e.message}")
216
+ nil
217
+ end
218
+
190
219
  def eager_load_app!
191
220
  return unless defined?(Rails) && Rails.respond_to?(:application)
192
221
 
@@ -1,4 +1,43 @@
1
+ require 'stringio'
2
+
1
3
  module ConsoleAgent
4
+ # Writes to two IO streams simultaneously
5
+ class TeeIO
6
+ def initialize(primary, secondary)
7
+ @primary = primary
8
+ @secondary = secondary
9
+ end
10
+
11
+ def write(str)
12
+ @primary.write(str)
13
+ @secondary.write(str)
14
+ end
15
+
16
+ def puts(*args)
17
+ @primary.puts(*args)
18
+ # Capture what puts would output
19
+ args.each { |a| @secondary.write("#{a}\n") }
20
+ @secondary.write("\n") if args.empty?
21
+ end
22
+
23
+ def print(*args)
24
+ @primary.print(*args)
25
+ args.each { |a| @secondary.write(a.to_s) }
26
+ end
27
+
28
+ def flush
29
+ @primary.flush if @primary.respond_to?(:flush)
30
+ end
31
+
32
+ def respond_to_missing?(method, include_private = false)
33
+ @primary.respond_to?(method, include_private) || super
34
+ end
35
+
36
+ def method_missing(method, *args, &block)
37
+ @primary.send(method, *args, &block)
38
+ end
39
+ end
40
+
2
41
  class Executor
3
42
  CODE_REGEX = /```ruby\s*\n(.*?)```/m
4
43
 
@@ -33,21 +72,43 @@ module ConsoleAgent
33
72
  def execute(code)
34
73
  return nil if code.nil? || code.strip.empty?
35
74
 
75
+ captured_output = StringIO.new
76
+ old_stdout = $stdout
77
+ # Tee output: capture it and also print to the real stdout
78
+ $stdout = TeeIO.new(old_stdout, captured_output)
79
+
36
80
  result = binding_context.eval(code, "(console_agent)", 1)
81
+
82
+ $stdout = old_stdout
37
83
  $stdout.puts colorize("=> #{result.inspect}", :green)
84
+
85
+ @last_output = captured_output.string
38
86
  result
39
87
  rescue SyntaxError => e
88
+ $stdout = old_stdout if old_stdout
40
89
  $stderr.puts colorize("SyntaxError: #{e.message}", :red)
90
+ @last_output = nil
41
91
  nil
42
92
  rescue => e
93
+ $stdout = old_stdout if old_stdout
43
94
  $stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
44
95
  e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
96
+ @last_output = captured_output&.string
45
97
  nil
46
98
  end
47
99
 
100
+ def last_output
101
+ @last_output
102
+ end
103
+
104
+ def last_cancelled?
105
+ @last_cancelled
106
+ end
107
+
48
108
  def confirm_and_execute(code)
49
109
  return nil if code.nil? || code.strip.empty?
50
110
 
111
+ @last_cancelled = false
51
112
  $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
52
113
  answer = $stdin.gets.to_s.strip.downcase
53
114
 
@@ -71,6 +132,7 @@ module ConsoleAgent
71
132
  end
72
133
  else
73
134
  $stdout.puts colorize("Cancelled.", :yellow)
135
+ @last_cancelled = true
74
136
  nil
75
137
  end
76
138
  end
@@ -1,3 +1,5 @@
1
+ require 'readline'
2
+
1
3
  module ConsoleAgent
2
4
  class Repl
3
5
  def initialize(binding_context)
@@ -9,6 +11,7 @@ module ConsoleAgent
9
11
  @history = []
10
12
  @total_input_tokens = 0
11
13
  @total_output_tokens = 0
14
+ @input_history = []
12
15
  end
13
16
 
14
17
  def one_shot(query)
@@ -52,17 +55,26 @@ module ConsoleAgent
52
55
  @total_output_tokens = 0
53
56
 
54
57
  loop do
55
- $stdout.print "\e[33mai> \e[0m"
56
- input = $stdin.gets
57
- break if input.nil?
58
+ input = Readline.readline("\e[33mai> \e[0m", false)
59
+ break if input.nil? # Ctrl-D
58
60
 
59
61
  input = input.strip
60
62
  break if input.downcase == 'exit' || input.downcase == 'quit'
61
63
  next if input.empty?
62
64
 
65
+ # Add to Readline history (avoid consecutive duplicates)
66
+ Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
67
+
63
68
  @history << { role: :user, content: input }
64
69
 
65
- result = send_query(input, conversation: @history)
70
+ begin
71
+ result = send_query(input, conversation: @history)
72
+ rescue Interrupt
73
+ $stdout.puts "\n\e[33m Aborted.\e[0m"
74
+ @history.pop # Remove the user message that never got a response
75
+ next
76
+ end
77
+
66
78
  track_usage(result)
67
79
  code = @executor.display_response(result.text)
68
80
  display_usage(result, show_session: true)
@@ -76,10 +88,26 @@ module ConsoleAgent
76
88
  exec_result = @executor.confirm_and_execute(code)
77
89
  end
78
90
 
79
- if exec_result
80
- result_str = exec_result.inspect
81
- result_str = result_str[0..500] + '...' if result_str.length > 500
82
- @history << { role: :user, content: "Execution result: #{result_str}" }
91
+ if @executor.last_cancelled?
92
+ @history << { role: :user, content: "User declined to execute the code." }
93
+ else
94
+ output_parts = []
95
+
96
+ # Capture printed output (puts, print, etc.)
97
+ if @executor.last_output && !@executor.last_output.strip.empty?
98
+ output_parts << "Output:\n#{@executor.last_output.strip}"
99
+ end
100
+
101
+ # Capture return value
102
+ if exec_result
103
+ output_parts << "Return value: #{exec_result.inspect}"
104
+ end
105
+
106
+ unless output_parts.empty?
107
+ result_str = output_parts.join("\n\n")
108
+ result_str = result_str[0..1000] + '...' if result_str.length > 1000
109
+ @history << { role: :user, content: "Code was executed. #{result_str}" }
110
+ end
83
111
  end
84
112
  end
85
113
  end
@@ -87,6 +115,7 @@ module ConsoleAgent
87
115
  display_session_summary
88
116
  $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
89
117
  rescue Interrupt
118
+ # Ctrl-C during Readline input — exit cleanly
90
119
  $stdout.puts
91
120
  display_session_summary
92
121
  $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
@@ -132,6 +161,8 @@ module ConsoleAgent
132
161
  total_output = 0
133
162
  result = nil
134
163
 
164
+ exhausted = false
165
+
135
166
  max_rounds.times do |round|
136
167
  if round == 0
137
168
  $stdout.puts "\e[2m Thinking...\e[0m"
@@ -163,7 +194,8 @@ module ConsoleAgent
163
194
  tool_result = tools.execute(tc[:name], tc[:arguments])
164
195
 
165
196
  preview = compact_tool_result(tc[:name], tool_result)
166
- $stdout.puts "\e[2m #{preview}\e[0m"
197
+ cached_tag = tools.last_cached? ? " (cached)" : ""
198
+ $stdout.puts "\e[2m #{preview}#{cached_tag}\e[0m"
167
199
  end
168
200
 
169
201
  if ConsoleAgent.configuration.debug
@@ -172,6 +204,17 @@ module ConsoleAgent
172
204
 
173
205
  messages << provider.format_tool_result(tc[:id], tool_result)
174
206
  end
207
+
208
+ exhausted = true if round == max_rounds - 1
209
+ end
210
+
211
+ # If we hit the tool round limit, force a final response without tools
212
+ if exhausted
213
+ $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: ConsoleAgent.configure { |c| c.max_tool_rounds = 200 }\e[0m"
214
+ messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
215
+ result = provider.chat(messages, system_prompt: context)
216
+ total_input += result.input_tokens || 0
217
+ total_output += result.output_tokens || 0
175
218
  end
176
219
 
177
220
  Providers::ChatResult.new(
@@ -197,6 +240,12 @@ module ConsoleAgent
197
240
  "(\"#{args['query']}\"#{dir})"
198
241
  when 'list_files'
199
242
  args['directory'] ? "(\"#{args['directory']}\")" : ''
243
+ when 'save_memory'
244
+ "(\"#{args['name']}\")"
245
+ when 'delete_memory'
246
+ "(\"#{args['name']}\")"
247
+ when 'recall_memories'
248
+ args['query'] ? "(\"#{args['query']}\")" : ''
200
249
  else
201
250
  ''
202
251
  end
@@ -244,6 +293,13 @@ module ConsoleAgent
244
293
  else
245
294
  truncate(result, 80)
246
295
  end
296
+ when 'save_memory'
297
+ (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
298
+ when 'delete_memory'
299
+ result.start_with?('Memory deleted') ? result : truncate(result, 80)
300
+ when 'recall_memories'
301
+ chunks = result.split("\n\n")
302
+ chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
247
303
  else
248
304
  truncate(result, 80)
249
305
  end
@@ -0,0 +1,27 @@
1
+ module ConsoleAgent
2
+ module Storage
3
+ class StorageError < StandardError; end
4
+
5
+ class Base
6
+ def read(key)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def write(key, content)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def list(pattern)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def exists?(key)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def delete(key)
23
+ raise NotImplementedError
24
+ end
25
+ end
26
+ end
27
+ end
@@ -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,21 @@ module ConsoleAgent
5
5
  class Registry
6
6
  attr_reader :definitions
7
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
+
8
11
  def initialize
9
12
  @definitions = []
10
13
  @handlers = {}
14
+ @cache = {}
15
+ @last_cached = false
11
16
  register_all
12
17
  end
13
18
 
19
+ def last_cached?
20
+ @last_cached
21
+ end
22
+
14
23
  def execute(tool_name, arguments = {})
15
24
  handler = @handlers[tool_name]
16
25
  unless handler
@@ -27,7 +36,18 @@ module ConsoleAgent
27
36
  arguments || {}
28
37
  end
29
38
 
30
- handler.call(args)
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
31
51
  rescue => e
32
52
  "Error executing #{tool_name}: #{e.message}"
33
53
  end
@@ -158,6 +178,58 @@ module ConsoleAgent
158
178
  },
159
179
  handler: ->(args) { ask_user(args['question']) }
160
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
+ )
161
233
  end
162
234
 
163
235
  def ask_user(question)
@@ -1,3 +1,3 @@
1
1
  module ConsoleAgent
2
- VERSION = '0.1.0'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
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
@@ -48,6 +65,7 @@ module ConsoleAgent
48
65
  lines << " Timeout: #{c.timeout}s"
49
66
  lines << " Max tool rounds:#{c.max_tool_rounds}" if c.context_mode == :smart
50
67
  lines << " Auto-execute: #{c.auto_execute}"
68
+ lines << " Memories: #{c.memories_enabled}"
51
69
  lines << " Debug: #{c.debug}"
52
70
  $stdout.puts lines.join("\n")
53
71
  nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: console_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -98,7 +98,10 @@ files:
98
98
  - lib/console_agent/providers/openai.rb
99
99
  - lib/console_agent/railtie.rb
100
100
  - lib/console_agent/repl.rb
101
+ - lib/console_agent/storage/base.rb
102
+ - lib/console_agent/storage/file_storage.rb
101
103
  - lib/console_agent/tools/code_tools.rb
104
+ - lib/console_agent/tools/memory_tools.rb
102
105
  - lib/console_agent/tools/model_tools.rb
103
106
  - lib/console_agent/tools/registry.rb
104
107
  - lib/console_agent/tools/schema_tools.rb