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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1d37fc84ac3f2d29832d282e6db1fd394308de2b967843c3a0c9748560a7025
4
- data.tar.gz: 28bd6c25529dbedfa12d2353a1648304517bab933f0a4f25e91285ccaaf08e10
3
+ metadata.gz: fdcfb3c48b2f8421b2187980a453b80324e6dec40ab80e8e55aa1a938355c79c
4
+ data.tar.gz: 12fec02740fde7a87bb81e26dd6857263c96f9ebe5a8402040c72279e882e31f
5
5
  SHA512:
6
- metadata.gz: 6451c8b62eba1159e0233be04dabbc86bcb3b6f1dcbd6cd658c8d5517a27f1ad6a20884d30d87fabcbe75d8a51d12a6dfa2d4a59ed0adb648ce56d3b81765636
7
- data.tar.gz: 3448b25930e4613a4599698d98ec1c5a2d84b113a3221d98239dc0f09351455be817882b308910bfd8bf01eab00d01670c11256624bb7cd7a30745538095c2ea
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, embedded in your Rails console.
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
- The AI calls tools behind the scenes to learn your app — schema, models, associations, source code — so it writes accurate queries without you providing any context.
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
- Each step's return value is available to later steps as `step1`, `step2`, etc.
34
+ No context needed from you it figures out your app on its own.
95
35
 
96
- Plan prompt options:
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
- This app uses database-per-shard. User.count returns the current shard only.
43
+ ```bash
44
+ bundle install
45
+ rails generate console_agent:install
112
46
  ```
113
47
 
114
- Next time, it already knows no re-reading files, fewer tokens.
48
+ Set your API key in the generated initializer or via env var (`ANTHROPIC_API_KEY`):
115
49
 
116
- ### Application Guide
117
-
118
- Run `ai_init` to have the AI explore your app and generate a guide that gets loaded into every future conversation:
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
- The guide is a markdown file covering your app's models, relationships, data architecture, and gotchas. Unlike memories (which require a tool call to recall), the guide is injected directly into the system prompt — so the AI starts every session already knowing your app.
57
+ ## Commands
137
58
 
138
- Run `ai_init` again anytime to update it.
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
- Prefix any input with `>` to run Ruby code directly — no LLM round-trip. The result is added to the conversation context, so the AI knows what happened:
163
-
164
- ```
165
- ai> >User.count
166
- => 8
167
- ai> how many users do I have?
168
- Thinking...
169
-
170
- You have **8 users** in your database, as confirmed by the `User.count` you just ran.
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
- List recent sessions:
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
- #42 sf_user_123_calendar find user 123 with calendar issues
195
- [interactive] 5m ago 2340 tokens
89
+ ## Features
196
90
 
197
- #41 count all active users
198
- [one_shot] 1h ago 850 tokens
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
- #40 debug_payments explain payment flow
201
- [interactive] 2h ago 4100 tokens
100
+ ## Configuration
202
101
 
203
- Use ai_resume(id_or_name) to resume a session.
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
- Resume a session by name or ID previous output is replayed, then you continue where you left off:
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
- Name or rename a session after the fact:
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
- Filter sessions by search term:
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
- ## Configuration
127
+ ### Custom Authentication
236
128
 
237
- All settings live in `config/initializers/console_agent.rb` and can be changed at runtime:
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.provider = :anthropic # or :openai
242
- config.auto_execute = false # true to skip confirmations
243
- config.max_tokens = 4096 # max tokens per LLM response
244
- config.max_tool_rounds = 10 # max tool calls per query
245
- config.session_logging = true # log sessions to DB (run ConsoleAgent.setup!)
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
- For the admin UI, mount the engine:
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
- - Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0
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 :authenticate!
5
+ before_action :console_agent_authenticate!
6
6
 
7
7
  private
8
8
 
9
- def authenticate!
10
- username = ConsoleAgent.configuration.admin_username
11
- password = ConsoleAgent.configuration.admin_password
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
- return unless username && password
16
+ return unless username && password
14
17
 
15
- authenticate_or_request_with_http_basic('ConsoleAgent Admin') do |u, p|
16
- ActiveSupport::SecurityUtils.secure_compare(u, username) &
17
- ActiveSupport::SecurityUtils.secure_compare(p, password)
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
- attr_accessor :provider, :api_key, :model, :max_tokens,
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
- @max_tokens = 4096
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 = 100
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
- $stderr.puts colorize("SyntaxError: #{e.message}", :red)
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
- $stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
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
@@ -50,7 +50,7 @@ module ConsoleAgent
50
50
 
51
51
  body = {
52
52
  model: config.resolved_model,
53
- max_tokens: config.max_tokens,
53
+ max_tokens: config.resolved_max_tokens,
54
54
  temperature: config.temperature,
55
55
  messages: format_messages(messages)
56
56
  }
@@ -50,7 +50,7 @@ module ConsoleAgent
50
50
 
51
51
  body = {
52
52
  model: config.resolved_model,
53
- max_tokens: config.max_tokens,
53
+ max_tokens: config.resolved_max_tokens,
54
54
  temperature: config.temperature,
55
55
  messages: formatted
56
56
  }
@@ -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
- result, _ = send_query(query)
22
- track_usage(result)
23
- code = @executor.display_response(result.text)
24
- display_usage(result)
25
-
26
- exec_result = nil
27
- executed = false
28
- has_code = code && !code.strip.empty?
29
-
30
- if has_code
31
- exec_result = if ConsoleAgent.configuration.auto_execute
32
- @executor.execute(code)
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: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
38
+ conversation: conversation,
42
39
  mode: 'one_shot',
43
- code_executed: has_code ? code : nil,
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 to run directly | /usage | /name <label>\e[0m"
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
- begin
300
- result, tool_messages = send_query(input, conversation: @history)
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
- track_usage(result)
309
- code = @executor.display_response(result.text)
310
- display_usage(result, show_session: true)
311
-
312
- # Save after response is displayed so viewer shows progress before Execute prompt
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
- @context ||= context_builder.build
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
- result = with_escape_monitoring do
473
- provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
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
@@ -1,3 +1,3 @@
1
1
  module ConsoleAgent
2
- VERSION = '0.6.0'.freeze
2
+ VERSION = '0.8.0'.freeze
3
3
  end
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.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr