console_agent 0.7.0 → 0.9.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: 662ece5e5732e350b22871c8029421edf06ed4d9f98183f5cee7e95a3c1099f6
4
- data.tar.gz: 483642598de39d23ace26f5d90863a8712b8799357c7809b2ba770f2bdc2522a
3
+ metadata.gz: 5f82de0e3bdd6d1e3189c3b1bd0c88c386a6b31b4dff317656365352490f06c9
4
+ data.tar.gz: 1347a93e946c254809cdf6bfa90e3dea0378b7aef9a5e04950bdf0151569cd79
5
5
  SHA512:
6
- metadata.gz: 5095d8f4cb0706e84c81be1b4a859de6ac48336ccb9c950feeb0dcf0df676d78fddc53a2314a21cbe6ee9d79b808f5edf0d854f3262b72c5befbb4420d9b979b
7
- data.tar.gz: 4c96f0e1664c1357cc7ec3f53aada9d1dcf4e05cbc92e19edfd052b2e99c75d1596d5a49440b8b9478fd576140b36929589a87e009dd971b4599cfa8ab149520
6
+ metadata.gz: cec5e56aaddea19c5d1b9046fcbd7fd4707d16efa3fab65f9977703e24d28d177c12331c9cba7590048b1ad2f412d67bcb9ffe78c5cef288d6da40916cb53a86
7
+ data.tar.gz: 9e67f0c30bbd38561c400523ca19240f931ff5c08b353fa545037472b6b2195f931b5987b21d3c643a35e2cb3377647447cc676aa7544ba5a2ccec2a505f7e16
data/CHANGELOG.md ADDED
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.8.0]
6
+
7
+ - Add authentication function support so host apps can avoid using basic auth
8
+ - Add `/think` and `/cost` commands with Sonnet vs Opus support
9
+ - Gracefully handle token limit exceeded errors
10
+
11
+ ## [0.7.0]
12
+
13
+ - Include binding variables and their classes in the Rails console context
14
+ - Add `ai_setup` command
15
+ - Add `/compact` mechanism for conversation management
16
+ - Catch errors and attempt to auto-fix them
17
+
18
+ ## [0.6.0]
19
+
20
+ - Add core memory (`console_agent.md`) that persists across sessions in the system prompt
21
+ - Add `ai_init` command to seed core memory
22
+ - Allow reading partial files
23
+ - Fix rspec hanging issues
24
+
25
+ ## [0.5.0]
26
+
27
+ - Auto-accept single-step plans
28
+ - Support `>` shorthand to run code directly
29
+ - Add `script/release` for releases
30
+
31
+ ## [0.4.0]
32
+
33
+ - Fix resuming sessions repeatedly
34
+ - Fix terminal flashing/loading in production (kubectl)
35
+ - Better escaping during thinking output
36
+
37
+ ## [0.3.0]
38
+
39
+ - Add plan mechanism with "auto" execution mode
40
+ - Add session logging to DB with `/console_agent` admin UI
41
+ - List and resume past sessions with pagination
42
+ - Add shift-tab for auto-execute mode
43
+ - Add usage display and debug toggle
44
+ - Store sessions incrementally; improved code segment display
45
+
46
+ ## [0.2.0]
47
+
48
+ - Add memory system with individual file storage
49
+ - Add `ask_user` tool
50
+ - Add registry cache
51
+ - Fix REPL up-key and ctrl-a navigation
52
+ - Show tool usage and model processing info
53
+ - Add token count information and debug ability
54
+ - Use tools-based approach instead of sending everything at once
55
+
56
+ ## [0.1.0]
57
+
58
+ - Initial implementation
data/README.md CHANGED
@@ -77,15 +77,21 @@ end
77
77
  | `/auto` | Toggle auto-execute (skip confirmations) |
78
78
  | `/compact` | Compress history into a summary (saves tokens) |
79
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 |
80
82
  | `/debug` | Toggle raw API output |
81
83
  | `/name <label>` | Name the session for easy resume |
82
84
 
83
85
  Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
84
86
 
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.
88
+
85
89
  ## Features
86
90
 
