console_agent 0.6.0 → 0.8.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 +77 -188
- data/app/controllers/console_agent/application_controller.rb +12 -8
- data/lib/console_agent/configuration.rb +36 -4
- data/lib/console_agent/console_methods.rb +13 -8
- data/lib/console_agent/executor.rb +6 -3
- data/lib/console_agent/providers/anthropic.rb +1 -1
- data/lib/console_agent/providers/openai.rb +1 -1
- data/lib/console_agent/repl.rb +332 -72
- data/lib/console_agent/tools/registry.rb +4 -0
- data/lib/console_agent/version.rb +1 -1
- 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: fdcfb3c48b2f8421b2187980a453b80324e6dec40ab80e8e55aa1a938355c79c
|
|
4
|
+
data.tar.gz: 12fec02740fde7a87bb81e26dd6857263c96f9ebe5a8402040c72279e882e31f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7af9a3c4fdbdf71abb7452d8e6747ba2b22c64cd08d123b761c5ca7ebb870c3ba9a01e458defe0308dcbf9435ec71879f7f3562503defdfd54e2026d3069679d
|
|
7
|
+
data.tar.gz: c84e401d6b6f5c6c7840b5d701d3a0e653ba3ea46651141ae5101b2c93bdca35487566786a87e284dbba97f902c5dad597a00b8dc9fce6e1c01885b01e99a48d
|
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,29 +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
|
-
| `ai_init` | Generate/update app guide for better context |
|
|
63
|
-
|
|
64
|
-
### Multi-Step Plans
|
|
65
|
-
|
|
66
|
-
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:
|
|
67
20
|
|
|
68
21
|
```
|
|
69
22
|
ai> get the most recent salesforce token and count events via the API
|
|
70
|
-
Thinking...
|
|
71
|
-
-> describe_table("oauth2_tokens")
|
|
72
|
-
28 columns
|
|
73
|
-
-> read_file("lib/salesforce_api.rb")
|
|
74
|
-
202 lines
|
|
75
|
-
|
|
76
23
|
Plan (2 steps):
|
|
77
24
|
1. Find the most recent active Salesforce OAuth2 token
|
|
78
25
|
token = Oauth2Token.where(provider: "salesforce", active: true)
|
|
@@ -82,179 +29,121 @@ ai> get the most recent salesforce token and count events via the API
|
|
|
82
29
|
api.query("SELECT COUNT(Id) FROM Event")
|
|
83
30
|
|
|
84
31
|
Accept plan? [y/N/a(uto)] a
|
|
85
|
-
Step 1/2: Find the most recent active Salesforce OAuth2 token
|
|
86
|
-
...
|
|
87
|
-
=> #<Oauth2Token id: 1, provider: "salesforce", ...>
|
|
88
|
-
|
|
89
|
-
Step 2/2: Query event count via SOQL
|
|
90
|
-
...
|
|
91
|
-
=> [{"expr0"=>42}]
|
|
92
32
|
```
|
|
93
33
|
|
|
94
|
-
|
|
34
|
+
No context needed from you — it figures out your app on its own.
|
|
95
35
|
|
|
96
|
-
|
|
97
|
-
- **y** — accept, then confirm each step one at a time
|
|
98
|
-
- **a** — accept and auto-run all steps (stays in manual mode for future queries)
|
|
99
|
-
- **N** — decline; you're asked what to change and the AI revises
|
|
100
|
-
|
|
101
|
-
### Memories
|
|
102
|
-
|
|
103
|
-
The AI remembers what it learns about your codebase across sessions:
|
|
36
|
+
## Install
|
|
104
37
|
|
|
38
|
+
```ruby
|
|
39
|
+
# Gemfile
|
|
40
|
+
gem 'console_agent', group: :development
|
|
105
41
|
```
|
|
106
|
-
ai> how does sharding work?
|
|
107
|
-
-> read_file("config/initializers/sharding.rb")
|
|
108
|
-
-> save_memory("Sharding architecture")
|
|
109
|
-
Memory saved
|
|
110
42
|
|
|
111
|
-
|
|
43
|
+
```bash
|
|
44
|
+
bundle install
|
|
45
|
+
rails generate console_agent:install
|
|
112
46
|
```
|
|
113
47
|
|
|
114
|
-
|
|
48
|
+
Set your API key in the generated initializer or via env var (`ANTHROPIC_API_KEY`):
|
|
115
49
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
irb> ai_init
|
|
122
|
-
No existing guide. Exploring the app...
|
|
123
|
-
Thinking...
|
|
124
|
-
-> list_models
|
|
125
|
-
240 models
|
|
126
|
-
-> describe_model("User")
|
|
127
|
-
119 associations, 6 validations
|
|
128
|
-
-> describe_model("Account")
|
|
129
|
-
25 associations
|
|
130
|
-
-> search_code("Sharding", dir: "config")
|
|
131
|
-
Found 36 matches
|
|
132
|
-
...
|
|
133
|
-
Guide saved to .console_agent/console_agent.md (3204 chars)
|
|
50
|
+
```ruby
|
|
51
|
+
# config/initializers/console_agent.rb
|
|
52
|
+
ConsoleAgent.configure do |config|
|
|
53
|
+
config.api_key = 'sk-ant-...'
|
|
54
|
+
end
|
|
134
55
|
```
|
|
135
56
|
|
|
136
|
-
|
|
57
|
+
## Commands
|
|
137
58
|
|
|
138
|
-
|
|
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 |
|
|
139
70
|
|
|
140
71
|
### Interactive Mode
|
|
141
72
|
|
|
142
|
-
|
|
143
|
-
irb> ai!
|
|
144
|
-
ConsoleAgent interactive mode. Type 'exit' to leave.
|
|
145
|
-
Auto-execute: OFF (Shift-Tab or /auto to toggle) | > code to run directly | /usage | /name <label>
|
|
146
|
-
|
|
147
|
-
ai> show me all tables
|
|
148
|
-
...
|
|
149
|
-
ai> count orders by status
|
|
150
|
-
...
|
|
151
|
-
ai> /auto
|
|
152
|
-
Auto-execute: ON
|
|
153
|
-
ai> delete cancelled orders older than 90 days
|
|
154
|
-
...
|
|
155
|
-
ai> exit
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
Toggle `/auto` to skip confirmation prompts. `/debug` shows raw API traffic. `/usage` shows token stats.
|
|
159
|
-
|
|
160
|
-
### Direct Code Execution
|
|
73
|
+
`ai!` starts a conversation. Slash commands available inside:
|
|
161
74
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
Useful for quick checks, setting up variables, or giving the AI concrete data to work with.
|
|
174
|
-
|
|
175
|
-
### Sessions
|
|
176
|
-
|
|
177
|
-
Sessions are saved automatically when session logging is enabled. You can name, list, and resume them.
|
|
178
|
-
|
|
179
|
-
```
|
|
180
|
-
ai> /name sf_user_123_calendar
|
|
181
|
-
Session named: sf_user_123_calendar
|
|
182
|
-
ai> exit
|
|
183
|
-
Session #42 saved.
|
|
184
|
-
Resume with: ai_resume "sf_user_123_calendar"
|
|
185
|
-
Left ConsoleAgent interactive mode.
|
|
186
|
-
```
|
|
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
|
+
| `/cost` | Show per-model cost breakdown |
|
|
81
|
+
| `/think` | Upgrade to thinking model (Opus) for the rest of the session |
|
|
82
|
+
| `/debug` | Toggle raw API output |
|
|
83
|
+
| `/name <label>` | Name the session for easy resume |
|
|
187
84
|
|
|
188
|
-
|
|
85
|
+
Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
|
|
189
86
|
|
|
190
|
-
|
|
191
|
-
irb> ai_sessions
|
|
192
|
-
[Sessions — showing 3]
|
|
87
|
+
Say "think harder" in any query to auto-upgrade to the thinking model for that session. After 5+ tool rounds, you'll also be prompted to switch.
|
|
193
88
|
|
|
194
|
-
|
|
195
|
-
[interactive] 5m ago 2340 tokens
|
|
89
|
+
## Features
|
|
196
90
|
|
|
197
|
-
|
|
198
|
-
|
|
91
|
+
- **Tool use** — AI introspects your schema, models, files, and code to write accurate queries
|
|
92
|
+
- **Multi-step plans** — complex tasks are broken into steps, executed sequentially with `step1`/`step2` references
|
|
93
|
+
- **Two-tier models** — defaults to Sonnet for speed/cost; `/think` upgrades to Opus when you need it
|
|
94
|
+
- **Cost tracking** — `/cost` shows per-model token usage and estimated spend
|
|
95
|
+
- **Memories** — AI saves what it learns about your app across sessions
|
|
96
|
+
- **App guide** — `ai_init` generates a guide injected into every system prompt
|
|
97
|
+
- **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
|
|
98
|
+
- **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
|
|
199
99
|
|
|
200
|
-
|
|
201
|
-
[interactive] 2h ago 4100 tokens
|
|
100
|
+
## Configuration
|
|
202
101
|
|
|
203
|
-
|
|
102
|
+
```ruby
|
|
103
|
+
ConsoleAgent.configure do |config|
|
|
104
|
+
config.provider = :anthropic # or :openai
|
|
105
|
+
config.auto_execute = false # true to skip confirmations
|
|
106
|
+
config.session_logging = true # requires ai_setup
|
|
107
|
+
config.model = 'claude-sonnet-4-6' # model used by /think (default)
|
|
108
|
+
config.thinking_model = 'claude-opus-4-6' # model used by /think (default)
|
|
109
|
+
end
|
|
204
110
|
```
|
|
205
111
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
```
|
|
209
|
-
irb> ai_resume "sf_user_123_calendar"
|
|
210
|
-
--- Replaying previous session output ---
|
|
211
|
-
ai> find user 123 with calendar issues
|
|
212
|
-
...previous output...
|
|
213
|
-
--- End of previous output ---
|
|
214
|
-
|
|
215
|
-
ConsoleAgent interactive mode (sf_user_123_calendar). Type 'exit' to leave.
|
|
216
|
-
ai> now check their calendar sync status
|
|
217
|
-
...
|
|
218
|
-
```
|
|
112
|
+
The default model is `claude-sonnet-4-6` (Anthropic) or `gpt-5.3-codex` (OpenAI). The thinking model defaults to `claude-opus-4-6` and is activated via `/think` or by saying "think harder".
|
|
219
113
|
|
|
220
|
-
|
|
114
|
+
## Web UI Authentication
|
|
221
115
|
|
|
222
|
-
|
|
223
|
-
irb> ai_name 41, "active_user_count"
|
|
224
|
-
Session #41 named: active_user_count
|
|
225
|
-
```
|
|
116
|
+
The engine mounts a session viewer at `/console_agent`. By default it's open — you can protect it with basic auth or a custom authentication function.
|
|
226
117
|
|
|
227
|
-
|
|
118
|
+
### Basic Auth
|
|
228
119
|
|
|
120
|
+
```ruby
|
|
121
|
+
ConsoleAgent.configure do |config|
|
|
122
|
+
config.admin_username = 'admin'
|
|
123
|
+
config.admin_password = ENV['CONSOLE_AGENT_PASSWORD']
|
|
124
|
+
end
|
|
229
125
|
```
|
|
230
|
-
irb> ai_sessions 20, search: "salesforce"
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
If you have an existing `console_agent_sessions` table, run `ConsoleAgent.migrate!` to add the `name` column.
|
|
234
126
|
|
|
235
|
-
|
|
127
|
+
### Custom Authentication
|
|
236
128
|
|
|
237
|
-
|
|
129
|
+
For apps with their own auth system, pass a proc to `authenticate`. It runs in the controller context, so you have access to `session`, `request`, `redirect_to`, etc.
|
|
238
130
|
|
|
239
131
|
```ruby
|
|
240
132
|
ConsoleAgent.configure do |config|
|
|
241
|
-
config.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
133
|
+
config.authenticate = proc {
|
|
134
|
+
user = User.find_by(id: session[:user_id])
|
|
135
|
+
unless user&.admin?
|
|
136
|
+
redirect_to '/login'
|
|
137
|
+
end
|
|
138
|
+
}
|
|
246
139
|
end
|
|
247
140
|
```
|
|
248
141
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
```ruby
|
|
252
|
-
mount ConsoleAgent::Engine => '/console_agent'
|
|
253
|
-
```
|
|
142
|
+
When `authenticate` is set, `admin_username` / `admin_password` are ignored.
|
|
254
143
|
|
|
255
144
|
## Requirements
|
|
256
145
|
|
|
257
|
-
|
|
146
|
+
Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0
|
|
258
147
|
|
|
259
148
|
## License
|
|
260
149
|
|
|
@@ -2,19 +2,23 @@ module ConsoleAgent
|
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
3
|
protect_from_forgery with: :exception
|
|
4
4
|
|
|
5
|
-
before_action :
|
|
5
|
+
before_action :console_agent_authenticate!
|
|
6
6
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
def console_agent_authenticate!
|
|
10
|
+
if (auth = ConsoleAgent.configuration.authenticate)
|
|
11
|
+
instance_exec(&auth)
|
|
12
|
+
else
|
|
13
|
+
username = ConsoleAgent.configuration.admin_username
|
|
14
|
+
password = ConsoleAgent.configuration.admin_password
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
return unless username && password
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
authenticate_or_request_with_http_basic('ConsoleAgent Admin') do |u, p|
|
|
19
|
+
ActiveSupport::SecurityUtils.secure_compare(u, username) &
|
|
20
|
+
ActiveSupport::SecurityUtils.secure_compare(p, password)
|
|
21
|
+
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -2,29 +2,44 @@ module ConsoleAgent
|
|
|
2
2
|
class Configuration
|
|
3
3
|
PROVIDERS = %i[anthropic openai].freeze
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
PRICING = {
|
|
6
|
+
'claude-sonnet-4-6' => { input: 3.0 / 1_000_000, output: 15.0 / 1_000_000 },
|
|
7
|
+
'claude-opus-4-6' => { input: 15.0 / 1_000_000, output: 75.0 / 1_000_000 },
|
|
8
|
+
'claude-haiku-4-5-20251001' => { input: 0.80 / 1_000_000, output: 4.0 / 1_000_000 },
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
DEFAULT_MAX_TOKENS = {
|
|
12
|
+
'claude-sonnet-4-6' => 16_000,
|
|
13
|
+
'claude-haiku-4-5-20251001' => 16_000,
|
|
14
|
+
'claude-opus-4-6' => 4_096,
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
|
|
6
18
|
:auto_execute, :temperature,
|
|
7
19
|
:timeout, :debug, :max_tool_rounds,
|
|
8
20
|
:storage_adapter, :memories_enabled,
|
|
9
21
|
:session_logging, :connection_class,
|
|
10
|
-
:admin_username, :admin_password
|
|
22
|
+
:admin_username, :admin_password,
|
|
23
|
+
:authenticate
|
|
11
24
|
|
|
12
25
|
def initialize
|
|
13
26
|
@provider = :anthropic
|
|
14
27
|
@api_key = nil
|
|
15
28
|
@model = nil
|
|
16
|
-
@
|
|
29
|
+
@thinking_model = nil
|
|
30
|
+
@max_tokens = nil
|
|
17
31
|
@auto_execute = false
|
|
18
32
|
@temperature = 0.2
|
|
19
33
|
@timeout = 30
|
|
20
34
|
@debug = false
|
|
21
|
-
@max_tool_rounds =
|
|
35
|
+
@max_tool_rounds = 200
|
|
22
36
|
@storage_adapter = nil
|
|
23
37
|
@memories_enabled = true
|
|
24
38
|
@session_logging = true
|
|
25
39
|
@connection_class = nil
|
|
26
40
|
@admin_username = nil
|
|
27
41
|
@admin_password = nil
|
|
42
|
+
@authenticate = nil
|
|
28
43
|
end
|
|
29
44
|
|
|
30
45
|
def resolved_api_key
|
|
@@ -41,6 +56,23 @@ module ConsoleAgent
|
|
|
41
56
|
def resolved_model
|
|
42
57
|
return @model if @model && !@model.empty?
|
|
43
58
|
|
|
59
|
+
case @provider
|
|
60
|
+
when :anthropic
|
|
61
|
+
'claude-sonnet-4-6'
|
|
62
|
+
when :openai
|
|
63
|
+
'gpt-5.3-codex'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolved_max_tokens
|
|
68
|
+
return @max_tokens if @max_tokens
|
|
69
|
+
|
|
70
|
+
DEFAULT_MAX_TOKENS.fetch(resolved_model, 4096)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolved_thinking_model
|
|
74
|
+
return @thinking_model if @thinking_model && !@thinking_model.empty?
|
|
75
|
+
|
|
44
76
|
case @provider
|
|
45
77
|
when :anthropic
|
|
46
78
|
'claude-opus-4-6'
|
|
@@ -130,6 +130,10 @@ module ConsoleAgent
|
|
|
130
130
|
nil
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
def ai_setup
|
|
134
|
+
ConsoleAgent.setup!
|
|
135
|
+
end
|
|
136
|
+
|
|
133
137
|
def ai_init
|
|
134
138
|
require 'console_agent/context_builder'
|
|
135
139
|
require 'console_agent/providers/base'
|
|
@@ -153,6 +157,7 @@ module ConsoleAgent
|
|
|
153
157
|
$stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
|
|
154
158
|
$stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
|
|
155
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"
|
|
156
161
|
$stderr.puts "\e[33m ai_status - show current configuration\e[0m"
|
|
157
162
|
$stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
|
|
158
163
|
return nil
|
|
@@ -249,6 +254,14 @@ module ConsoleAgent
|
|
|
249
254
|
end
|
|
250
255
|
|
|
251
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
|
+
|
|
252
265
|
# Try IRB workspace binding
|
|
253
266
|
if defined?(IRB) && IRB.respond_to?(:CurrentContext)
|
|
254
267
|
ctx = IRB.CurrentContext rescue nil
|
|
@@ -257,14 +270,6 @@ module ConsoleAgent
|
|
|
257
270
|
end
|
|
258
271
|
end
|
|
259
272
|
|
|
260
|
-
# Try Pry binding
|
|
261
|
-
if defined?(Pry) && respond_to?(:pry_instance, true)
|
|
262
|
-
pry_inst = pry_instance rescue nil
|
|
263
|
-
if pry_inst && pry_inst.respond_to?(:current_binding)
|
|
264
|
-
return pry_inst.current_binding
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
|
|
268
273
|
# Fallback
|
|
269
274
|
TOPLEVEL_BINDING
|
|
270
275
|
end
|
|
@@ -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
|
data/lib/console_agent/repl.rb
CHANGED
|
@@ -11,6 +11,7 @@ module ConsoleAgent
|
|
|
11
11
|
@history = []
|
|
12
12
|
@total_input_tokens = 0
|
|
13
13
|
@total_output_tokens = 0
|
|
14
|
+
@token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
|
|
14
15
|
@input_history = []
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -18,29 +19,25 @@ module ConsoleAgent
|
|
|
18
19
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
20
|
console_capture = StringIO.new
|
|
20
21
|
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?
|
|
22
|
+
conversation = [{ role: :user, content: query }]
|
|
23
|
+
exec_result, code, executed = one_shot_round(conversation)
|
|
24
|
+
|
|
25
|
+
# Auto-retry once if execution errored
|
|
26
|
+
if executed && @executor.last_error
|
|
27
|
+
error_msg = "Code execution failed with error: #{@executor.last_error}"
|
|
28
|
+
error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
|
|
29
|
+
conversation << { role: :assistant, content: @_last_result_text }
|
|
30
|
+
conversation << { role: :user, content: error_msg }
|
|
31
|
+
|
|
32
|
+
$stdout.puts "\e[2m Attempting to fix...\e[0m"
|
|
33
|
+
exec_result, code, executed = one_shot_round(conversation)
|
|
37
34
|
end
|
|
38
35
|
|
|
39
36
|
@_last_log_attrs = {
|
|
40
37
|
query: query,
|
|
41
|
-
conversation:
|
|
38
|
+
conversation: conversation,
|
|
42
39
|
mode: 'one_shot',
|
|
43
|
-
code_executed:
|
|
40
|
+
code_executed: code,
|
|
44
41
|
code_output: executed ? @executor.last_output : nil,
|
|
45
42
|
code_result: executed && exec_result ? exec_result.inspect : nil,
|
|
46
43
|
executed: executed,
|
|
@@ -61,6 +58,31 @@ module ConsoleAgent
|
|
|
61
58
|
nil
|
|
62
59
|
end
|
|
63
60
|
|
|
61
|
+
# Executes one LLM round: send query, display, optionally execute code.
|
|
62
|
+
# Returns [exec_result, code, executed].
|
|
63
|
+
def one_shot_round(conversation)
|
|
64
|
+
result, _ = send_query(nil, conversation: conversation)
|
|
65
|
+
track_usage(result)
|
|
66
|
+
code = @executor.display_response(result.text)
|
|
67
|
+
display_usage(result)
|
|
68
|
+
@_last_result_text = result.text
|
|
69
|
+
|
|
70
|
+
exec_result = nil
|
|
71
|
+
executed = false
|
|
72
|
+
has_code = code && !code.strip.empty?
|
|
73
|
+
|
|
74
|
+
if has_code
|
|
75
|
+
exec_result = if ConsoleAgent.configuration.auto_execute
|
|
76
|
+
@executor.execute(code)
|
|
77
|
+
else
|
|
78
|
+
@executor.confirm_and_execute(code)
|
|
79
|
+
end
|
|
80
|
+
executed = !@executor.last_cancelled?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[exec_result, has_code ? code : nil, executed]
|
|
84
|
+
end
|
|
85
|
+
|
|
64
86
|
def explain(query)
|
|
65
87
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
88
|
console_capture = StringIO.new
|
|
@@ -188,6 +210,7 @@ module ConsoleAgent
|
|
|
188
210
|
@history = []
|
|
189
211
|
@total_input_tokens = 0
|
|
190
212
|
@total_output_tokens = 0
|
|
213
|
+
@token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
|
|
191
214
|
@interactive_query = nil
|
|
192
215
|
@interactive_session_id = nil
|
|
193
216
|
@interactive_session_name = nil
|
|
@@ -195,6 +218,7 @@ module ConsoleAgent
|
|
|
195
218
|
@last_interactive_output = nil
|
|
196
219
|
@last_interactive_result = nil
|
|
197
220
|
@last_interactive_executed = false
|
|
221
|
+
@compact_warned = false
|
|
198
222
|
end
|
|
199
223
|
|
|
200
224
|
def interactive_loop
|
|
@@ -202,7 +226,7 @@ module ConsoleAgent
|
|
|
202
226
|
name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
|
|
203
227
|
# Write banner to real stdout (bypass TeeIO) so it doesn't accumulate on resume
|
|
204
228
|
@interactive_old_stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
|
|
205
|
-
@interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | > code
|
|
229
|
+
@interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | > code | /usage | /cost | /compact | /think | /name <label>\e[0m"
|
|
206
230
|
|
|
207
231
|
# Bind Shift-Tab to insert /auto command and submit
|
|
208
232
|
if Readline.respond_to?(:parse_and_bind)
|
|
@@ -236,6 +260,21 @@ module ConsoleAgent
|
|
|
236
260
|
next
|
|
237
261
|
end
|
|
238
262
|
|
|
263
|
+
if input == '/compact'
|
|
264
|
+
compact_history
|
|
265
|
+
next
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
if input == '/cost'
|
|
269
|
+
display_cost_summary
|
|
270
|
+
next
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if input == '/think'
|
|
274
|
+
upgrade_to_thinking_model
|
|
275
|
+
next
|
|
276
|
+
end
|
|
277
|
+
|
|
239
278
|
if input.start_with?('/name')
|
|
240
279
|
name = input.sub('/name', '').strip
|
|
241
280
|
if name.empty?
|
|
@@ -287,6 +326,11 @@ module ConsoleAgent
|
|
|
287
326
|
# Add to Readline history (avoid consecutive duplicates)
|
|
288
327
|
Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
|
|
289
328
|
|
|
329
|
+
# Auto-upgrade to thinking model on "think harder" phrases
|
|
330
|
+
if input =~ /think\s*harder/i
|
|
331
|
+
upgrade_to_thinking_model
|
|
332
|
+
end
|
|
333
|
+
|
|
290
334
|
@interactive_query ||= input
|
|
291
335
|
@history << { role: :user, content: input }
|
|
292
336
|
|
|
@@ -296,65 +340,24 @@ module ConsoleAgent
|
|
|
296
340
|
# Save immediately so the session is visible in the admin UI while the AI thinks
|
|
297
341
|
log_interactive_turn
|
|
298
342
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
rescue Interrupt
|
|
302
|
-
$stdout.puts "\n\e[33m Aborted.\e[0m"
|
|
343
|
+
status = send_and_execute
|
|
344
|
+
if status == :interrupted
|
|
303
345
|
@history.pop # Remove the user message that never got a response
|
|
304
346
|
log_interactive_turn
|
|
305
347
|
next
|
|
306
348
|
end
|
|
307
349
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
log_interactive_turn
|
|
314
|
-
|
|
315
|
-
# Add tool call/result messages so the LLM remembers what it learned
|
|
316
|
-
@history.concat(tool_messages) if tool_messages && !tool_messages.empty?
|
|
317
|
-
@history << { role: :assistant, content: result.text }
|
|
318
|
-
|
|
319
|
-
if code && !code.strip.empty?
|
|
320
|
-
if ConsoleAgent.configuration.auto_execute
|
|
321
|
-
exec_result = @executor.execute(code)
|
|
322
|
-
else
|
|
323
|
-
exec_result = @executor.confirm_and_execute(code)
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
unless @executor.last_cancelled?
|
|
327
|
-
@last_interactive_code = code
|
|
328
|
-
@last_interactive_output = @executor.last_output
|
|
329
|
-
@last_interactive_result = exec_result ? exec_result.inspect : nil
|
|
330
|
-
@last_interactive_executed = true
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
if @executor.last_cancelled?
|
|
334
|
-
@history << { role: :user, content: "User declined to execute the code." }
|
|
335
|
-
else
|
|
336
|
-
output_parts = []
|
|
337
|
-
|
|
338
|
-
# Capture printed output (puts, print, etc.)
|
|
339
|
-
if @executor.last_output && !@executor.last_output.strip.empty?
|
|
340
|
-
output_parts << "Output:\n#{@executor.last_output.strip}"
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
# Capture return value
|
|
344
|
-
if exec_result
|
|
345
|
-
output_parts << "Return value: #{exec_result.inspect}"
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
unless output_parts.empty?
|
|
349
|
-
result_str = output_parts.join("\n\n")
|
|
350
|
-
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
351
|
-
@history << { role: :user, content: "Code was executed. #{result_str}" }
|
|
352
|
-
end
|
|
353
|
-
end
|
|
350
|
+
# Auto-retry once when execution fails — send error back to LLM for a fix
|
|
351
|
+
if status == :error
|
|
352
|
+
$stdout.puts "\e[2m Attempting to fix...\e[0m"
|
|
353
|
+
log_interactive_turn
|
|
354
|
+
send_and_execute
|
|
354
355
|
end
|
|
355
356
|
|
|
356
357
|
# Update with the AI response, tokens, and any execution results
|
|
357
358
|
log_interactive_turn
|
|
359
|
+
|
|
360
|
+
warn_if_history_large
|
|
358
361
|
end
|
|
359
362
|
|
|
360
363
|
$stdout = @interactive_old_stdout
|
|
@@ -374,6 +377,87 @@ module ConsoleAgent
|
|
|
374
377
|
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
375
378
|
end
|
|
376
379
|
|
|
380
|
+
# Sends conversation to LLM, displays response, executes code if present.
|
|
381
|
+
# Returns :success, :error, :cancelled, :no_code, or :interrupted.
|
|
382
|
+
def send_and_execute
|
|
383
|
+
begin
|
|
384
|
+
result, tool_messages = send_query(nil, conversation: @history)
|
|
385
|
+
rescue Providers::ProviderError => e
|
|
386
|
+
if e.message.include?("prompt is too long") && @history.length >= 6
|
|
387
|
+
$stdout.puts "\e[33m Context limit reached. Auto-compacting history...\e[0m"
|
|
388
|
+
compact_history
|
|
389
|
+
begin
|
|
390
|
+
result, tool_messages = send_query(nil, conversation: @history)
|
|
391
|
+
rescue Providers::ProviderError => e2
|
|
392
|
+
$stderr.puts "\e[31m Still too large after compaction: #{e2.message}\e[0m"
|
|
393
|
+
return :error
|
|
394
|
+
end
|
|
395
|
+
else
|
|
396
|
+
$stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
|
|
397
|
+
return :error
|
|
398
|
+
end
|
|
399
|
+
rescue Interrupt
|
|
400
|
+
$stdout.puts "\n\e[33m Aborted.\e[0m"
|
|
401
|
+
return :interrupted
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
track_usage(result)
|
|
405
|
+
code = @executor.display_response(result.text)
|
|
406
|
+
display_usage(result, show_session: true)
|
|
407
|
+
|
|
408
|
+
# Save after response is displayed so viewer shows progress before Execute prompt
|
|
409
|
+
log_interactive_turn
|
|
410
|
+
|
|
411
|
+
# Add tool call/result messages so the LLM remembers what it learned
|
|
412
|
+
@history.concat(tool_messages) if tool_messages && !tool_messages.empty?
|
|
413
|
+
@history << { role: :assistant, content: result.text }
|
|
414
|
+
|
|
415
|
+
return :no_code unless code && !code.strip.empty?
|
|
416
|
+
|
|
417
|
+
exec_result = if ConsoleAgent.configuration.auto_execute
|
|
418
|
+
@executor.execute(code)
|
|
419
|
+
else
|
|
420
|
+
@executor.confirm_and_execute(code)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
unless @executor.last_cancelled?
|
|
424
|
+
@last_interactive_code = code
|
|
425
|
+
@last_interactive_output = @executor.last_output
|
|
426
|
+
@last_interactive_result = exec_result ? exec_result.inspect : nil
|
|
427
|
+
@last_interactive_executed = true
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
if @executor.last_cancelled?
|
|
431
|
+
@history << { role: :user, content: "User declined to execute the code." }
|
|
432
|
+
:cancelled
|
|
433
|
+
elsif @executor.last_error
|
|
434
|
+
error_msg = "Code execution failed with error: #{@executor.last_error}"
|
|
435
|
+
error_msg = error_msg[0..1000] + '...' if error_msg.length > 1000
|
|
436
|
+
@history << { role: :user, content: error_msg }
|
|
437
|
+
:error
|
|
438
|
+
else
|
|
439
|
+
output_parts = []
|
|
440
|
+
|
|
441
|
+
# Capture printed output (puts, print, etc.)
|
|
442
|
+
if @executor.last_output && !@executor.last_output.strip.empty?
|
|
443
|
+
output_parts << "Output:\n#{@executor.last_output.strip}"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Capture return value
|
|
447
|
+
if exec_result
|
|
448
|
+
output_parts << "Return value: #{exec_result.inspect}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
unless output_parts.empty?
|
|
452
|
+
result_str = output_parts.join("\n\n")
|
|
453
|
+
result_str = result_str[0..1000] + '...' if result_str.length > 1000
|
|
454
|
+
@history << { role: :user, content: "Code was executed. #{result_str}" }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
:success
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
377
461
|
def provider
|
|
378
462
|
@provider ||= Providers.build
|
|
379
463
|
end
|
|
@@ -383,7 +467,32 @@ module ConsoleAgent
|
|
|
383
467
|
end
|
|
384
468
|
|
|
385
469
|
def context
|
|
386
|
-
@
|
|
470
|
+
base = @context_base ||= context_builder.build
|
|
471
|
+
vars = binding_variable_summary
|
|
472
|
+
vars ? "#{base}\n\n#{vars}" : base
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Summarize local and instance variables from the user's console session
|
|
476
|
+
# so the LLM knows what's available to reference in generated code.
|
|
477
|
+
def binding_variable_summary
|
|
478
|
+
parts = []
|
|
479
|
+
|
|
480
|
+
locals = @binding_context.local_variables.reject { |v| v.to_s.start_with?('_') }
|
|
481
|
+
locals.first(20).each do |var|
|
|
482
|
+
val = @binding_context.local_variable_get(var) rescue nil
|
|
483
|
+
parts << "#{var} (#{val.class})"
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
ivars = (@binding_context.eval("instance_variables") rescue [])
|
|
487
|
+
ivars.reject { |v| v.to_s =~ /\A@_/ }.first(20).each do |var|
|
|
488
|
+
val = @binding_context.eval(var.to_s) rescue nil
|
|
489
|
+
parts << "#{var} (#{val.class})"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
return nil if parts.empty?
|
|
493
|
+
"The user's console session has these variables available: #{parts.join(', ')}. You can reference them directly in code."
|
|
494
|
+
rescue
|
|
495
|
+
nil
|
|
387
496
|
end
|
|
388
497
|
|
|
389
498
|
def init_system_prompt(existing_guide)
|
|
@@ -455,8 +564,18 @@ module ConsoleAgent
|
|
|
455
564
|
last_tool_names = []
|
|
456
565
|
|
|
457
566
|
exhausted = false
|
|
567
|
+
thinking_suggested = false
|
|
458
568
|
|
|
459
569
|
max_rounds.times do |round|
|
|
570
|
+
if round == 5 && !thinking_suggested && !on_thinking_model?
|
|
571
|
+
thinking_suggested = true
|
|
572
|
+
thinking_name = ConsoleAgent.configuration.resolved_thinking_model
|
|
573
|
+
$stdout.puts "\e[33m This query is using many tool rounds. Switch to thinking model (#{thinking_name})? [y/N]\e[0m"
|
|
574
|
+
answer = Readline.readline(" ", false).to_s.strip.downcase
|
|
575
|
+
if answer == 'y'
|
|
576
|
+
upgrade_to_thinking_model
|
|
577
|
+
end
|
|
578
|
+
end
|
|
460
579
|
if round == 0
|
|
461
580
|
$stdout.puts "\e[2m Thinking...\e[0m"
|
|
462
581
|
else
|
|
@@ -469,8 +588,22 @@ module ConsoleAgent
|
|
|
469
588
|
$stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
|
|
470
589
|
end
|
|
471
590
|
|
|
472
|
-
|
|
473
|
-
|
|
591
|
+
begin
|
|
592
|
+
result = with_escape_monitoring do
|
|
593
|
+
provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
|
|
594
|
+
end
|
|
595
|
+
rescue Providers::ProviderError => e
|
|
596
|
+
if e.message.include?("prompt is too long") && messages.length >= 6
|
|
597
|
+
$stdout.puts "\e[33m Context limit hit mid-session. Compacting messages...\e[0m"
|
|
598
|
+
messages = compact_messages(messages)
|
|
599
|
+
unless @_retried_compact
|
|
600
|
+
@_retried_compact = true
|
|
601
|
+
retry
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
raise
|
|
605
|
+
ensure
|
|
606
|
+
@_retried_compact = nil
|
|
474
607
|
end
|
|
475
608
|
total_input += result.input_tokens || 0
|
|
476
609
|
total_output += result.output_tokens || 0
|
|
@@ -698,6 +831,10 @@ module ConsoleAgent
|
|
|
698
831
|
def track_usage(result)
|
|
699
832
|
@total_input_tokens += result.input_tokens || 0
|
|
700
833
|
@total_output_tokens += result.output_tokens || 0
|
|
834
|
+
|
|
835
|
+
model = ConsoleAgent.configuration.resolved_model
|
|
836
|
+
@token_usage[model][:input] += result.input_tokens || 0
|
|
837
|
+
@token_usage[model][:output] += result.output_tokens || 0
|
|
701
838
|
end
|
|
702
839
|
|
|
703
840
|
def display_usage(result, show_session: false)
|
|
@@ -805,6 +942,129 @@ module ConsoleAgent
|
|
|
805
942
|
$stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
|
|
806
943
|
end
|
|
807
944
|
|
|
945
|
+
def display_cost_summary
|
|
946
|
+
if @token_usage.empty?
|
|
947
|
+
$stdout.puts "\e[2m No usage yet.\e[0m"
|
|
948
|
+
return
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
total_cost = 0.0
|
|
952
|
+
$stdout.puts "\e[36m Cost estimate:\e[0m"
|
|
953
|
+
|
|
954
|
+
@token_usage.each do |model, usage|
|
|
955
|
+
pricing = Configuration::PRICING[model]
|
|
956
|
+
input_str = "in: #{format_tokens(usage[:input])}"
|
|
957
|
+
output_str = "out: #{format_tokens(usage[:output])}"
|
|
958
|
+
|
|
959
|
+
if pricing
|
|
960
|
+
cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
|
|
961
|
+
total_cost += cost
|
|
962
|
+
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str} ~$#{'%.2f' % cost}\e[0m"
|
|
963
|
+
else
|
|
964
|
+
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str} (pricing unknown)\e[0m"
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
$stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
def upgrade_to_thinking_model
|
|
972
|
+
config = ConsoleAgent.configuration
|
|
973
|
+
current = config.resolved_model
|
|
974
|
+
thinking = config.resolved_thinking_model
|
|
975
|
+
|
|
976
|
+
if current == thinking
|
|
977
|
+
$stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
|
|
978
|
+
else
|
|
979
|
+
config.model = thinking
|
|
980
|
+
@provider = nil
|
|
981
|
+
$stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def on_thinking_model?
|
|
986
|
+
config = ConsoleAgent.configuration
|
|
987
|
+
config.resolved_model == config.resolved_thinking_model
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def warn_if_history_large
|
|
991
|
+
chars = @history.sum { |m| m[:content].to_s.length }
|
|
992
|
+
|
|
993
|
+
if chars > 120_000 && @history.length >= 6
|
|
994
|
+
$stdout.puts "\e[33m Context growing large (~#{format_tokens(chars)} chars). Auto-compacting...\e[0m"
|
|
995
|
+
compact_history
|
|
996
|
+
elsif chars > 50_000 && !@compact_warned
|
|
997
|
+
@compact_warned = true
|
|
998
|
+
$stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
def compact_history
|
|
1003
|
+
if @history.length < 6
|
|
1004
|
+
$stdout.puts "\e[33m History too short to compact (#{@history.length} messages). Need at least 6.\e[0m"
|
|
1005
|
+
return
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
before_chars = @history.sum { |m| m[:content].to_s.length }
|
|
1009
|
+
before_count = @history.length
|
|
1010
|
+
|
|
1011
|
+
$stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
|
|
1012
|
+
|
|
1013
|
+
system_prompt = <<~PROMPT
|
|
1014
|
+
You are a conversation summarizer. The user will provide a conversation history from a Rails console AI assistant session.
|
|
1015
|
+
|
|
1016
|
+
Produce a concise summary that captures:
|
|
1017
|
+
- What the user has been working on and their goals
|
|
1018
|
+
- Key findings and data discovered (include specific values, IDs, record counts)
|
|
1019
|
+
- Current state: what worked, what failed, where things stand
|
|
1020
|
+
- Important variable names, model names, or table names referenced
|
|
1021
|
+
- Any code that was executed and its results
|
|
1022
|
+
|
|
1023
|
+
Be concise but preserve all information that would be needed to continue the conversation naturally.
|
|
1024
|
+
Do NOT include any preamble — just output the summary directly.
|
|
1025
|
+
PROMPT
|
|
1026
|
+
|
|
1027
|
+
history_text = @history.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
|
|
1028
|
+
messages = [{ role: :user, content: "Summarize this conversation history:\n\n#{history_text}" }]
|
|
1029
|
+
|
|
1030
|
+
begin
|
|
1031
|
+
result = provider.chat(messages, system_prompt: system_prompt)
|
|
1032
|
+
track_usage(result)
|
|
1033
|
+
|
|
1034
|
+
summary = result.text.to_s.strip
|
|
1035
|
+
if summary.empty?
|
|
1036
|
+
$stdout.puts "\e[33m Compaction failed: empty summary returned.\e[0m"
|
|
1037
|
+
return
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
@history = [{ role: :user, content: "CONVERSATION SUMMARY (compacted):\n#{summary}" }]
|
|
1041
|
+
@compact_warned = false
|
|
1042
|
+
|
|
1043
|
+
after_chars = @history.first[:content].length
|
|
1044
|
+
$stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
|
|
1045
|
+
summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
|
|
1046
|
+
display_usage(result)
|
|
1047
|
+
rescue => e
|
|
1048
|
+
$stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
def compact_messages(messages)
|
|
1053
|
+
return messages if messages.length < 6
|
|
1054
|
+
|
|
1055
|
+
to_summarize = messages[0...-4]
|
|
1056
|
+
to_keep = messages[-4..]
|
|
1057
|
+
|
|
1058
|
+
history_text = to_summarize.map { |m| "#{m[:role]}: #{m[:content].to_s[0..500]}" }.join("\n\n")
|
|
1059
|
+
|
|
1060
|
+
summary_result = provider.chat(
|
|
1061
|
+
[{ role: :user, content: "Summarize this conversation context concisely, preserving key facts, IDs, and findings:\n\n#{history_text}" }],
|
|
1062
|
+
system_prompt: "You are a conversation summarizer. Be concise but preserve all actionable information."
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
[{ role: :user, content: "CONTEXT SUMMARY:\n#{summary_result.text}" }] + to_keep
|
|
1066
|
+
end
|
|
1067
|
+
|
|
808
1068
|
def display_exit_info
|
|
809
1069
|
display_session_summary
|
|
810
1070
|
if @interactive_session_id
|
|
@@ -339,8 +339,12 @@ module ConsoleAgent
|
|
|
339
339
|
# Make result available as step1, step2, etc. for subsequent steps
|
|
340
340
|
@executor.binding_context.local_variable_set(:"step#{i + 1}", exec_result)
|
|
341
341
|
output = @executor.last_output
|
|
342
|
+
error = @executor.last_error
|
|
342
343
|
|
|
343
344
|
step_report = "Step #{i + 1} (#{step['description']}):\n"
|
|
345
|
+
if error
|
|
346
|
+
step_report += "ERROR: #{error}\n"
|
|
347
|
+
end
|
|
344
348
|
if output && !output.strip.empty?
|
|
345
349
|
step_report += "Output: #{output.strip}\n"
|
|
346
350
|
end
|