console_agent 0.7.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: 662ece5e5732e350b22871c8029421edf06ed4d9f98183f5cee7e95a3c1099f6
4
- data.tar.gz: 483642598de39d23ace26f5d90863a8712b8799357c7809b2ba770f2bdc2522a
3
+ metadata.gz: fdcfb3c48b2f8421b2187980a453b80324e6dec40ab80e8e55aa1a938355c79c
4
+ data.tar.gz: 12fec02740fde7a87bb81e26dd6857263c96f9ebe5a8402040c72279e882e31f
5
5
  SHA512:
6
- metadata.gz: 5095d8f4cb0706e84c81be1b4a859de6ac48336ccb9c950feeb0dcf0df676d78fddc53a2314a21cbe6ee9d79b808f5edf0d854f3262b72c5befbb4420d9b979b
7
- data.tar.gz: 4c96f0e1664c1357cc7ec3f53aada9d1dcf4e05cbc92e19edfd052b2e99c75d1596d5a49440b8b9478fd576140b36929589a87e009dd971b4599cfa8ab149520
6
+ metadata.gz: 7af9a3c4fdbdf71abb7452d8e6747ba2b22c64cd08d123b761c5ca7ebb870c3ba9a01e458defe0308dcbf9435ec71879f7f3562503defdfd54e2026d3069679d
7
+ data.tar.gz: c84e401d6b6f5c6c7840b5d701d3a0e653ba3ea46651141ae5101b2c93bdca35487566786a87e284dbba97f902c5dad597a00b8dc9fce6e1c01885b01e99a48d
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,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'
@@ -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)
@@ -263,6 +265,16 @@ module ConsoleAgent
263
265
  next
264
266
  end
265
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
+
266
278
  if input.start_with?('/name')
267
279
  name = input.sub('/name', '').strip
268
280
  if name.empty?
@@ -314,6 +326,11 @@ module ConsoleAgent
314
326
  # Add to Readline history (avoid consecutive duplicates)
315
327
  Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
316
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
+
317
334
  @interactive_query ||= input
318
335
  @history << { role: :user, content: input }
319
336
 
@@ -365,6 +382,20 @@ module ConsoleAgent
365
382
  def send_and_execute
366
383
  begin
367
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
368
399
  rescue Interrupt
369
400
  $stdout.puts "\n\e[33m Aborted.\e[0m"
370
401
  return :interrupted
@@ -533,8 +564,18 @@ module ConsoleAgent
533
564
  last_tool_names = []
534
565
 
535
566
  exhausted = false
567
+ thinking_suggested = false
536
568
 
537
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
538
579
  if round == 0
539
580
  $stdout.puts "\e[2m Thinking...\e[0m"
540
581
  else
@@ -547,8 +588,22 @@ module ConsoleAgent
547
588
  $stdout.puts "\e[2m #{llm_status(round, messages, total_input, last_thinking, last_tool_names)}\e[0m"
548
589
  end
549
590
 
550
- result = with_escape_monitoring do
551
- 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
552
607
  end
553
608
  total_input += result.input_tokens || 0
554
609
  total_output += result.output_tokens || 0
@@ -776,6 +831,10 @@ module ConsoleAgent
776
831
  def track_usage(result)
777
832
  @total_input_tokens += result.input_tokens || 0
778
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
779
838
  end
780
839
 
781
840
  def display_usage(result, show_session: false)
@@ -883,12 +942,61 @@ module ConsoleAgent
883
942
  $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
884
943
  end
885
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
+
886
990
  def warn_if_history_large
887
991
  chars = @history.sum { |m| m[:content].to_s.length }
888
- return if chars < 50_000 || @compact_warned
889
992
 
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"
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
892
1000
  end
893
1001
 
894
1002
  def compact_history
@@ -941,6 +1049,22 @@ module ConsoleAgent
941
1049
  end
942
1050
  end
943
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
+
944
1068
  def display_exit_info
945
1069
  display_session_summary
946
1070
  if @interactive_session_id
@@ -1,3 +1,3 @@
1
1
  module ConsoleAgent
2
- VERSION = '0.7.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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr