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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc46d0592feb84b4d85481d1535dccbe417a4445593828424c12a84d96fcbc9c
4
- data.tar.gz: 10fe29dc81cc425a498c6e7d6c6b82aaa586ec674c081ba3f7b5b1143b68df18
3
+ metadata.gz: 4605d79e21eecd9bdcc4d48ffee20e04f3881dbe285201887f997aefaefd13e6
4
+ data.tar.gz: f6b0f23b20d640d9f0e8d266f41a9c88dc6e1eb76c2d5185a5f05d30dae17890
5
5
  SHA512:
6
- metadata.gz: 86760d6c3b7c4920fc2c01741be308fc3d3f133e264c8dc37cab6b1ab90e9b920a410d57c86d8f96e743396d6919735d7fad62ee584667c8ea177c4825a12d05
7
- data.tar.gz: 6446b9b2af4803ccd860fd109484ef37de87850517ad117eb52892974a65a017c1b03188dfc1eb7f24aad3859749ce4f6d282220c8d5d78238e67cfdb7438def
6
+ metadata.gz: 28c4cb1aeef7d423f014c1fab6737f7dee89cd68a2a38380b708312c16d2f7c069d645078c013fc770fae005d8eb7e4bd118120aca0aac3eb7a22bafedecb995
7
+ data.tar.gz: 42b4f79d3d649a985c68d694fd390cc97f34982f5c4ea6c790845336e8723ab646eff7f814c56eed4a21c1946db8671b71564ffab25d609796a4f6595a0da5ed
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.12.0]
6
+
7
+ - Add `slack_allowed_usernames` to restrict Slack channel access
8
+
9
+ ## [0.11.0]
10
+
11
+ - Add Slack channel integration with system instructions and connection pooling
12
+ - Extract channels abstraction and ConversationEngine from Repl
13
+ - Add built-in safety guards with `/danger` bypass and progressive safety allowlists
14
+ - Add local model support with prompt truncation warnings
15
+ - Add `clear!` command to clear bot messages in thread
16
+ - Match code blocks in LLM results
17
+ - Fix long query display and add cost tracking to session viewer
18
+ - Strip quotes from session names when saving
19
+
5
20
  ## [0.10.0]
6
21
 
7
22
  - Add `/expand` command to view previous results
data/README.md CHANGED
@@ -12,7 +12,7 @@ irb> ai "find the 5 most recent orders over $100"
12
12
 
13
13
  Order.where("total > ?", 100).order(created_at: :desc).limit(5)
14
14
 
