console_agent 0.10.0 → 0.12.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,473 @@
1
+ require 'json'
2
+ require 'uri'
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'console_agent/channel/slack'
6
+ require 'console_agent/conversation_engine'
7
+ require 'console_agent/context_builder'
8
+ require 'console_agent/providers/base'
9
+ require 'console_agent/executor'
10
+
11
+ module ConsoleAgent
12
+ class SlackBot
13
+ def initialize
14
+ @bot_token = ConsoleAgent.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
15
+ @app_token = ConsoleAgent.configuration.slack_app_token || ENV['SLACK_APP_TOKEN']
16
+ @channel_ids = resolve_channel_ids
17
+
18
+ raise ConfigurationError, "SLACK_BOT_TOKEN is required" unless @bot_token
19
+ raise ConfigurationError, "SLACK_APP_TOKEN is required (Socket Mode)" unless @app_token
20
+ raise ConfigurationError, "slack_allowed_usernames must be configured (e.g. ['alice'] or 'ALL')" unless ConsoleAgent.configuration.slack_allowed_usernames
21
+
22
+ @bot_user_id = nil
23
+ @sessions = {} # thread_ts → { channel:, engine:, thread: }
24
+ @user_cache = {} # slack user_id → display_name
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def start
29
+ @bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
30
+ log_startup
31
+
32
+ loop do
33
+ run_socket_mode
34
+ puts "Reconnecting in 5s..."
35
+ sleep 5
36
+ end
37
+ rescue Interrupt
38
+ puts "\nSlackBot shutting down."
39
+ end
40
+
41
+ private
42
+
43
+ # --- Socket Mode connection ---
44
+
45
+ def run_socket_mode
46
+ url = obtain_wss_url
47
+ uri = URI.parse(url)
48
+
49
+ tcp = TCPSocket.new(uri.host, uri.port || 443)
50
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
51
+ ssl.hostname = uri.host
52
+ ssl.connect
53
+
54
+ # WebSocket handshake
55
+ path = "#{uri.path}?#{uri.query}"
56
+ handshake = [
57
+ "GET #{path} HTTP/1.1",
58
+ "Host: #{uri.host}",
59
+ "Upgrade: websocket",
60
+ "Connection: Upgrade",
61
+ "Sec-WebSocket-Key: #{SecureRandom.base64(16)}",
62
+ "Sec-WebSocket-Version: 13",
63
+ "", ""
64
+ ].join("\r\n")
65
+
66
+ ssl.write(handshake)
67
+
68
+ # Read HTTP 101 response headers
69
+ response_line = ssl.gets
70
+ unless response_line&.include?("101")
71
+ raise "WebSocket handshake failed: #{response_line}"
72
+ end
73
+ # Consume remaining headers
74
+ loop do
75
+ line = ssl.gets
76
+ break if line.nil? || line.strip.empty?
77
+ end
78
+
79
+ puts "Connected to Slack Socket Mode."
80
+
81
+ # Main read loop
82
+ loop do
83
+ data = read_ws_frame(ssl)
84
+ next unless data
85
+
86
+ begin
87
+ msg = JSON.parse(data, symbolize_names: true)
88
+ rescue JSON::ParserError
89
+ next
90
+ end
91
+
92
+ # Acknowledge immediately (Slack requires fast ack)
93
+ if msg[:envelope_id]
94
+ send_ws_frame(ssl, JSON.generate({ envelope_id: msg[:envelope_id] }))
95
+ end
96
+
97
+ case msg[:type]
98
+ when "hello"
99
+ # Connection confirmed
100
+ when "disconnect"
101
+ puts "Slack disconnect: #{msg[:reason]}"
102
+ break
103
+ when "events_api"
104
+ handle_event(msg)
105
+ end
106
+ end
107
+ rescue EOFError, IOError, Errno::ECONNRESET, OpenSSL::SSL::SSLError => e
108
+ puts "Socket Mode connection lost: #{e.message}"
109
+ ensure
110
+ ssl&.close rescue nil
111
+ tcp&.close rescue nil
112
+ end
113
+
114
+ def obtain_wss_url
115
+ result = slack_api("apps.connections.open", token: @app_token)
116
+ raise "Failed to obtain WSS URL: #{result["error"]}" unless result["ok"]
117
+ result["url"]
118
+ end
119
+
120
+ # --- WebSocket frame reading/writing (RFC 6455 minimal implementation) ---
121
+
122
+ def read_ws_frame(ssl)
123
+ first_byte = ssl.read(1)&.unpack1("C")
124
+ return nil unless first_byte
125
+
126
+ opcode = first_byte & 0x0F
127
+ # Handle ping (opcode 9) → send pong (opcode 10)
128
+ if opcode == 9
129
+ payload = read_ws_payload(ssl)
130
+ send_ws_pong(ssl, payload)
131
+ return nil
132
+ end
133
+ # Close frame (opcode 8)
134
+ return nil if opcode == 8
135
+ # Only process text frames (opcode 1)
136
+ return nil unless opcode == 1
137
+
138
+ read_ws_payload(ssl)
139
+ end
140
+
141
+ def read_ws_payload(ssl)
142
+ second_byte = ssl.read(1)&.unpack1("C")
143
+ return nil unless second_byte
144
+
145
+ masked = (second_byte & 0x80) != 0
146
+ length = second_byte & 0x7F
147
+
148
+ if length == 126
149
+ length = ssl.read(2).unpack1("n")
150
+ elsif length == 127
151
+ length = ssl.read(8).unpack1("Q>")
152
+ end
153
+
154
+ if masked
155
+ mask_key = ssl.read(4).bytes
156
+ raw = ssl.read(length).bytes
157
+ raw.each_with_index.map { |b, i| (b ^ mask_key[i % 4]).chr }.join
158
+ else
159
+ ssl.read(length)
160
+ end
161
+ end
162
+
163
+ def send_ws_frame(ssl, text)
164
+ bytes = text.encode("UTF-8").bytes
165
+ # Client frames must be masked per RFC 6455
166
+ mask_key = 4.times.map { rand(256) }
167
+ masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
168
+
169
+ frame = [0x81].pack("C") # FIN + text opcode
170
+ if bytes.length < 126
171
+ frame << [(bytes.length | 0x80)].pack("C")
172
+ elsif bytes.length < 65536
173
+ frame << [126 | 0x80].pack("C")
174
+ frame << [bytes.length].pack("n")
175
+ else
176
+ frame << [127 | 0x80].pack("C")
177
+ frame << [bytes.length].pack("Q>")
178
+ end
179
+ frame << mask_key.pack("C*")
180
+ frame << masked.pack("C*")
181
+ ssl.write(frame)
182
+ end
183
+
184
+ def send_ws_pong(ssl, payload)
185
+ payload ||= ""
186
+ bytes = payload.bytes
187
+ mask_key = 4.times.map { rand(256) }
188
+ masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
189
+
190
+ frame = [0x8A].pack("C") # FIN + pong opcode
191
+ frame << [(bytes.length | 0x80)].pack("C")
192
+ frame << mask_key.pack("C*")
193
+ frame << masked.pack("C*")
194
+ ssl.write(frame)
195
+ end
196
+
197
+ # --- Slack Web API (minimal, uses Net::HTTP) ---
198
+
199
+ def slack_api(method, token: @bot_token, **params)
200
+ uri = URI("https://slack.com/api/#{method}")
201
+ http = Net::HTTP.new(uri.host, uri.port)
202
+ http.use_ssl = true
203
+
204
+ if params.empty?
205
+ req = Net::HTTP::Post.new(uri.path)
206
+ else
207
+ req = Net::HTTP::Post.new(uri.path)
208
+ req.body = JSON.generate(params)
209
+ req["Content-Type"] = "application/json; charset=utf-8"
210
+ end
211
+ req["Authorization"] = "Bearer #{token}"
212
+
213
+ resp = http.request(req)
214
+ JSON.parse(resp.body)
215
+ rescue => e
216
+ { "ok" => false, "error" => e.message }
217
+ end
218
+
219
+ def post_message(channel:, thread_ts:, text:)
220
+ slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
221
+ end
222
+
223
+ # --- Event handling ---
224
+
225
+ def handle_event(msg)
226
+ event = msg.dig(:payload, :event)
227
+ return unless event
228
+ return unless event[:type] == "message"
229
+
230
+ # Ignore bot messages, subtypes (edits/deletes), own messages
231
+ return if event[:bot_id]
232
+ return if event[:user] == @bot_user_id
233
+ return if event[:subtype]
234
+
235
+ text = event[:text]
236
+ return unless text && !text.strip.empty?
237
+
238
+ channel_id = event[:channel]
239
+ return unless watched_channel?(channel_id)
240
+
241
+ thread_ts = event[:thread_ts] || event[:ts]
242
+ user_id = event[:user]
243
+ user_name = resolve_user_name(user_id)
244
+
245
+ allowed_list = Array(ConsoleAgent.configuration.slack_allowed_usernames).map(&:to_s).map(&:downcase)
246
+ unless allowed_list.include?('all') || allowed_list.include?(user_name.to_s.downcase)
247
+ puts "[#{channel_id}/#{thread_ts}] @#{user_name} << (ignored — not in allowed usernames)"
248
+ post_message(channel: channel_id, thread_ts: thread_ts, text: "Sorry, I don't recognize your username (@#{user_name}). Ask an admin to add you to the allowed usernames list.")
249
+ return
250
+ end
251
+
252
+ puts "[#{channel_id}/#{thread_ts}] @#{user_name} << #{text.strip}"
253
+
254
+ session = @mutex.synchronize { @sessions[thread_ts] }
255
+
256
+ command = text.strip.downcase
257
+ if command == 'cancel' || command == 'stop'
258
+ cancel_session(session, channel_id, thread_ts)
259
+ return
260
+ end
261
+
262
+ if command == 'clear'
263
+ count = count_bot_messages(channel_id, thread_ts)
264
+ if count == 0
265
+ post_message(channel: channel_id, thread_ts: thread_ts, text: "No bot messages to clear.")
266
+ else
267
+ post_message(channel: channel_id, thread_ts: thread_ts,
268
+ text: "This will permanently delete #{count} bot message#{'s' unless count == 1} from this thread. Type `clear!` to confirm.")
269
+ end
270
+ return
271
+ end
272
+
273
+ if command == 'clear!'
274
+ cancel_session(session, channel_id, thread_ts) if session
275
+ clear_bot_messages(channel_id, thread_ts)
276
+ return
277
+ end
278
+
279
+ if session
280
+ handle_thread_reply(session, text.strip)
281
+ else
282
+ # New thread, or existing thread after bot restart — start a fresh session
283
+ start_session(channel_id, thread_ts, text.strip, user_name)
284
+ end
285
+ rescue => e
286
+ ConsoleAgent.logger.error("SlackBot event handling error: #{e.class}: #{e.message}")
287
+ end
288
+
289
+ def start_session(channel_id, thread_ts, text, user_name)
290
+ channel = Channel::Slack.new(
291
+ slack_bot: self,
292
+ channel_id: channel_id,
293
+ thread_ts: thread_ts,
294
+ user_name: user_name
295
+ )
296
+
297
+ sandbox_binding = Object.new.instance_eval { binding }
298
+ engine = ConversationEngine.new(
299
+ binding_context: sandbox_binding,
300
+ channel: channel,
301
+ slack_thread_ts: thread_ts
302
+ )
303
+
304
+ # Try to restore conversation history from a previous session (e.g. after bot restart)
305
+ restored = restore_from_db(engine, thread_ts)
306
+
307
+ session = { channel: channel, engine: engine, thread: nil }
308
+ @mutex.synchronize { @sessions[thread_ts] = session }
309
+
310
+ session[:thread] = Thread.new do
311
+ Thread.current.report_on_exception = false
312
+ begin
313
+ channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
314
+ if restored
315
+ puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
316
+ channel.display_dim("_(session restored — continuing from previous conversation)_")
317
+ end
318
+ engine.process_message(text)
319
+ rescue => e
320
+ channel.display_error("Error: #{e.class}: #{e.message}")
321
+ ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
322
+ ensure
323
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
324
+ end
325
+ end
326
+ end
327
+
328
+ def restore_from_db(engine, thread_ts)
329
+ require 'console_agent/session_logger'
330
+ saved = SessionLogger.find_by_slack_thread(thread_ts)
331
+ return false unless saved
332
+
333
+ engine.init_interactive
334
+ engine.restore_session(saved)
335
+ true
336
+ rescue => e
337
+ ConsoleAgent.logger.warn("SlackBot: failed to restore session for #{thread_ts}: #{e.message}")
338
+ false
339
+ end
340
+
341
+ def handle_thread_reply(session, text)
342
+ channel = session[:channel]
343
+ engine = session[:engine]
344
+
345
+ # If the engine is blocked waiting for user input (ask_user), push to queue
346
+ if waiting_for_reply?(channel)
347
+ channel.receive_reply(text)
348
+ return
349
+ end
350
+
351
+ # Otherwise treat as a new message in the conversation
352
+ session[:thread] = Thread.new do
353
+ Thread.current.report_on_exception = false
354
+ begin
355
+ engine.process_message(text)
356
+ rescue => e
357
+ channel.display_error("Error: #{e.class}: #{e.message}")
358
+ ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
359
+ ensure
360
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
361
+ end
362
+ end
363
+ end
364
+
365
+ def cancel_session(session, channel_id, thread_ts)
366
+ if session
367
+ session[:channel].cancel!
368
+ session[:channel].display("Stopped.")
369
+ puts "[#{channel_id}/#{thread_ts}] cancel requested"
370
+ else
371
+ post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
372
+ puts "[#{channel_id}/#{thread_ts}] cancel: no session"
373
+ end
374
+ @mutex.synchronize { @sessions.delete(thread_ts) }
375
+ end
376
+
377
+ def count_bot_messages(channel_id, thread_ts)
378
+ result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
379
+ return 0 unless result["ok"]
380
+ (result["messages"] || []).count { |m| m["user"] == @bot_user_id }
381
+ rescue
382
+ 0
383
+ end
384
+
385
+ def clear_bot_messages(channel_id, thread_ts)
386
+ result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
387
+ unless result["ok"]
388
+ puts "[#{channel_id}/#{thread_ts}] clear: failed to fetch replies: #{result["error"]}"
389
+ return
390
+ end
391
+
392
+ bot_messages = (result["messages"] || []).select { |m| m["user"] == @bot_user_id }
393
+ bot_messages.each do |m|
394
+ puts "[#{channel_id}/#{thread_ts}] clearing #{channel_id.length} / #{m["ts"]}"
395
+ slack_api("chat.delete", channel: channel_id, ts: m["ts"])
396
+ end
397
+ puts "[#{channel_id}/#{thread_ts}] cleared #{bot_messages.length} bot messages"
398
+ rescue => e
399
+ puts "[#{channel_id}/#{thread_ts}] clear failed: #{e.message}"
400
+ end
401
+
402
+ def slack_get(method, **params)
403
+ uri = URI("https://slack.com/api/#{method}")
404
+ uri.query = URI.encode_www_form(params)
405
+ http = Net::HTTP.new(uri.host, uri.port)
406
+ http.use_ssl = true
407
+ req = Net::HTTP::Get.new(uri)
408
+ req["Authorization"] = "Bearer #{@bot_token}"
409
+ resp = http.request(req)
410
+ JSON.parse(resp.body)
411
+ rescue => e
412
+ { "ok" => false, "error" => e.message }
413
+ end
414
+
415
+ def waiting_for_reply?(channel)
416
+ channel.instance_variable_get(:@reply_queue).num_waiting > 0
417
+ end
418
+
419
+ def watched_channel?(channel_id)
420
+ return true if @channel_ids.nil? || @channel_ids.empty?
421
+ @channel_ids.include?(channel_id)
422
+ end
423
+
424
+ def resolve_channel_ids
425
+ ids = ConsoleAgent.configuration.slack_channel_ids || ENV['CONSOLE_AGENT_SLACK_CHANNELS']
426
+ return nil if ids.nil?
427
+ ids = ids.split(',').map(&:strip) if ids.is_a?(String)
428
+ ids
429
+ end
430
+
431
+ def resolve_user_name(user_id)
432
+ return @user_cache[user_id] if @user_cache.key?(user_id)
433
+
434
+ # users.info requires form-encoded params, not JSON
435
+ uri = URI("https://slack.com/api/users.info")
436
+ uri.query = URI.encode_www_form(user: user_id)
437
+ http = Net::HTTP.new(uri.host, uri.port)
438
+ http.use_ssl = true
439
+ req = Net::HTTP::Get.new(uri)
440
+ req["Authorization"] = "Bearer #{@bot_token}"
441
+ resp = http.request(req)
442
+ result = JSON.parse(resp.body)
443
+
444
+ name = result.dig("user", "profile", "display_name")
445
+ name = result.dig("user", "real_name") if name.nil? || name.empty?
446
+ name = result.dig("user", "name") if name.nil? || name.empty?
447
+ @user_cache[user_id] = name || user_id
448
+ rescue => e
449
+ ConsoleAgent.logger.warn("Failed to resolve user name for #{user_id}: #{e.message}")
450
+ @user_cache[user_id] = user_id
451
+ end
452
+
453
+ def log_startup
454
+ channel_info = if @channel_ids && !@channel_ids.empty?
455
+ "channels: #{@channel_ids.join(', ')}"
456
+ else
457
+ "all channels"
458
+ end
459
+ puts "ConsoleAgent SlackBot started (#{channel_info}, bot: #{@bot_user_id})"
460
+
461
+ channel = Channel::Slack.new(slack_bot: self, channel_id: "boot", thread_ts: "boot")
462
+ engine = ConversationEngine.new(
463
+ binding_context: Object.new.instance_eval { binding },
464
+ channel: channel
465
+ )
466
+ puts "\nFull system prompt for Slack sessions:"
467
+ puts "-" * 60
468
+ puts engine.context
469
+ puts "-" * 60
470
+ puts
471
+ end
472
+ end
473
+ end
@@ -8,9 +8,10 @@ module ConsoleAgent
8
8
  # Tools that should never be cached (side effects or user interaction)
