rails_console_ai 0.35.0 → 0.38.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +102 -10
  4. data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
  5. data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
  6. data/app/models/rails_console_ai/artifact.rb +231 -0
  7. data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
  8. data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
  9. data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
  10. data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
  11. data/config/routes.rb +8 -0
  12. data/lib/generators/rails_console_ai/templates/initializer.rb +12 -1
  13. data/lib/rails_console_ai/channel/console.rb +132 -18
  14. data/lib/rails_console_ai/channel/slack.rb +1 -1
  15. data/lib/rails_console_ai/configuration.rb +114 -15
  16. data/lib/rails_console_ai/context_builder.rb +48 -18
  17. data/lib/rails_console_ai/conversation_engine.rb +231 -61
  18. data/lib/rails_console_ai/line_editor.rb +142 -0
  19. data/lib/rails_console_ai/providers/anthropic.rb +3 -1
  20. data/lib/rails_console_ai/providers/base.rb +26 -1
  21. data/lib/rails_console_ai/providers/bedrock.rb +5 -2
  22. data/lib/rails_console_ai/providers/local.rb +17 -37
  23. data/lib/rails_console_ai/providers/openai.rb +31 -10
  24. data/lib/rails_console_ai/providers/openrouter.rb +108 -0
  25. data/lib/rails_console_ai/slack_bot.rb +58 -23
  26. data/lib/rails_console_ai/slash_commands.rb +150 -0
  27. data/lib/rails_console_ai/sub_agent.rb +35 -2
  28. data/lib/rails_console_ai/tools/registry.rb +233 -3
  29. data/lib/rails_console_ai/version.rb +1 -1
  30. data/lib/rails_console_ai.rb +223 -0
  31. metadata +8 -1