15
- Execute? [y/N/edit] y
15
+ Execute? [y/N/edit/danger] y
16
16
  => [#<Order id: 4821, ...>, ...]
17
17
  ```
18
18
 
@@ -75,6 +75,8 @@ end
75
75
  | Command | What it does |
76
76
  |---------|-------------|
77
77
  | `/auto` | Toggle auto-execute (skip confirmations) |
78
+ | `/danger` | Toggle safe mode off/on (allow side effects) |
79
+ | `/safe` | Show safety guard status |
78
80
  | `/compact` | Compress history into a summary (saves tokens) |
79
81
  | `/usage` | Show token stats |
80
82
  | `/cost` | Show per-model cost breakdown |
@@ -101,6 +103,49 @@ Say "think harder" in any query to auto-upgrade to the thinking model for that s
101
103
  - **History compaction** — `/compact` summarizes long conversations to reduce cost and latency
102
104
  - **Output trimming** — older execution outputs are automatically replaced with references; the LLM can recall them on demand via `recall_output`, and you can `/expand <id>` to see them
103
105
  - **Debug mode** — `/debug` shows context breakdown, token counts, and per-call cost estimates before and after each LLM call
106
+ - **Safe mode** — configurable guards that block side effects (DB writes, HTTP mutations, email delivery) during AI code execution
107
+
108
+ ## Safety Guards
109
+
110
+ Safety guards prevent AI-generated code from causing side effects. When a guard blocks an operation, the user is prompted to re-run with safe mode disabled.
111
+
112
+ ### Built-in Guards
113
+
114
+ ```ruby
115
+ ConsoleAgent.configure do |config|
116
+ config.use_builtin_safety_guard :database_writes # blocks INSERT/UPDATE/DELETE/DROP/etc.
117
+ config.use_builtin_safety_guard :http_mutations # blocks POST/PUT/PATCH/DELETE via Net::HTTP
118
+ config.use_builtin_safety_guard :mailers # disables ActionMailer delivery
119
+ end
120
+ ```
121
+
122
+ - **`:database_writes`** — intercepts the ActiveRecord connection adapter to block write SQL. Works on Rails 5+ with any database adapter.
123
+ - **`:http_mutations`** — intercepts `Net::HTTP#request` to block non-GET/HEAD/OPTIONS requests. Covers libraries built on Net::HTTP (HTTParty, RestClient, Faraday).
124
+ - **`:mailers`** — sets `ActionMailer::Base.perform_deliveries = false` during execution.
125
+
126
+ ### Custom Guards
127
+
128
+ Write your own guards using the around-block pattern:
129
+
130
+ ```ruby
131
+ ConsoleAgent.configure do |config|
132
+ config.safety_guard :jobs do |&execute|
133
+ Sidekiq::Testing.fake! { execute.call }
134
+ end
135
+ end
136
+ ```
137
+
138
+ Raise `ConsoleAgent::SafetyError` in your app code to trigger the safe mode prompt:
139
+
140
+ ```ruby
141
+ raise ConsoleAgent::SafetyError, "Stripe charge blocked"
142
+ ```
143
+
144
+ ### Toggling Safe Mode
145
+
146
+ - **`/danger`** in interactive mode toggles all guards off/on for the session
147
+ - **`d`** at the `Execute? [y/N/edit/danger]` prompt disables guards for that single execution
148
+ - When a guard blocks an operation, the user is prompted: `Re-run with safe mode disabled? [y/N]`
104
149
 
105
150
  ## Configuration
106
151
 
@@ -146,6 +191,61 @@ end
146
191
 
147
192
  When `authenticate` is set, `admin_username` / `admin_password` are ignored.
148
193
 
194
+ ## Channels
195
+
196
+ ConsoleAgent can run through different channels beyond the Rails console. Each channel is a separate process that connects the same AI engine to a different interface.
197
+
198
+ ### Slack
199
+
200
+ Run ConsoleAgent as a Slack bot. Each Slack thread becomes an independent AI session with full tool use, multi-step plans, and safety guards always on.
201
+
202
+ #### Slack App Setup
203
+
204
+ 1. Create a new app at https://api.slack.com/apps → **Create New App** → **From scratch**
205
+
206
+ 2. **Enable Socket Mode** — Settings → Socket Mode → toggle ON. Generate an App-Level Token with the `connections:write` scope. Copy the `xapp-...` token.
207
+
208
+ 3. **Bot Token Scopes** — OAuth & Permissions → Bot Token Scopes, add:
209
+ - `chat:write`
210
+ - `channels:history` (public channels)
211
+ - `groups:history` (private channels, optional)
212
+ - `im:history` (direct messages)
213
+ - `users:read`
214
+
215
+ 4. **Event Subscriptions** — Event Subscriptions → toggle ON, then under "Subscribe to bot events" add:
216
+ - `message.channels` (public channels)
217
+ - `message.groups` (private channels, optional)
218
+ - `message.im` (direct messages)
219
+
220
+ 5. **App Home** — Show Tabs → toggle **Messages Tab** ON and check **"Allow users to send Slash commands and messages from the messages tab"**
221
+
222
+ 6. **Install to workspace** — Install App → Install to Workspace. Copy the `xoxb-...` Bot User OAuth Token.
223
+
224
+ 7. **Invite the bot** to a channel with `/invite @YourBotName`, or DM it directly.
225
+
226
+ #### Configuration
227
+
228
+ ```ruby
229
+ ConsoleAgent.configure do |config|
230
+ config.slack_bot_token = ENV['SLACK_BOT_TOKEN'] # xoxb-...
231
+ config.slack_app_token = ENV['SLACK_APP_TOKEN'] # xapp-...
232
+
233
+ # Optional: restrict to specific Slack channel IDs
234
+ # config.slack_channel_ids = 'C1234567890,C0987654321'
235
+
236
+ # Required: which users the bot responds to (by display name)
237
+ config.slack_allowed_usernames = ['alice', 'bob'] # or 'ALL' for everyone
238
+ end
239
+ ```
240
+
241
+ #### Running
242
+
243
+ ```bash
244
+ bundle exec rake console_agent:slack
245
+ ```
246
+
247
+ This starts a long-running process (run it separately from your web server). Each new message creates a session; threaded replies continue the conversation. The bot auto-executes code with safety guards always enabled — there is no `/danger` equivalent in Slack.
248
+
149
249
  ## Requirements
150
250
 
151
251
  Ruby >= 2.5, Rails >= 5.0, Faraday >= 1.0
@@ -1,5 +1,19 @@
1
1
  module ConsoleAgent
2
2
  module SessionsHelper
3
+ def estimated_cost(session)
4
+ pricing = Configuration::PRICING[session.model]
5
+ return nil unless pricing
6
+
7
+ (session.input_tokens * pricing[:input]) + (session.output_tokens * pricing[:output])
8
+ end
9
+
10
+ def format_cost(session)
11
+ cost = estimated_cost(session)
12
+ return '-' unless cost
13
+
14
+ cost < 0.01 ? "<$0.01" : "$#{'%.2f' % cost}"
15
+ end
16
+
3
17
  # Convert ANSI escape codes to HTML spans for terminal-style rendering
4
18
  def ansi_to_html(text)
5
19
  return '' if text.nil? || text.empty?
@@ -4,7 +4,7 @@ module ConsoleAgent
4
4
 
5
5
  validates :query, presence: true
6
6
  validates :conversation, presence: true
7
- validates :mode, presence: true, inclusion: { in: %w[one_shot interactive explain] }
7
+ validates :mode, presence: true, inclusion: { in: %w[one_shot interactive explain slack] }
8
8
 
9
9
  scope :recent, -> { order(created_at: :desc) }
10
10
  scope :named, ->(name) { where(name: name) }
@@ -14,10 +14,10 @@
14
14
  <th>Time</th>
15
15
  <th>User</th>
16
16
  <th>Name</th>
17
- <th>Query</th>
17
+ <th style="max-width: 400px;">Query</th>
18
18
  <th>Mode</th>
19
19
  <th>Tokens</th>
20
- <th>Executed?</th>
20
+ <th>Cost</th>
21
21
  <th>Duration</th>
22
22
  </tr>
23
23
  </thead>
@@ -27,10 +27,10 @@
27
27
  <td class="mono"><%= session.created_at.strftime('%Y-%m-%d %H:%M') %></td>
28
28
  <td><%= session.user_name %></td>
29
29
  <td><%= session.name.present? ? session.name : '-' %></td>
30
- <td><a href="<%= console_agent.session_path(session) %>"><%= truncate(session.query, length: 80) %></a></td>
30
+ <td class="query-cell"><a href="<%= console_agent.session_path(session) %>" title="<%= h session.query.truncate(200) %>"><%= truncate(session.query.gsub(/\s+/, ' ').strip, length: 80) %></a></td>
31
31
  <td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
32
32
  <td class="mono"><%= session.input_tokens + session.output_tokens %></td>
33
- <td><%= session.executed? ? '<span class="check">Yes</span>'.html_safe : '<span class="cross">No</span>'.html_safe %></td>
33
+ <td class="mono"><%= format_cost(session) %></td>
34
34
  <td class="mono"><%= session.duration_ms ? "#{session.duration_ms}ms" : '-' %></td>
35
35
  </tr>
36
36
  <% end %>
@@ -2,9 +2,19 @@
2
2
 
3
3
  <div class="meta-card">
4
4
  <div class="meta-grid">
5
- <div class="meta-item">
5
+ <div class="meta-item" style="grid-column: 1 / -1;">
6
6
  <label>Query</label>
7
- <span><%= @session.query %></span>
7
+ <% if @session.query.length > 300 %>
8
+ <div class="query-preview">
9
+ <span><%= truncate(@session.query, length: 300) %></span>
10
+ <details style="margin-top: 4px;">
11
+ <summary style="cursor: pointer; font-size: 12px; color: #4a6fa5;">Show full query</summary>
12
+ <pre style="white-space: pre-wrap; word-wrap: break-word; font-size: 13px; margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 4px; max-height: 400px; overflow-y: auto;"><%= @session.query %></pre>
13
+ </details>
14
+ </div>
15
+ <% else %>
16
+ <span><%= @session.query %></span>
17
+ <% end %>
8
18
  </div>
9
19
  <% if @session.name.present? %>
10
20
  <div class="meta-item">
@@ -29,12 +39,12 @@
29
39
  <span class="mono"><%= @session.input_tokens %> / <%= @session.output_tokens %></span>
30
40
  </div>
31
41
  <div class="meta-item">
32
- <label>Duration</label>
33
- <span class="mono"><%= @session.duration_ms ? "#{@session.duration_ms}ms" : '-' %></span>
42
+ <label>Est. Cost</label>
43
+ <span class="mono"><%= format_cost(@session) %></span>
34
44
  </div>
35
45
  <div class="meta-item">
36
- <label>Executed?</label>
37
- <span><%= @session.executed? ? '<span class="check">Yes</span>'.html_safe : '<span class="cross">No</span>'.html_safe %></span>
46
+ <label>Duration</label>
47
+ <span class="mono"><%= @session.duration_ms ? "#{@session.duration_ms}ms" : '-' %></span>
38
48
  </div>
39
49
  <div class="meta-item">
40
50
  <label>Time</label>
@@ -54,6 +54,7 @@
54
54
  overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
55
55
  }