9
9
  NO_CACHE = %w[ask_user save_memory delete_memory execute_plan].freeze
10
10
 
11
- def initialize(executor: nil, mode: :default)
11
+ def initialize(executor: nil, mode: :default, channel: nil)
12
12
  @executor = executor
13
13
  @mode = mode
14
+ @channel = channel
14
15
  @definitions = []
15
16
  @handlers = {}
16
17
  @cache = {}
@@ -302,8 +303,12 @@ module ConsoleAgent
302
303
  # Ask for plan approval (unless auto-execute)
303
304
  skip_confirmations = auto
304
305
  unless auto
305
- $stdout.print "\e[33m Accept plan? [y/N/a(uto)] \e[0m"
306
- answer = $stdin.gets.to_s.strip.downcase
306
+ if @channel
307
+ answer = @channel.confirm(" Accept plan? [y/N/a(uto)] ")
308
+ else
309
+ $stdout.print "\e[33m Accept plan? [y/N/a(uto)] \e[0m"
310
+ answer = $stdin.gets.to_s.strip.downcase
311
+ end
307
312
  case answer
308
313
  when 'a', 'auto'
309
314
  skip_confirmations = true
@@ -326,8 +331,12 @@ module ConsoleAgent
326
331
 
327
332
  # Per-step confirmation (unless auto-execute or plan-level auto)
