console_agent 0.1.0 → 0.3.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.
@@ -0,0 +1,83 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>ConsoleAgent Admin</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ body {
9
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
10
+ background: #f5f5f5;
11
+ color: #333;
12
+ line-height: 1.6;
13
+ }
14
+ .header {
15
+ background: #1a1a2e;
16
+ color: #fff;
17
+ padding: 16px 24px;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ }
22
+ .header h1 { font-size: 18px; font-weight: 600; }
23
+ .header a { color: #8be9fd; text-decoration: none; font-size: 14px; }
24
+ .container { max-width: 1200px; margin: 24px auto; padding: 0 24px; }
25
+ table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
26
+ th { background: #f8f9fa; text-align: left; padding: 12px 16px; font-size: 13px; font-weight: 600; color: #555; border-bottom: 2px solid #e9ecef; }
27
+ td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
28
+ tr:hover td { background: #f8f9fa; }
29
+ a { color: #4a6fa5; text-decoration: none; }
30
+ a:hover { text-decoration: underline; }
31
+ .badge {
32
+ display: inline-block; padding: 2px 8px; border-radius: 12px;
33
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
34
+ }
35
+ .badge-one_shot { background: #d4edda; color: #155724; }
36
+ .badge-interactive { background: #cce5ff; color: #004085; }
37
+ .badge-explain { background: #fff3cd; color: #856404; }
38
+ .meta-card {
39
+ background: #fff; border-radius: 8px; padding: 20px;
40
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
41
+ }
42
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
43
+ .meta-item label { font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; display: block; }
44
+ .meta-item span { font-size: 14px; color: #333; }
45
+ .terminal {
46
+ background: #1e1e1e; border-radius: 8px; overflow: hidden;
47
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
48
+ }
49
+ .terminal pre {
50
+ background: transparent; color: #d4d4d4;
51
+ padding: 16px 20px; margin: 0;
52
+ font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
53
+ font-size: 13px; line-height: 1.5;
54
+ overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
55
+ }
56
+ .mono { font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace; font-size: 13px; }
57
+ .text-muted { color: #888; }
58
+ .back-link { margin-bottom: 16px; display: inline-block; }
59
+ .check { color: #28a745; }
60
+ .cross { color: #dc3545; }
61
+ .pagination {
62
+ display: flex; justify-content: space-between; align-items: center;
63
+ margin-top: 16px; padding: 12px 0;
64
+ }
65
+ .pagination a {
66
+ padding: 6px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px;
67
+ font-size: 13px; color: #4a6fa5;
68
+ }
69
+ .pagination a:hover { background: #f8f9fa; text-decoration: none; }
70
+ .pagination .disabled { padding: 6px 14px; font-size: 13px; color: #ccc; }
71
+ .pagination .page-info { font-size: 13px; color: #888; }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <div class="header">
76
+ <h1>ConsoleAgent Admin</h1>
77
+ <a href="<%= console_agent.root_path %>">Sessions</a>
78
+ </div>
79
+ <div class="container">
80
+ <%= yield %>
81
+ </div>
82
+ </body>
83
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ ConsoleAgent::Engine.routes.draw do
2
+ root to: 'sessions#index'
3
+ resources :sessions, only: [:index, :show]
4
+ end
@@ -1,11 +1,13 @@
1
1
  module ConsoleAgent
2
2
  class Configuration
3
3
  PROVIDERS = %i[anthropic openai].freeze
4
- CONTEXT_MODES = %i[full smart].freeze
5
4
 
6
5
  attr_accessor :provider, :api_key, :model, :max_tokens,
7
- :auto_execute, :context_mode, :temperature,
8
- :timeout, :debug, :max_tool_rounds
6
+ :auto_execute, :temperature,
7
+ :timeout, :debug, :max_tool_rounds,
8
+ :storage_adapter, :memories_enabled,
9
+ :session_logging, :connection_class,
10
+ :admin_username, :admin_password
9
11
 
10
12
  def initialize
11
13
  @provider = :anthropic
@@ -13,11 +15,16 @@ module ConsoleAgent
13
15
  @model = nil
14
16
  @max_tokens = 4096
15
17
  @auto_execute = false
16
- @context_mode = :smart
17
18
  @temperature = 0.2
18
19
  @timeout = 30
19
20
  @debug = false
20
- @max_tool_rounds = 10
21
+ @max_tool_rounds = 100
22
+ @storage_adapter = nil
23
+ @memories_enabled = true
24
+ @session_logging = true
25
+ @connection_class = nil
26
+ @admin_username = nil
27
+ @admin_password = nil
21
28
  end
22
29
 
23
30
  def resolved_api_key
@@ -4,16 +4,141 @@ module ConsoleAgent
4
4
  ConsoleAgent.status
5
5
  end
6
6
 
7
+ def ai_memories(n = nil)
8
+ require 'yaml'
9
+ require 'console_agent/tools/memory_tools'
10
+ storage = ConsoleAgent.storage
11
+ keys = storage.list('memories/*.md').sort
12
+
13
+ if keys.empty?
14
+ $stdout.puts "\e[2mNo memories stored yet.\e[0m"
15
+ return nil
16
+ end
17
+
18
+ memories = keys.filter_map do |key|
19
+ content = storage.read(key)
20
+ next if content.nil? || content.strip.empty?
21
+ next unless content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
22
+ fm = YAML.safe_load($1, permitted_classes: [Time, Date]) || {}
23
+ fm.merge('description' => $2.strip, 'file' => key)
24
+ end
25
+
26
+ if memories.empty?
27
+ $stdout.puts "\e[2mNo memories stored yet.\e[0m"
28
+ return nil
29
+ end
30
+
31
+ shown = n ? memories.last(n) : memories.last(5)
32
+ total = memories.length
33
+
34
+ $stdout.puts "\e[36m[Memories — showing last #{shown.length} of #{total}]\e[0m"
35
+ shown.each do |m|
36
+ $stdout.puts "\e[33m #{m['name']}\e[0m"
37
+ $stdout.puts "\e[2m #{m['description']}\e[0m"
38
+ tags = Array(m['tags'])
39
+ $stdout.puts "\e[2m tags: #{tags.join(', ')}\e[0m" unless tags.empty?
40
+ $stdout.puts
41
+ end
42
+
43
+ path = storage.respond_to?(:root_path) ? File.join(storage.root_path, 'memories') : 'memories/'
44
+ $stdout.puts "\e[2mStored in: #{path}/\e[0m"
45
+ $stdout.puts "\e[2mUse ai_memories(n) to show last n.\e[0m"
46
+ nil
47
+ end
48
+
49
+ def ai_sessions(n = 10, search: nil)
50
+ require 'console_agent/session_logger'
51
+ session_class = Object.const_get('ConsoleAgent::Session')
52
+
53
+ scope = session_class.recent
54
+ scope = scope.search(search) if search
55
+ sessions = scope.limit(n)
56
+
57
+ if sessions.empty?
58
+ $stdout.puts "\e[2mNo sessions found.\e[0m"
59
+ return nil
60
+ end
61
+
62
+ $stdout.puts "\e[36m[Sessions — showing #{sessions.length}#{search ? " matching \"#{search}\"" : ''}]\e[0m"
63
+ $stdout.puts
64
+
65
+ sessions.each do |s|
66
+ id_str = "\e[2m##{s.id}\e[0m"
67
+ name_str = s.name ? "\e[33m#{s.name}\e[0m " : ""
68
+ query_str = s.name ? "\e[2m#{truncate_str(s.query, 50)}\e[0m" : truncate_str(s.query, 50)
69
+ mode_str = "\e[2m[#{s.mode}]\e[0m"
70
+ time_str = "\e[2m#{time_ago(s.created_at)}\e[0m"
71
+ tokens = (s.input_tokens || 0) + (s.output_tokens || 0)
72
+ token_str = tokens > 0 ? "\e[2m#{tokens} tokens\e[0m" : ""
73
+
74
+ $stdout.puts " #{id_str} #{name_str}#{query_str}"
75
+ $stdout.puts " #{mode_str} #{time_str} #{token_str}"
76
+ $stdout.puts
77
+ end
78
+
79
+ $stdout.puts "\e[2mUse ai_resume(id_or_name) to resume a session.\e[0m"
80
+ $stdout.puts "\e[2mUse ai_sessions(n, search: \"term\") to filter.\e[0m"
81
+ nil
82
+ rescue => e
83
+ $stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
84
+ nil
85
+ end
86
+
87
+ def ai_resume(identifier)
88
+ __ensure_console_agent_user
89
+
90
+ require 'console_agent/context_builder'
91
+ require 'console_agent/providers/base'
92
+ require 'console_agent/executor'
93
+ require 'console_agent/repl'
94
+ require 'console_agent/session_logger'
95
+
96
+ session = __find_session(identifier)
97
+ unless session
98
+ $stderr.puts "\e[31mSession not found: #{identifier}\e[0m"
99
+ return nil
100
+ end
101
+
102
+ repl = Repl.new(__console_agent_binding)
103
+ repl.resume(session)
104
+ rescue => e
105
+ $stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
106
+ nil
107
+ end
108
+
109
+ def ai_name(identifier, new_name)
110
+ require 'console_agent/session_logger'
111
+
112
+ session = __find_session(identifier)
113
+ unless session
114
+ $stderr.puts "\e[31mSession not found: #{identifier}\e[0m"
115
+ return nil
116
+ end
117
+
118
+ ConsoleAgent::SessionLogger.update(session.id, name: new_name)
119
+ $stdout.puts "\e[36mSession ##{session.id} named: #{new_name}\e[0m"
120
+ nil
121
+ rescue => e
122
+ $stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
123
+ nil
124
+ end
125
+
7
126
  def ai(query = nil)
8
127
  if query.nil?
9
128
  $stderr.puts "\e[33mUsage: ai \"your question here\"\e[0m"
10
- $stderr.puts "\e[33m ai \"query\" - ask + confirm execution\e[0m"
11
- $stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
12
- $stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
13
- $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
129
+ $stderr.puts "\e[33m ai \"query\" - ask + confirm execution\e[0m"
130
+ $stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
131
+ $stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
132
+ $stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
133
+ $stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
134
+ $stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
135
+ $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
136
+ $stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
14
137
  return nil
15
138
  end
16
139
 
140
+ __ensure_console_agent_user
141
+
17
142
  require 'console_agent/context_builder'
18
143
  require 'console_agent/providers/base'
19
144
  require 'console_agent/executor'
@@ -27,6 +152,8 @@ module ConsoleAgent
27
152
  end
28
153
 
29
154
  def ai!(query = nil)
155
+ __ensure_console_agent_user
156
+
30
157
  require 'console_agent/context_builder'
31
158
  require 'console_agent/providers/base'
32
159
  require 'console_agent/executor'
@@ -50,6 +177,8 @@ module ConsoleAgent
50
177
  return nil
51
178
  end
52
179
 
180
+ __ensure_console_agent_user
181
+
53
182
  require 'console_agent/context_builder'
54
183
  require 'console_agent/providers/base'
55
184
  require 'console_agent/executor'
@@ -64,6 +193,40 @@ module ConsoleAgent
64
193
 
65
194
  private
66
195
 
196
+ def __find_session(identifier)
197
+ session_class = Object.const_get('ConsoleAgent::Session')
198
+ if identifier.is_a?(Integer)
199
+ session_class.find_by(id: identifier)
200
+ else
201
+ session_class.where(name: identifier.to_s).recent.first ||
202
+ session_class.find_by(id: identifier.to_i)
203
+ end
204
+ end
205
+
206
+ def truncate_str(str, max)
207
+ return '' if str.nil?
208
+ str.length > max ? str[0...max] + '...' : str
209
+ end
210
+
211
+ def time_ago(time)
212
+ return '' unless time
213
+ seconds = Time.now - time
214
+ case seconds
215
+ when 0...60 then "just now"
216
+ when 60...3600 then "#{(seconds / 60).to_i}m ago"
217
+ when 3600...86400 then "#{(seconds / 3600).to_i}h ago"
218
+ else "#{(seconds / 86400).to_i}d ago"
219
+ end
220
+ end
221
+
222
+ def __ensure_console_agent_user
223
+ return if ConsoleAgent.current_user
224
+ $stdout.puts "\e[36mConsoleAgent logs all AI sessions for audit purposes.\e[0m"
225
+ $stdout.print "\e[36mPlease enter your name: \e[0m"
226
+ name = $stdin.gets.to_s.strip
227
+ ConsoleAgent.current_user = name.empty? ? ENV['USER'] : name
228
+ end
229
+
67
230
  def __console_agent_binding
68
231
  # Try IRB workspace binding
69
232
  if defined?(IRB) && IRB.respond_to?(:CurrentContext)
@@ -5,31 +5,17 @@ module ConsoleAgent
5
5
  end
6
6
 
7
7
  def build
8
- case @config.context_mode
9
- when :smart
10
- build_smart
11
- else
12
- build_full
13
- end
8
+ build_smart
14
9
  rescue => e
15
10
  ConsoleAgent.logger.warn("ConsoleAgent: context build error: #{e.message}")
16
- system_instructions + "\n\n" + environment_context
17
- end
18
-
19
- def build_full
20
- parts = []
21
- parts << system_instructions
22
- parts << environment_context
23
- parts << schema_context
24
- parts << models_context
25
- parts << routes_context
26
- parts.compact.join("\n\n")
11
+ smart_system_instructions + "\n\n" + environment_context
27
12
  end
28
13
 
29
14
  def build_smart
30
15
  parts = []
31
16
  parts << smart_system_instructions
32
17
  parts << environment_context
18
+ parts << memory_context
33
19
  parts.compact.join("\n\n")
34
20
  end
35
21
 
@@ -48,10 +34,29 @@ module ConsoleAgent
48
34
  you need specific information to write accurate code — such as which user they are, which
49
35
  record to target, or what value to use.
50
36
 
37
+ You have memory tools to persist what you learn across sessions:
38
+ - save_memory: persist facts or procedures you learn about this codebase.
39
+ If a memory with the same name already exists, it will be updated in place.
40
+ - delete_memory: remove a memory by name
41
+ - recall_memories: search your saved memories for details
42
+
43
+ IMPORTANT: Check the Memories section below BEFORE answering. If a memory is relevant,
44
+ use recall_memories to get full details and apply that knowledge to your answer.
45
+ When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
46
+ When you discover important patterns about this app, save them as memories.
47
+
48
+ You have an execute_plan tool to run multi-step code. When a task requires multiple
49
+ sequential operations, use execute_plan with an array of steps (each with a description
50
+ and Ruby code). The plan is shown to the user for review before execution begins.
51
+ After each step runs, its return value is stored as step1, step2, etc. — use these
52
+ variables in later steps to reference earlier results (e.g. `api = SalesforceApi.new(step1)`).
53
+ For simple single-expression answers, you may respond with a ```ruby code block instead.
54
+
51
55
  RULES:
52
56
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
53
- - Respond with a single ```ruby code block that directly answers the question.
54
- - Include a brief one-line explanation before the code block.
57
+ - For multi-step tasks, use execute_plan to break the work into small, clear steps.
58
+ - For simple queries, respond with a single ```ruby code block.
59
+ - Include a brief one-line explanation before any code block.
55
60
  - Use the app's actual model names, associations, and schema.
56
61
  - Prefer ActiveRecord query interface over raw SQL.
57
62
  - For destructive operations, add a comment warning.
@@ -62,23 +67,6 @@ module ConsoleAgent
62
67
  PROMPT
63
68
  end
64
69
 
65
- def system_instructions
66
- <<~PROMPT.strip
67
- You are a Ruby on Rails console assistant. The user is in a `rails console` session.
68
- You help them query data, debug issues, and understand their application.
69
-
70
- RULES:
71
- - Give ONE concise answer. Do not offer multiple alternatives or variations.
72
- - Respond with a single ```ruby code block that directly answers the question.
73
- - Include a brief one-line explanation before the code block.
74
- - Use the app's actual model names, associations, and schema.
75
- - Prefer ActiveRecord query interface over raw SQL.
76
- - For destructive operations, add a comment warning.
77
- - If the request is ambiguous, ask a clarifying question instead of guessing.
78
- - Keep code concise and idiomatic.
79
- PROMPT
80
- end
81
-
82
70
  def environment_context
83
71
  lines = ["## Environment"]
84
72
  lines << "- Ruby #{RUBY_VERSION}"
@@ -100,101 +88,22 @@ module ConsoleAgent
100
88
  lines.join("\n")
101
89
  end
102
90
 
103
- def schema_context
104
- return nil unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
105
-
106
- conn = ActiveRecord::Base.connection
107
- tables = conn.tables.sort
108
- return nil if tables.empty?
109
-
110
- lines = ["## Database Schema"]
111
- tables.each do |table|
112
- next if table == 'schema_migrations' || table == 'ar_internal_metadata'
113
-
114
- cols = conn.columns(table).map { |c| "#{c.name}:#{c.type}" }
115
- lines << "- #{table} (#{cols.join(', ')})"
116
- end
117
- lines.join("\n")
118
- rescue => e
119
- ConsoleAgent.logger.debug("ConsoleAgent: schema introspection failed: #{e.message}")
120
- nil
121
- end
122
-
123
- def models_context
124
- return nil unless defined?(ActiveRecord::Base)
125
-
126
- eager_load_app!
127
- base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
128
- models = ObjectSpace.each_object(Class).select { |c|
129
- c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
130
- }.sort_by(&:name)
131
-
132
- return nil if models.empty?
133
-
134
- lines = ["## Models"]
135
- models.each do |model|
136
- parts = [model.name]
137
-
138
- assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
139
- parts << " associations: #{assocs.join(', ')}" unless assocs.empty?
140
-
141
- scopes = model.methods.grep(/^_scope_/).map { |m| m.to_s.sub('_scope_', '') } rescue []
142
- if scopes.empty?
143
- # Alternative: check singleton methods that return ActiveRecord::Relation
144
- scope_names = (model.singleton_methods - base_class.singleton_methods).select { |m|
145
- m != :all && !m.to_s.start_with?('_') && !m.to_s.start_with?('find')
146
- }.first(10)
147
- # We won't list these to avoid noise — scopes are hard to detect reliably
148
- end
91
+ def memory_context
92
+ return nil unless @config.memories_enabled
149
93
 
150
- validations = model.validators.map { |v|
151
- attrs = v.attributes.join(', ')
152
- "#{v.class.name.demodulize.underscore.sub('_validator', '')} on #{attrs}"
153
- }.uniq.first(5) rescue []
154
- parts << " validations: #{validations.join('; ')}" unless validations.empty?
155
-
156
- lines << "- #{parts.join("\n")}"
157
- end
158
- lines.join("\n")
159
- rescue => e
160
- ConsoleAgent.logger.debug("ConsoleAgent: model introspection failed: #{e.message}")
161
- nil
162
- end
163
-
164
- def routes_context
165
- return nil unless defined?(Rails) && Rails.respond_to?(:application)
166
-
167
- routes = Rails.application.routes.routes
168
- return nil if routes.empty?
169
-
170
- lines = ["## Routes (summary)"]
171
- count = 0
172
- routes.each do |route|
173
- next if route.internal?
174
- path = route.path.spec.to_s.sub('(.:format)', '')
175
- verb = route.verb.to_s
176
- next if verb.empty?
177
- action = route.defaults[:controller].to_s + '#' + route.defaults[:action].to_s
178
- lines << "- #{verb} #{path} -> #{action}"
179
- count += 1
180
- break if count >= 50
181
- end
182
- lines << "- ... (#{routes.size - count} more routes)" if routes.size > count
94
+ require 'console_agent/tools/memory_tools'
95
+ summaries = Tools::MemoryTools.new.memory_summaries
96
+ return nil if summaries.nil? || summaries.empty?
183
97
 
98
+ lines = ["## Memories"]
99
+ lines.concat(summaries)
100
+ lines << ""
101
+ lines << "Call recall_memories to get details before answering. Do NOT guess from the name alone."
184
102
  lines.join("\n")
185
103
  rescue => e
186
- ConsoleAgent.logger.debug("ConsoleAgent: route introspection failed: #{e.message}")
104
+ ConsoleAgent.logger.debug("ConsoleAgent: memory context failed: #{e.message}")
187
105
  nil
188
106
  end
189
107
 
190
- def eager_load_app!
191
- return unless defined?(Rails) && Rails.respond_to?(:application)
192
-
193
- if Rails.application.respond_to?(:eager_load!)
194
- Rails.application.eager_load!
195
- end
196
- rescue => e
197
- ConsoleAgent.logger.debug("ConsoleAgent: eager_load failed: #{e.message}")
198
- end
199
108
  end
200
109
  end
@@ -0,0 +1,5 @@
1
+ module ConsoleAgent
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ConsoleAgent
4
+ end
5
+ end
@@ -1,8 +1,50 @@
1
+ require 'stringio'
2
+
1
3
  module ConsoleAgent
4
+ # Writes to two IO streams simultaneously
5
+ class TeeIO
6
+ attr_reader :secondary
7
+
8
+ def initialize(primary, secondary)
9
+ @primary = primary
10
+ @secondary = secondary
11
+ end
12
+
13
+ def write(str)
14
+ @primary.write(str)
15
+ @secondary.write(str)
16
+ end
17
+
18
+ def puts(*args)
19
+ @primary.puts(*args)
20
+ # Capture what puts would output
21
+ args.each { |a| @secondary.write("#{a}\n") }
22
+ @secondary.write("\n") if args.empty?
23
+ end
24
+
25
+ def print(*args)
26
+ @primary.print(*args)
27
+ args.each { |a| @secondary.write(a.to_s) }
28
+ end
29
+
30
+ def flush
31
+ @primary.flush if @primary.respond_to?(:flush)
32
+ end
33
+
34
+ def respond_to_missing?(method, include_private = false)
35
+ @primary.respond_to?(method, include_private) || super
36
+ end
37
+
38
+ def method_missing(method, *args, &block)
39
+ @primary.send(method, *args, &block)
40
+ end
41
+ end
42
+
2
43
  class Executor
3
44
  CODE_REGEX = /```ruby\s*\n(.*?)```/m
4
45
 
5
46
  attr_reader :binding_context
47
+ attr_accessor :on_prompt
6
48
 
7
49
  def initialize(binding_context)
8
50
  @binding_context = binding_context
@@ -33,23 +75,53 @@ module ConsoleAgent
33
75
  def execute(code)
34
76
  return nil if code.nil? || code.strip.empty?
35
77
 
78
+ captured_output = StringIO.new
79
+ old_stdout = $stdout
80
+ # Tee output: capture it and also print to the real stdout
81
+ $stdout = TeeIO.new(old_stdout, captured_output)
82
+
36
83
  result = binding_context.eval(code, "(console_agent)", 1)
84
+
85
+ $stdout = old_stdout
37
86
  $stdout.puts colorize("=> #{result.inspect}", :green)
87
+
88
+ @last_output = captured_output.string
38
89
  result
39
90
  rescue SyntaxError => e
91
+ $stdout = old_stdout if old_stdout
40
92
  $stderr.puts colorize("SyntaxError: #{e.message}", :red)
93
+ @last_output = nil
41
94
  nil
42
95
  rescue => e
96
+ $stdout = old_stdout if old_stdout
43
97
  $stderr.puts colorize("Error: #{e.class}: #{e.message}", :red)
44
98
  e.backtrace.first(3).each { |line| $stderr.puts colorize(" #{line}", :red) }
99
+ @last_output = captured_output&.string
45
100
  nil
46
101
  end
47
102
 
103
+ def last_output
104
+ @last_output
105
+ end
106
+
107
+ def last_answer
108
+ @last_answer
109
+ end
110
+
111
+ def last_cancelled?
112
+ @last_cancelled
113
+ end
114
+
48
115
  def confirm_and_execute(code)
49
116
  return nil if code.nil? || code.strip.empty?
50
117
 
118
+ @last_cancelled = false
119
+ @last_answer = nil
51
120
  $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
121
+ @on_prompt&.call
52
122
  answer = $stdin.gets.to_s.strip.downcase
123
+ @last_answer = answer
124
+ echo_stdin(answer)
53
125
 
54
126
  case answer
55
127
  when 'y', 'yes'
@@ -60,7 +132,9 @@ module ConsoleAgent
60
132
  $stdout.puts colorize("# Edited code:", :yellow)
61
133
  $stdout.puts highlight_code(edited)
62
134
  $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
63
- if $stdin.gets.to_s.strip.downcase == 'y'
135
+ edit_answer = $stdin.gets.to_s.strip.downcase
136
+ echo_stdin(edit_answer)
137
+ if edit_answer == 'y'
64
138
  execute(edited)
65
139
  else
66
140
  $stdout.puts colorize("Cancelled.", :yellow)
@@ -71,12 +145,18 @@ module ConsoleAgent
71
145
  end
72
146
  else
73
147
  $stdout.puts colorize("Cancelled.", :yellow)
148
+ @last_cancelled = true
74
149
  nil
75
150
  end
76
151
  end
77
152
 
78
153
  private
79
154
 
155
+ # Write stdin input to the capture IO only (avoids double-echo on terminal)
156
+ def echo_stdin(text)
157
+ $stdout.secondary.write("#{text}\n") if $stdout.respond_to?(:secondary)
158
+ end
159
+
80
160
  def open_in_editor(code)
81
161
  require 'tempfile'
82
162
  editor = ENV['EDITOR'] || 'vi'