@@ -0,0 +1,119 @@
1
+ <div class="btn-bar">
2
+ <%= link_to '← All artifacts', artifacts_path, class: 'btn btn-secondary' %>
3
+ <%= link_to 'Download', download_artifact_path(@artifact.slug), class: 'btn' %>
4
+ <%= link_to 'Open raw', raw_artifact_path(@artifact.slug), class: 'btn btn-secondary', target: '_blank', rel: 'noopener noreferrer' %>
5
+ <%# Copies the artifact's source, exactly as stored. Fetches the raw URL on
6
+ click rather than embedding a second copy of the content in this page. %>
7
+ <button type="button" class="btn btn-secondary" id="copy-artifact"
8
+ data-url="<%= raw_artifact_path(@artifact.slug) %>"
9
+ title="Copy the artifact's content to the clipboard">Copy</button>
10
+ <%# button_to, not link_to: the engine layout loads no JavaScript, so a
11
+ data-method link would quietly do nothing. Matches skills/agents. %>
12
+ <span style="margin-left:auto;">
13
+ <%= button_to 'Delete', artifact_path(@artifact.slug), method: :delete,
14
+ class: 'btn btn-danger', form: { style: 'display:inline' },
15
+ data: { confirm: 'Delete this artifact? This cannot be undone.' } %>
16
+ </span>
17
+ </div>
18
+
19
+ <div class="meta-card">
20
+ <h2 style="font-size:18px; margin-bottom:4px;"><%= @artifact.name %></h2>
21
+ <% if @artifact.description.present? %>
22
+ <p class="text-muted" style="margin-bottom:16px;"><%= @artifact.description %></p>
23
+ <% end %>
24
+ <div class="meta-grid">
25
+ <div class="meta-item">
26
+ <label title="How the assistant addresses this artifact">Handle</label>
27
+ <span class="mono"><%= @artifact.slug %></span>
28
+ </div>
29
+ <div class="meta-item"><label>Revision</label><span><%= @artifact.revision %></span></div>
30
+ <div class="meta-item"><label>Type</label><span class="mono"><%= @artifact.content_type %></span></div>
31
+ <div class="meta-item"><label>Size</label><span><%= @artifact.human_size %></span></div>
32
+ <div class="meta-item"><label>Created by</label><span><%= @artifact.created_by || '—' %></span></div>
33
+ <div class="meta-item">
34
+ <label>Session</label>
35
+ <span>
36
+ <% if @artifact.session_id %>
37
+ <%= link_to "##{@artifact.session_id}", session_path(@artifact.session_id) %>
38
+ <% else %>
39
+ —
40
+ <% end %>
41
+ </span>
42
+ </div>
43
+ <div class="meta-item"><label>Created</label><span><%= @artifact.created_at.strftime('%Y-%m-%d %H:%M') %></span></div>
44
+ <div class="meta-item"><label>Updated</label><span><%= @artifact.updated_at.strftime('%Y-%m-%d %H:%M') %></span></div>
45
+ </div>
46
+ </div>
47
+
48
+ <% if @artifact.renderable? %>
49
+ <div class="meta-card" style="padding:0; overflow:hidden;">
50
+ <%#
51
+ Sandboxed with no allow-same-origin and no allow-scripts: the frame gets an
52
+ opaque origin, so even if the content carried a script it could not run, and
53
+ could not reach this page's session if it did. The same restrictions are set
54
+ as a CSP header on the raw response, which is what protects the "Open raw"
55
+ link where no iframe attribute applies.
56
+ %>
57
+ <iframe src="<%= raw_artifact_path(@artifact.slug) %>"
58
+ sandbox
59
+ referrerpolicy="no-referrer"
60
+ title="<%= @artifact.name %>"
61
+ style="width:100%; height:70vh; border:0; background:#fff; display:block;"></iframe>
62
+ </div>
63
+ <p class="text-muted" style="font-size:12px; margin-top:8px;">
64
+ Rendered in a sandboxed frame — scripts and network requests are blocked, so inline CSS shows but JavaScript will not run.
65
+ </p>
66
+ <% else %>
67
+ <div class="terminal">
68
+ <pre><%= @artifact.content %></pre>
69
+ </div>
70
+ <% end %>
71
+
72
+ <%# The only JavaScript in the admin UI. nonce: true picks up the host app's CSP
73
+ nonce when it has one, and is a no-op otherwise. %>
74
+ <%= javascript_tag nonce: true do %>
75
+ (function () {
76
+ var button = document.getElementById('copy-artifact');
77
+ if (!button) return;
78
+ var label = button.textContent;
79
+
80
+ function flash(text) {
81
+ button.textContent = text;
82
+ setTimeout(function () { button.textContent = label; }, 2000);
83
+ }
84
+
85
+ // navigator.clipboard needs a secure context (https or localhost). Over
86
+ // plain http elsewhere, fall back to the old textarea + execCommand route.
87
+ function writeClipboard(text) {
88
+ if (navigator.clipboard && window.isSecureContext) {
89
+ return navigator.clipboard.writeText(text);
90
+ }
91
+ return new Promise(function (resolve, reject) {
92
+ var area = document.createElement('textarea');
93
+ area.value = text;
94
+ area.setAttribute('readonly', '');
95
+ area.style.position = 'fixed';
96
+ area.style.top = '-1000px';
97
+ document.body.appendChild(area);
98
+ area.select();
99
+ var ok = false;
100
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
101
+ document.body.removeChild(area);
102
+ ok ? resolve() : reject(new Error('execCommand copy failed'));
103
+ });
104
+ }
105
+
106
+ button.addEventListener('click', function () {
107
+ button.disabled = true;
108
+ fetch(button.getAttribute('data-url'), { credentials: 'same-origin' })
109
+ .then(function (response) {
110
+ if (!response.ok) throw new Error('HTTP ' + response.status);
111
+ return response.text();
112
+ })
113
+ .then(writeClipboard)
114
+ .then(function () { flash('Copied'); })
115
+ .catch(function () { flash('Copy failed'); })
116
+ .then(function () { button.disabled = false; });
117
+ });
118
+ })();
119
+ <% end %>
@@ -36,6 +36,12 @@
36
36
  <span><%= @session.slack_channel_name %></span>
37
37
  </div>
38
38
  <% end %>