328
333
  unless skip_confirmations
329
- $stdout.print "\e[33m Run? [y/N/edit] \e[0m"
330
- step_answer = $stdin.gets.to_s.strip.downcase
334
+ if @channel
335
+ step_answer = @channel.confirm(" Run? [y/N/edit] ")
336
+ else
337
+ $stdout.print "\e[33m Run? [y/N/edit] \e[0m"
338
+ step_answer = $stdin.gets.to_s.strip.downcase
339
+ end
331
340
 
332
341
  case step_answer
333
342
  when 'e', 'edit'
@@ -335,8 +344,12 @@ module ConsoleAgent
335
344
  if edited && edited != step['code']
336
345
  $stdout.puts "\e[33m # Edited code:\e[0m"
337
346
  $stdout.puts highlight_plan_code(edited)
338
- $stdout.print "\e[33m Run edited code? [y/N] \e[0m"
339
- confirm = $stdin.gets.to_s.strip.downcase
347
+ if @channel
348
+ confirm = @channel.confirm(" Run edited code? [y/N] ")
349
+ else
350
+ $stdout.print "\e[33m Run edited code? [y/N] \e[0m"
351
+ confirm = $stdin.gets.to_s.strip.downcase
352
+ end
340
353
  unless confirm == 'y' || confirm == 'yes'
