robot_lab-web 0.2.6

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 04b904262b238719fac85842e3ae48532e8bc31988c3323165fe19abf3c116b7
4
+ data.tar.gz: 6461c66fb622adb4874e7eced683247026d30655ad0e24ec708fb0030a306222
5
+ SHA512:
6
+ metadata.gz: 98cabb7f7ea51eb42d84bc6750f5d2f47ca36d9bbd507dc095088ebd6bef4b1f15629f4599f852c71e94d7fb6b1ab1cdd9373a13cab71dd181ece556839f396b
7
+ data.tar.gz: 2b623bfb33bd537e499b36b937f092ae732bbdf2f5f2f827275b9824d52a70a7e6546333805132cec1882f0424a5cba419d9470e011cb4896f4cb09dc44219bb
@@ -0,0 +1,52 @@
1
+ name: Deploy Documentation to GitHub Pages
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ - develop
7
+ paths:
8
+ - "docs/**"
9
+ - "mkdocs.yml"
10
+ - ".github/workflows/deploy-github-pages.yml"
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: write
15
+ pages: write
16
+ id-token: write
17
+
18
+ jobs:
19
+ deploy:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout code
23
+ uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+
27
+ - name: Setup Python
28
+ uses: actions/setup-python@v5
29
+ with:
30
+ python-version: 3.x
31
+
32
+ - name: Install dependencies
33
+ run: |
34
+ pip install mkdocs
35
+ pip install mkdocs-material
36
+ pip install mkdocs-macros-plugin
37
+ pip install mike
38
+
39
+ - name: Configure Git
40
+ run: |
41
+ git config --local user.email "action@github.com"
42
+ git config --local user.name "GitHub Action"
43
+
44
+ - name: Build MkDocs site
45
+ run: mkdocs build
46
+
47
+ - name: Deploy to GitHub Pages
48
+ uses: peaceiris/actions-gh-pages@v4
49
+ with:
50
+ github_token: ${{ secrets.GITHUB_TOKEN }}
51
+ publish_dir: ./site
52
+ keep_files: true
data/.loki ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ # robot_lab-web — Sinatra + SSE web console for driving RobotLab robots.
3
+
4
+ import_up "repo_dev.loki"
5
+
6
+ class Tasks
7
+ @@gem_name ||= "robot_lab-web".freeze
8
+
9
+ header "robot_lab-web v#{gem_version} — web console (Sinatra + HTMX + SSE)"
10
+
11
+ desc "Start the web console (pass a boot file, e.g. asgard server examples/boot.rb)"
12
+ def server(*args)
13
+ sh "bundle exec ruby exe/robot_lab-web #{args.join(' ')}"
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ 1 lib/robot_lab/web.rb
2
+ 1 lib/robot_lab/web/components/chat.rb
3
+ 1 lib/robot_lab/web/components/dashboard.rb
4
+ 1 lib/robot_lab/web/components/layout.rb
5
+ 1 lib/robot_lab/web/components/message.rb
6
+ 4 lib/robot_lab/web/event.rb
7
+ 1 lib/robot_lab/web/event_sink.rb
8
+ 1 lib/robot_lab/web/registry.rb
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ inherit_from: ../.rubocop-base.yml
2
+
3
+ Style/FormatStringToken:
4
+ Enabled: false
5
+
6
+ Lint/MissingSuper:
7
+ Exclude:
8
+ - 'lib/robot_lab/web/components/**/*'
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ ## [Unreleased]
2
+
3
+ ### Changed
4
+ - Use **Falcon** as the application server (replacing Puma). Its async/fiber
5
+ reactor is the right fit for the long-lived SSE streams this console serves —
6
+ fiber-per-connection concurrency, verified with two interleaving streams. The
7
+ `exe` launches a single Falcon reactor; `config.ru` stays server-agnostic.
8
+ - Style with **Tailwind CSS** (Play CDN) instead of hand-rolled CSS. The dark
9
+ theme is preserved; styles are Tailwind utilities composed via `@apply` so the
10
+ server-rendered and JS-built message bubbles share one definition.
11
+ - Render views as **Phlex components** (replacing Slim) — `Layout`, `Dashboard`,
12
+ `Chat`, `Message`, `ErrorPage` — via `phlex-sinatra`, with inline SVG icons
13
+ from `phlex-icons-hero`. Drops the `slim` dependency.
14
+
15
+ ### Added
16
+ - Core (Sinatra-free) surface: `RobotLab::Web::Event` (immutable, role-validated,
17
+ `to_h`/`from_h`), `ActivityLog` (bounded thread-safe ring buffer), `EventSink`
18
+ (thread-local capture), and `Registry` (in-memory robot registry).
19
+ - `RobotLab::Web::StreamHook` — taps robot_lab's hook system and turns each
20
+ run/tool/error moment into an `Event` delivered to the current sink.
21
+ - `RobotLab::Web.run(robot, message) { |event| ... }` — stream a run's events
22
+ from plain Ruby.
23
+ - Token-delta streaming: `RobotLab::Web.run` passes a streaming block to the
24
+ robot, emitting a `:delta` event per RubyLLM content chunk so the reply renders
25
+ token by token. Deltas bypass the `ActivityLog`; the final `:robot` event still
26
+ carries the whole reply. The browser accumulates deltas into a live bubble.
27
+ - Opt-in Sinatra app (`require "robot_lab/web/app"`): dashboard, chat page,
28
+ `POST /robots/:name/stream` (Server-Sent Events) and a non-streaming
29
+ `POST /robots/:name/chat` HTMX fallback. CSRF + hashed session secret +
30
+ cookie hardening + production boot guard.
31
+ - `robot_lab-web` executable and `config.ru` launcher; `examples/boot.rb`.
32
+
33
+ ## [0.1.0] - 2026-06-20
34
+
35
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # RobotLab::Web
2
+
3
+ A Rails-free **web console for [robot_lab](../robot_lab)**: register your robots,
4
+ chat with them from the browser, and watch each lifecycle event — tool calls,
5
+ tool results, the final reply, errors — **stream in real time** over
6
+ Server-Sent Events.
7
+
8
+ It's the standalone counterpart to `robot_lab-rails`: one event stream, a
9
+ Sinatra + HTMX front end, no Turbo and no Rails required.
10
+
11
+ > ⚠️ **Developer tool.** The console is **unauthenticated by default**. Run it on
12
+ > localhost or a trusted private network. It ships sensible hardening (CSRF with
13
+ > constant-time compare, a hashed session secret, `httponly`/`SameSite` cookies,
14
+ > a production boot guard) but is not meant to face untrusted users.
15
+
16
+ ## How it works
17
+
18
+ `robot_lab-web` registers a per-run hook (`RobotLab::Web::StreamHook`) on
19
+ robot_lab's own hook system. As a robot runs, the hook turns each moment into an
20
+ immutable `RobotLab::Web::Event` and pushes it to the current sink — an SSE
21
+ stream for the browser, or a plain array in tests. This is robot_lab's native
22
+ equivalent of an `on_event:` callback.
23
+
24
+ ```
25
+ browser ──POST /robots/:name/stream──▶ Sinatra App
26
+ └─ RobotLab::Web.run(robot, msg) { |event| write_sse }
27
+ └─ robot.run(msg, hooks: [StreamHook])
28
+ └─ StreamHook ─emits─▶ EventSink ─▶ SSE frames
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Write a boot file that builds your robots and registers each one:
34
+
35
+ ```ruby
36
+ # my_robots.rb
37
+ require "robot_lab"
38
+ require "robot_lab/web"
39
+
40
+ assistant = RobotLab.build(name: "assistant", system_prompt: "You are concise.")
41
+ RobotLab::Web.register(assistant)
42
+ ```
43
+
44
+ Launch the console (Falcon):
45
+
46
+ ```bash
47
+ robot_lab-web my_robots.rb # http://127.0.0.1:9292
48
+ robot_lab-web --port 4567 my_robots.rb
49
+ ```
50
+
51
+ …or point Falcon (or any Rack server) at the server-agnostic `config.ru`:
52
+
53
+ ```bash
54
+ ROBOT_LAB_WEB_BOOT=my_robots.rb falcon serve -c config.ru
55
+ ```
56
+
57
+ Then open the dashboard, pick a robot, and chat. (Running a robot needs the
58
+ provider key for its model, e.g. `ANTHROPIC_API_KEY`.)
59
+
60
+ ### Consuming the stream from Ruby
61
+
62
+ The same path the web routes use is available directly — handy for tests or a
63
+ custom front end:
64
+
65
+ ```ruby
66
+ RobotLab::Web.run(assistant, "Hello") do |event|
67
+ puts "#{event.role}: #{event.text}"
68
+ end
69
+ ```
70
+
71
+ ## Endpoints
72
+
73
+ | Method | Path | Purpose |
74
+ |--------|--------------------------|------------------------------------------------------|
75
+ | GET | `/` | Dashboard: registered robots + recent activity |
76
+ | GET | `/robots/:name` | Chat page for a robot |
77
+ | POST | `/robots/:name/stream` | Run a task, stream events as SSE (`message`/`done`/`error`) |
78
+ | POST | `/robots/:name/chat` | Non-streaming fallback; returns rendered transcript HTML |
79
+
80
+ ## Development
81
+
82
+ After checking out the repo, run `bin/setup` to install dependencies, then
83
+ `rake test`. The core (event model, activity log, sink, stream hook) is
84
+ pure-Ruby and unit-tested in isolation; the Sinatra app is covered with
85
+ `rack-test`.
86
+
87
+ Views are **Phlex components** (`lib/robot_lab/web/components/`), rendered in
88
+ Sinatra via `phlex-sinatra`, styled with Tailwind, with Heroicons from
89
+ `phlex-icons-hero`.
90
+
91
+ The Sinatra stack is opt-in: `require "robot_lab/web"` loads only the core;
92
+ `require "robot_lab/web/app"` (or `RobotLab::Web.app`) pulls in Sinatra/Falcon/Phlex.
93
+
94
+ ## License
95
+
96
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
10
+ t.verbose = true
11
+ t.ruby_opts << '-rtest_helper'
12
+ end
13
+
14
+ task default: :test
15
+
16
+ desc 'Run tests with verbose output'
17
+ task :test_verbose do
18
+ ENV['TESTOPTS'] = '--verbose'
19
+ Rake::Task[:test].invoke
20
+ end
21
+
22
+ desc 'Run a single test file'
23
+ task :test_file, [:file] do |_t, args|
24
+ ruby "test/#{args[:file]}"
25
+ end
26
+
27
+ desc 'Check code complexity with Flog (warn >=20, fail >=50)'
28
+ task :flog_check do
29
+ require 'flog'
30
+
31
+ method_warn = 20.0
32
+ method_fail = 50.0
33
+
34
+ flogger = Flog.new(all: true)
35
+ flogger.flog(*Dir.glob('lib/**/*.rb'))
36
+
37
+ warnings = []
38
+ failures = []
39
+
40
+ flogger.each_by_score do |method, score|
41
+ next if method.end_with?('#none')
42
+
43
+ if score > method_fail
44
+ failures << "#{format('%.1f', score)}: #{method}"
45
+ elsif score > method_warn
46
+ warnings << "#{format('%.1f', score)}: #{method}"
47
+ end
48
+ end
49
+
50
+ unless warnings.empty?
51
+ puts "\nFlog warnings (#{method_warn}–#{method_fail}) — target for future refactoring:"
52
+ warnings.each { |v| puts " #{v}" }
53
+ end
54
+
55
+ if failures.empty?
56
+ puts "\nFlog: no methods exceed the failure threshold (>=#{method_fail})"
57
+ else
58
+ puts "\nFlog failures (>=#{method_fail}) — must be refactored:"
59
+ failures.each { |v| puts " #{v}" }
60
+ abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{method_fail}"
61
+ end
62
+ end
63
+
64
+ desc 'Check for structural code duplication with Flay (mass >= 50)'
65
+ task :flay_check do
66
+ require 'flay'
67
+
68
+ mass_threshold = 50
69
+
70
+ flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
71
+ flay.process(*Dir.glob('lib/**/*.rb'))
72
+ flay.analyze
73
+
74
+ if flay.hashes.empty?
75
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
76
+ else
77
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
78
+ flay.report
79
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
80
+ end
81
+ end
82
+
83
+ desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
84
+ task :quality do
85
+ gates = [
86
+ ['Tests + Coverage', 'bundle exec rake test'],
87
+ ['RuboCop', 'bundle exec rubocop'],
88
+ ['Flog Complexity', 'bundle exec rake flog_check'],
89
+ ['Flay Duplication', 'bundle exec rake flay_check']
90
+ ]
91
+
92
+ results = gates.map do |label, command|
93
+ puts "\n#{'=' * 60}"
94
+ puts "Quality Gate: #{label}"
95
+ puts '=' * 60
96
+ [label, system(command) ? :pass : :fail]
97
+ end
98
+
99
+ green = ->(s) { "\e[32m#{s}\e[0m" }
100
+ red = ->(s) { "\e[31m#{s}\e[0m" }
101
+ width = results.map { |label, _| label.length }.max
102
+
103
+ puts "\n#{'=' * 60}"
104
+ puts 'Quality Gate Summary'
105
+ puts '=' * 60
106
+ results.each do |label, status|
107
+ badge = status == :pass ? green.call('PASS') : red.call('FAIL')
108
+ puts " [#{badge}] #{label.ljust(width)}"
109
+ end
110
+ puts '-' * 60
111
+
112
+ passed = results.count { |_, s| s == :pass }
113
+ failed = results.count { |_, s| s == :fail }
114
+ tally = "#{passed} passed, #{failed} failed"
115
+ puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
116
+ puts '=' * 60
117
+
118
+ abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
119
+ puts "\n#{green.call('All quality gates passed.')}"
120
+ end
121
+
122
+ namespace :docs do
123
+ desc 'Build MkDocs documentation'
124
+ task :build do
125
+ sh 'mkdocs build'
126
+ end
127
+
128
+ desc 'Serve MkDocs documentation locally on http://localhost:8000'
129
+ task :serve do
130
+ sh 'mkdocs serve'
131
+ end
132
+ end
data/config.ru ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Server-agnostic Rack entry point for robot_lab-web. Falcon is the default
4
+ # server (see exe/robot_lab-web), but any Rack server can run this file:
5
+ #
6
+ # falcon serve -c config.ru # boots the console at localhost:9292
7
+ #
8
+ # By default no robots are registered. Point ROBOT_LAB_WEB_BOOT at a Ruby file
9
+ # that requires robot_lab, builds your robots, and calls RobotLab::Web.register
10
+ # on each — it is loaded here before the app starts.
11
+ #
12
+ # ROBOT_LAB_WEB_BOOT=./my_robots.rb falcon serve -c config.ru
13
+
14
+ require 'robot_lab/web'
15
+
16
+ boot = ENV.fetch('ROBOT_LAB_WEB_BOOT', nil)
17
+ require File.expand_path(boot) if boot && File.exist?(boot)
18
+
19
+ run RobotLab::Web.app
@@ -0,0 +1,173 @@
1
+ # API Reference
2
+
3
+ Every public class, module, and method in `robot_lab-web`. See [How It Works](how_it_works.md) for the concepts behind each one.
4
+
5
+ ## `RobotLab::Web` (module)
6
+
7
+ ### `register(robot, name: nil) → String`
8
+
9
+ Registers `robot` (anything responding to `#run` and `#name`) so the console can list and drive it. Returns the string key it was stored under. Delegates to `Registry.register`.
10
+
11
+ ```ruby
12
+ RobotLab::Web.register(assistant)
13
+ RobotLab::Web.register(assistant, name: "support")
14
+ ```
15
+
16
+ ### `run(robot, message, sink = nil, &block) → RobotResult`
17
+
18
+ Runs `robot` against `message`, delivering each lifecycle `Event` to `sink` (or `block`, if `sink` is omitted) as it happens, and returning the robot's `RobotResult` once the run completes. This is the one call both Sinatra routes wrap, and the cleanest way to consume the stream from plain Ruby or a test. See [How It Works — `RobotLab::Web.run`](how_it_works.md#robotlabwebrun-the-one-call-that-ties-it-together) for exactly what it wires up.
19
+
20
+ ```ruby
21
+ events = []
22
+ RobotLab::Web.run(assistant, "hello") { |event| events << event }
23
+ ```
24
+
25
+ ### `app → RobotLab::Web::App`
26
+
27
+ Lazily requires `"robot_lab/web/app"` (pulling in Sinatra/Falcon/Phlex on first call) and returns the `App` class — a Rack app, suitable for `run RobotLab::Web.app` in a `config.ru`.
28
+
29
+ ### `Error`
30
+
31
+ `RobotLab::Web::Error < StandardError` — reserved for gem-specific error conditions. Not currently raised by any code path in this gem's own logic.
32
+
33
+ ---
34
+
35
+ ## `RobotLab::Web::Event`
36
+
37
+ ```ruby
38
+ Event = Struct.new(:role, :content, :robot_name, :timestamp, :event_id, keyword_init: true)
39
+ ```
40
+
41
+ Immutable (deep-frozen on construction). See [How It Works — The Event Model](how_it_works.md#the-event-model) for the full role/content table.
42
+
43
+ ### `new(role:, content:, robot_name: nil, timestamp: nil, event_id: nil)`
44
+
45
+ Raises `ArgumentError` if `role` isn't one of `Event::ROLES` (`:user`, `:delta`, `:robot`, `:tool_call`, `:tool_result`, `:error`). `timestamp` defaults to `Time.now.utc`; `event_id` defaults to `SecureRandom.uuid`.
46
+
47
+ ### Readers
48
+
49
+ `role`, `content`, `robot_name`, `timestamp`, `event_id` — plain `Struct` readers.
50
+
51
+ ### `tool_name → String, nil`
52
+
53
+ `content[:name]`/`content['name']` when `content` is a Hash (works for both `:tool_call` and `:tool_result`); `nil` otherwise.
54
+
55
+ ### `error? → Boolean`
56
+
57
+ `true` when `role == :error`.
58
+
59
+ ### `error_message → String, nil`
60
+
61
+ `content[:message]`/`content['message']` (or `content.to_s` for a non-Hash) when `error?`; `nil` otherwise.
62
+
63
+ ### `text → Object`
64
+
65
+ A role-agnostic display value: for Hash content, `content[:result] || content[:message] || content[:args] || content`; for anything else, `content` itself unchanged.
66
+
67
+ ### `to_h → Hash`
68
+
69
+ `{role:, content:, robot_name:, timestamp: (ISO 8601, ms precision), event_id:}` — JSON-serializable.
70
+
71
+ ### `self.from_h(hash) → Event, nil`
72
+
73
+ Rebuilds an `Event` from a Hash with either symbol or string keys (as produced by parsing JSON). Returns `nil` instead of raising if the role fails validation.
74
+
75
+ ---
76
+
77
+ ## `RobotLab::Web::EventSink` (module)
78
+
79
+ The thread-local hook-to-consumer bridge. See [How It Works](how_it_works.md#eventsink-a-thread-local-mailbox).
80
+
81
+ ### `capture(sink) { ... } → Object`
82
+
83
+ Installs `sink` (anything responding to `#call`) as the current consumer for the duration of the block, restoring whatever was previously installed (supports nesting) once the block returns or raises.
84
+
85
+ ### `current → #call, nil`
86
+
87
+ The sink installed by the innermost enclosing `capture` call on this thread, or `nil`.
88
+
89
+ ### `emit(event) → void`
90
+
91
+ Delivers `event` to `current` if one is installed. Any `StandardError` the sink raises is caught and discarded — a broken consumer must never break the run it's observing.
92
+
93
+ ---
94
+
95
+ ## `RobotLab::Web::ActivityLog`
96
+
97
+ A thread-safe, bounded ring buffer. See [How It Works](how_it_works.md#activitylog).
98
+
99
+ ### Class methods (delegate to a process-wide singleton, `ActivityLog.instance`)
100
+
101
+ | Method | Description |
102
+ |---|---|
103
+ | `log(type, details = {})` | Records an entry (`{type:, details:, timestamp:}`); raises if `type`/`details` are malformed |
104
+ | `safe_log(type, details = {})` | Same, but swallows any error — use this from instrumentation paths |
105
+ | `recent(limit = 10)` | The `limit` most recent entries, newest first |
106
+ | `clear` | Empties the log |
107
+
108
+ ### Instance
109
+
110
+ `MAX_EVENTS = 50` — the ring buffer's capacity; oldest entries are dropped once exceeded. `#log`/`#recent`/`#clear` are the same operations, mutex-guarded, on a fresh instance if you don't want the process-wide singleton (mainly useful in tests).
111
+
112
+ ---
113
+
114
+ ## `RobotLab::Web::Registry` (module)
115
+
116
+ An in-memory `Hash` of registered robots, keyed by name. See [How It Works](how_it_works.md#registry).
117
+
118
+ ### `Registry.register(robot, name: nil) → String`
119
+
120
+ Stores `robot` under `(name || robot.name).to_s`, returning that key.
121
+
122
+ ### `fetch(name) → robot, nil`
123
+
124
+ Looks up a registered robot by name (coerced to `String`).
125
+
126
+ ### `names → Array<String>`
127
+
128
+ All registered names, alphabetically sorted.
129
+
130
+ ### `all → Array`
131
+
132
+ All registered robot objects, in insertion order.
133
+
134
+ ### `clear → void`
135
+
136
+ Empties the registry — mainly useful in tests.
137
+
138
+ ---
139
+
140
+ ## `RobotLab::Web::StreamHook`
141
+
142
+ `RobotLab::Hook` subclass, `namespace = :web_stream`. See [How It Works — The Hook-to-Sink Bridge](how_it_works.md#the-hook-to-sink-bridge) for the full callback table. Not instantiated directly — registered per-run via `robot.run(message, hooks: [RobotLab::Web::StreamHook])`, which is exactly what `RobotLab::Web.run` does for you.
143
+
144
+ ---
145
+
146
+ ## `RobotLab::Web::App` *(requires `"robot_lab/web/app"`)*
147
+
148
+ A `Sinatra::Base` subclass — a Rack app. See [Security](security.md) for the session/CSRF/host-authorization configuration, and [How It Works — The SSE Wire Protocol](how_it_works.md#the-sse-wire-protocol) for the streaming route's frame format.
149
+
150
+ | Route | Behavior |
151
+ |---|---|
152
+ | `GET /` | Renders `Components::Dashboard` with `Registry.names` and `ActivityLog.recent(15)` |
153
+ | `GET /robots/:name` | 404s (via `Components::ErrorPage`) if unregistered; else renders `Components::Chat` |
154
+ | `POST /robots/:name/stream` | SSE stream of one run — see [How It Works](how_it_works.md#the-sse-wire-protocol) |
155
+ | `POST /robots/:name/chat` | Runs to completion, returns rendered transcript HTML fragments (delta events excluded) |
156
+
157
+ `self.session_secret_source` / `self.persisted_dev_secret` — the session-secret resolution logic; see [Security — Session Secret](security.md#session-secret-persistence).
158
+
159
+ ---
160
+
161
+ ## `RobotLab::Web::Components` *(requires `"robot_lab/web/app"`)*
162
+
163
+ Phlex (`Phlex::HTML`) view components, each a small, focused constructor + `view_template`:
164
+
165
+ | Component | Constructor | Renders |
166
+ |---|---|---|
167
+ | `Layout` | `new(csrf:, content:)` | The HTML document shell — Tailwind CDN + shared styles, top nav, and the wrapped page `content` |
168
+ | `Dashboard` | `new(robots:, activity:)` | Registered robot list + recent-activity feed |
169
+ | `Chat` | `new(name:)` | The transcript container, composer form, and the inline vanilla-JS SSE streaming client |
170
+ | `Message` | `new(event:)` | One transcript message from an `Event` — used server-side by the non-streaming `/chat` fallback |
171
+ | `ErrorPage` | `new(message:)` | The 404 page body |
172
+
173
+ These aren't meant to be used outside `App` itself, but are documented here since they're real, independently-instantiable public classes (not `private_constant`).
@@ -0,0 +1,95 @@
1
+ # Getting Started
2
+
3
+ ## Prerequisites
4
+
5
+ - Ruby 3.2+ (per the gemspec's `required_ruby_version`)
6
+ - `robot_lab` — a hard dependency; `robot_lab/web` requires it directly (see [How It Works — Core vs. Opt-In Web Stack](how_it_works.md#core-vs-opt-in-web-stack)), so load order relative to your own `require "robot_lab"` never matters
7
+ - A provider API key for whatever model your robots use (e.g. `ANTHROPIC_API_KEY`) — the console itself needs no credentials, but actually running a robot through it does
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem "robot_lab"
13
+ gem "robot_lab-web"
14
+ ```
15
+
16
+ ```sh
17
+ bundle install
18
+ ```
19
+
20
+ ## Writing a Boot File
21
+
22
+ A boot file just builds your robots and registers each one — the console reads this registry to list and drive them:
23
+
24
+ ```ruby
25
+ # my_robots.rb
26
+ require "robot_lab"
27
+ require "robot_lab/web"
28
+
29
+ assistant = RobotLab.build(name: "assistant", system_prompt: "You are a concise, friendly assistant.")
30
+ RobotLab::Web.register(assistant)
31
+
32
+ # Register as many as you like:
33
+ # RobotLab::Web.register(RobotLab.build(name: "support", template: :support))
34
+ ```
35
+
36
+ `register(robot, name: nil)` keys the robot by `name:` if given, otherwise by `robot.name` — see [API Reference](api_reference.md#registryregisterrobot-name-nil-string).
37
+
38
+ ## Launching the Console
39
+
40
+ **Via the bundled executable** (Falcon, the default server):
41
+
42
+ ```sh
43
+ robot_lab-web my_robots.rb # http://127.0.0.1:9292
44
+ robot_lab-web --port 4567 my_robots.rb # custom port
45
+ robot_lab-web --host 0.0.0.0 my_robots.rb # custom bind host
46
+ ```
47
+
48
+ **Or via `config.ru`**, with any Rack server (the app itself is server-agnostic — `exe/robot_lab-web` picks Falcon specifically for its async/fiber reactor, but nothing about the Sinatra app requires it):
49
+
50
+ ```sh
51
+ ROBOT_LAB_WEB_BOOT=my_robots.rb falcon serve -c config.ru
52
+ ```
53
+
54
+ `ROBOT_LAB_WEB_BOOT` (or the boot file argument to the executable) is required by path before the app starts — `config.ru`/`exe/robot_lab-web` both check `File.exist?(boot)` and simply skip loading it if the path doesn't resolve, so a typo'd path silently produces a console with zero registered robots rather than an error.
55
+
56
+ Then open the dashboard, pick a robot, and start chatting.
57
+
58
+ ## Running from a Source Checkout
59
+
60
+ If you're working inside a clone of `robot_lab-web` itself (rather than the installed gem), `exe/robot_lab-web` calls `require 'bundler/setup'` when it finds a `Gemfile` sitting beside it — this is what resolves the gem's own `lib` and its dependencies, including a local `robot_lab` path dependency during development (see `Gemfile.local`). Run it from the gem's own directory (or via `bundle exec`) so that path resolution works; an installed gem has no adjacent `Gemfile` and falls through to plain RubyGems resolution instead.
61
+
62
+ ## Consuming the Stream from Ruby
63
+
64
+ The same path the web routes use is available directly — useful in tests, a script, or a custom front end that isn't the bundled Sinatra app:
65
+
66
+ ```ruby
67
+ require "robot_lab"
68
+ require "robot_lab/web"
69
+
70
+ RobotLab::Web.run(assistant, "Hello") do |event|
71
+ puts "#{event.role}: #{event.text}"
72
+ end
73
+ ```
74
+
75
+ This doesn't touch Sinatra/Falcon/Phlex at all — see [How It Works](how_it_works.md#core-vs-opt-in-web-stack) for exactly what loading `"robot_lab/web"` alone gives you versus `"robot_lab/web/app"`.
76
+
77
+ ## The Four Endpoints
78
+
79
+ | Method | Path | Purpose |
80
+ |---|---|---|
81
+ | `GET` | `/` | Dashboard: registered robots + a recent-activity feed |
82
+ | `GET` | `/robots/:name` | Chat page for one robot |
83
+ | `POST` | `/robots/:name/stream` | Run a task, stream events as Server-Sent Events (`message`/`done`/`error` frames) |
84
+ | `POST` | `/robots/:name/chat` | Non-streaming fallback — runs to completion and returns rendered transcript HTML fragments (for HTMX `hx-swap="beforeend"`-style appending) |
85
+
86
+ See [How It Works](how_it_works.md#the-sse-wire-protocol) for the exact frame format and why the streaming route can't just be a plain `EventSource`.
87
+
88
+ ## Development
89
+
90
+ ```sh
91
+ bin/setup # install dependencies
92
+ rake test # run the test suite
93
+ ```
94
+
95
+ The core (event model, activity log, sink, stream hook) is pure Ruby and unit-tested in isolation; the Sinatra app is covered with `rack-test`.