56
56
  .mono { font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace; font-size: 13px; }
57
+ .query-cell { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
58
  .text-muted { color: #888; }
58
59
  .back-link { margin-bottom: 16px; display: inline-block; }
59
60
  .check { color: #28a745; }
@@ -0,0 +1,23 @@
1
+ module ConsoleAgent
2
+ module Channel
3
+ class Base
4
+ def display(text); raise NotImplementedError; end
5
+ def display_dim(text); raise NotImplementedError; end
6
+ def display_warning(text); raise NotImplementedError; end
7
+ def display_error(text); raise NotImplementedError; end
8
+ def display_code(code); raise NotImplementedError; end
9
+ def display_result(text); raise NotImplementedError; end
10
+ def display_result_output(text); end # stdout output from code execution
11
+ def prompt(text); raise NotImplementedError; end
12
+ def confirm(text); raise NotImplementedError; end
13
+ def user_identity; raise NotImplementedError; end
14
+ def mode; raise NotImplementedError; end
15
+ def cancelled?; false; end
16
+ def supports_danger?; true; end
17
+ def supports_editing?; false; end
18
+ def edit_code(code); code; end
19
+ def wrap_llm_call(&block); yield; end
20
+ def system_instructions; nil; end
21
+ end
22
+ end
23
+ end