341
354
  feedback = ask_feedback("What would you like changed?")
342
355
  results << "Step #{i + 1}: User declined after edit. Feedback: #{feedback}"
@@ -354,6 +367,17 @@ module ConsoleAgent
354
367
  end
355
368
 
356
369
  exec_result = @executor.execute(step['code'])
370
+
371
+ # On safety error, offer to re-run with guards disabled (console only)
372
+ if @executor.last_safety_error
373
+ if @channel && !@channel.supports_danger?
374
+ results << "Step #{i + 1} (#{step['description']}):\nBLOCKED by safety guard: #{@executor.last_error}. Write operations are not permitted in this channel."
375
+ break
376
+ else
377
+ exec_result = @executor.offer_danger_retry(step['code'])
378
+ end
379
+ end
380
+
357
381
  # Make result available as step1, step2, etc. for subsequent steps
358
382
  @executor.binding_context.local_variable_set(:"step#{i + 1}", exec_result)
359
383
  output = @executor.last_output
@@ -407,18 +431,26 @@ module ConsoleAgent
407
431
  end
408
432
 
409
433
  def ask_feedback(prompt)
410
- $stdout.print "\e[36m #{prompt} > \e[0m"
411
- feedback = $stdin.gets
412
- return '(no feedback provided)' if feedback.nil?
413
- feedback.strip.empty? ? '(no feedback provided)' : feedback.strip
434
+ if @channel
435
+ @channel.prompt(" #{prompt} > ")
436
+ else
437
+ $stdout.print "\e[36m #{prompt} > \e[0m"
438
+ feedback = $stdin.gets
439
+ return '(no feedback provided)' if feedback.nil?
440
+ feedback.strip.empty? ? '(no feedback provided)' : feedback.strip
441
+ end
414
442
  end