39
+ <% if @session.try(:slack_thread_ts).present? %>
40
+ <div class="meta-item">
41
+ <label>Slack Thread</label>
42
+ <span class="mono"><%= @session.slack_thread_ts %></span>
43
+ </div>
44
+ <% end %>
39
45
  <div class="meta-item">
40
46
  <label>Provider / Model</label>
41
47
  <span><%= @session.provider %> / <%= @session.model %></span>
data/config/routes.rb CHANGED
@@ -2,6 +2,14 @@ RailsConsoleAi::Engine.routes.draw do
2
2
  root to: 'sessions#index'
3
3
  resources :sessions, only: [:index, :show]
4
4
 
5
+ # Addressed by token, not id — see ArtifactsController#load_artifact.
6
+ resources :artifacts, only: [:index, :show, :destroy] do
7
+ member do
8
+ get :raw
9
+ get :download
10
+ end
11
+ end
12
+
5
13
  resources :skills do
6
14
  member do
7
15
  post :approve
@@ -1,5 +1,5 @@
1
1
  RailsConsoleAi.configure do |config|
2
- # LLM provider: :anthropic, :openai, or :local
2
+ # LLM provider: :anthropic, :openai, :openrouter, :bedrock, or :local
3
3
  config.provider = :anthropic
4
4
 
5
5
  # API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
@@ -32,6 +32,12 @@ RailsConsoleAi.configure do |config|
32
32
  # config.local_model = 'qwen2.5:7b'
33
33
  # config.local_api_key = nil
34
34
 
35
+ # OpenRouter provider (400+ models via unified API):
36
+ # config.provider = :openrouter
37
+ # config.model = 'anthropic/claude-sonnet-5'
38
+ # config.openrouter_site_url = 'https://yoursite.com' # optional, for attribution
39
+ # config.openrouter_app_name = 'MyApp' # optional, shown on openrouter.ai
40
+
35
41
  # Slack: which users the bot responds to (legacy — prefer channels config below)
36
42
  # config.slack_allowed_usernames = ['alice', 'bob']
37
43
 
@@ -47,6 +53,11 @@ RailsConsoleAi.configure do |config|
47
53
  # Run RailsConsoleAi.setup! in the Rails console to create the table
48
54
  config.session_logging = true
49
55
 
56
+ # Public base URL of this app, so links the assistant hands out (artifact URLs)
57
+ # are absolute — a bare path is useless once pasted into Slack or opened on a
58
+ # phone. Falls back to the app's default_url_options, then to a relative path.
59
+ # config.base_url = 'https://secure.example.com'
60
+
50
61
  # Database connection for RailsConsoleAi tables (default: ActiveRecord::Base)
51
62
  # Set to a class that responds to .connection if tables live on a different DB
52
63
  # config.connection_class = Sharding::CentralizedModel
@@ -1,5 +1,6 @@
1
- require 'readline'
2
1
  require 'rails_console_ai/channel/base'
2
+ require 'rails_console_ai/line_editor'
3
+ require 'rails_console_ai/slash_commands'
3
4
 
4
5
  module RailsConsoleAi
5
6
  module Channel
@@ -186,6 +187,16 @@ module RailsConsoleAi
186
187
  @interactive_console_capture = StringIO.new
187
188
  @real_stdout = $stdout
188
189
  $stdout = TeeIO.new(@real_stdout, @interactive_console_capture)
190
+ init_slash_commands
191
+ end
192
+
193
+ def init_slash_commands
194
+ @slash_commands = SlashCommands.new
195
+ # Point the editor at the real stdout: Reline draws its completion menu
196
+ # through a Ruby IO, and $stdout is a TeeIO right now — without this the
197
+ # menu's escape codes get written into the saved session log.
198
+ @editor = LineEditor.resolve(RailsConsoleAi.configuration.line_editor, output: @real_stdout)
199
+ @editor.complete_with { @slash_commands.completion_entries }
189
200
  end
190
201
 
191
202
  def run_interactive_loop
@@ -196,14 +207,13 @@ module RailsConsoleAi
196
207
  config = RailsConsoleAi.configuration
