console_agent 0.5.0 → 0.7.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 +47 -173
- data/lib/console_agent/console_methods.rb +27 -8
- data/lib/console_agent/context_builder.rb +29 -18
- data/lib/console_agent/executor.rb +6 -3
- data/lib/console_agent/railtie.rb +1 -1
- data/lib/console_agent/repl.rb +354 -99
- data/lib/console_agent/tools/code_tools.rb +19 -7
- data/lib/console_agent/tools/registry.rb +26 -17
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 662ece5e5732e350b22871c8029421edf06ed4d9f98183f5cee7e95a3c1099f6
|
|
4
|
+
data.tar.gz: 483642598de39d23ace26f5d90863a8712b8799357c7809b2ba770f2bdc2522a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5095d8f4cb0706e84c81be1b4a859de6ac48336ccb9c950feeb0dcf0df676d78fddc53a2314a21cbe6ee9d79b808f5edf0d854f3262b72c5befbb4420d9b979b
|
|
7
|
+
data.tar.gz: 4c96f0e1664c1357cc7ec3f53aada9d1dcf4e05cbc92e19edfd052b2e99c75d1596d5a49440b8b9478fd576140b36929589a87e009dd971b4599cfa8ab149520
|
data/README.md
CHANGED
|
@@ -1,40 +1,6 @@
|
|
|
1
1
|
# ConsoleAgent
|
|
2
2
|
|
|
3
|
-
Claude Code
|
|
4
|
-
|
|
5
|
-
Ask questions in plain English. The AI explores your schema, models, and source code on its own, then writes and runs the Ruby code for you.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```ruby
|
|
10
|
-
# Gemfile
|
|
11
|
-
gem 'console_agent', group: :development
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
bundle install
|
|
16
|
-
rails generate console_agent:install
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Set your API key in the generated initializer or as an env var (`ANTHROPIC_API_KEY`):
|
|
20
|
-
|
|
21
|
-
```ruby
|
|
22
|
-
# config/initializers/console_agent.rb
|
|
23
|
-
ConsoleAgent.configure do |config|
|
|
24
|
-
config.api_key = 'sk-ant-...'
|
|
25
|
-
end
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
To set up session logging (OPTIONAL), create the table from the console:
|
|
29
|
-
|
|
30
|
-
```ruby
|
|
31
|
-
ConsoleAgent.setup!
|
|
32
|
-
# => ConsoleAgent: created console_agent_sessions table.
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
To reset the table (e.g. after upgrading), run `ConsoleAgent.teardown!` then `ConsoleAgent.setup!`.
|
|
36
|
-
|
|
37
|
-
## Usage
|
|
3
|
+
Claude Code for your Rails Console.
|
|
38
4
|
|
|
39
5
|
```
|
|
40
6
|
irb> ai "find the 5 most recent orders over $100"
|
|
@@ -50,28 +16,10 @@ Execute? [y/N/edit] y
|
|
|
50
16
|
=> [#<Order id: 4821, ...>, ...]
|
|
51
17
|
```
|
|
52
18
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
### Commands
|
|
56
|
-
|
|
57
|
-
| Command | What it does |
|
|
58
|
-
|---------|-------------|
|
|
59
|
-
| `ai "query"` | One-shot: ask, review code, confirm |
|
|
60
|
-
| `ai! "query"` | Interactive: ask and keep chatting |
|
|
61
|
-
| `ai? "query"` | Explain only, never executes |
|
|
62
|
-
|
|
63
|
-
### Multi-Step Plans
|
|
64
|
-
|
|
65
|
-
For complex tasks, the AI builds a plan and executes it step by step:
|
|
19
|
+
For complex tasks it builds multi-step plans, executing each step sequentially:
|
|
66
20
|
|
|
67
21
|
```
|
|
68
22
|
ai> get the most recent salesforce token and count events via the API
|
|
69
|
-
Thinking...
|
|
70
|
-
-> describe_table("oauth2_tokens")
|
|
71
|
-
28 columns
|
|
72
|
-
-> read_file("lib/salesforce_api.rb")
|
|
73
|
-
202 lines
|
|
74
|
-
|
|
75
23
|
Plan (2 steps):
|
|
76
24
|
1. Find the most recent active Salesforce OAuth2 token
|
|
77
25
|
token = Oauth2Token.where(provider: "salesforce", active: true)
|
|
@@ -81,155 +29,81 @@ ai> get the most recent salesforce token and count events via the API
|
|
|
81
29
|
api.query("SELECT COUNT(Id) FROM Event")
|
|
82
30
|
|
|
83
31
|
Accept plan? [y/N/a(uto)] a
|
|
84
|
-
Step 1/2: Find the most recent active Salesforce OAuth2 token
|
|
85
|
-
...
|
|
86
|
-
=> #<Oauth2Token id: 1, provider: "salesforce", ...>
|
|
87
|
-
|
|
88
|
-
Step 2/2: Query event count via SOQL
|
|
89
|
-
...
|
|
90
|
-
=> [{"expr0"=>42}]
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
Each step's return value is available to later steps as `step1`, `step2`, etc.
|
|
94
|
-
|
|
95
|
-
Plan prompt options:
|
|
96
|
-
- **y** — accept, then confirm each step one at a time
|
|
97
|
-
- **a** — accept and auto-run all steps (stays in manual mode for future queries)
|
|
98
|
-
- **N** — decline; you're asked what to change and the AI revises
|
|
99
|
-
|
|
100
|
-
### Memories
|
|
101
|
-
|
|
102
|
-
The AI remembers what it learns about your codebase across sessions:
|
|
103
|
-
|
|
104
|
-
```
|
|
105
|
-
ai> how does sharding work?
|
|
106
|
-
-> read_file("config/initializers/sharding.rb")
|
|
107
|
-
-> save_memory("Sharding architecture")
|
|
108
|
-
Memory saved
|
|
109
|
-
|
|
110
|
-
This app uses database-per-shard. User.count returns the current shard only.
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Next time, it already knows — no re-reading files, fewer tokens.
|
|
114
|
-
|
|
115
|
-
### Interactive Mode
|
|
116
|
-
|
|
117
|
-
```
|
|
118
|
-
irb> ai!
|
|
119
|
-
ConsoleAgent interactive mode. Type 'exit' to leave.
|
|
120
|
-
Auto-execute: OFF (Shift-Tab or /auto to toggle) | > code to run directly | /usage | /name <label>
|
|
121
|
-
|
|
122
|
-
ai> show me all tables
|
|
123
|
-
...
|
|
124
|
-
ai> count orders by status
|
|
125
|
-
...
|
|
126
|
-
ai> /auto
|
|
127
|
-
Auto-execute: ON
|
|
128
|
-
ai> delete cancelled orders older than 90 days
|
|
129
|
-
...
|
|
130
|
-
ai> exit
|
|
131
32
|
```
|
|
132
33
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
### Direct Code Execution
|
|
34
|
+
No context needed from you — it figures out your app on its own.
|
|
136
35
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
```
|
|
140
|
-
ai> >User.count
|
|
141
|
-
=> 8
|
|
142
|
-
ai> how many users do I have?
|
|
143
|
-
Thinking...
|
|
36
|
+
## Install
|
|
144
37
|
|
|
145
|
-
|
|
38
|
+
```ruby
|
|
39
|
+
# Gemfile
|
|
40
|
+
gem 'console_agent', group: :development
|
|
146
41
|
```
|
|
147
42
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
Sessions are saved automatically when session logging is enabled. You can name, list, and resume them.
|
|
153
|
-
|
|
154
|
-
```
|
|
155
|
-
ai> /name sf_user_123_calendar
|
|
156
|
-
Session named: sf_user_123_calendar
|
|
157
|
-
ai> exit
|
|
158
|
-
Session #42 saved.
|
|
159
|
-
Resume with: ai_resume "sf_user_123_calendar"
|
|
160
|
-
Left ConsoleAgent interactive mode.
|
|
43
|
+
```bash
|
|
44
|
+
bundle install
|
|
45
|
+
rails generate console_agent:install
|
|
161
46
|
```
|
|
162
47
|
|
|
163
|
-
|
|
48
|
+
Set your API key in the generated initializer or via env var (`ANTHROPIC_API_KEY`):
|
|
164
49
|
|
|
50
|
+
```ruby
|
|
51
|
+
# config/initializers/console_agent.rb
|
|
52
|
+
ConsoleAgent.configure do |config|
|
|
53
|
+
config.api_key = 'sk-ant-...'
|
|
54
|
+
end
|
|
165
55
|
```
|
|
166
|
-
irb> ai_sessions
|
|
167
|
-
[Sessions — showing 3]
|
|
168
|
-
|
|
169
|
-
#42 sf_user_123_calendar find user 123 with calendar issues
|
|
170
|
-
[interactive] 5m ago 2340 tokens
|
|
171
56
|
|
|
172
|
-
|
|
173
|
-
[one_shot] 1h ago 850 tokens
|
|
174
|
-
|
|
175
|
-
#40 debug_payments explain payment flow
|
|
176
|
-
[interactive] 2h ago 4100 tokens
|
|
177
|
-
|
|
178
|
-
Use ai_resume(id_or_name) to resume a session.
|
|
179
|
-
```
|
|
57
|
+
## Commands
|
|
180
58
|
|
|
181
|
-
|
|
59
|
+
| Command | What it does |
|
|
60
|
+
|---------|-------------|
|
|
61
|
+
| `ai "query"` | Ask, review generated code, confirm execution |
|
|
62
|
+
| `ai!` | Enter interactive mode (multi-turn conversation) |
|
|
63
|
+
| `ai? "query"` | Explain only, no execution |
|
|
64
|
+
| `ai_init` | Generate app guide for better AI context |
|
|
65
|
+
| `ai_setup` | Install session logging table |
|
|
66
|
+
| `ai_sessions` | List recent sessions |
|
|
67
|
+
| `ai_resume` | Resume a session by name or ID |
|
|
68
|
+
| `ai_memories` | Show stored memories |
|
|
69
|
+
| `ai_status` | Show current configuration |
|
|
182
70
|
|
|
183
|
-
|
|
184
|
-
irb> ai_resume "sf_user_123_calendar"
|
|
185
|
-
--- Replaying previous session output ---
|
|
186
|
-
ai> find user 123 with calendar issues
|
|
187
|
-
...previous output...
|
|
188
|
-
--- End of previous output ---
|
|
189
|
-
|
|
190
|
-
ConsoleAgent interactive mode (sf_user_123_calendar). Type 'exit' to leave.
|
|
191
|
-
ai> now check their calendar sync status
|
|
192
|
-
...
|
|
193
|
-
```
|
|
71
|
+
### Interactive Mode
|
|
194
72
|
|
|
195
|
-
|
|
73
|
+
`ai!` starts a conversation. Slash commands available inside:
|
|
196
74
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
75
|
+
| Command | What it does |
|
|
76
|
+
|---------|-------------|
|
|
77
|
+
| `/auto` | Toggle auto-execute (skip confirmations) |
|
|
78
|
+
| `/compact` | Compress history into a summary (saves tokens) |
|
|
79
|
+
| `/usage` | Show token stats |
|
|
80
|
+
| `/debug` | Toggle raw API output |
|
|
81
|
+
| `/name <label>` | Name the session for easy resume |
|
|
201
82
|
|
|
202
|
-
|
|
83
|
+
Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
|
|
203
84
|
|
|
204
|
-
|
|
205
|
-
irb> ai_sessions 20, search: "salesforce"
|
|
206
|
-
```
|
|
85
|
+
## Features
|
|
207
86
|
|
|
208
|
-
|
|
87
|
+
- **Tool use** — AI introspects your schema, models, files, and code to write accurate queries
|
|
88
|
+
- **Multi-step plans** — complex tasks are broken into steps, executed sequentially with `step1`/`step2` references
|
|
89
|
+
- **Memories** — AI saves what it learns about your app across sessions
|
|
90
|
+
- **App guide** — `ai_init` generates a guide injected into every system prompt
|
|
91
|
+
- **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
|
|
92
|
+
- **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
|
|
209
93
|
|
|
210
94
|
## Configuration
|
|
211
95
|
|
|
212
|
-
All settings live in `config/initializers/console_agent.rb` and can be changed at runtime:
|
|
213
|
-
|
|
214
96
|
```ruby
|
|
215
97
|
ConsoleAgent.configure do |config|
|
|
216
98
|
config.provider = :anthropic # or :openai
|
|
217
99
|
config.auto_execute = false # true to skip confirmations
|
|
218
|
-
config.
|
|
219
|
-
config.max_tool_rounds = 10 # max tool calls per query
|
|
220
|
-
config.session_logging = true # log sessions to DB (run ConsoleAgent.setup!)
|
|
100
|
+
config.session_logging = true # requires ai_setup
|
|
221
101
|
end
|
|
222
102
|
```
|
|
223
103
|
|
|
224
|
-
For the admin UI, mount the engine:
|
|
225
|
-
|
|
226
|
-
```ruby
|
|
227
|
-
mount ConsoleAgent::Engine => '/console_agent'
|
|
228
|
-
```
|
|
229
|
-
|
|
230
104
|
## Requirements
|
|
231
105
|
|
|
232
|
-
|
|
106
|
+
Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0
|
|
233
107
|
|
|
234
108
|
## License
|
|
235
109
|
|
|
@@ -130,15 +130,34 @@ module ConsoleAgent
|
|
|
130
130
|
nil
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
def ai_setup
|
|
134
|
+
ConsoleAgent.setup!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ai_init
|
|
138
|
+
require 'console_agent/context_builder'
|
|
139
|
+
require 'console_agent/providers/base'
|
|
140
|
+
require 'console_agent/executor'
|
|
141
|
+
require 'console_agent/repl'
|
|
142
|
+
|
|
143
|
+
repl = Repl.new(__console_agent_binding)
|
|
144
|
+
repl.init_guide
|
|
145
|
+
rescue => e
|
|
146
|
+
$stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
133
150
|
def ai(query = nil)
|
|
134
151
|
if query.nil?
|
|
135
152
|
$stderr.puts "\e[33mUsage: ai \"your question here\"\e[0m"
|
|
136
153
|
$stderr.puts "\e[33m ai \"query\" - ask + confirm execution\e[0m"
|
|
137
154
|
$stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
|
|
138
155
|
$stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
|
|
156
|
+
$stderr.puts "\e[33m ai_init - generate/update app guide for better AI context\e[0m"
|
|
139
157
|
$stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
|
|
140
158
|
$stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
|
|
141
159
|
$stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
|
|
160
|
+
$stderr.puts "\e[33m ai_setup - install session logging table\e[0m"
|
|
142
161
|
$stderr.puts "\e[33m ai_status - show current configuration\e[0m"
|
|
143
162
|
$stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
|
|
144
163
|
return nil
|
|
@@ -235,6 +254,14 @@ module ConsoleAgent
|
|
|
235
254
|
end
|
|
236
255
|
|
|
237
256
|
def __console_agent_binding
|
|
257
|
+
# Try Pry first (pry-rails replaces IRB but IRB may still be loaded)
|
|
258
|
+
if defined?(Pry)
|
|
259
|
+
pry_inst = ObjectSpace.each_object(Pry).find { |p|
|
|
260
|
+
p.respond_to?(:binding_stack) && !p.binding_stack.empty?
|
|
261
|
+
} rescue nil
|
|
262
|
+
return pry_inst.current_binding if pry_inst
|
|
263
|
+
end
|
|
264
|
+
|
|
238
265
|
# Try IRB workspace binding
|
|
239
266
|
if defined?(IRB) && IRB.respond_to?(:CurrentContext)
|
|
240
267
|
ctx = IRB.CurrentContext rescue nil
|
|
@@ -243,14 +270,6 @@ module ConsoleAgent
|
|
|
243
270
|
end
|
|
244
271
|
end
|
|
245
272
|
|
|
246
|
-
# Try Pry binding
|
|
247
|
-
if defined?(Pry) && respond_to?(:pry_instance, true)
|
|
248
|
-
pry_inst = pry_instance rescue nil
|
|
249
|
-
if pry_inst && pry_inst.respond_to?(:current_binding)
|
|
250
|
-
return pry_inst.current_binding
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
273
|
# Fallback
|
|
255
274
|
TOPLEVEL_BINDING
|
|
256
275
|
end
|
|
@@ -15,10 +15,32 @@ module ConsoleAgent
|
|
|
15
15
|
parts = []
|
|
16
16
|
parts << smart_system_instructions
|
|
17
17
|
parts << environment_context
|
|
18
|
+
parts << guide_context
|
|
18
19
|
parts << memory_context
|
|
19
20
|
parts.compact.join("\n\n")
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
def environment_context
|
|
24
|
+
lines = ["## Environment"]
|
|
25
|
+
lines << "- Ruby #{RUBY_VERSION}"
|
|
26
|
+
lines << "- Rails #{Rails.version}" if defined?(Rails) && Rails.respond_to?(:version)
|
|
27
|
+
|
|
28
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
29
|
+
adapter = ActiveRecord::Base.connection.adapter_name rescue 'unknown'
|
|
30
|
+
lines << "- Database adapter: #{adapter}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if defined?(Bundler)
|
|
34
|
+
key_gems = %w[devise cancancan pundit sidekiq delayed_job resque
|
|
35
|
+
paperclip carrierwave activestorage shrine
|
|
36
|
+
pg mysql2 sqlite3 mongoid]
|
|
37
|
+
loaded = key_gems.select { |g| Gem.loaded_specs.key?(g) }
|
|
38
|
+
lines << "- Key gems: #{loaded.join(', ')}" unless loaded.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
lines.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
22
44
|
private
|
|
23
45
|
|
|
24
46
|
def smart_system_instructions
|
|
@@ -67,25 +89,14 @@ module ConsoleAgent
|
|
|
67
89
|
PROMPT
|
|
68
90
|
end
|
|
69
91
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
lines << "- Rails #{Rails.version}" if defined?(Rails) && Rails.respond_to?(:version)
|
|
74
|
-
|
|
75
|
-
if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
76
|
-
adapter = ActiveRecord::Base.connection.adapter_name rescue 'unknown'
|
|
77
|
-
lines << "- Database adapter: #{adapter}"
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
if defined?(Bundler)
|
|
81
|
-
key_gems = %w[devise cancancan pundit sidekiq delayed_job resque
|
|
82
|
-
paperclip carrierwave activestorage shrine
|
|
83
|
-
pg mysql2 sqlite3 mongoid]
|
|
84
|
-
loaded = key_gems.select { |g| Gem.loaded_specs.key?(g) }
|
|
85
|
-
lines << "- Key gems: #{loaded.join(', ')}" unless loaded.empty?
|
|
86
|
-
end
|
|
92
|
+
def guide_context
|
|
93
|
+
content = ConsoleAgent.storage.read(ConsoleAgent::GUIDE_KEY)
|
|
94
|
+
return nil if content.nil? || content.strip.empty?
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
"## Application Guide\n\n#{content.strip}"
|
|
97
|
+
rescue => e
|
|
98
|
+
ConsoleAgent.logger.debug("ConsoleAgent: guide context failed: #{e.message}")
|
|
99
|
+
nil
|
|
89
100
|
end
|
|
90
101
|
|
|
91
102
|
def memory_context
|
|
@@ -43,7 +43,7 @@ module ConsoleAgent
|
|
|
43
43
|
class Executor
|
|
44
44
|
CODE_REGEX = /```ruby\s*\n(.*?)```/m
|
|
45
45
|
|
|
46
|
-
attr_reader :binding_context
|
|
46
|
+
attr_reader :binding_context, :last_error
|
|
47
47
|
attr_accessor :on_prompt
|
|
48
48
|
|
|
49
49
|
def initialize(binding_context)
|
|
@@ -75,6 +75,7 @@ module ConsoleAgent
|
|
|
75
75
|
def execute(code)
|
|
76
76
|
return nil if code.nil? || code.strip.empty?
|
|
77
77
|
|
|
78
|
+
@last_error = nil
|
|
78
79
|
captured_output = StringIO.new
|
|
79
80
|
old_stdout = $stdout
|
|
80
81
|
# Tee output: capture it and also print to the real stdout
|
|
@@ -89,12 +90,14 @@ module ConsoleAgent
|
|
|
89
90
|
result
|
|
90
91
|
rescue SyntaxError => e
|
|
91
92
|
$stdout = old_stdout if old_stdout
|
|
92
|
-
|
|
93
|
+
@last_error = "SyntaxError: #{e.message}"
|
|
94
|
+
$stderr.puts colorize(@last_error, :red)
|
|
93
95
|
@last_output = nil
|
|
94
96
|
nil
|
|
95
97
|
rescue => e
|
|
96
98
|
$stdout = old_stdout if old_stdout
|
|
97
|
-
|
|
99
|
+
@last_error = "#{e.class}: #{e.message}"
|
|
100
|
+
$stderr.puts colorize("Error: #{@last_error}", :red)
|
|
98
101
|
e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
|
|
99
102
|
@last_output = captured_output&.string
|
|
100
103
|
nil
|
|
@@ -15,7 +15,7 @@ module ConsoleAgent
|
|
|
15
15
|
|
|
16
16
|
# Welcome message
|
|
17
17
|
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
18
|
-
$stdout.puts "\e[36m[ConsoleAgent] AI assistant loaded. Try: ai \"show me all tables\"\e[0m"
|
|
18
|
+
$stdout.puts "\e[36m[ConsoleAgent v#{ConsoleAgent::VERSION}] AI assistant loaded. Try: ai \"show me all tables\"\e[0m"
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Pre-build context in background
|
data/lib/console_agent/repl.rb
CHANGED
|
@@ -18,29 +18,25 @@ module ConsoleAgent
|
|
|
18
18
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
19
|
console_capture = StringIO.new
|
|
20
20
|
exec_result = with_console_capture(console_capture) do
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
else
|
|
34
|
-
@executor.confirm_and_execute(code)
|
|
35
|
-
end
|
|
36
|
-
executed = !@executor.last_cancelled?
|
|
21
|
+
conversation = [{ role: :user, content: query }]
|
|
22
|
+
exec_result, code, executed = one_shot_round(conversation)
|
|
23
|
+
|
|
24
|
+
# Auto-retry once if execution errored
|
|
25
|
+
if executed && @executor.last_error
|
|
26
|
+
error_msg = "Code execution failed with error: #{@executor.last_error}"
|
|
27
|
+
error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
|
|
28
|
+
conversation << { role: :assistant, content: @_last_result_text }
|
|
29
|
+
conversation << { role: :user, content: error_msg }
|
|
30
|
+
|
|
31
|
+
$stdout.puts "\e[2m Attempting to fix...\e[0m"
|
|
32
|
+
exec_result, code, executed = one_shot_round(conversation)
|
|
37
33
|
end
|
|
38
34
|
|
|
39
35
|
@_last_log_attrs = {
|
|
40
36
|
query: query,
|
|
41
|
-
conversation:
|
|
37
|
+
conversation: conversation,
|
|
42
38
|
mode: 'one_shot',
|
|
43
|
-
code_executed:
|
|
39
|
+
code_executed: code,
|
|
44
40
|
code_output: executed ? @executor.last_output : nil,
|
|
45
41
|
code_result: executed && exec_result ? exec_result.inspect : nil,
|
|
46
42
|
executed: executed,
|
|
@@ -61,6 +57,31 @@ module ConsoleAgent
|
|
|
61
57
|
nil
|
|
62
58
|
end
|
|
63
59
|
|
|
60
|
+
# Executes one LLM round: send query, display, optionally execute code.
|
|
61
|
+
# Returns [exec_result, code, executed].
|
|
62
|
+
def one_shot_round(conversation)
|
|
63
|
+
result, _ = send_query(nil, conversation: conversation)
|
|
64
|
+
track_usage(result)
|
|
65
|
+
code = @executor.display_response(result.text)
|
|
66
|
+
display_usage(result)
|
|
67
|
+
@_last_result_text = result.text
|
|
68
|
+
|
|
69
|
+
exec_result = nil
|
|
70
|
+
executed = false
|
|
71
|
+
has_code = code && !code.strip.empty?
|
|
72
|
+
|
|
73
|
+
if has_code
|
|
74
|
+
exec_result = if ConsoleAgent.configuration.auto_execute
|
|
75
|
+
@executor.execute(code)
|
|
76
|
+
else
|
|
77
|
+
@executor.confirm_and_execute(code)
|
|
78
|
+
end
|
|
79
|
+
executed = !@executor.last_cancelled?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
[exec_result, has_code ? code : nil, executed]
|
|
83
|
+
end
|
|
84
|
+
|
|
64
85
|
def explain(query)
|
|
65
86
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
87
|
console_capture = StringIO.new
|
|
@@ -90,6 +111,62 @@ module ConsoleAgent
|
|
|
90
111
|
nil
|
|
91
112
|
end
|
|
92
113
|
|
|
114
|
+
def init_guide
|
|
115
|
+
storage = ConsoleAgent.storage
|
|
116
|
+
existing_guide = begin
|
|
117
|
+
content = storage.read(ConsoleAgent::GUIDE_KEY)
|
|
118
|
+
(content && !content.strip.empty?) ? content.strip : nil
|
|
119
|
+
rescue
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if existing_guide
|
|
124
|
+
$stdout.puts "\e[36m Existing guide found (#{existing_guide.length} chars). Will update.\e[0m"
|
|
125
|
+
else
|
|
126
|
+
$stdout.puts "\e[36m No existing guide. Exploring the app...\e[0m"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
require 'console_agent/tools/registry'
|
|
130
|
+
init_tools = Tools::Registry.new(mode: :init)
|
|
131
|
+
sys_prompt = init_system_prompt(existing_guide)
|
|
132
|
+
messages = [{ role: :user, content: "Explore this Rails application and generate the application guide." }]
|
|
133
|
+
|
|
134
|
+
# Temporarily increase timeout — init conversations are large
|
|
135
|
+
original_timeout = ConsoleAgent.configuration.timeout
|
|
136
|
+
ConsoleAgent.configuration.timeout = [original_timeout, 120].max
|
|
137
|
+
|
|
138
|
+
result, _ = send_query_with_tools(messages, system_prompt: sys_prompt, tools_override: init_tools)
|
|
139
|
+
|
|
140
|
+
guide_text = result.text.to_s.strip
|
|
141
|
+
# Strip markdown code fences if the LLM wrapped the response
|
|
142
|
+
guide_text = guide_text.sub(/\A```(?:markdown)?\s*\n?/, '').sub(/\n?```\s*\z/, '')
|
|
143
|
+
# Strip LLM preamble/thinking before the actual guide content
|
|
144
|
+
guide_text = guide_text.sub(/\A.*?(?=^#\s)/m, '') if guide_text =~ /^#\s/m
|
|
145
|
+
|
|
146
|
+
if guide_text.empty?
|
|
147
|
+
$stdout.puts "\e[33m No guide content generated.\e[0m"
|
|
148
|
+
return nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
storage.write(ConsoleAgent::GUIDE_KEY, guide_text)
|
|
152
|
+
|
|
153
|
+
path = storage.respond_to?(:root_path) ? File.join(storage.root_path, ConsoleAgent::GUIDE_KEY) : ConsoleAgent::GUIDE_KEY
|
|
154
|
+
$stdout.puts "\e[32m Guide saved to #{path} (#{guide_text.length} chars)\e[0m"
|
|
155
|
+
display_usage(result)
|
|
156
|
+
nil
|
|
157
|
+
rescue Interrupt
|
|
158
|
+
$stdout.puts "\n\e[33m Interrupted.\e[0m"
|
|
159
|
+
nil
|
|
160
|
+
rescue Providers::ProviderError => e
|
|
161
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
|
|
162
|
+
nil
|
|
163
|
+
rescue => e
|
|
164
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
165
|
+
nil
|
|
166
|
+
ensure
|
|
167
|
+
ConsoleAgent.configuration.timeout = original_timeout if original_timeout
|
|
168
|
+
end
|
|
169
|
+
|
|
93
170
|
def interactive
|
|
94
171
|
init_interactive_state
|
|
95
172
|
interactive_loop
|
|
@@ -139,6 +216,7 @@ module ConsoleAgent
|
|
|
139
216
|
@last_interactive_output = nil
|
|
140
217
|
@last_interactive_result = nil
|
|
141
218
|
@last_interactive_executed = false
|
|
219
|
+
@compact_warned = false
|
|
142
220
|
end
|
|
143
221
|
|
|
144
222
|
def interactive_loop
|
|
@@ -146,7 +224,7 @@ module ConsoleAgent
|
|
|
146
224
|
name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
|
|
147
225
|
# Write banner to real stdout (bypass TeeIO) so it doesn't accumulate on resume
|
|
148
226
|
@interactive_old_stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
|
|
149
|
-
@interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | > code to run directly | /usage | /name <label>\e[0m"
|
|
227
|
+
@interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | > code to run directly | /usage | /compact | /name <label>\e[0m"
|
|
150
228
|
|
|
151
229
|
# Bind Shift-Tab to insert /auto command and submit
|
|
152
230
|
if Readline.respond_to?(:parse_and_bind)
|
|
@@ -180,6 +258,11 @@ module ConsoleAgent
|
|
|
180
258
|
next
|
|
181
259
|
end
|
|
182
260
|
|
|
261
|
+
if input == '/compact'
|
|
262
|
+
compact_history
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
183
266
|
if input.start_with?('/name')
|
|
184
267
|
name = input.sub('/name', '').strip
|
|
185
268
|
if name.empty?
|
|
@@ -240,65 +323,24 @@ module ConsoleAgent
|
|
|
240
323
|
# Save immediately so the session is visible in the admin UI while the AI thinks
|
|
241
324
|
log_interactive_turn
|
|
242
325
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
rescue Interrupt
|
|
246
|
-
$stdout.puts "\n\e[33m Aborted.\e[0m"
|
|
326
|
+
status = send_and_execute
|
|
327
|
+
if status == :interrupted
|
|
247
328
|
@history.pop # Remove the user message that never got a response
|
|
248
329
|
log_interactive_turn
|
|
249
330
|
next
|
|
250
331
|
end
|
|
251
332
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
log_interactive_turn
|
|
258
|
-
|
|
259
|
-
# Add tool call/result messages so the LLM remembers what it learned
|
|
260
|
-
@history.concat(tool_messages) if tool_messages && !tool_messages.empty?
|
|
261
|
-
@history << { role: :assistant, content: result.text }
|
|
262
|
-
|
|
263
|
-
if code && !code.strip.empty?
|
|
264
|
-
if ConsoleAgent.configuration.auto_execute
|
|
265
|
-
exec_result = @executor.execute(code)
|
|
266
|
-
else
|
|
267
|
-
exec_result = @executor.confirm_and_execute(code)
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
unless @executor.last_cancelled?
|
|
271
|
-
@last_interactive_code = code
|
|
272
|
-
@last_interactive_output = @executor.last_output
|
|
273
|
-
@last_interactive_result = exec_result ? exec_result.inspect : nil
|
|
274
|
-
@last_interactive_executed = true
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
if @executor.last_cancelled?
|
|
278
|
-
@history << { role: :user, content: "User declined to execute the code." }
|
|
279
|
-
else
|
|
280
|
-
output_parts = []
|
|
281
|
-
|
|
282
|
-
# Capture printed output (puts, print, etc.)
|
|
283
|
-
if @executor.last_output && !@executor.last_output.strip.empty?
|
|
284
|
-
output_parts << "Output:\n#{@executor.last_output.strip}"
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
# Capture return value
|
|
288
|
-
if exec_result
|
|
289
|
-
output_parts << "Return value: #{exec_result.inspect}"
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
unless output_parts.empty?
|
|
293
|
-
result_str = output_parts.join("\n\n")
|
|
294
|
-
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
295
|
-
@history << { role: :user, content: "Code was executed. #{result_str}" }
|
|
296
|
-
end
|
|
297
|
-
end
|
|
333
|
+
# Auto-retry once when execution fails — send error back to LLM for a fix
|
|
334
|
+
if status == :error
|
|
335
|
+
$stdout.puts "\e[2m Attempting to fix...\e[0m"
|
|
336
|
+
log_interactive_turn
|
|
337
|
+
send_and_execute
|
|
298
338
|
end
|
|
299
339
|
|
|
300
340
|
# Update with the AI response, tokens, and any execution results
|
|
301
341
|
log_interactive_turn
|
|
342
|
+
|
|
343
|
+
warn_if_history_large
|
|
302
344
|
end
|
|
303
345
|
|
|
304
346
|
$stdout = @interactive_old_stdout
|
|
@@ -318,6 +360,73 @@ module ConsoleAgent
|
|
|
318
360
|
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
319
361
|
end
|
|
320
362
|
|
|
363
|
+
# Sends conversation to LLM, displays response, executes code if present.
|
|
364
|
+
# Returns :success, :error, :cancelled, :no_code, or :interrupted.
|
|
365
|
+
def send_and_execute
|
|
366
|
+
begin
|
|
367
|
+
result, tool_messages = send_query(nil, conversation: @history)
|
|
368
|
+
rescue Interrupt
|
|
369
|
+
$stdout.puts "\n\e[33m Aborted.\e[0m"
|
|
370
|
+
return :interrupted
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
track_usage(result)
|
|
374
|
+
code = @executor.display_response(result.text)
|
|
375
|
+
display_usage(result, show_session: true)
|
|
376
|
+
|
|
377
|
+
# Save after response is displayed so viewer shows progress before Execute prompt
|
|
378
|
+
log_interactive_turn
|
|
379
|
+
|
|
380
|
+
# Add tool call/result messages so the LLM remembers what it learned
|
|
381
|
+
@history.concat(tool_messages) if tool_messages && !tool_messages.empty?
|
|
382
|
+
@history << { role: :assistant, content: result.text }
|
|
383
|
+
|
|
384
|
+
return :no_code unless code && !code.strip.empty?
|
|
385
|
+
|
|
386
|
+
exec_result = if ConsoleAgent.configuration.auto_execute
|
|
387
|
+
@executor.execute(code)
|
|
388
|
+
else
|
|
389
|
+
@executor.confirm_and_execute(code)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
unless @executor.last_cancelled?
|
|
393
|
+
@last_interactive_code = code
|
|
394
|
+
@last_interactive_output = @executor.last_output
|
|
395
|
+
@last_interactive_result = exec_result ? exec_result.inspect : nil
|
|
396
|
+
@last_interactive_executed = true
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
if @executor.last_cancelled?
|
|
400
|
+
@history << { role: :user, content: "User declined to execute the code." }
|
|
401
|
+
:cancelled
|
|
402
|
+
elsif @executor.last_error
|
|
403
|
+
error_msg = "Code execution failed with error: #{@executor.last_error}"
|
|
404
|
+
error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
|
|
405
|
+
@history << { role: :user, content: error_msg }
|
|
406
|
+
:error
|
|
407
|
+
else
|
|
408
|
+
output_parts = []
|
|
409
|
+
|
|
410
|
+
# Capture printed output (puts, print, etc.)
|
|
411
|
+
if @executor.last_output && !@executor.last_output.strip.empty?
|
|
412
|
+
output_parts << "Output:\n#{@executor.last_output.strip}"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Capture return value
|
|
416
|
+
if exec_result
|
|
417
|
+
output_parts << "Return value: #{exec_result.inspect}"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
unless output_parts.empty?
|
|
421
|
+
result_str = output_parts.join("\n\n")
|
|
422
|
+
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
423
|
+
@history << { role: :user, content: "Code was executed. #{result_str}" }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
:success
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
321
430
|
def provider
|
|
322
431
|
@provider ||= Providers.build
|
|
323
432
|
end
|
|
@@ -327,7 +436,76 @@ module ConsoleAgent
|
|
|
327
436
|
end
|
|
328
437
|
|
|
329
438
|
def context
|
|
330
|
-
@
|
|
439
|
+
base = @context_base ||= context_builder.build
|
|
440
|
+
vars = binding_variable_summary
|
|
441
|
+
vars ? "#{base}\n\n#{vars}" : base
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Summarize local and instance variables from the user's console session
|
|
445
|
+
# so the LLM knows what's available to reference in generated code.
|
|
446
|
+
def binding_variable_summary
|
|
447
|
+
parts = []
|
|
448
|
+
|
|
449
|
+
locals = @binding_context.local_variables.reject { |v| v.to_s.start_with?('_') }
|
|
450
|
+
locals.first(20).each do |var|
|
|
451
|
+
val = @binding_context.local_variable_get(var) rescue nil
|
|
452
|
+
parts << "#{var} (#{val.class})"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
ivars = (@binding_context.eval("instance_variables") rescue [])
|
|
456
|
+
ivars.reject { |v| v.to_s =~ /\A@_/ }.first(20).each do |var|
|
|
457
|
+
val = @binding_context.eval(var.to_s) rescue nil
|
|
458
|
+
parts << "#{var} (#{val.class})"
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
return nil if parts.empty?
|
|
462
|
+
"The user's console session has these variables available: #{parts.join(', ')}. You can reference them directly in code."
|
|
463
|
+
rescue
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def init_system_prompt(existing_guide)
|
|
468
|
+
env = context_builder.environment_context
|
|
469
|
+
|
|
470
|
+
prompt = <<~PROMPT
|
|
471
|
+
You are a Rails application analyst. Your job is to explore this Rails app using the
|
|
472
|
+
available tools and produce a concise markdown guide that will be injected into future
|
|
473
|
+
AI assistant sessions.
|
|
474
|
+
|
|
475
|
+
#{env}
|
|
476
|
+
|
|
477
|
+
EXPLORATION STRATEGY — be efficient to avoid timeouts:
|
|
478
|
+
1. Start with list_models to see all models and their associations
|
|
479
|
+
2. Pick the 5-8 CORE models and call describe_model on those only
|
|
480
|
+
3. Call describe_table on only 3-5 key tables (skip tables whose models already told you enough)
|
|
481
|
+
4. Use search_code sparingly — only for specific patterns you suspect (sharding, STI, concerns)
|
|
482
|
+
5. Use read_file only when you need to understand a specific pattern (read small sections, not whole files)
|
|
483
|
+
6. Do NOT exhaustively describe every table or model — focus on what's important
|
|
484
|
+
|
|
485
|
+
IMPORTANT: Keep your total tool calls under 20. Prioritize breadth over depth.
|
|
486
|
+
|
|
487
|
+
Produce a markdown document with these sections:
|
|
488
|
+
- **Application Overview**: What the app does, key domain concepts
|
|
489
|
+
- **Key Models & Relationships**: Core models and how they relate
|
|
490
|
+
- **Data Architecture**: Important tables, notable columns, any partitioning/sharding
|
|
491
|
+
- **Important Patterns**: Custom concerns, service objects, key abstractions
|
|
492
|
+
- **Common Maintenance Tasks**: Typical console operations for this app
|
|
493
|
+
- **Gotchas**: Non-obvious behaviors, tricky associations, known quirks
|
|
494
|
+
|
|
495
|
+
Keep it concise — aim for 1-2 pages. Focus on what a console user needs to know.
|
|
496
|
+
Do NOT wrap the output in markdown code fences.
|
|
497
|
+
PROMPT
|
|
498
|
+
|
|
499
|
+
if existing_guide
|
|
500
|
+
prompt += <<~UPDATE
|
|
501
|
+
|
|
502
|
+
Here is the existing guide. Update and merge with any new findings:
|
|
503
|
+
|
|
504
|
+
#{existing_guide}
|
|
505
|
+
UPDATE
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
prompt.strip
|
|
331
509
|
end
|
|
332
510
|
|
|
333
511
|
def send_query(query, conversation: nil)
|
|
@@ -342,45 +520,43 @@ module ConsoleAgent
|
|
|
342
520
|
send_query_with_tools(messages)
|
|
343
521
|
end
|
|
344
522
|
|
|
345
|
-
def send_query_with_tools(messages)
|
|
523
|
+
def send_query_with_tools(messages, system_prompt: nil, tools_override: nil)
|
|
346
524
|
require 'console_agent/tools/registry'
|
|
347
|
-
tools = Tools::Registry.new(executor: @executor)
|
|
525
|
+
tools = tools_override || Tools::Registry.new(executor: @executor)
|
|
526
|
+
active_system_prompt = system_prompt || context
|
|
348
527
|
max_rounds = ConsoleAgent.configuration.max_tool_rounds
|
|
349
528
|
total_input = 0
|
|
350
529
|
total_output = 0
|
|
351
530
|
result = nil
|
|
352
531
|
new_messages = [] # Track messages added during tool use
|
|
532
|
+
last_thinking = nil
|
|
533
|
+
last_tool_names = []
|
|
353
534
|
|
|
354
535
|
exhausted = false
|
|
355
536
|
|
|
356
537
|
max_rounds.times do |round|
|
|
357
538
|
if round == 0
|
|
358
539
|
$stdout.puts "\e[2m Thinking...\e[0m"
|
|
540
|
+
else
|
|
541
|
+
# Show buffered thinking text before the "Calling LLM" line
|
|
542
|
+
if last_thinking
|
|
543
|
+
last_thinking.split("\n").each do |line|
|
|
544
|
+
$stdout.puts "\e[2m #{line}\e[0m"
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
$stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
|
|
359
548
|
end
|
|
360
549
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
provider.chat_with_tools(messages, tools: tools, system_prompt: context)
|
|
364
|
-
end
|
|
365
|
-
rescue Interrupt
|
|
366
|
-
redirect = prompt_for_redirect
|
|
367
|
-
if redirect
|
|
368
|
-
messages << { role: :user, content: redirect }
|
|
369
|
-
new_messages << messages.last
|
|
370
|
-
next
|
|
371
|
-
else
|
|
372
|
-
raise
|
|
373
|
-
end
|
|
550
|
+
result = with_escape_monitoring do
|
|
551
|
+
provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
|
|
374
552
|
end
|
|
375
553
|
total_input += result.input_tokens || 0
|
|
376
554
|
total_output += result.output_tokens || 0
|
|
377
555
|
|
|
378
556
|
break unless result.tool_use?
|
|
379
557
|
|
|
380
|
-
#
|
|
381
|
-
|
|
382
|
-
$stdout.puts "\e[2m #{result.text.strip}\e[0m"
|
|
383
|
-
end
|
|
558
|
+
# Buffer thinking text for display before next LLM call
|
|
559
|
+
last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
|
|
384
560
|
|
|
385
561
|
# Add assistant message with tool calls to conversation
|
|
386
562
|
assistant_msg = provider.format_assistant_message(result)
|
|
@@ -388,6 +564,7 @@ module ConsoleAgent
|
|
|
388
564
|
new_messages << assistant_msg
|
|
389
565
|
|
|
390
566
|
# Execute each tool and show progress
|
|
567
|
+
last_tool_names = result.tool_calls.map { |tc| tc[:name] }
|
|
391
568
|
result.tool_calls.each do |tc|
|
|
392
569
|
# ask_user and execute_plan handle their own display
|
|
393
570
|
if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
|
|
@@ -419,7 +596,7 @@ module ConsoleAgent
|
|
|
419
596
|
if exhausted
|
|
420
597
|
$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"
|
|
421
598
|
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." }
|
|
422
|
-
result = provider.chat(messages, system_prompt:
|
|
599
|
+
result = provider.chat(messages, system_prompt: active_system_prompt)
|
|
423
600
|
total_input += result.input_tokens || 0
|
|
424
601
|
total_output += result.output_tokens || 0
|
|
425
602
|
end
|
|
@@ -477,13 +654,29 @@ module ConsoleAgent
|
|
|
477
654
|
end
|
|
478
655
|
end
|
|
479
656
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
657
|
+
|
|
658
|
+
def llm_status(round, messages, tokens_so_far, last_thinking = nil, last_tool_names = [])
|
|
659
|
+
status = "Calling LLM (round #{round + 1}, #{messages.length} msgs"
|
|
660
|
+
status += ", ~#{format_tokens(tokens_so_far)} ctx" if tokens_so_far > 0
|
|
661
|
+
status += ")"
|
|
662
|
+
if !last_thinking && last_tool_names.any?
|
|
663
|
+
# Summarize tools when there's no thinking text
|
|
664
|
+
counts = last_tool_names.tally
|
|
665
|
+
summary = counts.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")
|
|
666
|
+
status += " after #{summary}"
|
|
667
|
+
end
|
|
668
|
+
status += "..."
|
|
669
|
+
status
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def format_tokens(count)
|
|
673
|
+
if count >= 1_000_000
|
|
674
|
+
"#{(count / 1_000_000.0).round(1)}M"
|
|
675
|
+
elsif count >= 1_000
|
|
676
|
+
"#{(count / 1_000.0).round(1)}K"
|
|
677
|
+
else
|
|
678
|
+
count.to_s
|
|
679
|
+
end
|
|
487
680
|
end
|
|
488
681
|
|
|
489
682
|
def format_tool_args(name, args)
|
|
@@ -547,8 +740,12 @@ module ConsoleAgent
|
|
|
547
740
|
lines = result.split("\n")
|
|
548
741
|
"#{lines.length} files"
|
|
549
742
|
when 'read_file'
|
|
550
|
-
|
|
551
|
-
|
|
743
|
+
if result =~ /^Lines (\d+)-(\d+) of (\d+):/
|
|
744
|
+
"lines #{$1}-#{$2} of #{$3}"
|
|
745
|
+
else
|
|
746
|
+
lines = result.split("\n")
|
|
747
|
+
"#{lines.length} lines"
|
|
748
|
+
end
|
|
552
749
|
when 'search_code'
|
|
553
750
|
if result.start_with?('Found')
|
|
554
751
|
result.split("\n").first
|
|
@@ -686,6 +883,64 @@ module ConsoleAgent
|
|
|
686
883
|
$stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
|
|
687
884
|
end
|
|
688
885
|
|
|
886
|
+
def warn_if_history_large
|
|
887
|
+
chars = @history.sum { |m| m[:content].to_s.length }
|
|
888
|
+
return if chars < 50_000 || @compact_warned
|
|
889
|
+
|
|
890
|
+
@compact_warned = true
|
|
891
|
+
$stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def compact_history
|
|
895
|
+
if @history.length < 6
|
|
896
|
+
$stdout.puts "\e[33m History too short to compact (#{@history.length} messages). Need at least 6.\e[0m"
|
|
897
|
+
return
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
before_chars = @history.sum { |m| m[:content].to_s.length }
|
|
901
|
+
before_count = @history.length
|
|
902
|
+
|
|
903
|
+
$stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
|
|
904
|
+
|
|
905
|
+
system_prompt = <<~PROMPT
|
|
906
|
+
You are a conversation summarizer. The user will provide a conversation history from a Rails console AI assistant session.
|
|
907
|
+
|
|
908
|
+
Produce a concise summary that captures:
|
|
909
|
+
- What the user has been working on and their goals
|
|
910
|
+
- Key findings and data discovered (include specific values, IDs, record counts)
|
|
911
|
+
- Current state: what worked, what failed, where things stand
|
|
912
|
+
- Important variable names, model names, or table names referenced
|
|
913
|
+
- Any code that was executed and its results
|
|
914
|
+
|
|
915
|
+
Be concise but preserve all information that would be needed to continue the conversation naturally.
|
|
916
|
+
Do NOT include any preamble — just output the summary directly.
|
|
917
|
+
PROMPT
|
|
918
|
+
|
|
919
|
+
history_text = @history.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
|
|
920
|
+
messages = [{ role: :user, content: "Summarize this conversation history:\n\n#{history_text}" }]
|
|
921
|
+
|
|
922
|
+
begin
|
|
923
|
+
result = provider.chat(messages, system_prompt: system_prompt)
|
|
924
|
+
track_usage(result)
|
|
925
|
+
|
|
926
|
+
summary = result.text.to_s.strip
|
|
927
|
+
if summary.empty?
|
|
928
|
+
$stdout.puts "\e[33m Compaction failed: empty summary returned.\e[0m"
|
|
929
|
+
return
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
@history = [{ role: :user, content: "CONVERSATION SUMMARY (compacted):\n#{summary}" }]
|
|
933
|
+
@compact_warned = false
|
|
934
|
+
|
|
935
|
+
after_chars = @history.first[:content].length
|
|
936
|
+
$stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
|
|
937
|
+
summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
|
|
938
|
+
display_usage(result)
|
|
939
|
+
rescue => e
|
|
940
|
+
$stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
|
|
689
944
|
def display_exit_info
|
|
690
945
|
display_session_summary
|
|
691
946
|
if @interactive_session_id
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module ConsoleAgent
|
|
2
2
|
module Tools
|
|
3
3
|
class CodeTools
|
|
4
|
-
MAX_FILE_LINES =
|
|
4
|
+
MAX_FILE_LINES = 500
|
|
5
5
|
MAX_LIST_ENTRIES = 100
|
|
6
6
|
MAX_SEARCH_RESULTS = 50
|
|
7
7
|
|
|
@@ -28,7 +28,7 @@ module ConsoleAgent
|
|
|
28
28
|
"Error listing files: #{e.message}"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def read_file(path)
|
|
31
|
+
def read_file(path, start_line: nil, end_line: nil)
|
|
32
32
|
return "Error: path is required." if path.nil? || path.strip.empty?
|
|
33
33
|
|
|
34
34
|
root = rails_root
|
|
@@ -45,12 +45,24 @@ module ConsoleAgent
|
|
|
45
45
|
return "File '#{path}' not found." unless File.exist?(full_path)
|
|
46
46
|
return "Error: '#{path}' is a directory, not a file." if File.directory?(full_path)
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
all_lines = File.readlines(full_path)
|
|
49
|
+
total = all_lines.length
|
|
50
|
+
|
|
51
|
+
# Apply line range if specified (1-based, inclusive)
|
|
52
|
+
if start_line || end_line
|
|
53
|
+
s = [(start_line || 1).to_i, 1].max
|
|
54
|
+
e = [(end_line || total).to_i, total].min
|
|
55
|
+
return "Error: start_line (#{s}) is beyond end of file (#{total} lines)." if s > total
|
|
56
|
+
lines = all_lines[(s - 1)..(e - 1)] || []
|
|
57
|
+
offset = s - 1
|
|
58
|
+
numbered = lines.each_with_index.map { |line, i| "#{offset + i + 1}: #{line}" }
|
|
59
|
+
header = "Lines #{s}-#{[e, s + lines.length - 1].min} of #{total}:\n"
|
|
60
|
+
header + numbered.join
|
|
61
|
+
elsif total > MAX_FILE_LINES
|
|
62
|
+
numbered = all_lines.first(MAX_FILE_LINES).each_with_index.map { |line, i| "#{i + 1}: #{line}" }
|
|
63
|
+
numbered.join + "\n... truncated (#{total} total lines, showing first #{MAX_FILE_LINES}). Use start_line/end_line to read specific sections."
|
|
52
64
|
else
|
|
53
|
-
|
|
65
|
+
all_lines.each_with_index.map { |line, i| "#{i + 1}: #{line}" }.join
|
|
54
66
|
end
|
|
55
67
|
rescue => e
|
|
56
68
|
"Error reading file '#{path}': #{e.message}"
|
|
@@ -8,8 +8,9 @@ module ConsoleAgent
|
|
|
8
8
|
# Tools that should never be cached (side effects or user interaction)
|
|
9
9
|
NO_CACHE = %w[ask_user save_memory delete_memory execute_plan].freeze
|
|
10
10
|
|
|
11
|
-
def initialize(executor: nil)
|
|
11
|
+
def initialize(executor: nil, mode: :default)
|
|
12
12
|
@executor = executor
|
|
13
|
+
@mode = mode
|
|
13
14
|
@definitions = []
|
|
14
15
|
@handlers = {}
|
|
15
16
|
@cache = {}
|
|
@@ -142,15 +143,17 @@ module ConsoleAgent
|
|
|
142
143
|
|
|
143
144
|
register(
|
|
144
145
|
name: 'read_file',
|
|
145
|
-
description: 'Read the contents of a file in this Rails app.
|
|
146
|
+
description: 'Read the contents of a file in this Rails app. Returns up to 500 lines by default. Use start_line/end_line to read specific sections of large files.',
|
|
146
147
|
parameters: {
|
|
147
148
|
'type' => 'object',
|
|
148
149
|
'properties' => {
|
|
149
|
-
'path' => { 'type' => 'string', 'description' => 'Relative file path (e.g. "app/models/user.rb")' }
|
|
150
|
+
'path' => { 'type' => 'string', 'description' => 'Relative file path (e.g. "app/models/user.rb")' },
|
|
151
|
+
'start_line' => { 'type' => 'integer', 'description' => 'First line to read (1-based). Optional — omit to start from beginning.' },
|
|
152
|
+
'end_line' => { 'type' => 'integer', 'description' => 'Last line to read (1-based, inclusive). Optional — omit to read to end.' }
|
|
150
153
|
},
|
|
151
154
|
'required' => ['path']
|
|
152
155
|
},
|
|
153
|
-
handler: ->(args) { code.read_file(args['path']) }
|
|
156
|
+
handler: ->(args) { code.read_file(args['path'], start_line: args['start_line'], end_line: args['end_line']) }
|
|
154
157
|
)
|
|
155
158
|
|
|
156
159
|
register(
|
|
@@ -167,21 +170,23 @@ module ConsoleAgent
|
|
|
167
170
|
handler: ->(args) { code.search_code(args['query'], args['directory']) }
|
|
168
171
|
)
|
|
169
172
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
'
|
|
173
|
+
unless @mode == :init
|
|
174
|
+
register(
|
|
175
|
+
name: 'ask_user',
|
|
176
|
+
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.',
|
|
177
|
+
parameters: {
|
|
178
|
+
'type' => 'object',
|
|
179
|
+
'properties' => {
|
|
180
|
+
'question' => { 'type' => 'string', 'description' => 'The question to ask the user' }
|
|
181
|
+
},
|
|
182
|
+
'required' => ['question']
|
|
177
183
|
},
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
handler: ->(args) { ask_user(args['question']) }
|
|
181
|
-
)
|
|
184
|
+
handler: ->(args) { ask_user(args['question']) }
|
|
185
|
+
)
|
|
182
186
|
|
|
183
|
-
|
|
184
|
-
|
|
187
|
+
register_memory_tools
|
|
188
|
+
register_execute_plan
|
|
189
|
+
end
|
|
185
190
|
end
|
|
186
191
|
|
|
187
192
|
def register_memory_tools
|
|
@@ -334,8 +339,12 @@ module ConsoleAgent
|
|
|
334
339
|
# Make result available as step1, step2, etc. for subsequent steps
|
|
335
340
|
@executor.binding_context.local_variable_set(:"step#{i + 1}", exec_result)
|
|
336
341
|
output = @executor.last_output
|
|
342
|
+
error = @executor.last_error
|
|
337
343
|
|
|
338
344
|
step_report = "Step #{i + 1} (#{step['description']}):\n"
|
|
345
|
+
if error
|
|
346
|
+
step_report += "ERROR: #{error}\n"
|
|
347
|
+
end
|
|
339
348
|
if output && !output.strip.empty?
|
|
340
349
|
step_report += "Output: #{output.strip}\n"
|
|
341
350
|
end
|
data/lib/console_agent.rb
CHANGED