415
443
 
416
444
  def ask_user(question)
417
- $stdout.puts "\e[36m ? #{question}\e[0m"
418
- $stdout.print "\e[36m > \e[0m"
419
- answer = $stdin.gets
420
- return '(no answer provided)' if answer.nil?
421
- answer.strip.empty? ? '(no answer provided)' : answer.strip
445
+ if @channel
446
+ @channel.prompt(" ? #{question}\n > ")
447
+ else
448
+ $stdout.puts "\e[36m ? #{question}\e[0m"
449
+ $stdout.print "\e[36m > \e[0m"
450
+ answer = $stdin.gets
451
+ return '(no answer provided)' if answer.nil?
452
+ answer.strip.empty? ? '(no answer provided)' : answer.strip
453
+ end
422
454
  end
423
455
 
424
456
  def register(name:, description:, parameters:, handler:)
@@ -1,3 +1,3 @@
1
1
  module ConsoleAgent
2
- VERSION = '0.10.0'.freeze
2
+ VERSION = '0.12.0'.freeze
3
3
  end
data/lib/console_agent.rb CHANGED
@@ -58,8 +58,8 @@ module ConsoleAgent
58
58
  def status
59
59
  c = configuration
60
60
  key = c.resolved_api_key
61
- masked_key = if key.nil? || key.empty?
62
- "\e[31m(not set)\e[0m"
61
+ masked_key = if key.nil? || key.empty? || key == 'no-key'
62
+ c.provider == :local ? "\e[32m(not required)\e[0m" : "\e[31m(not set)\e[0m"
63
63
  else