197
208
  @real_stdout.puts "\e[2m Provider: #{config.provider} | Model: #{config.resolved_model}\e[0m"
198
209
  safe_info = guards.empty? ? '' : " | Safe mode: #{guards.enabled? ? 'ON' : 'OFF'} (/danger to toggle)"
199
- @real_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle)#{safe_info} | > code | /usage | /cost | /compact | /think | /name <label>\e[0m"
210
+ @real_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle)#{safe_info} | > code\e[0m"
211
+ @real_stdout.puts "\e[2m Type / for commands, skills and agents#{@editor.is_a?(LineEditor::Reline_) ? ' (menu appears as you type)' : ' (Tab to complete)'}\e[0m"
200
212
 
201
- if Readline.respond_to?(:parse_and_bind)
202
- Readline.parse_and_bind('"\e[Z": "\C-a\C-k/auto\C-m"')
203
- end
213
+ @editor.bind_auto_toggle
204
214
 
205
215
  loop do
206
- input = Readline.readline("\001\e[33m\002ai> \001\e[0m\002", false)
216
+ input = @editor.readline(@editor.prompt('ai> ', "\e[33m"))
207
217
  break if input.nil?
208
218
 
209
219
  input = input.strip
@@ -211,8 +221,13 @@ module RailsConsoleAi
211
221
  break if input.downcase == 'exit' || input.downcase == 'quit'
212
222
  next if input.empty?
213
223
 
214
- handled = handle_slash_command(input)
215
- next if handled
224
+ # What the user typed, kept for history/logging even when a skill
225
+ # command expands into a much longer prompt.
226
+ typed = input
227
+
228
+ outcome = handle_slash_command(input)
229
+ next if outcome == :handled
230
+ input = outcome if outcome.is_a?(String)
216
231
 
217
232
  # Direct code execution with ">" prefix
218
233
  if input.start_with?('>') && !input.start_with?('>=')
@@ -220,14 +235,13 @@ module RailsConsoleAi
220
235
  next
221
236
  end
222
237
 
223
- # Add to Readline history
224
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
238
+ @editor.push_history(typed)
225
239
 
226
240
  @engine.maybe_auto_upgrade_thinking(input)
227
241
 
228
- @engine.set_interactive_query(input)
242
+ @engine.set_interactive_query(typed)
229
243
  @engine.add_user_message(input)
230
- @interactive_console_capture.write("ai> #{input}\n")
244
+ @interactive_console_capture.write("ai> #{typed}\n")
231
245
  @engine.log_interactive_turn
232
246
 
233
247
  expected_stdout = $stdout
@@ -254,6 +268,9 @@ module RailsConsoleAi
254
268
 
255
269
  @engine.log_interactive_turn
256
270
  @engine.warn_if_history_large
271
+ # A turn may have created a skill or agent via save_skill/save_agent —
272
+ # drop the memo so the next "/" reflects it.
273
+ @slash_commands.refresh!
257
274
  end
258
275
 
259
276
  $stdout = @real_stdout
@@ -269,6 +286,9 @@ module RailsConsoleAi
269
286
  $stderr.puts "\e[31mRailsConsoleAi Error: #{e.class}: #{e.message}\e[0m"
270
287
  end
271
288
 
289
+ # Returns :handled when the command did its own thing, a String when it
290
+ # should be sent to the model as this turn's message, or nil when the input
291
+ # isn't a slash command at all.
272
292
  def handle_slash_command(input)
273
293
  case input
274
294
  when '?', '/'
@@ -301,6 +321,8 @@ module RailsConsoleAi
301
321
  @engine.upgrade_to_thinking_model
302
322
  when '/unthink'
303
323
  @engine.downgrade_from_thinking_model
324
+ when '/cheap'
325
+ @engine.switch_to_cheap_model
304
326
  when /\A\/expand/
305
327
  expand_id = input.sub('/expand', '').strip.to_i
306
328
  full_output = expand_output(expand_id)
