rails_console_ai 0.13.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/app/controllers/rails_console_ai/application_controller.rb +28 -0
  6. data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
  7. data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
  8. data/app/models/rails_console_ai/session.rb +23 -0
  9. data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
  10. data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
  11. data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/generators/rails_console_ai/install_generator.rb +26 -0
  14. data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
  15. data/lib/rails_console_ai/channel/base.rb +23 -0
  16. data/lib/rails_console_ai/channel/console.rb +457 -0
  17. data/lib/rails_console_ai/channel/slack.rb +182 -0
  18. data/lib/rails_console_ai/configuration.rb +185 -0
  19. data/lib/rails_console_ai/console_methods.rb +277 -0
  20. data/lib/rails_console_ai/context_builder.rb +120 -0
  21. data/lib/rails_console_ai/conversation_engine.rb +1142 -0
  22. data/lib/rails_console_ai/engine.rb +5 -0
  23. data/lib/rails_console_ai/executor.rb +461 -0
  24. data/lib/rails_console_ai/providers/anthropic.rb +122 -0
  25. data/lib/rails_console_ai/providers/base.rb +118 -0
  26. data/lib/rails_console_ai/providers/bedrock.rb +171 -0
  27. data/lib/rails_console_ai/providers/local.rb +112 -0
  28. data/lib/rails_console_ai/providers/openai.rb +114 -0
  29. data/lib/rails_console_ai/railtie.rb +34 -0
  30. data/lib/rails_console_ai/repl.rb +65 -0
  31. data/lib/rails_console_ai/safety_guards.rb +207 -0
  32. data/lib/rails_console_ai/session_logger.rb +90 -0
  33. data/lib/rails_console_ai/slack_bot.rb +473 -0
  34. data/lib/rails_console_ai/storage/base.rb +27 -0
  35. data/lib/rails_console_ai/storage/file_storage.rb +63 -0
  36. data/lib/rails_console_ai/tools/code_tools.rb +126 -0
  37. data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
  38. data/lib/rails_console_ai/tools/model_tools.rb +95 -0
  39. data/lib/rails_console_ai/tools/registry.rb +478 -0
  40. data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
  41. data/lib/rails_console_ai/version.rb +3 -0
  42. data/lib/rails_console_ai.rb +214 -0
  43. data/lib/tasks/rails_console_ai.rake +7 -0
  44. metadata +152 -0