64
64
  key[0..6] + '...' + key[-4..-1]
65
65
  end
@@ -69,11 +69,19 @@ module ConsoleAgent
69
69
  lines << " Provider: #{c.provider}"
70
70
  lines << " Model: #{c.resolved_model}"
71
71
  lines << " API key: #{masked_key}"
72
- lines << " Max tokens: #{c.max_tokens}"
72
+ lines << " Local URL: #{c.local_url}" if c.provider == :local
73
+ lines << " Max tokens: #{c.max_tokens || '(auto)'}"
73
74
  lines << " Temperature: #{c.temperature}"
74
75
  lines << " Timeout: #{c.timeout}s"
75
76
  lines << " Max tool rounds:#{c.max_tool_rounds}"
76
77
  lines << " Auto-execute: #{c.auto_execute}"
78
+ guards = c.safety_guards
79
+ if guards.empty?
80
+ lines << " Safe mode: \e[33m(no guards configured)\e[0m"
81
+ else
82
+ status = guards.enabled? ? "\e[32mON\e[0m" : "\e[31mOFF\e[0m"
83
+ lines << " Safe mode: #{status} (#{guards.names.join(', ')})"
84
+ end
77
85
  lines << " Memories: #{c.memories_enabled}"
78
86
  lines << " Session logging:#{session_table_status}"