@@ -314,9 +336,47 @@ module RailsConsoleAi
314
336
  when /\A\/name/
315
337
  handle_name_command(input)
316
338
  else
317
- return false
339
+ return dispatch_registry_command(input)
340
+ end
341
+ :handled
342
+ end
343
+
344
+ # Skills and agents are addressed by a slug derived from their name:
345
+ # "Restart user trial" -> /restart-user-trial.
346
+ def dispatch_registry_command(input)
347
+ return nil unless input.start_with?('/')
348
+
349
+ slug, _, args = input[1..].partition(/\s+/)
350
+ command = @slash_commands.find(slug)
351
+
352
+ unless command
353
+ # Only claim the input if it actually looks like a command. A message
354
+ # that merely opens with a path ("/tmp/foo.log has the error") must
355
+ # still reach the model untouched.
356
+ return nil unless slug =~ /\A[a-z0-9][a-z0-9_-]*\z/
357
+ warn_unknown_command(slug)
358
+ return :handled
359
+ end
360
+
361
+ case command.kind
362
+ when :skill
363
+ @engine.skill_command_prompt(command.record, args)
364
+ when :agent
365
+ @engine.run_agent_command(command.record, args)
366
+ :handled
367
+ else
368
+ nil # a built-in, already handled by the case above
369
+ end
370
+ end
371
+
372
+ def warn_unknown_command(slug)
373
+ @real_stdout.puts "\e[33m Unknown command: /#{slug}\e[0m"
374
+ near = @slash_commands.candidates.select { |c| c.start_with?("/#{slug[0, 3]}") }
375
+ if near.empty?
376
+ @real_stdout.puts "\e[2m Type / to see everything available.\e[0m"
377
+ else
378
+ @real_stdout.puts "\e[2m Did you mean: #{near.first(5).join(', ')}?\e[0m"
318
379
  end
319
- true
320
380
  end
321
381
 
322
382
  def retry_last_code
@@ -325,7 +385,7 @@ module RailsConsoleAi
325
385
 
326
386
  def handle_direct_execution(input)
327
387
  raw_code = input.sub(/\A>\s?/, '')
328
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
388
+ @editor.push_history(input)
329
389
  @interactive_console_capture.write("ai> #{input}\n")
330
390
  @engine.execute_direct(raw_code)
331
391
  @engine.log_interactive_turn
@@ -367,13 +427,14 @@ module RailsConsoleAi
367
427
  def display_model_info
368
428
  config = RailsConsoleAi.configuration
369
429
  model = @engine.effective_model
370
- thinking = config.resolved_thinking_model
371
430
  pricing = Configuration.pricing_for(model)
372
431
 
373
432
  @real_stdout.puts "\e[36m Model info:\e[0m"
374
433
  @real_stdout.puts "\e[2m Provider: #{config.provider}\e[0m"
375
- @real_stdout.puts "\e[2m Model: #{model}\e[0m"
376
- @real_stdout.puts "\e[2m Thinking model: #{thinking}\e[0m"
434
+ @real_stdout.puts "\e[2m Model: #{@engine.effective_model_label} [#{@engine.model_tier}]\e[0m"
435
+ @real_stdout.puts "\e[2m Default model: #{model_with_effort(config.resolved_model, config.effort)}\e[0m"
436
+ @real_stdout.puts "\e[2m Thinking model: #{model_with_effort(config.resolved_thinking_model, config.thinking_effort)}\e[0m"
437
+ @real_stdout.puts "\e[2m Cheap model: #{config.resolved_cheap_model}\e[0m"
377
438
  @real_stdout.puts "\e[2m Max tokens: #{config.resolved_max_tokens}\e[0m"
378
439
  if pricing
379
440
  @real_stdout.puts "\e[2m Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out\e[0m"
@@ -383,6 +444,12 @@ module RailsConsoleAi
383
444
  end
384
445
  @real_stdout.puts "\e[2m Bedrock region: #{config.bedrock_region}\e[0m" if config.provider == :bedrock
385
446
  @real_stdout.puts "\e[2m Local URL: #{config.local_url}\e[0m" if config.provider == :local