@@ -0,0 +1,90 @@
1
+ module RailsConsoleAI
2
+ module SessionLogger
3
+ class << self
4
+ def log(attrs)
5
+ return unless RailsConsoleAI.configuration.session_logging
6
+ return unless table_exists?
7
+
8
+ create_attrs = {
9
+ query: attrs[:query],
10
+ conversation: Array(attrs[:conversation]).to_json,
11
+ input_tokens: attrs[:input_tokens] || 0,
12
+ output_tokens: attrs[:output_tokens] || 0,
13
+ user_name: attrs[:user_name] || current_user_name,
14
+ mode: attrs[:mode].to_s,
15
+ name: attrs[:name],
16
+ code_executed: attrs[:code_executed],
17
+ code_output: attrs[:code_output],
18
+ code_result: attrs[:code_result],
19
+ console_output: attrs[:console_output],
20
+ executed: attrs[:executed] || false,
21
+ provider: RailsConsoleAI.configuration.provider.to_s,
22
+ model: RailsConsoleAI.configuration.resolved_model,
23
+ duration_ms: attrs[:duration_ms],
24
+ created_at: Time.respond_to?(:current) ? Time.current : Time.now
25
+ }
26
+ create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
+ record = session_class.create!(create_attrs)
28
+ record.id
29
+ rescue => e
30
+ msg = "RailsConsoleAI: session logging failed: #{e.class}: #{e.message}"
31
+ $stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
32
+ RailsConsoleAI.logger.warn(msg)
33
+ nil
34
+ end
35
+
36
+ def find_by_slack_thread(thread_ts)
37
+ return nil unless RailsConsoleAI.configuration.session_logging
38
+ return nil unless table_exists?
39
+ session_class.where(slack_thread_ts: thread_ts).order(created_at: :desc).first
40
+ rescue => e
41
+ RailsConsoleAI.logger.warn("RailsConsoleAI: session lookup failed: #{e.class}: #{e.message}")
42
+ nil
43
+ end
44
+
45
+ def update(id, attrs)
46
+ return unless id
47
+ return unless RailsConsoleAI.configuration.session_logging
48
+ return unless table_exists?
49
+
50
+ updates = {}
51
+ updates[:conversation] = Array(attrs[:conversation]).to_json if attrs.key?(:conversation)
52
+ updates[:input_tokens] = attrs[:input_tokens] if attrs.key?(:input_tokens)
53
+ updates[:output_tokens] = attrs[:output_tokens] if attrs.key?(:output_tokens)
54
+ updates[:code_executed] = attrs[:code_executed] if attrs.key?(:code_executed)
55
+ updates[:code_output] = attrs[:code_output] if attrs.key?(:code_output)
56
+ updates[:code_result] = attrs[:code_result] if attrs.key?(:code_result)
57
+ updates[:console_output] = attrs[:console_output] if attrs.key?(:console_output)
58
+ updates[:executed] = attrs[:executed] if attrs.key?(:executed)
59
+ updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
60
+ updates[:name] = attrs[:name] if attrs.key?(:name)
61
+
62
+ session_class.where(id: id).update_all(updates) unless updates.empty?
63
+ rescue => e
64
+ msg = "RailsConsoleAI: session update failed: #{e.class}: #{e.message}"
65
+ $stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
66
+ RailsConsoleAI.logger.warn(msg)
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def table_exists?
73
+ # Only cache positive results — retry on failure so transient
74
+ # errors (boot timing, connection not ready) don't stick forever
75
+ return true if @table_exists
76
+ @table_exists = session_class.connection.table_exists?('rails_console_ai_sessions')
77
+ rescue
78
+ false
79
+ end
80
+
81
+ def session_class
82
+ Object.const_get('RailsConsoleAI::Session')
83
+ end
84
+
85
+ def current_user_name
86
+ RailsConsoleAI.current_user || ENV['USER']
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,473 @@
1
+ require 'json'
2
+ require 'uri'
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'rails_console_ai/channel/slack'
6
+ require 'rails_console_ai/conversation_engine'
7
+ require 'rails_console_ai/context_builder'
8
+ require 'rails_console_ai/providers/base'
9
+ require 'rails_console_ai/executor'
10
+
11
+ module RailsConsoleAI
12
+ class SlackBot
13
+ def initialize
14
+ @bot_token = RailsConsoleAI.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
15
+ @app_token = RailsConsoleAI.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 RailsConsoleAI.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(RailsConsoleAI.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
+ RailsConsoleAI.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
+ RailsConsoleAI.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 'rails_console_ai/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
+ RailsConsoleAI.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
+ RailsConsoleAI.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 = RailsConsoleAI.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
+ RailsConsoleAI.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 "RailsConsoleAI 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
@@ -0,0 +1,27 @@
1
+ module RailsConsoleAI
2
+ module Storage
3
+ class StorageError < StandardError; end
4
+
5
+ class Base
6
+ def read(key)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def write(key, content)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def list(pattern)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def exists?(key)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def delete(key)
23
+ raise NotImplementedError
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ require 'fileutils'
2
+ require 'rails_console_ai/storage/base'
3
+
4
+ module RailsConsoleAI
5
+ module Storage
6
+ class FileStorage < Base
7
+ attr_reader :root_path
8
+
9
+ def initialize(root_path = nil)
10
+ @root_path = root_path || default_root
11
+ end
12
+
13
+ def read(key)
14
+ path = full_path(key)
15
+ return nil unless File.exist?(path)
16
+ File.read(path)
17
+ end
18
+
19
+ def write(key, content)
20
+ path = full_path(key)
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+ File.write(path, content)
23
+ true
24
+ rescue Errno::EACCES, Errno::EROFS, IOError => e
25
+ raise StorageError, "Cannot write #{key}: #{e.message}"
26
+ end
27
+
28
+ def list(pattern)
29
+ Dir.glob(File.join(@root_path, pattern)).sort.map do |path|
30
+ path.sub("#{@root_path}/", '')
31
+ end
32
+ end
33
+
34
+ def exists?(key)
35
+ File.exist?(full_path(key))
36
+ end
37
+
38
+ def delete(key)
39
+ path = full_path(key)
40
+ return false unless File.exist?(path)
41
+ File.delete(path)
42
+ true
43
+ rescue Errno::EACCES, Errno::EROFS, IOError => e
44
+ raise StorageError, "Cannot delete #{key}: #{e.message}"
45
+ end
46
+
47
+ private
48
+
49
+ def full_path(key)
50
+ sanitized = key.gsub('..', '').gsub(%r{\A/+}, '')
51
+ File.join(@root_path, sanitized)
52
+ end
53
+
54
+ def default_root
55
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
56
+ File.join(Rails.root.to_s, '.rails_console_ai')
57
+ else
58
+ File.join(Dir.pwd, '.rails_console_ai')
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end