79
87
  lines << " Debug: #{c.debug}"
@@ -134,6 +142,12 @@ module ConsoleAgent
134
142
  migrations << 'name'
135
143
  end
136
144
 
145
+ unless conn.column_exists?(table, :slack_thread_ts)
146
+ conn.add_column(table, :slack_thread_ts, :string, limit: 255)
147
+ conn.add_index(table, :slack_thread_ts) unless conn.index_exists?(table, :slack_thread_ts)
148
+ migrations << 'slack_thread_ts'
149
+ end
150
+
137
151
  if migrations.empty?
138
152
  $stdout.puts "\e[32mConsoleAgent: #{table} is up to date.\e[0m"
139
153
  else
@@ -1,5 +1,5 @@
1
1
  ConsoleAgent.configure do |config|
2
- # LLM provider: :anthropic or :openai
2
+ # LLM provider: :anthropic, :openai, or :local
3
3
  config.provider = :anthropic
4
4
 
5
5
  # API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
@@ -23,6 +23,16 @@ ConsoleAgent.configure do |config|
23
23
  # HTTP timeout in seconds
24
24
  config.timeout = 30
25
25
 
26
+ # Local model provider (Ollama, vLLM, or any OpenAI-compatible server):
27
+ # config.provider = :local
28
+ # config.local_url = 'http://localhost:11434'
29
+ # config.local_model = 'qwen2.5:7b'
30
+ # config.local_api_key = nil
31
+
32
+ # Slack: which users the bot responds to (required for Slack mode)
33
+ # config.slack_allowed_usernames = ['alice', 'bob'] # specific users
34
+ # config.slack_allowed_usernames = 'ALL' # everyone
35
+
26
36
  # Debug mode: prints full API requests/responses and tool calls to stderr
27
37
  # config.debug = true
28
38
 
@@ -38,4 +48,27 @@ ConsoleAgent.configure do |config|
38
48
  # When nil, all requests are denied. Set credentials or use config.authenticate.
39
49
  # config.admin_username = 'admin'
40
50
  # config.admin_password = 'changeme'
51
+
52
+ # Safety guards: prevent side effects (DB writes, HTTP calls, etc.) during code execution.
53
+ # When enabled, code runs in safe mode by default. Users can toggle with /danger in the REPL.
54
+ #
55
+ # Built-in guard for database writes (works on Rails 5+, all adapters):
56
+ # config.use_builtin_safety_guard :database_writes
57
+ #
58
+ # Built-in guard for HTTP mutations — blocks POST/PUT/PATCH/DELETE via Net::HTTP.
59
+ # Covers most Ruby HTTP libraries (HTTParty, RestClient, Faraday) since they use Net::HTTP:
60
+ # config.use_builtin_safety_guard :http_mutations
61
+ #
62
+ # Allowlist specific hosts or tables so they pass through without blocking:
63
+ # config.use_builtin_safety_guard :http_mutations,
64
+ # allow: [/s3\.amazonaws\.com/, /googleapis\.com/]
65
+ # config.use_builtin_safety_guard :database_writes,
66
+ # allow: ['console_agent_sessions']
67
+ #
68
+ # Built-in guard for mailers — disables ActionMailer delivery:
69
+ # config.use_builtin_safety_guard :mailers
70
+ #
71
+ # config.safety_guard :jobs do |&execute|
72
+ # Sidekiq::Testing.fake! { execute.call }
73
+ # end
41
74
  end
@@ -0,0 +1,7 @@
1
+ namespace :console_agent do
2
+ desc "Start the ConsoleAgent Slack bot (Socket Mode)"
3
+ task slack: :environment do
4
+ require 'console_agent/slack_bot'
5
+ ConsoleAgent::SlackBot.new.start
6
+ end
7
+ end