447
+ @real_stdout.puts "\e[2m OpenRouter URL: #{config.openrouter_url}\e[0m" if config.openrouter_url
448
+ end
449
+
450
+ def model_with_effort(model, effort)
451
+ effort = Configuration.effort_for(model, effort)
452
+ effort ? "#{model} (#{effort} effort)" : model
386
453
  end
387
454
 
388
455
  def handle_name_command(input)
@@ -412,6 +479,7 @@ module RailsConsoleAi
412
479
  @real_stdout.puts "\e[2m /model Show provider, model, and pricing info\e[0m"
413
480
  @real_stdout.puts "\e[2m /think Switch to thinking model\e[0m"
414
481
  @real_stdout.puts "\e[2m /unthink Switch back to default model\e[0m"
482
+ @real_stdout.puts "\e[2m /cheap Switch to the cheaper, faster model\e[0m"
415
483
  @real_stdout.puts "\e[2m /compact Summarize conversation to reduce context\e[0m"
416
484
  @real_stdout.puts "\e[2m /usage Show session token totals\e[0m"
417
485
  @real_stdout.puts "\e[2m /cost Show cost estimate by model\e[0m"
@@ -424,6 +492,52 @@ module RailsConsoleAi
424
492
  @real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
425
493
  @real_stdout.puts "\e[2m Ctrl-C Cancel the current operation\e[0m"
426
494
  @real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
495
+ display_registry_help
496
+ end
497
+
498
+ # Skills and agents, listed the same way as the built-ins so "/" answers
499
+ # "what can I run?" in one place.
500
+ def display_registry_help
501
+ @slash_commands.refresh!
502
+
503
+ skills = @slash_commands.skills
504
+ agents = @slash_commands.agents
505
+ # Size the column to what's actually listed, and don't let one very long
506
+ # name push every description off the right edge.
507
+ width = [(skills + agents).map { |c| c.slug.length }.max.to_i + 2, 34].min
508
+
509
+ unless skills.empty?
510
+ @real_stdout.puts
511
+ @real_stdout.puts "\e[36m Skills:\e[0m \e[2m(runs here, following the recipe)\e[0m"
512
+ skills.each { |c| @real_stdout.puts registry_help_row(c, width) }
513
+ end
514
+
515
+ unless agents.empty?
516
+ @real_stdout.puts
517
+ @real_stdout.puts "\e[36m Agents:\e[0m \e[2m(runs in a separate context, reports back)\e[0m"
518
+ agents.each { |c| @real_stdout.puts registry_help_row(c, width) }
519
+ end
520
+
521
+ return if skills.empty? && agents.empty?
522
+ @real_stdout.puts
523
+ @real_stdout.puts "\e[2m Pass a request after the name: /#{(skills.first || agents.first).slug} <what you want>\e[0m"
524
+ end
525
+
526
+ # One "/slug description" line, clipped so a long description wraps into
527
+ # the next row instead of making the list unreadable.
528
+ def registry_help_row(command, width)
529
+ room = terminal_width - width - 6
530
+ desc = command.description.to_s
531
+ desc = "#{desc[0, room - 1]}\u2026" if room > 10 && desc.length > room
532
+ "\e[2m /#{command.slug.ljust(width)} #{desc}\e[0m"
533
+ end
534
+
535
+ def terminal_width
536
+ require 'io/console'
537
+ cols = IO.console && IO.console.winsize.last.to_i
538
+ cols && cols > 40 ? cols : 100
539
+ rescue StandardError
540
+ 100
427
541
  end
428
542
 
429
543
  def display_exit_info
@@ -71,7 +71,7 @@ module RailsConsoleAi
71
71
  stripped = strip_ansi(text).strip
72
72
  return if stripped.empty?
73
73
 
74
- if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_session:/
74
+ if stripped =~ /\AThinking\.\.\.|\AAttempting to fix|\ACancelled|\A_Session\b/
75
75
  post(stripped)
76
76
  else
77
77
  @output_log.write("#{stripped}\n")