87
91
  - **Tool use** — AI introspects your schema, models, files, and code to write accurate queries
88
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
89
95
  - **Memories** — AI saves what it learns about your app across sessions
90
96
  - **App guide** — `ai_init` generates a guide injected into every system prompt
91
97
  - **Sessions** — name, list, and resume interactive conversations (`ai_setup` to enable)
@@ -98,9 +104,43 @@ ConsoleAgent.configure do |config|
98
104
  config.provider = :anthropic # or :openai
99
105
  config.auto_execute = false # true to skip confirmations
100
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
110
+ ```
111
+
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".
113
+
114
+ ## Web UI Authentication
115
+
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.
117
+
118
+ ### Basic Auth
119
+
120
+ ```ruby
121
+ ConsoleAgent.configure do |config|
122
+ config.admin_username = 'admin'
123
+ config.admin_password = ENV['CONSOLE_AGENT_PASSWORD']
101
124
  end
102
125
  ```
103
126
 
127
+ ### Custom Authentication
128
+
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.
130
+
131
+ ```ruby
132
+ ConsoleAgent.configure do |config|
133
+ config.authenticate = proc {
134
+ user = User.find_by(id: session[:user_id])
135
+ unless user&.admin?
136
+ redirect_to '/login'
137
+ end
138
+ }
139
+ end
140
+ ```
141
+
142
+ When `authenticate` is set, `admin_username` / `admin_password` are ignored.
143
+
104
144
  ## Requirements
105
145
 
106
146
  Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0
@@ -2,19 +2,26 @@ 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
+ unless username && password
17
+ head :unauthorized
18
+ return
19
+ end
14
20
 
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)
21
+ authenticate_or_request_with_http_basic('ConsoleAgent Admin') do |u, p|
22
+ ActiveSupport::SecurityUtils.secure_compare(u, username) &
23
+ ActiveSupport::SecurityUtils.secure_compare(p, password)
24
+ end
18
25
  end
19
26
  end
20
27
  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'
@@ -84,7 +84,7 @@ module ConsoleAgent
84
84
  result = binding_context.eval(code, "(console_agent)", 1)
85
85
 
86
86
  $stdout = old_stdout
87
- $stdout.puts colorize("=> #{result.inspect}", :green)
87
+ display_result(result)
88
88
 
89
89
  @last_output = captured_output.string
90
90
  result
@@ -126,35 +126,69 @@ module ConsoleAgent
126
126
  @last_answer = answer
127
127
  echo_stdin(answer)
128
128
 
129
- case answer
130
- when 'y', 'yes'
131
- execute(code)
132
- when 'e', 'edit'
133
- edited = open_in_editor(code)
134
- if edited && edited != code
135
- $stdout.puts colorize("# Edited code:", :yellow)
136
- $stdout.puts highlight_code(edited)
137
- $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
138
- edit_answer = $stdin.gets.to_s.strip.downcase
139
- echo_stdin(edit_answer)
140
- if edit_answer == 'y'
141
- execute(edited)
129
+ loop do
130
+ case answer
131
+ when 'y', 'yes', 'a'
132
+ return execute(code)
133
+ when 'e', 'edit'
134
+ edited = open_in_editor(code)
135
+ if edited && edited != code
136
+ $stdout.puts colorize("# Edited code:", :yellow)
137
+ $stdout.puts highlight_code(edited)
138
+ $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
139
+ edit_answer = $stdin.gets.to_s.strip.downcase
140
+ echo_stdin(edit_answer)
141
+ if edit_answer == 'y'
142
+ return execute(edited)
143
+ else
144
+ $stdout.puts colorize("Cancelled.", :yellow)
145
+ return nil
146
+ end
142
147
  else
143
- $stdout.puts colorize("Cancelled.", :yellow)
144
- nil
148
+ return execute(code)
145
149
  end
150
+ when 'n', 'no', ''
151
+ $stdout.puts colorize("Cancelled.", :yellow)
152
+ @last_cancelled = true
153
+ return nil
146
154
  else
147
- execute(code)
155
+ $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
156
+ @on_prompt&.call
157
+ answer = $stdin.gets.to_s.strip.downcase
158
+ @last_answer = answer
159
+ echo_stdin(answer)
148
160
  end
149
- else
150
- $stdout.puts colorize("Cancelled.", :yellow)
151
- @last_cancelled = true
152
- nil
153
161
  end
154
162
  end
155
163
 
156
164
  private
157
165
 
166
+ MAX_DISPLAY_LINES = 10
167
+ MAX_DISPLAY_CHARS = 2000
168
+
169
+ def display_result(result)
170
+ full = "=> #{result.inspect}"
171
+ lines = full.lines
172
+ total_lines = lines.length
173
+ total_chars = full.length
174
+
175
+ if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
176
+ $stdout.puts colorize(full, :green)
177
+ else
178
+ # Truncate by lines first, then by chars
179
+ truncated = lines.first(MAX_DISPLAY_LINES).join
180
+ truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
181
+ $stdout.puts colorize(truncated, :green)
182
+
183
+ omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
184
+ omitted_chars = [total_chars - truncated.length, 0].max
185
+ parts = []
186
+ parts << "#{omitted_lines} lines" if omitted_lines > 0
187
+ parts << "#{omitted_chars} chars" if omitted_chars > 0
188
+ $stdout.puts colorize(" (omitting #{parts.join(', ')})", :yellow)
189
+ end
190
+ end
191
+
158
192
  # Write stdin input to the capture IO only (avoids double-echo on terminal)
159
193
  def echo_stdin(text)
160
194
  $stdout.secondary.write("#{text}\n") if $stdout.respond_to?(:secondary)
@@ -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
 
@@ -209,6 +210,7 @@ module ConsoleAgent
209
210
  @history = []
210
211
  @total_input_tokens = 0
211
212
  @total_output_tokens = 0
213
+ @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
212
214
  @interactive_query = nil
213
215
  @interactive_session_id = nil
214
216
  @interactive_session_name = nil
@@ -224,7 +226,7 @@ module ConsoleAgent
224
226
  name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
225
227
  # Write banner to real stdout (bypass TeeIO) so it doesn't accumulate on resume
226
228
  @interactive_old_stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\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"
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"
228
230
 
229
231
  # Bind Shift-Tab to insert /auto command and submit
230
232
  if Readline.respond_to?(:parse_and_bind)
@@ -239,6 +241,11 @@ module ConsoleAgent
239
241
  break if input.downcase == 'exit' || input.downcase == 'quit'
240
242
  next if input.empty?
241
243
 
244
+ if input == '?' || input == '/'
245
+ display_help
246
+ next
247
+ end
248
+
242
249
  if input == '/auto'
243
250
  ConsoleAgent.configuration.auto_execute = !ConsoleAgent.configuration.auto_execute
244
251
  mode = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
@@ -263,6 +270,26 @@ module ConsoleAgent
263
270
  next
264
271
  end
265
272
 
273
+ if input == '/system'
274
+ @interactive_old_stdout.puts "\e[2m#{context}\e[0m"
275
+ next
276
+ end
277
+
278
+ if input == '/context'
279
+ display_conversation
280
+ next
281
+ end
282
+
283
+ if input == '/cost'
284
+ display_cost_summary
285
+ next
286
+ end
287
+
288
+ if input == '/think'
289
+ upgrade_to_thinking_model
290
+ next
291
+ end
292
+
266
293
  if input.start_with?('/name')
267
294
  name = input.sub('/name', '').strip
268
295
  if name.empty?
@@ -314,6 +341,11 @@ module ConsoleAgent
314
341
  # Add to Readline history (avoid consecutive duplicates)
315
342
  Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
316
343
 
344
+ # Auto-upgrade to thinking model on "think harder" phrases
345
+ if input =~ /think\s*harder/i
346
+ upgrade_to_thinking_model
347
+ end
348
+
317
349
  @interactive_query ||= input
318
350
  @history << { role: :user, content: input }
319
351
 
@@ -365,6 +397,13 @@ module ConsoleAgent
365
397
  def send_and_execute
366
398
  begin
367
399
  result, tool_messages = send_query(nil, conversation: @history)
400
+ rescue Providers::ProviderError => e
401
+ if e.message.include?("prompt is too long") && @history.length >= 6
402
+ $stdout.puts "\e[33m Context limit reached. Run /compact to reduce context size, then try again.\e[0m"
403
+ else
404
+ $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
405
+ end
406
+ return :error
368
407
  rescue Interrupt
369
408
  $stdout.puts "\n\e[33m Aborted.\e[0m"
370
409
  return :interrupted
@@ -547,8 +586,12 @@ module ConsoleAgent
547
586
  $stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
548
587
  end
549
588
 
550
- result = with_escape_monitoring do
551
- provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
589
+ begin
590
+ result = with_escape_monitoring do
591
+ provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
592
+ end
593
+ rescue Providers::ProviderError => e
594
+ raise
552
595
  end
553
596
  total_input += result.input_tokens || 0
554
597
  total_output += result.output_tokens || 0
@@ -776,6 +819,10 @@ module ConsoleAgent
776
819
  def track_usage(result)
777
820
  @total_input_tokens += result.input_tokens || 0
778
821
  @total_output_tokens += result.output_tokens || 0
822
+
823
+ model = ConsoleAgent.configuration.resolved_model
824
+ @token_usage[model][:input] += result.input_tokens || 0
825
+ @token_usage[model][:output] += result.output_tokens || 0
779
826
  end
780
827
 
781
828
  def display_usage(result, show_session: false)
@@ -883,12 +930,58 @@ module ConsoleAgent
883
930
  $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
884
931
  end
885
932
 
933
+ def display_cost_summary
934
+ if @token_usage.empty?
935
+ $stdout.puts "\e[2m No usage yet.\e[0m"
936
+ return
937
+ end
938
+
939
+ total_cost = 0.0
940
+ $stdout.puts "\e[36m Cost estimate:\e[0m"
941
+
942
+ @token_usage.each do |model, usage|
943
+ pricing = Configuration::PRICING[model]
944
+ input_str = "in: #{format_tokens(usage[:input])}"
945
+ output_str = "out: #{format_tokens(usage[:output])}"
946
+
947
+ if pricing
948
+ cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
949
+ total_cost += cost
950
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} ~$#{'%.2f' % cost}\e[0m"
951
+ else
952
+ $stdout.puts "\e[2m #{model}: #{input_str} #{output_str} (pricing unknown)\e[0m"
953
+ end
954
+ end
955
+
956
+ $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
957
+ end
958
+
959
+ def upgrade_to_thinking_model
960
+ config = ConsoleAgent.configuration
961
+ current = config.resolved_model
962
+ thinking = config.resolved_thinking_model
963
+
964
+ if current == thinking
965
+ $stdout.puts "\e[36m Already using thinking model (#{current}).\e[0m"
966
+ else
967
+ config.model = thinking
968
+ @provider = nil
969
+ $stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
970
+ end
971
+ end
972
+
973
+ def on_thinking_model?
974
+ config = ConsoleAgent.configuration
975
+ config.resolved_model == config.resolved_thinking_model
976
+ end
977
+
886
978
  def warn_if_history_large
887
979
  chars = @history.sum { |m| m[:content].to_s.length }
888
- return if chars < 50_000 || @compact_warned
889
980
 
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"
981
+ if chars > 50_000 && !@compact_warned
982
+ @compact_warned = true
983
+ $stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
984
+ end
892
985
  end
893
986
 
894
987
  def compact_history
@@ -900,6 +993,9 @@ module ConsoleAgent
900
993
  before_chars = @history.sum { |m| m[:content].to_s.length }
901
994
  before_count = @history.length
902
995
 
996
+ # Extract successfully executed code before summarizing
997
+ executed_code = extract_executed_code(@history)
998
+
903
999
  $stdout.puts "\e[2m Compacting #{before_count} messages (~#{format_tokens(before_chars)} chars)...\e[0m"
904
1000
 
905
1001
  system_prompt = <<~PROMPT
@@ -910,8 +1006,8 @@ module ConsoleAgent
910
1006
  - Key findings and data discovered (include specific values, IDs, record counts)
911
1007
  - Current state: what worked, what failed, where things stand
912
1008
  - Important variable names, model names, or table names referenced
913
- - Any code that was executed and its results
914
1009
 
1010
+ Do NOT include code that was executed — that will be preserved separately.
915
1011
  Be concise but preserve all information that would be needed to continue the conversation naturally.
916
1012
  Do NOT include any preamble — just output the summary directly.
917
1013
  PROMPT
@@ -929,18 +1025,130 @@ module ConsoleAgent
929
1025
  return
930
1026
  end
931
1027
 
932
- @history = [{ role: :user, content: "CONVERSATION SUMMARY (compacted):\n#{summary}" }]
1028
+ content = "CONVERSATION SUMMARY (compacted):\n#{summary}"
1029
+ unless executed_code.empty?
1030
+ content += "\n\nCODE EXECUTED THIS SESSION (preserved for continuation):\n#{executed_code}"
1031
+ end
1032
+
1033
+ @history = [{ role: :user, content: content }]
933
1034
  @compact_warned = false
934
1035
 
935
1036
  after_chars = @history.first[:content].length
936
1037
  $stdout.puts "\e[36m Compacted: #{before_count} messages -> 1 summary (~#{format_tokens(before_chars)} -> ~#{format_tokens(after_chars)} chars)\e[0m"
937
1038
  summary.each_line { |line| $stdout.puts "\e[2m #{line.rstrip}\e[0m" }
1039
+ if !executed_code.empty?
1040
+ $stdout.puts "\e[2m (preserved #{executed_code.scan(/```ruby/).length} executed code block(s))\e[0m"
1041
+ end
938
1042
  display_usage(result)
939
1043
  rescue => e
940
1044
  $stdout.puts "\e[31m Compaction failed: #{e.message}\e[0m"
941
1045
  end
942
1046
  end
943
1047
 
1048
+ # Extracts code blocks that were successfully executed from conversation history.
1049
+ # Looks for:
1050
+ # 1. Assistant messages with ```ruby blocks followed by "Code was executed." user messages
1051
+ # 2. execute_plan tool calls followed by results without ERROR
1052
+ # Skips code that failed or was declined.
1053
+ def extract_executed_code(history)
1054
+ code_blocks = []
1055
+ history.each_cons(2) do |msg, next_msg|
1056
+ # Pattern 1: Assistant ```ruby blocks with successful execution
1057
+ if msg[:role].to_s == 'assistant' && next_msg[:role].to_s == 'user'
1058
+ content = msg[:content].to_s
1059
+ next_content = next_msg[:content].to_s
1060
+
1061
+ if next_content.start_with?('Code was executed.')
1062
+ content.scan(/```ruby\s*\n(.*?)```/m).each do |match|
1063
+ code = match[0].strip
1064
+ next if code.empty?
1065
+ result_summary = next_content[0..200].gsub("\n", "\n# ")
1066
+ code_blocks << "```ruby\n#{code}\n```\n# #{result_summary}"
1067
+ end
1068
+ end
1069
+ end
1070
+
1071
+ # Pattern 2: execute_plan tool calls in provider-formatted messages
1072
+ if msg[:role].to_s == 'assistant' && msg[:content].is_a?(Array)
1073
+ msg[:content].each do |block|
1074
+ next unless block.is_a?(Hash) && block['type'] == 'tool_use' && block['name'] == 'execute_plan'
1075
+ input = block['input'] || {}
1076
+ steps = input['steps'] || []
1077
+
1078
+ # Find the matching tool_result in subsequent messages
1079
+ tool_id = block['id']
1080
+ result_msg = find_tool_result(history, tool_id)
1081
+ next unless result_msg
1082
+
1083
+ result_text = result_msg.to_s
1084
+ # Extract only steps that succeeded (no ERROR in their result)
1085
+ steps.each_with_index do |step, i|
1086
+ step_num = i + 1
1087
+ # Check if this specific step had an error
1088
+ step_section = result_text[/Step #{step_num}\b.*?(?=Step #{step_num + 1}\b|\z)/m] || ''
1089
+ next if step_section.include?('ERROR:')
1090
+ next if step_section.include?('User declined')
1091
+
1092
+ code = step['code'].to_s.strip
1093
+ next if code.empty?
1094
+ desc = step['description'] || "Step #{step_num}"
1095
+ code_blocks << "```ruby\n# #{desc}\n#{code}\n```"
1096
+ end
1097
+ end
1098
+ end
1099
+ end
1100
+ code_blocks.join("\n\n")
1101
+ end
1102
+
1103
+ def find_tool_result(history, tool_id)
1104
+ history.each do |msg|
1105
+ next unless msg[:content].is_a?(Array)
1106
+ msg[:content].each do |block|
1107
+ next unless block.is_a?(Hash)
1108
+ if block['type'] == 'tool_result' && block['tool_use_id'] == tool_id
1109
+ return block['content']
1110
+ end
1111
+ # OpenAI format
1112
+ if msg[:role].to_s == 'tool' && msg[:tool_call_id] == tool_id
1113
+ return msg[:content]
1114
+ end
1115
+ end
1116
+ end
1117
+ nil
1118
+ end
1119
+
1120
+ def display_conversation
1121
+ if @history.empty?
1122
+ @interactive_old_stdout.puts "\e[2m (no conversation history yet)\e[0m"
1123
+ return
1124
+ end
1125
+
1126
+ @interactive_old_stdout.puts "\e[36m Conversation (#{@history.length} messages):\e[0m"
1127
+ @history.each_with_index do |msg, i|
1128
+ role = msg[:role].to_s
1129
+ content = msg[:content].to_s
1130
+ label = role == 'user' ? "\e[33m[user]\e[0m" : "\e[36m[assistant]\e[0m"
1131
+ @interactive_old_stdout.puts "#{label} #{content}"
1132
+ @interactive_old_stdout.puts if i < @history.length - 1
1133
+ end
1134
+ end
1135
+
1136
+ def display_help
1137
+ auto = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
1138
+ @interactive_old_stdout.puts "\e[36m Commands:\e[0m"
1139
+ @interactive_old_stdout.puts "\e[2m /auto Toggle auto-execute (currently #{auto}) (Shift-Tab)\e[0m"
1140
+ @interactive_old_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
1141
+ @interactive_old_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
1142
+ @interactive_old_stdout.puts "\e[2m /usage Show session token totals\e[0m"
1143
+ @interactive_old_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
1144
+ @interactive_old_stdout.puts "\e[2m /name <lbl> Name this session for easy resume\e[0m"
1145
+ @interactive_old_stdout.puts "\e[2m /context Show conversation history sent to the LLM\e[0m"
1146
+ @interactive_old_stdout.puts "\e[2m /system Show the system prompt\e[0m"
1147
+ @interactive_old_stdout.puts "\e[2m /debug Toggle debug mode\e[0m"
1148
+ @interactive_old_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
1149
+ @interactive_old_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
1150
+ end
1151
+
944
1152
  def display_exit_info
945
1153
  display_session_summary
946
1154
  if @interactive_session_id
@@ -1,3 +1,3 @@
1
1
  module ConsoleAgent
2
- VERSION = '0.7.0'.freeze
2
+ VERSION = '0.9.0'.freeze
3
3
  end
@@ -35,7 +35,7 @@ ConsoleAgent.configure do |config|
35
35
  # config.connection_class = Sharding::CentralizedModel
36
36
 
37
37
  # Admin UI credentials (mount ConsoleAgent::Engine => '/console_agent' in routes.rb)
38
- # When nil, no authentication is required (convenient for development)
38
+ # When nil, all requests are denied. Set credentials or use config.authenticate.
39
39
  # config.admin_username = 'admin'
40
40
  # config.admin_password = 'changeme'
41
41
  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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr
@@ -86,6 +86,7 @@ executables: []
86
86
  extensions: []
87
87
  extra_rdoc_files: []
88
88
  files:
89
+ - CHANGELOG.md
89
90
  - LICENSE
90
91
  - README.md
91
92
  - app/controllers/console_agent/application_controller.rb