robot_lab-cyborg 0.2.7
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 +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +94 -0
- data/LICENSE.txt +21 -0
- data/README.md +138 -0
- data/Rakefile +132 -0
- data/docs/api_reference.md +301 -0
- data/docs/custom_channels.md +123 -0
- data/docs/getting_started.md +156 -0
- data/docs/how_it_works.md +211 -0
- data/docs/index.md +57 -0
- data/examples/01_human_in_the_network.rb +61 -0
- data/examples/02_terminal_mentions.rb +95 -0
- data/examples/03_robot_interviews_cyborg.rb +125 -0
- data/examples/04_presence_and_availability.rb +70 -0
- data/examples/05_listening_and_duplex.rb +53 -0
- data/lib/robot_lab/cyborg/channel.rb +174 -0
- data/lib/robot_lab/cyborg/conversation.rb +97 -0
- data/lib/robot_lab/cyborg/interviewer.rb +314 -0
- data/lib/robot_lab/cyborg/version.rb +9 -0
- data/lib/robot_lab/cyborg.rb +511 -0
- data/mkdocs.yml +118 -0
- data/sig/robot_lab/cyborg.rbs +36 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d5289e0bf089059f451300a62b776b06141d684bb60ed6df407624313a695500
|
|
4
|
+
data.tar.gz: eab4d957cbd19ca2fb66e97d2ba2ddd806167c0dab6ffb623fad10204386cdd4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f6a29be1c412e3005a55e25bfaacfbb60d6a535a636e9692f188da47ee09da8736268561570beba5d2164066dca8abbbac68f8f538a8171be2815697e8929d41
|
|
7
|
+
data.tar.gz: 01b49e8eed2356da4eafb1bf7e48dc408a84e877bcd52aae5e5a75a80927dab83a8d4daf73ad875199cf44ef5e5bcf33b8d9b0aa2379d7e8d9b52433ba7bf17c
|
data/.envrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export RR=$(pwd)
|
|
@@ -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,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# robot_lab-cyborg — human-in-the-network peer worker for RobotLab.
|
|
3
|
+
|
|
4
|
+
import_up "repo_dev.loki"
|
|
5
|
+
|
|
6
|
+
class Tasks
|
|
7
|
+
@@gem_name ||= "robot_lab-cyborg".freeze
|
|
8
|
+
|
|
9
|
+
header "robot_lab-cyborg v#{gem_version} — human peer worker"
|
|
10
|
+
end
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inherit_from: ../.rubocop-base.yml
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Fixed (from the design/architecture review)
|
|
11
|
+
- **Consumer no longer wedges on a handler error.** An exception in an
|
|
12
|
+
`on_initiative`/channel path used to kill the Interviewer's consumer thread and
|
|
13
|
+
hang every future ask; it is now caught (recorded in `Interviewer#last_error`)
|
|
14
|
+
and the loop keeps serving. *(A1)*
|
|
15
|
+
- **Inbound bus tasks no longer block the poller.** A Cyborg answers an inbound
|
|
16
|
+
task on its own thread, so a slow or absent human never stalls bus intake, and
|
|
17
|
+
the default no longer deadlocks. *(A2)*
|
|
18
|
+
- **No more mis-attributed answers on a dumb channel.** On a channel that can't
|
|
19
|
+
correlate replies (`Channel#correlates? == false`, e.g. a terminal), questions
|
|
20
|
+
are *serialized* — one on the wire at a time — and an answer resolves that
|
|
21
|
+
active question. A `Terminal` now honors `receive(timeout:)` (via
|
|
22
|
+
`wait_readable`), so timeouts, `close`, and expiry actually work. *(A3, A4)*
|
|
23
|
+
- **Shared memory no longer leaks across runs.** `attach_memory`/`detach_memory`
|
|
24
|
+
make the target explicit, and access is thread-safe. *(A5)*
|
|
25
|
+
- Core `RobotLab::Robot::BusMessaging` synchronizes its message counter/outbox,
|
|
26
|
+
and gains `respond_to_tasks`/`serve` so a **Robot** auto-answers bus tasks the
|
|
27
|
+
way a Cyborg does — man/machine peers are now symmetric on the bus. *(A6, B1)*
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **Duplex to the human's channel.** Inbound bus messages/replies are now
|
|
31
|
+
delivered to the human's channel (`Cyborg#tell` for the output direction);
|
|
32
|
+
`ChannelMessage` carries `sender`/`kind`/`at` so a terminal can label who is
|
|
33
|
+
speaking and only prompt on questions. *(B2, B5)*
|
|
34
|
+
- **Always-on listening.** `Cyborg#listen`/`converse` keep reading the channel
|
|
35
|
+
with no question outstanding, so a human can address the network unprompted;
|
|
36
|
+
their input arrives via `on_human`. *(B3)*
|
|
37
|
+
- **`Conversation`** — `@mention` addressing, multi-mention fan-out, and
|
|
38
|
+
no-mention broadcast now live in the library (`Cyborg#converse`), not in
|
|
39
|
+
example code. Replies flow back via the duplex. *(B4)*
|
|
40
|
+
- **Typed/validated answers** — `Cyborg#ask(validate:, retries:)` plus
|
|
41
|
+
`ask_int`/`ask_confirm` re-ask on bad input and return coerced values. *(C)*
|
|
42
|
+
- **Presence** — `online!`/`away!`/`offline!`/`available?`; an offline human
|
|
43
|
+
declines inbound tasks immediately so the network can route around/escalate.
|
|
44
|
+
*(B8)*
|
|
45
|
+
- `Cyborg#ask_async` returns the pending `Question` for non-blocking waits — the
|
|
46
|
+
primitive a durable/suspendable human step would persist (see README, *Durable
|
|
47
|
+
human steps*, for the intended `robot_lab-durable` integration — *B7*).
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Split reaching the human into two concerns: **`Channel`** (the *means* — a
|
|
51
|
+
dumb bidirectional pipe: `deliver` out, `receive` in) and **`Interviewer`**
|
|
52
|
+
(the *process* that conducts the ask over a channel). `$stdin`/`$stdout` now
|
|
53
|
+
live only inside `Channel::Terminal`; `Cyborg` depends on an injected
|
|
54
|
+
`channel:` and no longer takes `input:`/`output:`.
|
|
55
|
+
- `Interviewer::Terminal` / `Interviewer::Scripted` moved to
|
|
56
|
+
`Channel::Terminal` / `Channel::Scripted`. `Cyborg.new` takes `channel:`
|
|
57
|
+
instead of `interviewer:` (an `interviewer:` may still be injected for full
|
|
58
|
+
control).
|
|
59
|
+
- Read the questions a Scripted human was shown via `cyborg.channel.asked`
|
|
60
|
+
(was `cyborg.interviewer.asked`).
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- **Asynchronous asking.** A human's answer may be the next inbound message, a
|
|
64
|
+
later one, or never. `Interviewer#ask` now returns a `Question` (a one-shot
|
|
65
|
+
future); `Cyborg#ask_async` exposes it. The synchronous `Cyborg#ask` /
|
|
66
|
+
`Interviewer#ask_and_wait` wait with an optional timeout.
|
|
67
|
+
- **Reply correlation.** Inbound messages carry an optional `in_reply_to` so a
|
|
68
|
+
rich transport (Slack threads, email) resolves the exact question; a bare
|
|
69
|
+
terminal falls back to oldest-outstanding (FIFO).
|
|
70
|
+
- **Bounded waiting.** `Cyborg.new(ask_timeout:)` and per-call `timeout:` give a
|
|
71
|
+
slow or absent human a deadline, after which the answer is the default (or nil).
|
|
72
|
+
- `Cyborg#on_human` — surfaces unsolicited human messages (initiative) that
|
|
73
|
+
answer no outstanding question. (Routing initiative onto the bus is future work.)
|
|
74
|
+
|
|
75
|
+
## [0.1.0] - 2026-07-24
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- `RobotLab::Cyborg` — a human peer worker that joins a RobotLab network at the
|
|
79
|
+
same level as the robots. It reuses `RobotLab::Robot::BusMessaging`, so its bus
|
|
80
|
+
behavior is identical to a robot's, but requires no LLM, model, or API key.
|
|
81
|
+
- **Receives tasking** as a pipeline step (`network.task :name, cyborg`) via the
|
|
82
|
+
`call`/`run` member contract, and over the bus (inbound messages are surfaced
|
|
83
|
+
to the human and answered, with optional `auto_reply`).
|
|
84
|
+
- **Issues tasking** to other members with `assign` (bus) and `delegate` (sync or
|
|
85
|
+
async, returning a `RobotResult` / `DelegationFuture`).
|
|
86
|
+
- **Shares memory** with the network via `remember` / `recall`.
|
|
87
|
+
- `RobotLab::Cyborg::Interviewer` — the pluggable human interface, with
|
|
88
|
+
`Terminal` (injectable IO) and `Scripted` (tests/automation) implementations.
|
|
89
|
+
- Registers itself with core via `RobotLab.register_extension(:cyborg, …)`.
|
|
90
|
+
- Minitest suite covering the interviewers, the network-member contract and shared
|
|
91
|
+
memory, the bus task/reply round-trip, and delegation.
|
|
92
|
+
|
|
93
|
+
[Unreleased]: https://github.com/MadBomber/robot_lab-cyborg/compare/v0.1.0...HEAD
|
|
94
|
+
[0.1.0]: https://github.com/MadBomber/robot_lab-cyborg/releases/tag/v0.1.0
|
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,138 @@
|
|
|
1
|
+
# robot_lab-cyborg
|
|
2
|
+
|
|
3
|
+
A [RobotLab](https://github.com/MadBomber/robot_lab) extension gem that puts a **human** into the network as a peer worker.
|
|
4
|
+
|
|
5
|
+
Robots on a RobotLab network are LLM-backed workers. A **Cyborg** is a *human*-backed worker that sits at the same level as the robots: it registers as a network task, speaks on the same [TypedBus](https://github.com/MadBomber/typed_bus) channels, reads and writes the same shared memory, **receives tasking** (as a pipeline step and as bus messages), and **issues tasking** to the other members — humans and robots alike.
|
|
6
|
+
|
|
7
|
+
A Cyborg reuses `RobotLab::Robot::BusMessaging` verbatim, so its bus behavior is byte-for-byte identical to a robot's. It deliberately does **not** subclass `Robot`: a human needs no model and no API key. The human is the "model," reached across an injectable **`Channel`** (the *means* — terminal now, Slack/email/web later) by an **`Interviewer`** (the *process* that conducts the ask).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "robot_lab"
|
|
15
|
+
gem "robot_lab-cyborg"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require "robot_lab"
|
|
22
|
+
require "robot_lab/cyborg"
|
|
23
|
+
|
|
24
|
+
# A human peer. By default it talks to a terminal; inject a Channel to
|
|
25
|
+
# reach the human over a web form, a queue, a chat app, or a test.
|
|
26
|
+
dewayne = RobotLab::Cyborg.new(name: "dewayne")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### A human as a pipeline step
|
|
30
|
+
|
|
31
|
+
The human is interchangeable with a robot anywhere a member is expected:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
writer = RobotLab.build(name: "writer", template: :writer)
|
|
35
|
+
|
|
36
|
+
network = RobotLab.create_network(name: "release") do
|
|
37
|
+
task :draft, writer, depends_on: :none
|
|
38
|
+
task :approve, dewayne, depends_on: [:draft] # the human signs off
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
network.run(message: "Draft the release notes")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Peers messaging over a shared bus
|
|
45
|
+
|
|
46
|
+
Robots and cyborgs talk to each other by name over one shared bus:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
bus = TypedBus::MessageBus.new
|
|
50
|
+
analyst = RobotLab.build(name: "analyst", bus: bus)
|
|
51
|
+
dewayne = RobotLab::Cyborg.new(name: "dewayne", bus: bus)
|
|
52
|
+
|
|
53
|
+
# The robot asks the human a question; the human's answer comes back as a reply.
|
|
54
|
+
analyst.send_message(to: :dewayne, content: "Approve the deploy? (yes/no)")
|
|
55
|
+
|
|
56
|
+
# The human issues work to the robot, too.
|
|
57
|
+
dewayne.assign(to: :analyst, task: "Summarize today's error budget.")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Shared memory
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
dewayne.remember(:decision, "ship it") # visible to every member
|
|
64
|
+
dewayne.recall(:sentiment, wait: 30) # block until a robot writes it
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Channels and the Interviewer
|
|
68
|
+
|
|
69
|
+
Reaching the human is split into two concerns:
|
|
70
|
+
|
|
71
|
+
- A **`Channel`** is the *means* — a dumb bidirectional pipe with two jobs: `deliver` a message **out** to the human, and surface messages the human sends **in**. It knows nothing about questions or answers. This is the injection point; `$stdin`/`$stdout` live only inside `Channel::Terminal`.
|
|
72
|
+
- An **`Interviewer`** is the *process* — it conducts the interaction over whatever channel is injected. Asking is **always asynchronous**: the human's answer may be the next inbound message, a later one, or never. So `ask` delivers the question and returns a `Question` you can wait on with a timeout; a background consumer matches answers to questions (by the channel's correlation id when it has one, else oldest-first) and routes anything unsolicited as human *initiative*.
|
|
73
|
+
|
|
74
|
+
Built-in channels:
|
|
75
|
+
|
|
76
|
+
- `Channel::Terminal` — the human at a keyboard (the default); IO is injectable.
|
|
77
|
+
- `Channel::Scripted` — canned answers in order, with exact correlation (tests, automation, replay).
|
|
78
|
+
- `Channel` — the abstract base; subclass it (implement `deliver`/`receive`) to bridge to Slack, email, a web UI, or a task queue.
|
|
79
|
+
|
|
80
|
+
A channel that can tie an answer back to its question (Slack threads, email) reports `correlates? == true`, and the Interviewer lets several questions be outstanding at once. A dumb channel (a bare terminal) reports `false`, and the Interviewer **serializes** — one question on the wire at a time — so an answer is never mis-attributed.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
scripted = RobotLab::Cyborg::Channel::Scripted.new(["yes", "ship it"])
|
|
84
|
+
bot = RobotLab::Cyborg.new(name: "dewayne", channel: scripted)
|
|
85
|
+
|
|
86
|
+
# Give a slow or absent human a bounded wait:
|
|
87
|
+
oncall = RobotLab::Cyborg.new(name: "oncall", ask_timeout: 30) # nil answer if no reply
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Cooperating in a network
|
|
91
|
+
|
|
92
|
+
- **Symmetric bus membership.** A Cyborg answers inbound bus tasks out of the box; a Robot opts in with one call — `robot.serve` (run each task through the model and reply) or `robot.respond_to_tasks { |m| ... }`. Both are first-class responders.
|
|
93
|
+
- **Duplex.** Inbound messages/replies are shown to the human on their channel automatically; `cyborg.tell("...")` pushes a line out yourself.
|
|
94
|
+
- **Addressing.** `cyborg.converse(peers: %w[analyst scribe])` starts a `Conversation`: the human addresses peers by `@mention` anywhere in a message (fan-out to all mentioned; **no mention broadcasts to everyone**), and replies come back on the channel.
|
|
95
|
+
- **Listening.** `converse`/`listen` keep reading the channel with no question pending, so the human can speak to the network unprompted (delivered via `on_human`).
|
|
96
|
+
- **Typed answers.** `ask_int`, `ask_confirm`, or `ask(validate:, retries:)` re-ask on bad input and return coerced values.
|
|
97
|
+
- **Presence.** `online!` / `away!` / `offline!` / `available?` — an offline human declines inbound tasks immediately, so the network can route around or escalate.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
you = RobotLab::Cyborg.new(name: "you", bus: bus)
|
|
101
|
+
you.converse(peers: %w[analyst scribe]) # @mention to address, no mention = broadcast
|
|
102
|
+
ready = you.ask_confirm("Deploy now?") # => true / false
|
|
103
|
+
you.away! # still asked, but use a bounded timeout
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Durable human steps (roadmap)
|
|
107
|
+
|
|
108
|
+
A human step currently holds a thread while it waits. `ask_async` returns the pending `Question` without blocking — the primitive a durable integration would persist. The intended path is to store a pending decision through **`robot_lab-durable`** (and `robot_lab-to`'s `DecisionManager`) so a human decision survives a process restart and doesn't pin a thread. Per-peer cryptographic identity/attribution (signed events) is the complementary trust direction, on top of the existing per-message `sender`/`from`.
|
|
109
|
+
|
|
110
|
+
## Examples
|
|
111
|
+
|
|
112
|
+
Runnable demos in [`examples/`](examples) — one feature area each:
|
|
113
|
+
|
|
114
|
+
- `01_human_in_the_network.rb` — a human as a pipeline step, peers messaging over a bus, shared memory (network → human). Key-free.
|
|
115
|
+
- `02_terminal_mentions.rb` — a **live** human addresses peers by `@mention` via the library `Conversation` (fan-out, and no-mention broadcast); replies return on their own through the duplex. Includes a real **LLM robot** (`@assistant`, Ollama) cooperating via `serve`, plus key-free canned peers.
|
|
116
|
+
- `03_robot_interviews_cyborg.rb` — the Interviewer the *other* way round: an **LLM robot** (Ollama) interviews the human via `delegate`, starting with a **typed** intake (`ask_confirm`/`ask_int`, which re-ask on bad input), then builds a categorized profile.
|
|
117
|
+
- `04_presence_and_availability.rb` — routing to a peer who's actually there: `online`/`away`/`offline`, an offline human declining immediately, and a bounded-timeout escalation. Key-free.
|
|
118
|
+
- `05_listening_and_duplex.rb` — always-on `listen`: the human speaks to the network **unprompted** and replies come back on the channel — both directions handled by the library. Key-free.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
ruby examples/01_human_in_the_network.rb
|
|
122
|
+
ruby examples/04_presence_and_availability.rb
|
|
123
|
+
ruby examples/05_listening_and_duplex.rb
|
|
124
|
+
|
|
125
|
+
# Examples 2 and 3 use a real robot on Ollama (ollama pull qwen3.6; override with
|
|
126
|
+
# OLLAMA_MODEL / OLLAMA_API_BASE). In example 2 the canned peers still work
|
|
127
|
+
# without it — only @assistant needs Ollama.
|
|
128
|
+
ruby examples/02_terminal_mentions.rb # then type: @analyst and @scribe: status?
|
|
129
|
+
ruby examples/03_robot_interviews_cyborg.rb # the robot asks you the questions
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run `rake test` to run the tests, or `rake quality` to run the full gate (tests, RuboCop, Flog, Flay). `bin/console` gives an interactive prompt.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
The gem is 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
|