Merge main (chat, hub list, releases 0.11-0.13) into the console as a hub of its own
Claude · 2026-10-27 · af8588ef18ce68a3e9a1a2d0a71cbb6a45412473 · parents 52f1924 5bf0c30
Conflicts: the capability keeps main's trigger_inputs, read from the Trigger objects' fixedInputs; the console keeps main's chat and its API list with the in-page hub's help text; Forget leaves a chat seat before it drops the hub. Vectors regenerated with main's small-talk cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jeoK8iFSW7tkCrzXLZ
Changes
.github/workflows/pages.yml | 7 +- README.md | 91 +++++-- _meta/chat.md | 204 +++++++++++++++ _meta/design.md | 3 +- _meta/federation.md | 52 ++-- _meta/platform.md | 17 +- deploy/README.md | 3 +- deploy/openmediavault/docker-compose.yml | 5 +- examples/small-talk/small-talk.toml | 64 +++++ pyproject.toml | 2 +- site/README.md | 46 ++-- site/build.sh | 8 +- site/catalog/index.html | 5 +- site/index.html | 30 ++- site/rendezvous/hubs.json | 5 + site/rendezvous/network.json | 2 +- site/rendezvous/seeds.json | 5 - site/snapshot.py | 40 +-- site/source.py | 5 +- src/pyrtan/__init__.py | 2 +- src/pyrtan/chat.py | 713 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/pyrtan/cli.py | 145 ++++++++++- src/pyrtan/federation.py | 83 ++++-- src/pyrtan/hub.py | 60 ++++- src/pyrtan/js/pyrtan.js | 3 +- src/pyrtan/js/theme.js | 40 ++- src/pyrtan/page.py | 172 ++++++++++++- src/pyrtan/service.py | 11 +- src/pyrtan/tree.py | 2 + tests/conformance/make_vectors.py | 3 + tests/conformance/vectors.json | 334 ++++++++++++++++++++++++ tests/test_catalog.py | 10 +- tests/test_chat.py | 496 +++++++++++++++++++++++++++++++++++ tests/test_examples.py | 9 + tests/test_federation.py | 72 +++--- tests/test_service_hub.py | 6 +- uv.lock | 2 +- 37 files changed, 2512 insertions(+), 245 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index f8454fe..977a587 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml@@ -2,8 +2,9 @@# and once an hour, build the landing page, the console (from this # checkout's pyrtan), the source browser (files, releases, branches and # history from git), the catalog (from examples/ and catalog/, through the-# gate), the seed list and a snapshot of the directory and of the gap-# reports as the seeds know them, and publish them. Set the+# gate), the hub list and a snapshot of the directory and of the gap+# reports as the hubs on it, and every hub they know of, know them, and+# publish them. pyrtan.com runs no hub: these static files are all of it. Set the# repository's Pages source to "GitHub Actions" and its custom domain to # pyrtan.com; site/CNAME carries the domain.@@ -14,7 +15,7 @@ on:branches: [main] workflow_dispatch: schedule:- - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the seeds+ - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the hub listpermissions: contents: read diff --git a/README.md b/README.md index e6eb6fe..746f95a 100644 --- a/README.md +++ b/README.md@@ -160,12 +160,13 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).| `pyrtan run --tree NAME --input k=v` | Runs a named tree. | | `pyrtan match "…" --trees DIR` | How a sentence reads, without running anything: the rewrites, every trigger's verdict, the winner. The tool for writing triggers. | | `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. |-| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory. |+| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off, `--chat-public` lets callers without the token in, to meet people only). || `pyrtan keep KEY [FILE] --at HUB` | Gives your hub a blob to keep; its keepers get copies. `kept` shows who holds what; `fetch KEY` gets a blob back from your hub or whoever holds it. | | `pyrtan follow [URL] --at HUB` | Makes your hub follow another: it then routes sentences to that hub's trees too. With no URL, `pyrtan join`. `--network NAME` follows within a network of yours; `--listed` also puts your hub in the directory. `unfollow` undoes it; `peers` lists services, hubs followed and followers; `directory` lists every hub that asked to be found, as the swarm knows it. | | `pyrtan join [INVITE] --at HUB` | Puts your hub in a network and follows a hub in it: the default network pyrtan.com publishes, or the one an invite (`NAME:SECRET@HUB`) names. | | `pyrtan network new NAME --at HUB` | Starts a network of your own, a network of one, and prints its invite. `network list` shows each network with its invite; `network leave NAME` cuts the links made in it. | | `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. |+| `pyrtan chat --at HUB` | Talks to a random stranger or a bot through a hub started `--chat`. `--bot` for a bot, `--people` for people only, `--interests` to meet someone who shares one, `--trace` to see the path behind each bot line, `--no-typing` to hide "Stranger is typing…". || `pyrtan gaps --at URL` | Every decision that met a value with no branch: the branches to write next. | | `pyrtan unanswered --at URL` | Every sentence no tree answered, with the tree that would: the trees to write next. `--scaffold DIR` writes them. | | `pyrtan new --from "…"` | Scaffolds a tree file from a sentence: numbers and hosts in it become slots, and the file passes `pyrtan check`. `pyrtan new NAME` scaffolds from a name. |@@ -199,10 +200,10 @@ while the page is open. Trees that need a box (files, programs, seriallines, brokers, a browser to drive) or a site that does not let a page read it say so; for those, join a hub started with `--cors https://pyrtan.com` by giving its URL and token. Forget it and the-browser is its own hub again. The wordmark is two colours, **Pyr** the+browser is its own hub again. The wordmark is two colours, **pyr** theprimary and **tan** the secondary: click either half, right-click it, or-press and hold it on a phone to pick that half's colour, kept in the-browser. To run the swarm on a NAS or any Docker host,+press and hold it on a phone to pick that half's colour; tan's picker also+sets light, dark or auto mode. The choice is kept in the browser. To run the swarm on a NAS or any Docker host,see [`deploy/README.md`](deploy/README.md). ## A federation@@ -271,16 +272,16 @@ purpose. It names the network rather than guarding it: anyone may join,and what keeps a stranger's hub harmless is that following is still the one act of trust. `pyrtan join` (or `pyrtan hub --join default`, or `$PYRTAN_JOIN=default`) puts a hub in it and follows the first hub in-`rendezvous/seeds.json` that answers, then the first from the snapshot;+`rendezvous/hubs.json` that answers, then the first from the snapshot;when none does, the hub is the network so far, and others join through-it. No hub has to be always up: every hour pyrtan.com's build asks-every hub it knows of for its directory (the seeds, then everyone the-last snapshot or a hub names), keeps the default network's part, and-publishes it as `rendezvous/directory.json`, so the directory stays-current while any listed hub is up, and pyrtan.com itself is static-files on GitHub Pages. To be a seed, a hub in the default network,+it. pyrtan.com runs no hub: it is static files on GitHub Pages. Every+hour its build asks every hub it knows of for its directory (the hub+list, then everyone the last snapshot or a hub names), keeps the default+network's part, and publishes it as `rendezvous/directory.json`, so the+directory stays current while any hub it knows of is up, and no hub has+to be always up. To be on the hub list, a hub in the default network,started `--listed` and `--cors https://pyrtan.com`, goes into-`seeds.json` by pull request.+`site/rendezvous/hubs.json` by pull request.## Members, and what they keep@@ -411,6 +412,63 @@ device is literal in the file and checked against the tree's lists by`pyrtan check`, and a service started `--egress public` watches no file and no serial line.+## Chat: strangers and bots++A hub started `--chat` keeps a lobby, Omegle-style: press Start in the+console's **Chat** tab (or run `pyrtan chat`) and you are paired at random+with someone else on the hub, with someone waiting on a hub it follows,+or with a bot. Press Stop twice (or Esc twice) to end it and meet someone+new. Nobody has a name, and nothing is kept.++```+$ pyrtan hub --trees examples --chat+$ pyrtan chat --bot --interests disk+ You're now chatting with a bot: hub, this hub's own trees. It is 11 trees, not a person and not a model, and every line it says comes with the path that produced it. Try “how full is the disk”.+hi+Bot: I'm hub, this hub's own trees. It is 11 trees, …+what's the weather in Oslo+Bot: Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.+top 3 stories on lobsters+Bot: No service in the swarm answers 'top 3 stories on lobsters'. To write the tree that would: pyrtan new top-stories-lobsters --from "top 3 stories on lobsters"+```++A **bot is not a model**: it is one holder of trees on the network (the+hub's own, a service, a trusted member, a hub it follows), picked at+random, one whose trees share a word with your interests first. What you+say is read against its triggers, then asked of the whole swarm; when+nobody answers, it says so and names the tree that would, and the+sentence is recorded as unanswered like any other. An `ask` node's+question is its next line and your reply is the answer. Every line it+says carries its trace. [`small-talk`](examples/small-talk/small-talk.toml)+answers "how are you" and "are you a bot"; a bare "hi" is filler, and+the bot answers it with who it is.++**Stranger is typing…** While someone types, their partner sees it, and+while a bot's trees run, "Bot is thinking…". It works both ways or not+at all: untick *Typing indicator* in the Chat tab (remembered in that+browser), or `pyrtan chat --no-typing`, and you neither share your+typing nor see anyone else's. An operator turns it off for the whole+hub with `pyrtan hub --chat --no-chat-typing` (`$PYRTAN_CHAT_TYPING=0`).+It is never stored: it goes out with the next line, or after five+seconds without news.++**Strangers across hubs.** A seat that finds nobody waiting asks the hubs+its hub follows with a signed `Meet`; the lines travel as `Say` and the+end as `Part`, typing as `Typing`, hub to hub, signed with the pair's+secret; the outbox logs a `Say` without its text and leaves `Typing` out. A hub answers a `Meet` only from a+hub that follows it, and never passes one on.++The lobby follows the hub's token: with one, only callers who have it+chat (a bot runs trees, so a lobby must not be a way round it). To let+strangers in and keep the token on everything else, start the hub+`--chat-public`: callers without the token are guests, who meet people+(here, or on a hub this one follows) and never a bot. There is no chat+server and no hub of pyrtan.com's: every hub started `--chat` is a lobby, and hubs+that follow each other share their strangers. It is+text only, a line is at most 1,000 characters, five in a burst and one a+second after; a seat nobody polls for a minute is let go. There is no+moderation but leaving. The design is in [`_meta/chat.md`](_meta/chat.md).+## When no service in the swarm answers A sentence nobody answers is not a dead end; it is the next tree. The hub@@ -523,14 +581,14 @@ _meta/ the design record, the tree format reference, and thedesign for pyrtan.com deploy/ Dockerfile at the root; a compose stack for OpenMediaVault and a systemd unit-site/ pyrtan.com: the landing page, the seed list, the catalog+site/ pyrtan.com: the landing page, the hub list, the catalogviewer, the source browser (files, releases, branches and history), and the build the pages workflow publishes ``` ## Status-0.10.0. The core, the service, the hub, the command, the console, the+0.13.0. The core, the service, the hub, the command, the console, thecatalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation between hubs, networks (any hub starts one; pyrtan.com publishes the@@ -539,8 +597,9 @@ part of the swarm), the blobs they keep and the trees they run(`pyrtan.js`, conformant with the Python runtime), six forms of I/O (HTTP, a browser, files, programs, serial lines, MQTT queues), each behind an allow-list, watches (a tree that runs when a clock, a file, a-topic or a serial line speaks), and `pyrtan grow` (the branch a gap asks-for, drafted by rules) are built and tested; a swarm can require a+topic or a serial line speaks), `pyrtan grow` (the branch a gap asks+for, drafted by rules), and the chat lobby (strangers on the hub and the+hubs it follows, bots made of trees, a typing indicator, and lobbies open to the public) are built and tested; a swarm can require atoken, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Not yet built: hosting. See [`_meta/design.md`](_meta/design.md) for the runtime's design and diff --git a/_meta/chat.md b/_meta/chat.md new file mode 100644 index 0000000..f3c7214 --- /dev/null +++ b/_meta/chat.md@@ -0,0 +1,204 @@+# Design — Chat: strangers and bots, Omegle-style++| | |+|---|---|+| **Status** | **Built, v0.11; the typing indicator since v0.12.** The lobby, bots made of trees, strangers across a follow, the console's Chat tab and `pyrtan chat`; tests with two hubs over real sockets and the console driven in a browser. "Stranger is typing…", on by default, off per person or per hub (§4). |+| **Date** | 2026-10-27 |+| **Decision** | A hub started `--chat` keeps a lobby where anyone who can reach it is paired at random with another person, on this hub or on a hub it follows, or with a bot. A bot is a holder of trees, and what it says is what `/intent` would have said: the chat adds no model and no new kind of knowledge. Nothing is kept. |+| **Where** | `src/pyrtan/chat.py` (the lobby), `src/pyrtan/hub.py` (`/chat`, `intent(only=…)`), `src/pyrtan/federation.py` (`Meet`, `Say`, `Part`), `src/pyrtan/page.py` (the Chat tab), `src/pyrtan/cli.py` (`pyrtan chat`, `hub --chat`), `examples/small-talk/`. |++## 0. The brief++*"A chat/peer section where you can talk Omegle-style with bots or random+users on the network."* Read as: press a button, be paired with someone+you do not know, talk, press it again for someone else. "Bots" are the+swarm's own: Pyrtan has no other kind. "The network" is the hub you are+on and the hubs it follows.++## 1. The lobby++A *seat* is a random id the client holds; holding it is being that seat.+A seat says whom it wants to meet (`anyone`, `people` or a `bot`) and up+to five interests, and waits. Pairing is at random among the seats+waiting here that want people, the ones sharing the most interests first;+the system line says what you share ("You both like chess."). A seat that+wants `anyone` and meets nobody in eight seconds meets a bot. A seat that+wants `people` never does.++Everything is a JSON request: `POST /chat/start`, `/chat/poll`,+`/chat/say`, `/chat/next`, `/chat/leave`, and `GET /chat` for the counts.+`poll` is a long poll (up to 25 s) on a condition the lobby notifies on+every event, and it wakes on its own when the seat is due to ask another+hub or meet a bot, so nothing runs in the background. A seat's own lines+come back to it as events too, so the log a client shows is the hub's, in+the hub's order. A seat nobody polls for a minute is let go and its+partner told, which is how a closed tab ends a conversation.++The lobby follows the hub's access rule: with a token, only callers with+the token chat. That is deliberate. A bot runs trees, so a lobby open to+callers the hub would refuse at `/intent` would be a way round the token.++**A public lobby (v0.13).** A hub that wants strangers from the+internet in its lobby, and its token on everything else, starts+`--chat-public` (`$PYRTAN_CHAT_PUBLIC`). `/chat` then answers without+the token, and a caller without it is a *guest*: a guest meets people,+on this hub or on a hub it follows, and never a bot. `want: bot` from a+guest is refused (`bots-need-token`), `anyone` is read as `people`, and+`/chat` counts no bots for them. `/api` says `chat_public` and, for the+caller asking, `chat_bots`; the console offers a guest "people only" and+says why. The rest of the hub (`/intent`, `/run`, `/gaps`, `/follow`,+`/keep`) still needs the token, so opening the lobby lends strangers+nothing but each other. A hub with no token has no guests: there,+anyone may meet a bot, as anyone may ask it at `/intent`.++**No centre.** There is no chat server, and pyrtan.com runs no hub. Every hub started+`--chat` is a lobby, and a seat that finds nobody at home asks the hubs+its hub follows (§3). A hub that joins the default network (`pyrtan+join`) follows a hub in it, and each follow is a way for the people+waiting on two hubs to meet; the network has as many lobbies as it has+hubs that chat. A+person runs one, or opens the Chat tab of any hub that is public.++## 2. Bots++A bot is one *holder* of trees, picked at random from everyone the hub+routes to and is up: its own trees, a service, a trusted member, a hub it+follows. A holder whose trees share a word (name, description, tags,+triggers) with the seat's interests is picked first, and the+introduction suggests a sentence from those trees. A bot's line is+produced by rules, in this order:++1. If the bot's last line was an `ask` node's question, the line is the+ answer: the hub resumes the snapshot with it. An `ask` is a+ conversation already; chat is where it reads like one.+2. Otherwise the line is read against the bot's own triggers only+ (`Hub.intent(only=holder)`). A match runs there, exactly as `/intent`+ would.+3. A line that reads as nothing (the fillers rule dropped all of it:+ "hi", "hello pyrtan", "thanks") gets the bot's introduction, and the+ swarm is asked nothing. Greetings are fillers to every tree in the+ swarm; making them triggers would change how "hi, what's the weather"+ reads everywhere.+4. Otherwise it is asked of the whole swarm, as `/intent`. When nobody+ answers, the bot says so with the tree that would, and the sentence is+ recorded as unanswered like any other. A chat is a stream of real+ sentences; that is where the next trees come from.++Every bot line carries the outcome's status, tree, peer, output and trace,+and the console shows the trace under the line. `examples/small-talk`+answers what is left once greetings are fillers: "how are you", "who are+you", "are you a bot", "what can you do", "bye", with one `decide` on the+topic its trigger fixes.++**A fix this needed.** A trigger may fix inputs (`inputs = { topic =+"how" }`), but `/capabilities` carried only the patterns, so a hub+routing a sentence to another holder dropped them. Capabilities now carry+`trigger_inputs`, and the hub reads triggers with them.++## 3. Strangers across hubs++A seat that finds nobody here asks up to three of the hubs this one+follows, every five seconds while it waits, with a signed `Meet {ref,+interests}`. The followed hub pairs it with one of its own waiting seats+or answers `met: false`. From then on each side's lines go as `Say {to,+from, text}` and the end as `Part {to, from}`, delivered to the other+hub's inbox and signed with the pair's secret, both ways. `ref` is a+second random id per seat, so another hub never learns the id that would+let it act as the seat, and it changes on `next`, so the last stranger+cannot reach the next conversation.++The trust rules are federation's. A hub answers a `Meet` only from a hub+that follows it: following is the operator's choice, and a hub that is+followed serves its followers. A `Say` or `Part` is taken only for a+conversation that hub is in. A hub pairs a `Meet` only with its own seats+and never passes one on. Members do not meet across hubs: a member is a+browser that chats through its hub's lobby like anyone.++While a seat is asking other hubs it is *busy*, and nobody can pair it+meanwhile; two seats that ask each other's hubs at once both get+`met: false` and try again on their next round.++## 4. Typing (v0.12)++While a person types, the client says so (`POST /chat/typing {seat,+typing: true}`), again every two seconds while they go on, and `false`+when the box empties, loses focus or the line is sent. The partner's+poll answers with `partner_typing: true` and the console shows+"Stranger is typing…". While a bot's trees run, its partner reads+`partner_typing: true` too, and the console says "Bot is thinking…".++It is a state, not an event: nothing about it enters the seat's events,+so a client that was away sees the conversation and not a history of+keystrokes. It goes out by itself five seconds after the last word of+it (a closed tab, a lost `false`), and at once with the next line. A+poll that sends `partner_typing` (what the client is showing) is woken+when that is no longer true, both when the partner starts and when+the indicator goes out; a poll that does not send it is never woken by+typing, so a client that does not show the indicator cannot be made to+spin by someone else's keystrokes.++**Enabling and disabling.** Two switches, both on by default:++- *Per person.* `typing_indicator: false` on `/chat/start`, `/chat/next`+ or `/chat/settings` (the console's "Typing indicator" box, remembered+ in that browser; `pyrtan chat --no-typing`). It works both ways, like+ read receipts: a seat with it off shares nothing and sees nothing. A+ person who would rather not be watched typing should not get to watch.+ Turned off mid-word, what the partner was shown goes out at once.+- *Per hub.* `pyrtan hub --chat --no-chat-typing`+ (`$PYRTAN_CHAT_TYPING=0`): nobody's typing is shared, `/chat` says+ `typing: false`, and the console hides the box.++Across hubs it is a `Typing {to, from, typing}` activity, sent on a+change and at most every two seconds while typing holds. The sender's hub+sends it only for a seat with the indicator on and the receiver's shows+it only to a seat with it on, so the rule holds across hubs without+either hub knowing the other seat's setting. The outbox and inbox logs+leave `Typing` out: it would push everything else off them. A hub from+before v0.12 refuses the activity; the first refusal stops the sending+for that conversation.++## 5. What this design refuses++- **A model behind the bot.** A bot that "chats" by generating text+ would be the one place in Pyrtan whose output cannot be explained.+ The bot says what a tree says, or that no tree says anything.+- **Keeping anything.** Conversations live in memory while someone is in+ them. No names, no accounts, no history, no transcripts. The hub's+ outbox and inbox logs show that a `Say` went, with its length, never+ its text. A restart drops the lobby.+- **Pretending to moderate.** Omegle closed in 2023 over abuse it could+ not moderate at scale; this lobby is not built to do better. What it+ does: text only, no links rendered, opt-in per hub (`--chat`), behind+ the hub's token when it has one unless the operator opens it+ (`--chat-public`, people only), a line at most 1,000 characters, five+ lines in a burst and one a second after, at most 500 seats, and either+ side can leave at any moment. An operator who opens a lobby to the+ internet should know that is all.+- **A socket.** Long polls, as members poll a mailbox; no WebSocket, no+ WebRTC, nothing the standard library's server cannot hold.+- **A second hop.** A `Meet` reaches the hubs this one follows and stops.++## 6. Failure modes, honestly++- A hub that dies mid-conversation sends no `Part`. The stranger on the+ other hub sees nothing until they say something (the `Say` fails, and+ the conversation ends with a line saying so) or press New.+- A followed hub that is slow to answer a `Meet` slows the poll that asked+ it, by up to its delivery timeout, three hubs at most.+- A bot whose holder is slow answers late; lines to it are answered in+ order, one at a time, and a reply for a conversation that has moved on+ is dropped.+- Two hubs on different versions: one without a lobby answers a `Meet`+ `no-chat`, one from before v0.11 refuses the activity type; either is+ `met: false` to the asker.++## 7. Sequencing++- **v0.11, built:** the lobby, bots, strangers across a follow, the Chat+ tab, `pyrtan chat`, `small-talk`, `trigger_inputs`.+- **v0.12, built:** the typing indicator, per person and per hub (§4).+- **v0.13, built:** public lobbies: `--chat-public`, guests meet people+ only (§1).+- **Later:** a member's lobby that works offline between two phones of+ the same hub.diff --git a/_meta/design.md b/_meta/design.md index 9792584..b794e0d 100644 --- a/_meta/design.md +++ b/_meta/design.md@@ -284,7 +284,8 @@ Five trees, all against real public APIs, all tested offline:as the only trust. Design in [`federation.md`](federation.md). 4. **v0.4, built: no server anywhere.** The directory hubs find each other by is kept and gossiped by every federated hub, with a static- seed list at pyrtan.com in place of a rendezvous hub; a hub can hold+ list of hubs at pyrtan.com in place of a rendezvous hub (then+ `seeds.json`, now `hubs.json`); a hub can holdtrees itself (`--trees`), so a federated swarm can be one process. 5. **v0.5, built: members and what they keep.** A browser or a phone joins a hub through a mailbox, carries the directory, and keeps the diff --git a/_meta/federation.md b/_meta/federation.md index cc12cea..8f5aef4 100644 --- a/_meta/federation.md +++ b/_meta/federation.md@@ -137,7 +137,7 @@ then answers for all of them. Rejected: it would route to hubs nobodychose. **A rendezvous hub that holds the directory** (v0.3) was built and then replaced: it was a server, and the point of a swarm is that there is none. What stands is §7: a directory every hub keeps and-gossips, a static seed list, and routing that only ever follows an+gossips, a static hub list, and routing that only ever follows anoperator's follow. **Multi-hop routing.** Announcing transitive capabilities with a hop@@ -206,34 +206,38 @@ Each entry remembers `via`, the hub it was last heard from.**Reading it.** `GET /directory` (public) on any federated hub lists what that hub knows, each entry with `link` computed from `seen`: `up` within ten minutes, `stale` within a day, `down` after. `pyrtan directory` asks-your own hub first and falls back to the seeds; the console shows it on-every federated hub; the page at pyrtan.com asks the seeds from the-visitor's browser.--**Seeds.** pyrtan.com publishes `rendezvous/seeds.json`, a static list-of hub URLs maintained by pull request, the role of a torrent's bootstrap-nodes. `pyrtan join` (and `pyrtan follow` with no URL) puts a hub in the-default network (§7a) and follows the first seed whose `/actor` answers;-`pyrtan hub --join default` (or `--follow seed`) does the same on start.-A seed is an ordinary federated hub in the default network, up when its-operator's box is; nothing needs it to be up always (below). `$PYRTAN_SEEDS` points at another list (a URL or a file),-`$PYRTAN_RENDEZVOUS` at one hub.--**The snapshot.** Seeds are home boxes, and home boxes go off. So the-site's build, which GitHub runs on every push and once an hour, asks each-seed's `/directory`, then every other hub it knows of (from the last+your own hub first and falls back to the hub list; the console shows it+on every federated hub; the page at pyrtan.com asks the listed hubs from+the visitor's browser.++**The hub list.** pyrtan.com runs no hub. It publishes+`rendezvous/hubs.json`, a static list of hub URLs maintained by pull+request, the role of a torrent's bootstrap nodes, and it is the only way+pyrtan.com learns that a hub exists. `pyrtan join` (and `pyrtan follow`+with no URL) puts a hub in the default network (§7a) and follows the+first listed hub whose `/actor` answers; `pyrtan hub --join default`+does the same on start. A listed hub is an ordinary federated hub in+the default network, up when its operator's box is; nothing needs it to+be up always (below). `$PYRTAN_HUB_LIST` points at another list (a URL+or a file), `$PYRTAN_RENDEZVOUS` at one hub. (Until v0.13 this was+`seeds.json`, and pyrtan.com briefly ran a hub of its own on Fly.io; both+are gone.)++**The snapshot.** Listed hubs are home boxes, and home boxes go off. So+the site's build, which GitHub runs on every push and once an hour, asks+each listed hub's `/directory`, then every other hub it knows of (from the lastsnapshot, and from what the hubs it asked named), in parallel rounds up to a bound, and bakes the merged answer into `rendezvous/directory.json` with the time, keeping the default network's part of each directory (and what was heard outside any network), keeping the last snapshot's entries for hubs that did not answer and dropping anything a week old-(`site/snapshot.py`, standard library, no pyrtan). The build is the-seed's directory work, done by GitHub: the directory stays current while-any listed hub is up, whether or not a seed is. The landing page-shows it when no seed answers the visitor's browser; `pyrtan directory`-falls back to it; `pyrtan follow` with no URL tries its hubs after the-seeds (`$PYRTAN_SNAPSHOT` overrides). A new hub can therefore bootstrap-from any listed hub that is up, and the only way the directory is empty+(`site/snapshot.py`, standard library, no pyrtan). The build does the+directory work a central hub would, done by GitHub: the directory stays+current while any hub it knows of is up, listed or not. The landing page+shows it when no listed hub answers the visitor's browser; `pyrtan+directory` falls back to it; `pyrtan follow` with no URL tries its hubs+after the listed ones (`$PYRTAN_SNAPSHOT` overrides). A new hub can+therefore bootstrap from any hub the snapshot knows of that is up, and the only way the directory is emptyis if nobody has been up for a week. Nothing of ours polls: GitHub does. **What an entry is not.** A hub two follows away cannot verify who made diff --git a/_meta/platform.md b/_meta/platform.md index 2fa3368..62deacb 100644 --- a/_meta/platform.md +++ b/_meta/platform.md@@ -135,7 +135,7 @@ federated hub keeps what it hears, one row per reporter and gap, withthe merge rules the directory uses (newer wins, nothing from the future, thirty days, two thousand rows), passes it on, and serves `/reports` publicly with counts and reporters summed. The site's snapshot asks the-seeds for `/reports` hourly and bakes `rendezvous/reports.json`; the+listed hubs, and every hub they know of, for `/reports` hourly and bakes `rendezvous/reports.json`; thecatalog page shows each tree's gaps beside it; `pyrtan reports NAMESPACE/NAME` reads a hub or the baked file. A tree without a namespace reports nothing: there is nobody to report to. Opting in is the@@ -180,13 +180,14 @@ says so and points at the hub's own `/`, which is the same console.### 2.6 The rendezvous (built; nothing runs)-pyrtan.com runs nothing. Hubs find each other through the directory-every federated hub keeps and gossips ([`federation.md`](federation.md)-§7); the site publishes `rendezvous/seeds.json`, a static list of hubs-to follow first, and its page reads the live directory from those seeds-in the visitor's browser. The first seed is the author's own NAS,-reachable through a tunnel. A hosted hub, when there is one, is just-another seed.+pyrtan.com runs nothing: it is static files on GitHub Pages, and runs no+hub of its own. Hubs find each other through the directory every+federated hub keeps and gossips ([`federation.md`](federation.md) §7);+the site publishes `rendezvous/hubs.json`, a static list of hubs their+operators added by pull request, and its page reads the live directory+from them in the visitor's browser, falling back to the snapshot GitHub+bakes hourly. A hosted hub, when there is one, goes on the list like+any other.### 2.7 Hosting (designed) diff --git a/deploy/README.md b/deploy/README.md index 0a0c7ca..73b5956 100644 --- a/deploy/README.md +++ b/deploy/README.md@@ -70,7 +70,8 @@ file's environment `PYRTAN_ADVERTISE` (that address), `PYRTAN_JOIN:default` and `PYRTAN_LISTED: "true"`, with `PYRTAN_TOKEN` set and `PYRTAN_EGRESS: public` (hubs that follow yours may ask it to run trees, and `any` would let them point a tree at your LAN). The hub then joins-the default network pyrtan.com publishes, follows the first seed in it,+the default network pyrtan.com publishes, follows the first hub in it+that answers (from pyrtan.com's hub list, then its snapshot),routes to it, and is listed in the directory every hub gossips; `pyrtan follow URL --at <your hub> --token …` follows any other. For a network of your own, `pyrtan network new home --at <your hub> --token …` prints diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index dc211ca..6e502cc 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml@@ -35,7 +35,7 @@ x-pyrtan: &pyrtanPYRTAN_EGRESS: any # Federation: once the hub is reachable from outside (a tunnel or a # port-forward with TLS), give it its public URL and it joins the- # default network pyrtan.com publishes, follows a seed in it, and lists+ # default network pyrtan.com publishes, follows a hub in it, and lists# itself in the directory. Needs the token, and PYRTAN_EGRESS: public # above: hubs that follow this one may ask it to run trees. # PYRTAN_JOIN may also hold invites to networks of your own@@ -45,6 +45,9 @@ x-pyrtan: &pyrtan# PYRTAN_LISTED: "true" # PYRTAN_REPORT_GAPS: "true" # opt in: the gaps your catalog trees meet (tree, node, value; never inputs) reach their authors via pyrtan.com # PYRTAN_WATCH: "true" # run the trees' [[watches]]: a clock, a file, a topic or a serial line starts a run+ # PYRTAN_CHAT: "true" # a lobby at /chat: people meet a random stranger here or on a hub this one follows, or a bot made of its trees+ # PYRTAN_CHAT_PUBLIC: "true" # open the lobby to people without the token (they meet people, never a bot); everything else keeps the token+ # PYRTAN_CHAT_TYPING: "0" # turn the lobby's "Stranger is typing…" off for everyone (each person can also turn it off for themselves)PYRTAN_UNANSWERED: /state/unanswered.jsonl # sentences nobody answered, kept across restarts (the hub's; services with a hub leave the record to it) PYRTAN_STATE: /state/federation.json PYRTAN_STORE: /state/store # blobs: the hub's own and the ones it keeps for others diff --git a/examples/small-talk/small-talk.toml b/examples/small-talk/small-talk.toml new file mode 100644 index 0000000..5f9cbbe --- /dev/null +++ b/examples/small-talk/small-talk.toml@@ -0,0 +1,64 @@+# What a stranger says to a bot in the chat before asking anything. A bare+# "hi" or "thanks" never reaches a tree (greetings and politeness are+# fillers, and the lobby answers a sentence that is nothing else with the+# bot's introduction); these are the questions that are left. Each trigger+# fixes the topic and one decide picks the line: no source is touched, so+# the tree runs anywhere, a phone included.++name = "small-talk"+version = 1+namespace = "pyrtan"+description = "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything."+tags = ["chat", "read-only"]+license = "MIT"+start = "topic"++[[triggers]]+pattern = "how are you [doing|today]"+inputs = { topic = "how" }+[[triggers]]+pattern = "(who|what) are you"+inputs = { topic = "who" }+[[triggers]]+pattern = "what (can|do) you do"+inputs = { topic = "what" }+[[triggers]]+pattern = "are you (a bot|human|real|a person)"+inputs = { topic = "who" }+[[triggers]]+pattern = "(bye|goodbye|see you|see ya)"+inputs = { topic = "bye" }++[inputs.topic]+type = "str"+one_of = ["how", "who", "what", "bye"]++[nodes.topic]+kind = "decide"+on = "inputs.topic"+branches = [+ { when = { eq = "how" }, then = "how" },+ { when = { eq = "who" }, then = "who" },+ { when = { eq = "what" }, then = "what" },+ { when = { eq = "bye" }, then = "bye" },+]++[nodes.how]+kind = "done"+say = "Deterministic, thanks: the same question takes the same path every time."+output = { topic = "how" }++[nodes.who]+kind = "done"+say = "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it."+output = { topic = "who" }++[nodes.what]+kind = "done"+say = "Only what my trees were written for. Ask something else and I'll say that nobody answers it yet, and which tree would."+output = { topic = "what" }++[nodes.bye]+kind = "done"+say = "Bye. Press New to meet someone else."+output = { topic = "bye" }diff --git a/pyproject.toml b/pyproject.toml index fb1ca55..7b1a867 100644 --- a/pyproject.toml +++ b/pyproject.toml@@ -1,6 +1,6 @@[project] name = "pyrtan"-version = "0.10.0"+version = "0.13.0"description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees." readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/site/README.md b/site/README.md index bdf324f..76a30de 100644 --- a/site/README.md +++ b/site/README.md@@ -6,14 +6,14 @@ static directory and publishes it.| Path | What | Built from | | :-- | :-- | :-- |-| `/` | the landing page, with the live directory read from the seeds in the visitor's browser | `site/index.html` |+| `/` | the landing page, with the live directory read from the listed hubs in the visitor's browser | `site/index.html` || `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it). Until it joins a hub the browser is one: it holds the catalog's trees (`/catalog/trees.json`) and runs them in the page. To join any hub started with `--cors https://pyrtan.com`, give its URL and token; its Member tab then joins that hub, and runs its trees when joined with the token | `pyrtan console --out` | | `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, `index.json` the command reads, and `trees.json`, the same trees as JSON for the console | `pyrtan index examples catalog --copy`, through `site/gate.py` | | `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` |-| `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.json` |+| `/rendezvous/hubs.json` | the hub list: hubs operators added by pull request, which the snapshot asks first and `pyrtan join` tries first | `site/rendezvous/hubs.json` || `/rendezvous/directory.json` | a snapshot of the default network's directory, from every hub the build can reach, refreshed hourly | `site/snapshot.py` |-| `/rendezvous/reports.json` | the gap reports the seeds carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |+| `/rendezvous/reports.json` | the gap reports the hubs carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |`site/build.sh out` builds the same thing locally; open `out/index.html`.@@ -54,11 +54,11 @@ always passes. See [`catalog/README.md`](../catalog/README.md) and[`CONTRIBUTING.md`](../CONTRIBUTING.md). `rendezvous/reports.json` is baked beside the directory snapshot: the gap-reports the seeds carry (from hubs that opted in with `--report-gaps`),+reports the hubs carry (from hubs that opted in with `--report-gaps`),merged hourly, which the catalog page shows beside each tree and `pyrtan reports` reads when given no hub.-## The default network and the seeds+## The default network and the hub list`site/rendezvous/network.json` is the default network: the name `pyrtan` and a secret that is public on purpose (it names the network,@@ -66,22 +66,24 @@ it guards nothing; see `_meta/federation.md` §7a). `pyrtan join` withno invite, `pyrtan hub --join default` and `$PYRTAN_JOIN=default` put a hub in it.-`site/rendezvous/seeds.json` lists hubs to follow first. A seed is a-federated hub in the default network (`--join default`), started with-`--listed` and `--cors https://pyrtan.com`, reachable from outside (a-tunnel or a port forward behind TLS; [`deploy/README.md`](../deploy/README.md)-says how). It need not be up always. Put yours in by pull request. The-landing page asks each seed's `/directory` from the visitor's browser,-so the directory on pyrtan.com is live and needs no backend; the-console link on each row opens `/console/?hub=<url>`. When no seed-answers, the page shows the snapshot instead, marked with its time:-`site/snapshot.py` asks the seeds on every build, hourly on the-schedule, then every other hub it knows of, keeps the default network's-part of what they say, keeps the last published snapshot's entries for-any hub that is down, and drops entries unheard of for a week. `pyrtan-join` tries the snapshot's hubs after the seeds, and `pyrtan directory`-falls back to it, so a new hub can join through any listed hub that-happens to be up, not only a seed.+pyrtan.com runs no hub of its own; it is these static files, and the+hub list is how it learns that a hub exists. `site/rendezvous/hubs.json`+lists hubs operators put in by pull request: each a federated hub in the+default network (`--join default`), started with `--listed` and `--cors+https://pyrtan.com`, reachable from outside (a tunnel or a port forward+behind TLS; [`deploy/README.md`](../deploy/README.md) says how). None+need be up always. The landing page asks each listed hub's `/directory`+from the visitor's browser, so the directory on pyrtan.com is live and+needs no backend; the console link on each row opens+`/console/?hub=<url>`. When none answers, or the list is empty, the page+shows the snapshot instead, marked with its time: `site/snapshot.py`+asks the listed hubs on every build, hourly on the schedule, then every+other hub it knows of, keeps the default network's part of what they+say, keeps the last published snapshot's entries for any hub that is+down, and drops entries unheard of for a week. `pyrtan join` tries the+snapshot's hubs after the listed ones, and `pyrtan directory` falls back+to it, so a new hub can join through any hub the snapshot knows of that+happens to be up.## Later: hosting@@ -90,6 +92,6 @@ with `--token`, `--cors https://pyrtan.com`, `--gaps` on a volume,`--egress public` and `--federate --advertise https://<tenant>.pyrtan.com --listed --state <volume>/federation.json`, behind TLS; the console connects to it like to any other, a self-hosted hub can follow it or be-followed by it, and it is one more seed. The control plane that+followed by it, and it can go on the hub list like any other. The control plane thatprovisions it is a separate, private repository, per `_meta/platform.md`. diff --git a/site/build.sh b/site/build.sh index 18f0bc3..d187c20 100755 --- a/site/build.sh +++ b/site/build.sh@@ -1,6 +1,6 @@#!/bin/sh # Assemble pyrtan.com into one static directory: the landing page, the-# console, the catalog (built from examples/ and catalog/), the seed list, the source. Run from the+# console, the catalog (built from examples/ and catalog/), the hub list, the source. Run from the# repository root with pyrtan installed (`uv run site/build.sh`); the Pages # workflow runs the same thing. set -eu@@ -16,12 +16,12 @@ version="$(pyrtan --version | cut -d' ' -f2)"sed "s/__VERSION__/$version/g" site/index.html > "$out/index.html" # The wordmark's colour picker, shared by the landing page and the catalog (the console inlines it). cp src/pyrtan/js/theme.js "$out/"-cp site/rendezvous/seeds.json site/rendezvous/network.json "$out/rendezvous/"-# The directory as the hubs know it now (the seeds, then every hub the+cp site/rendezvous/hubs.json site/rendezvous/network.json "$out/rendezvous/"+# The directory as the hubs know it now (the hub list, then every hub the# last snapshot or a hub names), the default network's part only, keeping # the last published snapshot's entries for hubs that are down # (SNAPSHOT_PREVIOUS may add more).-python3 site/snapshot.py site/rendezvous/seeds.json "$out/rendezvous/directory.json" \+python3 site/snapshot.py site/rendezvous/hubs.json "$out/rendezvous/directory.json" \--network site/rendezvous/network.json \ --previous https://pyrtan.com/rendezvous/directory.json \ --previous https://b-i-t-8.github.io/pyrtan/rendezvous/directory.json \ diff --git a/site/catalog/index.html b/site/catalog/index.html index db974fb..7e0cbe5 100644 --- a/site/catalog/index.html +++ b/site/catalog/index.html@@ -9,7 +9,8 @@<script src="../theme.js"></script> <style> :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 }:root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) } * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif }@@ -30,7 +31,7 @@</head> <body> <main>- <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span> catalog</h1>+ <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span> catalog</h1><p class="lede">Trees you can pull into your own swarm. Each is one TOML file, checked by the repository's tests, with the licence its author chose, under the namespace of whoever published it. <code>pyrtan pull NAMESPACE/NAME</code> verifies the file against <a href="index.json">index.json</a> before writing it. Where hubs that opted in have met a value a tree had no branch for, the <a href="../rendezvous/reports.json">gap reports</a> say so beside it.</p> <input type="search" id="q" placeholder="filter: weather, github, read-only…" autocomplete="off"> <div id="list"><p class="mute">Loading index.json…</p></div> diff --git a/site/index.html b/site/index.html index 392657e..ba6af81 100644 --- a/site/index.html +++ b/site/index.html@@ -9,7 +9,8 @@<script src="theme.js"></script> <style> :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 }:root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) } * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:17px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif }@@ -40,7 +41,7 @@</head> <body> <main>- <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span></h1>+ <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span></h1><p class="lede">Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees. It answers like an assistant. Inside, there is no model.</p> <div class="actions">@@ -79,13 +80,13 @@ pyrtan serve trees --port 8701 # then open http://127<p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p> <h2 id="directory">The directory</h2>- <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. This page asks the <a href="rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>- <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the seeds…</td></tr></tbody></table></div>+ <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. pyrtan.com runs no hub of its own: it is static pages. This page asks the hubs on its <a href="rendezvous/hubs.json">hub list</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>+ <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the hubs…</td></tr></tbody></table></div><pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS pyrtan join --at https://your-hub --token … # be in the default network pyrtan network new home --at https://your-hub --token … # or start your own: prints its invite pyrtan directory # who the swarm knows</pre>- <p>To become a seed, add your hub's URL to <code>rendezvous/seeds.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every listed hub that is.</p>+ <p>To be on the hub list, add your hub's URL to <code>site/rendezvous/hubs.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every hub it can reach.</p><h2>Hosting</h2> <p>Self-hosting is the product. A hosted option, running the same open-source hub per customer with secrets, retention, scheduled re-runs and alerts around it, is planned; it will speak the same API, so a swarm can move either way without changing a caller.</p>@@ -96,27 +97,28 @@ pyrtan directory # who thconst esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); (async () => { const note = document.getElementById("hubs-note");- let seeds = [];- try { seeds = (await (await fetch("rendezvous/seeds.json")).json()).hubs || []; } catch (e) {}- if (!seeds.length) { note.textContent = "No seeds listed yet."; return; }+ let listed = [];+ try { listed = (await (await fetch("rendezvous/hubs.json")).json()).hubs || []; } catch (e) {}const seen = new Map(); let answered = 0;- for (const seed of seeds) {+ for (const hub of listed) {try {- const r = await fetch(seed.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}});+ const r = await fetch(hub.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}});if (!r.ok) continue; const d = await r.json(); answered++; for (const h of d.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h); } catch (e) {} }- let source = answered ? `live from ${answered} seed${answered > 1 ? "s" : ""}` : "";+ let source = answered ? `live from ${answered} listed hub${answered > 1 ? "s" : ""}` : "";if (!answered) {+ // Nobody on the list answered (or the list is empty): the hourly snapshot.+ const why = listed.length ? "no listed hub answered from this browser" : "no hubs listed yet";try { const snap = await (await fetch("rendezvous/directory.json")).json(); for (const h of snap.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h);- source = `no seed answered from this browser; snapshot as of ${snap.as_of || "?"}`;- } catch (e) { note.textContent = "No seed answered from this browser, and there is no snapshot yet."; return; }+ source = `${why}; snapshot as of ${snap.as_of || "?"}`;+ } catch (e) { note.textContent = `${why[0].toUpperCase() + why.slice(1)}, and there is no snapshot yet.`; return; }} const now = Date.now() / 1000; for (const h of seen.values()) { const age = now - (h.seen || 0); h.link = age < 600 ? "up" : age < 86400 ? "stale" : "down"; }@@ -128,7 +130,7 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`; body.appendChild(tr); }- if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">The seeds know of nobody yet.</td></tr>`;+ if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">${esc(source)}: nobody heard of yet. Add your hub to the hub list.</td></tr>`;})(); </script> </body> diff --git a/site/rendezvous/hubs.json b/site/rendezvous/hubs.json new file mode 100644 index 0000000..794fe83 --- /dev/null +++ b/site/rendezvous/hubs.json@@ -0,0 +1,5 @@+{+ "pyrtan-hubs": 1,+ "about": "Hubs pyrtan.com asks every hour for the directory, and the ones `pyrtan join` with no invite tries first. pyrtan.com runs no hub: this list is how it learns that one exists. Add yours by pull request; it should be a federated hub in the default network, started with --listed and --cors https://pyrtan.com. It need not be up all the time: the hourly snapshot keeps what every hub it can reach knows.",+ "hubs": []+}diff --git a/site/rendezvous/network.json b/site/rendezvous/network.json index 84c8b5b..ac2765c 100644 --- a/site/rendezvous/network.json +++ b/site/rendezvous/network.json@@ -1,6 +1,6 @@{ "pyrtan-network": 1,- "about": "The default network: `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first seed in seeds.json that answers, then the snapshot's hubs. Its secret is public on purpose: it names the network rather than guarding it, so anyone may join, and following stays one-directional trust. A network of your own is `pyrtan network new NAME`; its invite is yours to share.",+ "about": "The default network: `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first hub in hubs.json that answers, then the snapshot's hubs. Its secret is public on purpose: it names the network rather than guarding it, so anyone may join, and following stays one-directional trust. A network of your own is `pyrtan network new NAME`; its invite is yours to share.","name": "pyrtan", "secret": "public-default-network-of-pyrtan-com" } diff --git a/site/rendezvous/seeds.json b/site/rendezvous/seeds.json deleted file mode 100644 index 0b17941..0000000 --- a/site/rendezvous/seeds.json +++ /dev/null@@ -1,5 +0,0 @@-{- "pyrtan-seeds": 1,- "about": "Hubs to follow first. `pyrtan follow` with no URL takes the first that answers /actor. Add yours by pull request to the site repository; a seed is any federated hub whose operator keeps it up and started it with --listed and --cors https://pyrtan.com.",- "hubs": []-}diff --git a/site/snapshot.py b/site/snapshot.py index 3ff3327..da218d1 100644 --- a/site/snapshot.py +++ b/site/snapshot.py@@ -1,9 +1,11 @@"""Bake a snapshot of the directory into the site. GitHub Actions runs this on every build and on a schedule: it asks each-seed hub for its ``/directory``, then every other hub it knows of (from-the last snapshot and from what the hubs it asked said), so the directory-stays fresh while any listed hub is up, even with every seed down. It+hub in the hub list (``rendezvous/hubs.json``, which operators add to by+pull request) for its ``/directory``, then every other hub it knows of+(from the last snapshot and from what the hubs it asked said), so the+directory stays fresh while any hub it knows of is up. pyrtan.com runs no+hub of its own; this is the whole of its part. Itmerges what they say with the snapshot already published (so a build when nobody answers keeps the last good one rather than publishing an empty list), drops entries nobody has heard of for a week, and writes@@ -11,14 +13,14 @@ list), drops entries nobody has heard of for a week, and writesof each directory (``--network``, the ``network.json`` the site publishes) and what was heard outside any network; a private network's hubs never reach the snapshot. The landing page-shows the live directory when a seed answers the visitor's browser and-this snapshot, marked with its time, when none does; ``pyrtan follow``-with no URL tries the hubs in it after the seeds.+shows the live directory when a listed hub answers the visitor's browser+and this snapshot, marked with its time, when none does; ``pyrtan join``+with no invite tries the hubs in it after the listed ones.Standard library only; no pyrtan import, so it runs before anything is installed.- python3 site/snapshot.py SEEDS OUT [--previous URL_OR_FILE ...]+ python3 site/snapshot.py HUBS OUT [--previous URL_OR_FILE ...][--network URL_OR_FILE] [--max N] [--reports OUT --previous-reports URL_OR_FILE ...]@@ -44,7 +46,7 @@ from typing import AnyTTL = 7 * 86400 REPORT_TTL = 30 * 86400 TIMEOUT = 8.0-MAX_ASK = 200 # hubs asked per run, seeds included+MAX_ASK = 200 # hubs asked per run, listed ones includedPARALLEL = 16@@ -113,11 +115,11 @@ def merge_reports(into: dict[str, dict[str, Any]], rows: Any, now: float) -> intdef main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("seeds", help="seeds.json, a path or URL")+ ap.add_argument("hub_list", help="hubs.json, a path or URL")ap.add_argument("out", help="where to write directory.json") ap.add_argument("--previous", action="append", default=[], help="an earlier directory.json to keep entries from (URL or path; repeatable, first that answers wins)") ap.add_argument("--network", default=None, help="the default network's network.json (URL or path): keep only its part of each directory")- ap.add_argument("--max", type=int, default=MAX_ASK, help=f"hubs to ask at most, seeds included (default {MAX_ASK})")+ ap.add_argument("--max", type=int, default=MAX_ASK, help=f"hubs to ask at most, listed ones included (default {MAX_ASK})")ap.add_argument("--reports", default=None, help="also write the gap reports the hubs carry to this reports.json") ap.add_argument("--previous-reports", action="append", default=[], help="an earlier reports.json to keep rows from (URL or path; repeatable, first that answers wins)") args = ap.parse_args(argv)@@ -153,10 +155,10 @@ def main(argv: list[str] | None = None) -> int:except Exception as e: print(f"snapshot: cannot read the default network from {args.network}: {e}; keeping every network's part", file=sys.stderr) try:- seeds = [h.rstrip("/") for h in fetch(args.seeds).get("hubs", []) if isinstance(h, str)]+ listed = [h.rstrip("/") for h in fetch(args.hub_list).get("hubs", []) if isinstance(h, str)]except Exception as e:- print(f"snapshot: cannot read seeds from {args.seeds}: {e}", file=sys.stderr)- seeds = []+ print(f"snapshot: cannot read the hub list from {args.hub_list}: {e}", file=sys.stderr)+ listed = []report["crawled"] = {"answered": [], "silent": []} def ask(hub: str) -> tuple[str, Any, Any, str | None]:@@ -172,11 +174,11 @@ def main(argv: list[str] | None = None) -> int:pass return hub, d, r, None- # The seeds first, then every hub the last snapshot heard of, most+ # The listed hubs first, then every hub the last snapshot heard of, most# recent first, then whoever the hubs asked name: rounds of parallel # asks until nobody new is named or --max is reached. asked: set[str] = set()- pending = list(dict.fromkeys(seeds + [h["id"] for h in sorted(hubs.values(), key=lambda h: -h["seen"])]))+ pending = list(dict.fromkeys(listed + [h["id"] for h in sorted(hubs.values(), key=lambda h: -h["seen"])]))while pending and len(asked) < args.max: batch = [h for h in pending if h not in asked][: args.max - len(asked)] pending = []@@ -184,7 +186,7 @@ def main(argv: list[str] | None = None) -> int:with ThreadPoolExecutor(max_workers=PARALLEL) as pool: results = list(pool.map(ask, batch)) for hub, d, r, error in results:- book = report if hub in seeds else report["crawled"]+ book = report if hub in listed else report["crawled"]if error is None and isinstance(d, dict) and isinstance(d.get("hubs"), list): merge(hubs, d["hubs"], hub, now, network) book["answered"].append(hub)@@ -192,19 +194,19 @@ def main(argv: list[str] | None = None) -> int:if isinstance(r, dict): merge_reports(reports, r.get("reports"), now) else:- book["silent"].append({"seed" if hub in seeds else "hub": hub, "error": error or "not a directory"})+ book["silent"].append({"hub": hub, "error": error or "not a directory"})pending = list(dict.fromkeys(pending)) out = { "pyrtan-directory": 1, "as_of": _dt.datetime.fromtimestamp(now, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),- "seeds": report,+ "listed": report,"hubs": sorted(hubs.values(), key=lambda h: (-h["seen"], h["id"])), } path = Path(args.out) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")- print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} seed(s) answered, {len(report['silent'])} silent; {len(report['crawled']['answered'])} other hub(s) answered, {len(report['crawled']['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))+ print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} listed hub(s) answered, {len(report['silent'])} silent; {len(report['crawled']['answered'])} other hub(s) answered, {len(report['crawled']['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))if args.reports: rows = sorted(reports.values(), key=lambda r: (-r["count"], -r["last"], r["id"])) rpath = Path(args.reports) diff --git a/site/source.py b/site/source.py index 6262d8e..89d864a 100644 --- a/site/source.py +++ b/site/source.py@@ -46,7 +46,8 @@ def git_bytes(*args: str) -> bytes:CSS = """ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9; --add:#e6f4ea; --del:#fbe9e7 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 } }+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 } }+ :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 }:root { --accent:var(--pyr) } * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif }@@ -107,7 +108,7 @@ class Site:</head> <body> <main>-<header><b><a href="{self.link(page, '../index.html')}"><span class="pyr">Pyr</span><span class="tan">tan</span></a> / <a href="{self.link(page, 'index.html')}">source</a></b><nav>{nav}</nav><span class="mute small">v{esc(self.version)} · {esc(self.branch)}</span></header>+<header><b><a href="{self.link(page, '../index.html')}"><span class="pyr">pyr</span><span class="tan">tan</span></a> / <a href="{self.link(page, 'index.html')}">source</a></b><nav>{nav}</nav><span class="mute small">v{esc(self.version)} · {esc(self.branch)}</span></header>{body} </main> </body> diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index 1300931..ec3238a 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py@@ -33,4 +33,4 @@ __all__ = ["Response", ]-__version__ = "0.10.0"+__version__ = "0.13.0"diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py new file mode 100644 index 0000000..3f7927c --- /dev/null +++ b/src/pyrtan/chat.py@@ -0,0 +1,713 @@+"""The lobby: talk to a stranger or a bot, Omegle-style.++A hub started with ``--chat`` keeps a lobby. Anyone who can reach the hub+(with its token, when it has one) takes a *seat*, says whom they want to+meet (``anyone``, ``people`` or a ``bot``) and a few interests, and is+paired at random: with someone else waiting here, with someone waiting at+a hub this one follows, or with a bot. Nobody has a name. Either side can+end it at any time and take the next stranger.++**A bot is not a model.** It is one holder of trees on the network, picked+at random (this hub's own trees, one of its services, a trusted member, a+hub it follows), preferring one whose trees share a word with the seat's+interests. What the stranger says to it is read against that holder's+triggers; when none answers, it is asked of the whole swarm; when nobody+answers, the bot says so and names the tree that would, exactly as+``/intent`` does (and the sentence is recorded as unanswered, like any+other). An ``ask`` node's question is the bot's next line, and what the+stranger says next is the answer. A sentence that is nothing but greeting+or politeness (the reading drops all of it) gets the bot's introduction and+asks the swarm nothing. Every bot line carries its trace.++**Strangers across hubs.** A seat that finds nobody waiting here asks the+hubs this one follows, a signed ``Meet`` each, until one pairs it with a+seat waiting there. From then on each side's lines travel as ``Say`` and+the end as ``Part``, both ways, signed with the pair's secret like every+activity. A hub answers a ``Meet`` only from a hub that follows it, pairs+it only with its own seats, and never passes one on: one hop, as+everywhere in the federation.++**Typing.** While a person types, their client says so (``/chat/typing``)+and the stranger sees "Stranger is typing…"; while a bot's trees run, its+partner sees "Bot is thinking…". It is a state, never an event: it goes out+on its own after five seconds without news, and with the next line. It+is shown both ways or not at all: a seat with the indicator off+(``typing_indicator: false``) neither shares its typing nor sees anyone+else's, and a hub started ``--no-chat-typing`` has it off for everyone.+Across hubs it travels as ``Typing``, at most every two seconds, and the+logs leave it out.++**Public lobbies.** The lobby follows the hub's token: a bot runs trees,+and the token is what guards them. A hub started ``--chat-public`` keeps+its token for everything else and opens ``/chat`` to callers without it,+as *guests*: a guest meets people (here, or on a hub this one follows)+and never a bot. ``want: bot`` from a guest is refused, and ``anyone`` is+read as ``people``. A hub with no token has no guests: anyone may meet a+bot there, as anyone may ask it at ``/intent``.++Endpoints, all POST with a JSON body except the first:++``GET /chat`` how many are waiting and talking; whether bots, other hubs and typing are in reach+``POST /chat/start`` ``{"want": "anyone"|"people"|"bot", "interests": [...], "typing_indicator": true}``: a seat+``POST /chat/poll`` ``{"seat", "after": n, "wait": s, "partner_typing": bool}``: the events after ``n``, waiting+ up to ``s`` s for one, or for the partner's typing to differ from what the client shows+``POST /chat/say`` ``{"seat", "text"}``+``POST /chat/typing`` ``{"seat", "typing": bool}``: this person is (or stopped) typing+``POST /chat/settings`` ``{"seat", "typing_indicator": bool}``+``POST /chat/next`` ``{"seat"}``: end this conversation and wait for the next, with the same wishes+``POST /chat/leave`` ``{"seat"}``++What it refuses: keeping anything. A conversation lives in memory while+someone is in it; a seat nobody polls for a minute is let go and its+partner told. A federated ``Say`` appears in the hub's outbox and inbox+logs without its text. There are no names, no history, no accounts and+no moderation but leaving: a seat says at most five lines in a burst and+one a second after that, each at most a thousand characters.+"""++from __future__ import annotations++import random+import re+import secrets+import threading+import time+from collections import deque+from dataclasses import dataclass, field+from typing import TYPE_CHECKING, Any, Union++if TYPE_CHECKING:+ from .federation import Remote+ from .hub import Hub++WANTS = ("anyone", "people", "bot")+SEAT_IDLE = 60.0 # a seat nobody polls for this long is let go+BOT_AFTER = 8.0 # an "anyone" seat that meets no stranger in this long meets a bot+MEET_EVERY = 5.0 # how often a waiting seat asks the hubs this one follows+MEET_HUBS = 3 # how many of them it asks each time+POLL_MAX = 25.0+LINE_MAX = 1000+EVENTS_MAX = 200 # events a seat holds for a client that has not polled+SEATS_MAX = 500+INTERESTS_MAX = 5+BURST, REFILL = 5.0, 1.0 # lines: a burst of five, then one a second+TYPING_TTL = 5.0 # an indicator nobody refreshes goes out after this long+TYPING_EVERY = 2.0 # how often "still typing" travels to another hub+REF = re.compile(r"^[A-Za-z0-9_-]{8,64}$")+INTEREST = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")+++def is_chat_path(path: str) -> bool:+ return path == "/chat" or path.startswith("/chat/")+++def interests_of(value: Any) -> list[str]:+ """At most five lowercase words, from a list or a comma-separated string."""+ words = value.replace(",", " ").split() if isinstance(value, str) else value if isinstance(value, list) else []+ out: list[str] = []+ for w in words:+ w = str(w).strip().lower().lstrip("#")+ if INTEREST.match(w) and w not in out:+ out.append(w)+ return out[:INTERESTS_MAX]+++def sample(tree: dict[str, Any]) -> str | None:+ """A sentence a tree answers to, from its first trigger: the first of+ each choice, optional words included, a slot's default where it has+ one. None when a slot has no default (there is nothing sensible to put)."""+ pattern = (tree.get("triggers") or [None])[0]+ if not isinstance(pattern, str):+ return None+ inputs = tree.get("inputs") or {}+ missing = False++ def slot(m: re.Match[str]) -> str:+ nonlocal missing+ default = (inputs.get(m.group(1)) or {}).get("default")+ if default is None:+ missing = True+ return ""+ return str(default)++ text = re.sub(r"[\[(]([^|\])]*)(?:\|[^\])]*)?[\])]", r"\1", pattern)+ text = re.sub(r"\{(\w+)(?::\w+)?\}", slot, text)+ text = re.sub(r"\s+", " ", text).strip()+ return None if missing or not text else text+++def _words(tree: dict[str, Any]) -> set[str]:+ """The words a tree is about: its name, description, tags and triggers."""+ text = " ".join([str(tree.get("name", "")), str(tree.get("description", "")), *map(str, tree.get("tags") or []), *map(str, tree.get("triggers") or [])])+ return set(re.findall(r"[a-z0-9]+", text.lower()))+++@dataclass+class Bot:+ """A holder of trees, as a partner: who it is, and the question it is waiting on."""++ url: str+ name: str+ where: str+ trees: list[dict[str, Any]]+ liked: list[dict[str, Any]] = field(default_factory=list) # its trees that share a word with the seat's interests+ snapshot: dict[str, Any] | None = None+ lock: threading.Lock = field(default_factory=threading.Lock, repr=False)++ def samples(self, prefer: list[dict[str, Any]] | None = None) -> list[str]:+ """What to suggest saying: from the preferred trees when any has a sample."""+ for trees in (prefer or [], self.trees):+ found = [s for s in (sample(t) for t in trees) if s]+ if found:+ return found+ return []++ def view(self) -> dict[str, Any]:+ return {"kind": "bot", "name": self.name, "where": self.where, "trees": [t.get("name") for t in self.trees]}+++@dataclass+class Far:+ """A stranger at another hub: that hub, and what it calls their seat."""++ remote: "Remote"+ ref: str+ hub: str+ typing: bool = True # whether its hub takes Typing; one from before it refuses the activity++ def view(self) -> dict[str, Any]:+ return {"kind": "stranger", "hub": self.hub}+++@dataclass+class Seat:+ id: str # what the client holds: knowing it is being this seat+ ref: str # what another hub calls it; never the id+ want: str+ interests: list[str]+ bots: bool = True # may meet a bot: false for a guest of a public lobby+ since: float = field(default_factory=time.time)+ seen: float = field(default_factory=time.time)+ state: str = "waiting" # waiting | talking | ended | left+ partner: Union["Seat", Bot, Far, None] = None+ events: deque[dict[str, Any]] = field(default_factory=lambda: deque(maxlen=EVENTS_MAX))+ n: int = 0+ asked: float = 0.0 # when it last asked the hubs we follow+ busy: bool = False # asking them now: nobody else may pair it meanwhile+ botless: bool = False # told there is no bot to meet+ typing: bool = True # the indicator: share this person's typing, and see the partner's+ heard: float = 0.0 # until when the partner reads as typing (or thinking) to this seat+ told: tuple[bool, float] = (False, 0.0) # the typing last sent to a partner on another hub, and when+ allowance: float = BURST+ allowance_at: float = field(default_factory=time.time)++ def view(self) -> dict[str, Any]:+ p = self.partner+ return {"state": self.state, "want": self.want, "interests": self.interests, "partner": {"kind": "stranger", "hub": None} if isinstance(p, Seat) else p.view() if p is not None else None}+++class Lobby:+ def __init__(self, hub: "Hub", *, typing: bool = True) -> None:+ self.hub = hub+ self.typing = typing # the hub's switch: off, nobody's typing is shared+ self.seats: dict[str, Seat] = {}+ self.refs: dict[str, Seat] = {}+ self.cond = threading.Condition(threading.RLock())+ self.bot_after = BOT_AFTER+ self.meet_every = MEET_EVERY+ self.seat_idle = SEAT_IDLE+ self.typing_ttl = TYPING_TTL+ self._parts: list[tuple["Remote", str, str]] = [] # Parts to send once the lock is let go: (remote, to, from)++ # ------------------------------------------------------------ views++ def stats(self, guest: bool = False) -> dict[str, Any]:+ with self.cond:+ self._sweep()+ seats = list(self.seats.values())+ fed = self.hub.federation+ return {+ "status": "ok",+ "waiting": sum(1 for s in seats if s.state == "waiting"),+ "talking": sum(1 for s in seats if s.state == "talking"),+ "bots": 0 if guest else sum(1 for h in self.hub.holders() if h.link != "down" and h.trees),+ "hubs": sum(1 for r in fed.following.values() if r.link != "down") if fed is not None else 0,+ "typing": self.typing,+ }++ def _view(self, seat: Seat, after: int) -> dict[str, Any]:+ events = [e for e in seat.events if e["n"] > after]+ return {"status": "ok", "seat": seat.id, **seat.view(), "bots": seat.bots, "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "events": events, "after": seat.n}++ # ------------------------------------------------------------ typing++ def _shows(self, seat: Seat) -> bool:+ return self.typing and seat.typing++ def _partner_typing(self, seat: Seat) -> bool:+ return self._shows(seat) and seat.state == "talking" and seat.heard > time.time()++ def _hear(self, seat: Seat, until: float) -> None:+ seat.heard = until+ self.cond.notify_all()++ def _tell_far(self, seat: Seat, typing: bool, *, force: bool = False) -> tuple[Far, dict[str, Any]] | None:+ """The Typing to send the stranger on another hub, if one is due:+ on a change, or every couple of seconds while it holds."""+ p = seat.partner+ if not isinstance(p, Far) or not p.typing:+ return None+ now = time.time()+ last, at = seat.told+ if not force and typing == last and (not typing or now - at < TYPING_EVERY):+ return None+ seat.told = (typing, now)+ return p, {"to": p.ref, "from": seat.ref, "typing": typing}++ def _send_typing(self, due: tuple[Far, dict[str, Any]] | None) -> None:+ fed = self.hub.federation+ if due is None or fed is None:+ return+ far, obj = due+ ok, _, _ = fed.deliver(far.remote, fed._activity("Typing", obj))+ if not ok:+ far.typing = False # a hub that will not take it is not asked again in this conversation++ # ------------------------------------------------------------ events++ def _add(self, seat: Seat, kind: str, text: str, **extra: Any) -> dict[str, Any]:+ seat.n += 1+ event = {"n": seat.n, "at": time.time(), "kind": kind, "text": text, **extra}+ seat.events.append(event)+ self.cond.notify_all()+ return event++ def _say_paired(self, a: Seat, where: str | None, common: list[str]) -> None:+ self._add(a, "system", f"You're now chatting with a random stranger{' on ' + where if where else ''}. Say hi!")+ if common:+ self._add(a, "system", f"You both like {', '.join(common)}.")++ # ------------------------------------------------------------ pairing++ def _pair(self, a: Seat, b: Seat) -> None:+ common = [w for w in a.interests if w in b.interests]+ for x, y in ((a, b), (b, a)):+ x.partner, x.state = y, "talking"+ self._say_paired(x, None, common)++ def _waiting(self, interests: list[str], exclude: Seat | None = None) -> Seat | None:+ """Someone waiting here to pair with: one who wants people, the most+ interests in common first, at random among equals."""+ found = [s for s in self.seats.values() if s is not exclude and s.state == "waiting" and not s.busy and s.want != "bot"]+ if not found:+ return None+ best = max(len(set(s.interests) & set(interests)) for s in found)+ return random.choice([s for s in found if len(set(s.interests) & set(interests)) == best])++ def _pair_bot(self, seat: Seat) -> bool:+ holders = [h for h in self.hub.holders() if h.link != "down" and h.trees]+ if not holders:+ if seat.want == "bot":+ seat.state = "ended"+ self._add(seat, "system", "No bots here: this hub holds no trees and reaches nobody who does.")+ elif not seat.botless:+ seat.botless = True+ self._add(seat, "system", "No bots here either; still looking for a person.")+ return False+ likes = set(seat.interests)++ def score(h: Any) -> int:+ return len(set().union(*(_words(t) for t in h.trees)) & likes)++ best = max(score(h) for h in holders)+ holder = random.choice([h for h in holders if score(h) == best])+ kind = getattr(holder, "kind", "service")+ where = {"local": "this hub's own trees", "member": "a device that is a member of this hub", "hub": "a hub this one follows"}.get(kind, "a service of this hub")+ name = self.hub.name if kind == "local" else (holder.name or holder.url)+ bot = Bot(holder.url, name, where, list(holder.trees), liked=[t for t in holder.trees if _words(t) & likes])+ seat.partner, seat.state = bot, "talking"+ self._add(seat, "system", self._introduction(bot, first=True))+ return True++ def _introduction(self, bot: Bot, *, first: bool = False) -> str:+ n = len(bot.trees)+ head = f"You're now chatting with a bot: {bot.name}, {bot.where}." if first else f"I'm {bot.name}, {bot.where}."+ tries = bot.samples(bot.liked)+ try_line = f" Try “{random.choice(tries)}”." if tries else ""+ return f"{head} It is {n} tree{'s' if n != 1 else ''}, not a person and not a model, and every line it says comes with the path that produced it.{try_line}"++ def _advance(self, seat: Seat) -> None:+ """What a waiting seat does next: meet someone here, ask the hubs we+ follow, or, when it wants anyone and has waited long enough, a bot."""+ fed = self.hub.federation+ with self.cond:+ self._sweep()+ if seat.state != "waiting" or seat.busy or seat.id not in self.seats:+ return+ if seat.want == "bot":+ self._pair_bot(seat)+ return+ other = self._waiting(seat.interests, exclude=seat)+ if other is not None:+ self._pair(seat, other)+ return+ now = time.time()+ far = fed is not None and bool(fed.following) and now - seat.asked >= self.meet_every+ if far:+ seat.busy, seat.asked = True, now+ if far:+ try:+ if self._meet_far(seat):+ return+ finally:+ with self.cond:+ seat.busy = False+ self.cond.notify_all()+ with self.cond:+ if seat.state == "waiting" and not seat.busy and seat.want == "anyone" and time.time() - seat.since >= self.bot_after:+ self._pair_bot(seat)++ def _meet_far(self, seat: Seat) -> bool:+ fed = self.hub.federation+ assert fed is not None+ hubs = [r for r in fed.following.values() if r.kind == "hub" and r.state == "accepted" and r.link != "down"]+ random.shuffle(hubs)+ ref = seat.ref+ for remote in hubs[:MEET_HUBS]:+ ok, _, answer = fed.deliver(remote, fed._activity("Meet", {"ref": ref, "interests": seat.interests}))+ if not (ok and isinstance(answer, dict) and answer.get("met") is True and isinstance(answer.get("ref"), str) and REF.match(answer["ref"])):+ continue+ with self.cond:+ if seat.state == "waiting" and seat.id in self.seats and seat.ref == ref:+ seat.partner, seat.state = Far(remote, answer["ref"], remote.name or remote.url), "talking"+ self._say_paired(seat, remote.name or remote.url, interests_of(answer.get("interests")))+ return True+ # It left, or moved on, while we asked: the stranger over there is told at once.+ self._send_part(remote, answer["ref"], ref)+ return True+ return False++ # ------------------------------------------------------------ ending++ def _end(self, seat: Seat) -> None:+ """End the seat's conversation, and tell its partner."""+ p = seat.partner+ seat.heard, seat.told = 0.0, (False, 0.0)+ if isinstance(p, Seat):+ p.partner, p.state, p.heard = None, "ended", 0.0+ self._add(p, "system", "Stranger has disconnected.")+ elif isinstance(p, Far):+ self._parts.append((p.remote, p.ref, seat.ref))+ seat.partner = None+ if seat.state == "talking":+ seat.state = "ended"+ self._add(seat, "system", "You have disconnected.")++ def _sweep(self) -> None:+ cutoff = time.time() - self.seat_idle+ for seat in [s for s in self.seats.values() if s.seen < cutoff]:+ self._end(seat)+ self._drop(seat)++ def _drop(self, seat: Seat) -> None:+ self.seats.pop(seat.id, None)+ self.refs.pop(seat.ref, None)+ seat.state = "left"+ self.cond.notify_all()++ def _send_part(self, remote: "Remote", to: str, frm: str) -> None:+ fed = self.hub.federation+ if fed is not None:+ fed.deliver(remote, fed._activity("Part", {"to": to, "from": frm}))++ def _flush(self) -> None:+ with self.cond:+ parts, self._parts = self._parts, []+ for remote, to, frm in parts:+ self._send_part(remote, to, frm)++ # ------------------------------------------------------------ requests++ def _seat(self, body: dict[str, Any]) -> Seat | None:+ with self.cond:+ seat = self.seats.get(str(body.get("seat", "")))+ if seat is not None:+ seat.seen = time.time()+ return seat++ def start(self, body: dict[str, Any], guest: bool = False) -> tuple[int, dict[str, Any]]:+ want = body.get("want", "anyone")+ if want not in WANTS:+ return 400, {"status": "error", "reason": "bad-request", "say": f"want is one of {', '.join(WANTS)}."}+ if guest and want == "bot":+ return 403, {"status": "error", "reason": "bots-need-token", "say": "Without this hub's token you meet people only: a bot runs the hub's trees."}+ if guest:+ want = "people"+ with self.cond:+ self._sweep()+ if len(self.seats) >= SEATS_MAX:+ return 503, {"status": "error", "reason": "lobby-full", "say": f"The lobby is full ({SEATS_MAX} seats); try again in a minute."}+ seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")), typing=body.get("typing_indicator") is not False, bots=not guest)+ self.seats[seat.id] = seat+ self.refs[seat.ref] = seat+ self._add(seat, "system", "Looking for a bot…" if want == "bot" else "Looking for someone you can chat with…")+ self._advance(seat)+ with self.cond:+ return 200, self._view(seat, 0)++ def poll(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ after = _int(body.get("after"))+ try:+ wait = min(max(float(body.get("wait") or 0), 0.0), POLL_MAX)+ except (TypeError, ValueError):+ return 400, {"status": "error", "reason": "bad-request", "say": "wait is a number of seconds."}+ # Only a client that says what it shows is woken by typing; any other+ # would be answered at once, again and again, while the partner types.+ shown = bool(body["partner_typing"]) if "partner_typing" in body else None+ deadline = time.time() + wait+ while True:+ with self.cond:+ seat.seen = time.time() # polling is being here, before anyone is swept+ self._advance(seat)+ self._flush()+ with self.cond:+ now = time.time()+ # A seat that left while this poll waited gets its last view, state "left".+ if seat.id not in self.seats or any(e["n"] > after for e in seat.events) or now >= deadline or (shown is not None and self._partner_typing(seat) != shown):+ return 200, self._view(seat, after)+ nap = deadline - now+ if shown and now < seat.heard < float("inf"):+ nap = min(nap, seat.heard - now) # wake when the indicator goes out+ if seat.state == "waiting":+ # Wake to ask the hubs we follow again, or to meet a bot.+ if self.hub.federation is not None and self.hub.federation.following:+ nap = min(nap, seat.asked + self.meet_every - now)+ if seat.want == "anyone":+ nap = min(nap, seat.since + self.bot_after - now)+ self.cond.wait(max(0.05, nap))++ def say(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ text = body.get("text")+ if not isinstance(text, str) or not text.strip():+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"text\": \"…\"}."}+ text = text.strip()+ if len(text) > LINE_MAX:+ return 413, {"status": "error", "reason": "too-long", "say": f"A line is at most {LINE_MAX} characters."}+ with self.cond:+ if seat.state != "talking" or seat.partner is None:+ return 409, {"status": "error", "reason": "not-talking", "say": "Nobody is listening: wait for a stranger, or press New."}+ now = time.time()+ seat.allowance = min(BURST, seat.allowance + (now - seat.allowance_at) * REFILL)+ seat.allowance_at = now+ if seat.allowance < 1:+ return 429, {"status": "error", "reason": "too-fast", "say": "Slow down: five lines at once, then one a second."}+ seat.allowance -= 1+ event = self._add(seat, "you", text)+ partner = seat.partner+ seat.told = (False, now) # the line ends the typing, here and on the other hub+ if isinstance(partner, Seat):+ partner.heard = 0.0+ self._add(partner, "stranger", text)+ if isinstance(partner, Far):+ fed = self.hub.federation+ assert fed is not None+ ok, error, _ = fed.deliver(partner.remote, fed._activity("Say", {"to": partner.ref, "from": seat.ref, "text": text}))+ if not ok:+ with self.cond:+ if seat.partner is partner:+ seat.partner, seat.state = None, "ended"+ self._add(seat, "system", f"The stranger's hub did not take that line ({error}); the conversation is over.")+ elif isinstance(partner, Bot):+ threading.Thread(target=self._bot_reply, args=(seat, partner, text), daemon=True).start()+ return 200, {"status": "ok", "n": event["n"]}++ def next(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ with self.cond:+ self._end(seat)+ self.refs.pop(seat.ref, None)+ seat.ref = secrets.token_urlsafe(12) # the last stranger cannot reach the next conversation+ self.refs[seat.ref] = seat+ seat.state, seat.since, seat.asked = "waiting", time.time(), 0.0+ seat.botless = False+ if isinstance(body.get("typing_indicator"), bool):+ seat.typing = body["typing_indicator"]+ self._add(seat, "system", "Looking for a bot…" if seat.want == "bot" else "Looking for someone you can chat with…")+ self._flush()+ self._advance(seat)+ with self.cond:+ return 200, self._view(seat, _int(body.get("after")))++ def typed(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ """This person is typing (``typing: true``, sent again every couple+ of seconds while they are) or stopped. Shared only when both the hub+ and this seat have the indicator on; ``shared`` says whether it was."""+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ typing = body.get("typing") is True+ with self.cond:+ if not self._shows(seat) or seat.state != "talking":+ return 200, {"status": "ok", "shared": False}+ p = seat.partner+ if isinstance(p, Seat):+ self._hear(p, time.time() + self.typing_ttl if typing else 0.0)+ due = self._tell_far(seat, typing)+ self._send_typing(due)+ return 200, {"status": "ok", "shared": True}++ def settings(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ if not isinstance(body.get("typing_indicator"), bool):+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"typing_indicator\": true|false}."}+ due = None+ with self.cond:+ seat.typing = body["typing_indicator"]+ if not seat.typing:+ # Off now: whatever the partner was shown of this person's typing goes out.+ if isinstance(seat.partner, Seat):+ self._hear(seat.partner, 0.0)+ if seat.told[0]:+ due = self._tell_far(seat, False, force=True)+ self.cond.notify_all()+ view = self._view(seat, _int(body.get("after")))+ self._send_typing(due)+ return 200, view++ def leave(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 200, {"status": "ok", "left": False}+ with self.cond:+ self._end(seat)+ self._drop(seat)+ return 200, {"status": "ok", "left": True}++ # ------------------------------------------------------------ bots++ def _bot_reply(self, seat: Seat, bot: Bot, text: str) -> None:+ try:+ self._bot_answer(seat, bot, text)+ except Exception as e: # a bot that breaks says so, rather than going quiet+ self._bot_line(seat, bot, f"(error: {e})", {"status": "error", "reason": "bot-error"})++ def _bot_answer(self, seat: Seat, bot: Bot, text: str) -> None:+ with bot.lock: # one line at a time, in order: a question waits for its answer+ with self.cond:+ if seat.partner is bot:+ self._hear(seat, float("inf")) # thinking, until its line+ if bot.snapshot is not None:+ snapshot, bot.snapshot = bot.snapshot, None+ _, answer = self.hub.resume({"snapshot": snapshot, "answer": text})+ else:+ _, answer = self.hub.intent({"text": text}, only=bot.url)+ if answer.get("reason") == "no-match":+ reading = (answer.get("output") or {}).get("reading") or {}+ if reading.get("cleaned") == "":+ # Nothing but greeting or politeness: the bot says who it is.+ self._bot_line(seat, bot, self._introduction(bot), {"status": "done", "reason": "greeting"})+ return+ _, answer = self.hub.intent({"text": text}) # not its trees: ask the swarm+ if answer.get("status") == "asked" and isinstance(answer.get("snapshot"), dict):+ bot.snapshot = answer["snapshot"]+ line = answer.get("say") or answer.get("question")+ if not line:+ line = f"({answer.get('status')}{': ' + str(answer['reason']) if answer.get('reason') else ''})"+ self._bot_line(seat, bot, str(line), answer)++ def _bot_line(self, seat: Seat, bot: Bot, text: str, answer: dict[str, Any]) -> None:+ with self.cond:+ if seat.partner is not bot:+ return # the stranger moved on while the bot was thinking+ seat.heard = 0.0+ extra = {k: answer[k] for k in ("status", "reason", "tree", "peer", "via", "output", "trace") if answer.get(k) is not None}+ self._add(seat, "bot", text, **extra)++ # ------------------------------------------------------------ federation++ def on_activity(self, kind: str, remote: "Remote", obj: Any, *, follower: bool) -> tuple[int, dict[str, Any]]:+ """A ``Meet``, ``Say``, ``Typing`` or ``Part`` from a hub we share a secret with.+ A ``Meet`` only from one that follows us: being followed is what lets+ a hub ask."""+ try:+ return self._on_activity(kind, remote, obj, follower)+ finally:+ self._flush()++ def _on_activity(self, kind: str, remote: "Remote", obj: Any, follower: bool) -> tuple[int, dict[str, Any]]:+ if not isinstance(obj, dict):+ return 400, {"status": "error", "reason": "bad-activity", "say": f"A {kind} carries an object."}+ if kind == "Meet":+ ref = obj.get("ref")+ if not follower:+ return 403, {"status": "error", "reason": "not-a-follower", "say": "Only a hub that follows this one may ask it for a stranger."}+ if not isinstance(ref, str) or not REF.match(ref):+ return 400, {"status": "error", "reason": "bad-activity", "say": "A Meet carries the asking seat's ref."}+ interests = interests_of(obj.get("interests"))+ with self.cond:+ self._sweep()+ seat = self._waiting(interests)+ if seat is None:+ return 200, {"status": "ok", "met": False}+ seat.partner, seat.state = Far(remote, ref, remote.name or remote.url), "talking"+ common = [w for w in seat.interests if w in interests]+ self._say_paired(seat, remote.name or remote.url, common)+ return 200, {"status": "ok", "met": True, "ref": seat.ref, "interests": common}+ to, frm = obj.get("to"), obj.get("from")+ with self.cond:+ seat = self.refs.get(to) if isinstance(to, str) else None+ p = seat.partner if seat is not None else None+ if seat is None or not isinstance(p, Far) or p.remote.url != remote.url or p.ref != frm:+ return 404, {"status": "error", "reason": "no-such-seat", "say": "No conversation here with that stranger."}+ if kind == "Part":+ seat.partner, seat.state, seat.heard = None, "ended", 0.0+ self._add(seat, "system", "Stranger has disconnected.")+ return 200, {"status": "ok"}+ if kind == "Typing":+ # Their hub sends it only when their indicator is on; ours shows it only when this seat's is.+ self._hear(seat, time.time() + self.typing_ttl if obj.get("typing") is True else 0.0)+ return 200, {"status": "ok"}+ text = obj.get("text")+ if not isinstance(text, str) or not text.strip() or len(text) > LINE_MAX:+ return 400, {"status": "error", "reason": "bad-activity", "say": f"A Say carries a line of at most {LINE_MAX} characters."}+ seat.heard = 0.0+ self._add(seat, "stranger", text.strip())+ return 200, {"status": "ok"}++ # ------------------------------------------------------------ routing++ def route(self, method: str, path: str, body: dict[str, Any], *, guest: bool = False) -> tuple[int, dict[str, Any]]:+ """``guest``: the caller lacks the hub's token, and came in because the lobby is public."""+ if method == "GET" and path == "/chat":+ try:+ return 200, self.stats(guest)+ finally:+ self._flush()+ handlers = {"/chat/start": self.start, "/chat/poll": self.poll, "/chat/say": self.say, "/chat/typing": self.typed, "/chat/settings": self.settings, "/chat/next": self.next, "/chat/leave": self.leave}+ if method == "POST" and path in handlers:+ try:+ return self.start(body, guest) if path == "/chat/start" else handlers[path](body)+ finally:+ self._flush() # the Parts an ending owes another hub, sent once no lock is held+ return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}+++def _int(value: Any) -> int:+ try:+ return int(value or 0)+ except (TypeError, ValueError):+ return 0+++def _gone() -> dict[str, Any]:+ return {"status": "error", "reason": "no-seat", "say": "This seat is gone (it left, or nobody polled it for a minute): start again."}diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index e329764..8a79f27 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -6,11 +6,13 @@pyrtan run --tree NAME --input k=v run a named tree pyrtan match TEXT [--trees PATH] how a sentence reads: the rewrites and every trigger's verdict pyrtan ask TEXT --at URL answer a sentence through a service or hub+ pyrtan chat [--at HUB] [--bot|--people] talk to a random stranger or a bot, through a hub started --chatpyrtan serve [PATH ...] [--port N] host trees as a service pyrtan hub [--peer URL ...] route between services pyrtan hub --trees DIR and hold trees itself: a swarm in one process pyrtan hub --federate --advertise URL [--follow URL ...] [--listed] and between hubs- pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: a seed+ pyrtan hub --chat and keep a lobby: strangers and bots, Omegle-style+ pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: join the default networkpyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following pyrtan directory [--at HUB] every hub that asked to be found, as the swarm knows it@@ -37,6 +39,7 @@ import argparseimport json import os import sys+import threadingimport time from pathlib import Path from typing import Any@@ -95,6 +98,16 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--no-trace", action="store_true") _token_arg(p, "the token the service or hub requires, if any")+ p = sub.add_parser("chat", help="talk to a random stranger or a bot, through a hub started with --chat")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="the hub (default $PYRTAN_AT or the local hub)")+ who = p.add_mutually_exclusive_group()+ who.add_argument("--bot", action="store_true", help="meet a bot: trees on the network, not a person")+ who.add_argument("--people", action="store_true", help="meet people only; never a bot")+ p.add_argument("--interests", default="", metavar="WORDS", help="up to five words, comma-separated; a stranger or bot that shares one is met first")+ p.add_argument("--trace", action="store_true", help="print the path behind each bot line")+ p.add_argument("--no-typing", action="store_true", help="turn the typing indicator off: no \"Stranger is typing…\" (a terminal reads whole lines, so it never says you are typing)")+ _token_arg(p, "the token the hub requires, if any")+p = sub.add_parser("serve", help="host trees as a service") p.add_argument("paths", nargs="*") p.add_argument("--host", default="127.0.0.1")@@ -150,7 +163,7 @@ def main(argv: list[str] | None = None) -> int:_access_args(p) p.add_argument("--federate", action="store_true", help="answer /actor and /inbox so other hubs can follow this one, and follow others") p.add_argument("--advertise", default=os.environ.get("PYRTAN_ADVERTISE"), help="the URL other hubs reach this hub at (implies --federate; default $PYRTAN_ADVERTISE, else this host and port)")- p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; 'seed' means join the default network, as --join default)")+ p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; to join the default network, --join default)")p.add_argument("--join", action="append", default=[h for h in os.environ.get("PYRTAN_JOIN", "").replace(",", " ").split() if h], metavar="INVITE", help="a network to be in on start: 'default' for the public one pyrtan.com publishes, or an invite NAME:SECRET[@HUB] (repeatable; default $PYRTAN_JOIN)") p.add_argument("--federation-secret", default=os.environ.get("PYRTAN_FEDERATION_SECRET"), help="only hubs that sign their Follow with this secret may follow (default $PYRTAN_FEDERATION_SECRET; without it, follows are open)") p.add_argument("--closed", action="store_true", help="take no follows at all (this hub can still follow others)")@@ -158,6 +171,9 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--listed", action="store_true", default=os.environ.get("PYRTAN_LISTED", "").lower() in ("1", "true", "yes"), help="put this hub in the directory it gossips, so others can find it (default $PYRTAN_LISTED)") p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)")+ p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)")+ p.add_argument("--chat-public", action="store_true", default=os.environ.get("PYRTAN_CHAT_PUBLIC", "").lower() in ("1", "true", "yes"), help="open the lobby to callers without the token (implies --chat): they meet people only, never a bot, and everything else still needs the token (default $PYRTAN_CHAT_PUBLIC)")+ p.add_argument("--chat-typing", action=argparse.BooleanOptionalAction, default=os.environ.get("PYRTAN_CHAT_TYPING", "").lower() not in ("0", "false", "no", "off"), help="the lobby's typing indicator: \"Stranger is typing…\" for anyone who keeps theirs on (default on; $PYRTAN_CHAT_TYPING=0 turns it off)")p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)") p.add_argument("--quiet", action="store_true")@@ -205,7 +221,7 @@ def main(argv: list[str] | None = None) -> int:_token_arg(p, "your hub's token, if any") p = sub.add_parser("directory", help="every hub that asked to be found, as a hub knows it")- p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first live seed)")+ p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first hub from pyrtan.com's hub list that answers)")p.add_argument("--json", action="store_true") p = sub.add_parser("peers", help="a hub's services, and the hubs it follows and is followed by")@@ -262,7 +278,7 @@ def main(argv: list[str] | None = None) -> int:args = parser.parse_args(argv) commands = {- "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new,+ "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "chat": cmd_chat, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new,"watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow, "search": cmd_search, "pull": cmd_pull, "index": cmd_index, "publish": cmd_publish, "console": cmd_console, "follow": cmd_follow, "join": cmd_join, "network": cmd_network, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory, "reports": cmd_reports,@@ -457,6 +473,115 @@ def cmd_ask(args: argparse.Namespace) -> int:return _report(outcome, args.json, not args.no_trace)+def cmd_chat(args: argparse.Namespace) -> int:+ at = args.at.rstrip("/")+ want = "bot" if args.bot else "people" if args.people else "anyone"++ def post(path: str, body: dict[str, Any], timeout: float = 15.0) -> tuple[int, Any]:+ return http_json("POST", f"{at}{path}", body, timeout=timeout, token=args.token)++ try:+ status, body = post("/chat/start", {"want": want, "interests": args.interests, "typing_indicator": not args.no_typing})+ except Exception as e:+ print(f"pyrtan: {at}: {e}", file=sys.stderr)+ return 3+ if status == 401:+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ return 3+ if status != 200 or not isinstance(body, dict) or "seat" not in body:+ print(f"pyrtan: {at}: {body.get('say') if isinstance(body, dict) else body or status}", file=sys.stderr)+ return 3+ seat = body["seat"]+ state = {"after": 0, "owed": 0, "partner": body.get("partner"), "typing": False} # owed: lines said to a bot it has not answered yet+ lock = threading.Condition()+ stop = threading.Event()+ print("Type to talk. /next for someone new, /quit or Ctrl-D to leave.", flush=True)++ def show(answer: dict[str, Any]) -> None:+ with lock:+ for e in answer.get("events", []):+ if e["n"] <= state["after"]:+ continue+ state["after"] = e["n"]+ if e["kind"] == "system":+ print(f" {e['text']}", flush=True)+ elif e["kind"] == "bot":+ print(f"Bot: {e['text']}", flush=True)+ if args.trace and isinstance(e.get("trace"), dict):+ print(outcome_from_dict({**e, "say": e["text"]}).trace.render(), flush=True)+ state["owed"] = max(0, state["owed"] - 1)+ elif e["kind"] == "stranger":+ print(f"Stranger: {e['text']}", flush=True)+ state["partner"] = answer.get("partner")+ typing = bool(answer.get("partner_typing"))+ if typing and not state["typing"]:+ print(" Bot is thinking…" if (state["partner"] or {}).get("kind") == "bot" else " Stranger is typing…", flush=True)+ state["typing"] = typing+ lock.notify_all()++ show(body)++ def pump() -> None:+ while not stop.is_set():+ try:+ ask = {"seat": seat, "after": state["after"], "wait": 20}+ if body.get("typing_indicator"):+ ask["partner_typing"] = state["typing"] # woken when it changes+ st, answer = post("/chat/poll", ask, timeout=40.0)+ except Exception as e:+ if stop.is_set():+ return # leaving: the hub not answering any more is expected+ print(f" ({at} did not answer: {e})", flush=True)+ stop.wait(2)+ continue+ if st == 404:+ print(" This seat is gone; start again.", flush=True)+ stop.set()+ with lock:+ lock.notify_all()+ return+ if st == 200 and isinstance(answer, dict):+ show(answer)+ if answer.get("state") == "left":+ return++ threading.Thread(target=pump, daemon=True).start()+ try:+ for line in sys.stdin:+ line = line.strip()+ if stop.is_set() or line in ("/quit", "/q"):+ break+ if line in ("/next", "/n"):+ st, answer = post("/chat/next", {"seat": seat, "after": state["after"]})+ if st == 200 and isinstance(answer, dict):+ show(answer)+ continue+ if not line:+ continue+ with lock:+ to_bot = (state["partner"] or {}).get("kind") == "bot"+ if to_bot:+ state["owed"] += 1+ st, answer = post("/chat/say", {"seat": seat, "text": line})+ if st != 200:+ with lock:+ state["owed"] -= 1 if to_bot else 0+ print(f" {answer.get('say') if isinstance(answer, dict) else answer}", flush=True)+ else:+ # Input ran out (a pipe): wait for the bot to answer what it was told.+ with lock:+ lock.wait_for(lambda: state["owed"] <= 0 or stop.is_set(), timeout=60)+ except KeyboardInterrupt:+ pass+ finally:+ stop.set()+ try:+ post("/chat/leave", {"seat": seat}, timeout=5.0)+ except Exception:+ pass+ return 0++def _report(outcome: Outcome, as_json: bool, with_trace: bool) -> int: if as_json: print(json.dumps(outcome.to_dict(with_trace=with_trace), indent=2, ensure_ascii=False, default=str))@@ -662,14 +787,14 @@ def _from_report(r: dict[str, Any]) -> dict[str, Any]:def cmd_hub(args: argparse.Namespace) -> int: from .hub import serve_hub- follow = [url for url in args.follow if url != "seed"]- join = list(args.join) + (["default"] if "seed" in args.follow and "default" not in args.join else [])+ follow = list(args.follow)+ join = list(args.join)hub, server = serve_hub( args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors, federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret, state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,- store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch,+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public,) fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") + (", listed" if hub.federation.listed else "") if hub.federation else "" local = f", holding {len(hub.local.trees)} tree(s)" if hub.local else ""@@ -677,6 +802,8 @@ def cmd_hub(args: argparse.Namespace) -> int:local += f", watching {len(hub.local.service.watcher)}" if hub.handout_registry is not None: local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members"+ if hub.lobby is not None:+ local += ", chatting at /chat" + (" (open to callers without the token, people only)" if hub.chat_public and args.token else "")print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) try: server.wait()@@ -765,7 +892,7 @@ def cmd_join(args: argparse.Namespace) -> int:code = _hub_post(args, "/join", {"invite": args.invite, "listed": bool(args.listed)}, said) if code == 1 and args.invite == "default": tried = bootstrap_candidates()- print("pyrtan join: no seed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_SEEDS or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr)+ print("pyrtan join: no listed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_HUB_LIST or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr)return 3 return code@@ -880,7 +1007,7 @@ def cmd_directory(args: argparse.Namespace) -> int:break body = None if body is None and not args.at:- # Nobody live: the snapshot pyrtan.com bakes from the seeds, with its age.+ # Nobody live: the snapshot pyrtan.com bakes from the hub list, with its age.source = os.environ.get("PYRTAN_SNAPSHOT") or "https://pyrtan.com/rendezvous/directory.json" try: if source.startswith(("http://", "https://")): diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 1502f9f..90a1b10 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -42,8 +42,10 @@ started ``--listed`` puts an entry for itself (id, name, the trees itholds, when) in every ``Announce`` it sends; every hub keeps the entries it hears and passes them on in its own announces, in both directions of every follow. Nothing central holds the directory: every federated hub-does, and pyrtan.com publishes only static files: the default network,-a list of *seeds* (hubs to follow first), and an hourly snapshot. An+does, and pyrtan.com publishes only static files on GitHub Pages: the+default network, the *hub list* (hubs operators added by pull request,+which pyrtan.com asks every hour), and an hourly snapshot of what they+know. No hub is run for it, and none has to be up for it. Anentry is a hint (a hub two follows away cannot verify who said it), kept until its hub has not been heard of for a week, and routing never uses it: finding a hub in the directory and following it is still the one act@@ -62,6 +64,12 @@ private network's hubs never reach the public directory, and ``GETnames the network, it does not guard it, and nothing here needs it to. A follow outside any network (and every member's) works as before.+**Chat.** A hub with a lobby (``--chat``) asks the hubs it follows for a+stranger with ``Meet``, and the two seats talk in ``Say`` and end with+``Part``, with ``Typing`` while someone types, both ways between any two+hubs that share a secret; a ``Say`` is logged without its text and a+``Typing`` not at all. See :mod:`pyrtan.chat`.+What this module refuses: open relaying (no hop beyond one), asymmetric secrets (there is no key infrastructure to lean on in the standard library, and a pair secret is enough for hubs whose operators chose each@@ -88,7 +96,7 @@ from . import __version__from .service import http_json from .store import BlobStore, StoreError, check_key, digest as blob_digest-DEFAULT_SEEDS = "https://pyrtan.com/rendezvous/seeds.json"+DEFAULT_HUB_LIST = "https://pyrtan.com/rendezvous/hubs.json"DEFAULT_SNAPSHOT = "https://pyrtan.com/rendezvous/directory.json" DEFAULT_NETWORK = "https://pyrtan.com/rendezvous/network.json" NETWORK_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")@@ -102,7 +110,9 @@ DIRECTORY_TTL = 7 * 86400 # an entry unheard of for this long is droppedDIRECTORY_MAX = 500 HEARD_UP = 600.0 # a directory entry seen this recently reads "up" HEARD_STALE = 86400.0-ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer")+ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Typing", "Part")+CHAT_TYPES = ("Meet", "Say", "Typing", "Part") # a lobby's: two strangers on two hubs+UNLOGGED = ("Typing",) # said every couple of seconds while someone types: not worth a log lineASK_TIMEOUT = 30.0 # seconds a hub waits for a member to answer an Ask MEMBER_PREFIX = "urn:pyrtan:member:" # an actor with no inbox: a browser or a phone, which polls a mailbox MAILBOX_MAX = 200 # activities a hub holds for a member that has not polled@@ -114,17 +124,17 @@ ASK_WINDOW = 600.0 # a member asked to keep within this counSIGNED_PATHS = ("/inbox", "/run", "/intent", "/resume", "/capabilities")-# ---------------------------------------------------------------- seeds+# ---------------------------------------------------------------- the hub list-def seed_urls() -> list[str]:- """Hubs to follow first, from ``$PYRTAN_RENDEZVOUS`` (one hub),- ``$PYRTAN_SEEDS`` (a URL or file holding ``{"hubs": [...]}``), or the- list at pyrtan.com. Never raises: no seeds is an empty list."""+def rendezvous_hubs() -> list[str]:+ """Hubs to try first, from ``$PYRTAN_RENDEZVOUS`` (one hub),+ ``$PYRTAN_HUB_LIST`` (a URL or file holding ``{"hubs": [...]}``), or+ the hub list at pyrtan.com. Never raises: no list is an empty list."""single = os.environ.get("PYRTAN_RENDEZVOUS") if single: return [single.rstrip("/")]- source = os.environ.get("PYRTAN_SEEDS") or DEFAULT_SEEDS+ source = os.environ.get("PYRTAN_HUB_LIST") or DEFAULT_HUB_LISTtry: if source.startswith(("http://", "https://")): status, body = http_json("GET", source, timeout=10.0)@@ -142,7 +152,7 @@ def seed_urls() -> list[str]:def snapshot_hubs() -> list[str]: """Hubs from the directory snapshot pyrtan.com bakes hourly (``$PYRTAN_SNAPSHOT`` overrides the URL or names a file), most recently- heard first. What a new hub can try when every seed is down."""+ heard first. What a new hub can try when no listed hub answers."""source = os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT try: if source.startswith(("http://", "https://")):@@ -230,16 +240,16 @@ def default_network() -> Network | None:def bootstrap_candidates() -> list[str]:- """Where to look for a first hub: the seeds, then the snapshot's hubs."""- seeds = seed_urls()- return seeds + [h for h in snapshot_hubs() if h not in seeds]+ """Where to look for a first hub: the hub list, then the snapshot's hubs."""+ listed = rendezvous_hubs()+ return listed + [h for h in snapshot_hubs() if h not in listed]-def first_live_seed(seeds: list[str] | None = None) -> str | None:- """The first seed whose ``/actor`` answers as a hub; when none does, the- first hub from the snapshot that answers; else None."""- tried = seeds if seeds is not None else seed_urls()- for batch in ([tried] if seeds is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):+def first_live_hub(hubs: list[str] | None = None) -> str | None:+ """The first listed hub whose ``/actor`` answers as a hub; when none+ does, the first hub from the snapshot that answers; else None."""+ tried = hubs if hubs is not None else rendezvous_hubs()+ for batch in ([tried] if hubs is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):for url in (batch() if callable(batch) else batch): try: status, actor = http_json("GET", f"{url}/actor", timeout=5.0)@@ -352,6 +362,14 @@ def _shape_report(e: dict[str, Any], reporter: str, now: float) -> dict[str, Anyreturn {"reporter": who.rstrip("/"), "id": tree_id, "node": node[:80], "on": on[:120], "observed": observed, "count": count, "last": last}+def _logged(activity: dict[str, Any]) -> dict[str, Any]:+ """An activity as the outbox and inbox logs keep it: a chat line without its text."""+ obj = activity.get("object")+ if activity.get("type") == "Say" and isinstance(obj, dict) and "text" in obj:+ return {**activity, "object": {**obj, "text": f"<{len(str(obj['text']))} characters>"}}+ return activity++def heard(seen: float, now: float | None = None) -> str: """How recently a directory entry's hub announced, as gossip carried it.""" age = (now if now is not None else time.time()) - seen@@ -379,6 +397,7 @@ class Federation:self.handout: Callable[[], dict[str, Any]] = lambda: {} # the trees trusted members run: name -> the tree as JSON self._handed: dict[str, dict[str, dict[str, Any]]] = {} # member -> key -> {sha256, at, confirmed} self._asks: dict[str, dict[str, Any]] = {} # run id -> {member, event, answer}+ self.chat: Any = None # the hub's lobby, when it keeps one (pyrtan.chat.Lobby)self.ask_timeout = ASK_TIMEOUT self.following: dict[str, Remote] = {} self.followers: dict[str, Remote] = {}@@ -654,7 +673,7 @@ class Federation:# the path decided on and the value no branch covered, with a count; # never the inputs or the rest of the state. Every federated hub keeps # what it hears and passes it on, like the directory, so the reports- # reach the seeds and the snapshot pyrtan.com bakes, where an author+ # reach the listed hubs and the snapshot pyrtan.com bakes, where an author# reads them beside the tree. Nothing is reported for a tree without a # namespace: there is nobody to report to.@@ -740,10 +759,12 @@ class Federation:try: status, answer = http_json("POST", remote.inbox, activity, timeout=10.0, headers=headers) except Exception as e:- self.outbox.append({**activity, "to": remote.url, "delivered": False, "error": str(e)})+ if activity.get("type") not in UNLOGGED:+ self.outbox.append({**_logged(activity), "to": remote.url, "delivered": False, "error": str(e)})return False, str(e), None ok = status in (200, 202)- self.outbox.append({**activity, "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})+ if activity.get("type") not in UNLOGGED:+ self.outbox.append({**_logged(activity), "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})if ok and isinstance(answer, dict): if activity.get("type") == "Announce": # An inbox answers an Announce with its own gossip: one round trip, both directions.@@ -1116,27 +1137,35 @@ class Federation:if h.get("X-Pyrtan-Actor", "").rstrip("/") != actor: return 401, {"status": "error", "reason": "unsigned", "say": "The signature's actor must be the activity's actor."} kind = activity["type"]- self.inbox_log.append({**activity, "received": time.time()})+ if activity.get("type") not in UNLOGGED:+ self.inbox_log.append({**_logged(activity), "received": time.time()})if kind == "Follow": return self._on_follow(actor, activity, h, trusted) followed = self.following.get(actor) follower = self.followers.get(actor)- if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer"):+ if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES):# Announce from a hub we follow: its trees are ours to route to;- # from a follower: only the directory it carries. Keeping goes- # both ways between any two that share a secret.+ # from a follower: only the directory it carries. Keeping and+ # chatting go both ways between any two that share a secret.remote = followed or follower else: remote = followed if kind in ("Accept", "Reject") else follower if remote is None: return 403, {"status": "error", "reason": "unknown-actor", "say": f"{actor} is not a hub this one follows or is followed by."} problem = verify(remote.secret, "POST", "/inbox", activity, h)- if problem and kind in ("Announce", "Keep", "Kept", "Fetch", "Answer") and followed and follower and followed is not follower:+ if problem and kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES) and followed and follower and followed is not follower:# Mutual follow: the announce may be signed with the other pair. remote = follower if remote is followed else followed problem = verify(remote.secret, "POST", "/inbox", activity, h) if problem: return 401, {"status": "error", "reason": "unsigned", "say": problem}+ if kind in CHAT_TYPES:+ if self.chat is None:+ return 404, {"status": "error", "reason": "no-chat", "say": f"{self.id} keeps no lobby."}+ if remote.kind != "hub":+ return 403, {"status": "error", "reason": "not-a-hub", "say": "Strangers meet hub to hub; a member chats through its hub's lobby."}+ remote.last_ok = time.time()+ return self.chat.on_activity(kind, remote, activity.get("object"), follower=remote is follower)if kind == "Accept": with self._lock: remote.state = "accepted" diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 8ae3c3d..3cf3f75 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -19,6 +19,12 @@ Endpoints:``GET /unanswered`` sentences no peer answered, each with a proposed tree ``GET /`` the console; ``GET /api`` is the JSON index+With ``--chat``, a hub also keeps a lobby at ``/chat``, where people meet a+stranger here or at a hub it follows, or a bot made of the trees it routes+to; see :mod:`pyrtan.chat`. ``--chat-public`` opens the lobby to callers+without the hub's token, who meet people only: a bot runs trees, and the+token is what guards them.+Federated (``--federate``), a hub also answers ``GET /actor``, ``POST /inbox``, ``POST /follow`` and ``/unfollow`` (operator), ``GET /following``, ``/followers`` and ``/outbox``, ``GET`` and ``POST /networks``, ``POST@@ -43,7 +49,8 @@ from dataclasses import dataclass, fieldfrom typing import Any from . import __version__-from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_seed, parse_invite, sign+from .chat import Lobby, is_chat_path+from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_hub, parse_invite, signfrom .grow import Unanswered, no_match_outcome from .intent import Trigger, read from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER@@ -120,8 +127,10 @@ class _HubResolver:class Hub:- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None) -> None:+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False) -> None:self.name = name+ self.lobby = Lobby(self, typing=chat_typing) if chat or chat_public else None+ self.chat_public = chat_public # callers without the token may chat, with people onlyself.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them self.poll_every = poll_every self.token = token or None@@ -151,6 +160,7 @@ class Hub:} self.federation = Federation(advertise, name=self.name, secret=secret, state=state, open_follows=open_follows, capabilities=self.announced_capabilities, listed=listed, store=store, replicas=replicas, auto_keys=auto, report_gaps=report_gaps, own_reports=self.gap_reports) self.federation.handout = self.handout_trees+ self.federation.chat = self.lobbyreturn self.federation def handout_trees(self) -> dict[str, Tree]:@@ -275,15 +285,18 @@ class Hub:out.extend(self.federation.following.values()) return out- def candidates(self) -> list[tuple[tuple[str, str], Trigger]]:+ def candidates(self, only: str | None = None) -> list[tuple[tuple[str, str], Trigger]]:+ """Every trigger this hub may route by, as ((holder url, tree), trigger);+ ``only`` keeps one holder's."""out: list[tuple[tuple[str, str], Trigger]] = [] for holder in self.holders():- if holder.link == "down":+ if holder.link == "down" or (only is not None and holder.url != only):continue for tree in holder.trees:+ fixed = tree.get("trigger_inputs") if isinstance(tree.get("trigger_inputs"), dict) else {}for pattern in tree.get("triggers", []): try:- out.append(((holder.url, tree["name"]), Trigger(pattern, language=tree.get("language") or {})))+ out.append(((holder.url, tree["name"]), Trigger(pattern, dict(fixed.get(pattern) or {}), language=tree.get("language") or {})))except Exception: continue return out@@ -324,12 +337,17 @@ class Hub:trees.append({**t, "peer": remote.url, "link": remote.link, "via": "federation"}) return {"name": self.name, "trees": trees}- def intent(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ def intent(self, body: dict[str, Any], only: str | None = None) -> tuple[int, dict[str, Any]]:+ """Read a sentence and route it. ``only`` (a holder's url) reads it+ against that holder's trees alone, as a chat bot does; a sentence+ they do not answer is then not recorded, since another holder may."""text = body.get("text") if not isinstance(text, str) or not text.strip(): return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}- reading = read(text, self.candidates(), _dt.datetime.now(_dt.timezone.utc))+ reading = read(text, self.candidates(only), _dt.datetime.now(_dt.timezone.utc))matches = reading.matches+ if not matches and only is not None:+ return 200, {"status": "error", "reason": "no-match", "say": f"{only} holds no tree that answers {text!r}.", "output": {"reading": {"cleaned": reading.cleaned, "steps": reading.steps}}}if not matches: entry = self.unanswered.record(text) answer = no_match_outcome(text, "No service in the swarm")@@ -342,7 +360,7 @@ class Hub:peer_url, tree_name = matches[0].tree # type: ignore[misc] for m in matches: m.tree = tree_name # the step names the tree, not the (peer, tree) pair- peer = self.holder(tree_name) or self.peers.get(peer_url)+ peer = next((h for h in self.holders() if h.url == only), None) if only is not None else self.holder(tree_name) or self.peers.get(peer_url)status, answer = self.forward(peer, "/run", {"tree": tree_name, "inputs": matches[0].inputs, "depth": body.get("depth", 0), "trace": body.get("trace", True), "hops": body.get("hops", [])}) if isinstance(answer.get("trace"), dict) and isinstance(answer["trace"].get("steps"), list): # The hub read the sentence; the peer only ran the tree. Put the@@ -462,6 +480,10 @@ class Hub:out.extend({**u, "peer": peer.url} for u in body.get("unanswered", [])) return {"unanswered": out}+ def _trusted(self, headers: dict[str, str]) -> bool:+ """Whether the caller holds this hub's token (always, when it has none)."""+ return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)+def route(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str] | None = None) -> tuple[int, Any]: body = body or {} headers = headers or {}@@ -567,6 +589,10 @@ class Hub:return 200, self.unanswered_all() if method == "GET" and path == "/watches": return 200, self.watches()+ if is_chat_path(path):+ if self.lobby is None:+ return 404, {"status": "error", "reason": "no-chat", "say": "This hub keeps no lobby; start it with --chat."}+ return self.lobby.route(method, path, body, guest=not self._trusted(headers))if method == "GET" and path in ("/", "/index.html", "/console"): return 200, PAGE if method == "GET" and path == "/manifest.webmanifest":@@ -577,9 +603,11 @@ class Hub:return 200, ("text/javascript; charset=utf-8", PYRTAN_JS) if method == "GET" and path == "/api": endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register"]+ if self.lobby is not None:+ endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"]if fed is not None: endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"]- return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "endpoints": endpoints}+ return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "chat_bots": self.lobby is not None and self._trusted(headers), "endpoints": endpoints}if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):@@ -605,14 +633,15 @@ def join_network(fed: Federation, invite: Any) -> tuple[int, dict[str, Any]]:"""Join a network from an invite (``name:secret[@hub]``), or the default network pyrtan.com publishes when ``invite`` is empty or ``default``, and follow a hub in it. When the default network cannot be read, the- first live seed is followed outside any network, as before networks."""+ first listed hub that answers is followed outside any network, as+ before networks."""if invite in (None, "", "default"): net = default_network() hubs = bootstrap_candidates() if net is None:- url = first_live_seed()+ url = first_live_hub()if url is None:- return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no seed or snapshot hub answers; give an invite or a hub URL."}+ return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no listed or snapshot hub answers; give an invite or a hub URL."}try: remote = fed.follow(url) except ValueError as e:@@ -667,10 +696,13 @@ def serve_hub(report_gaps: bool = False, unanswered: str | Path | None = None, watch: bool = False,+ chat: bool = False,+ chat_typing: bool = True,+ chat_public: bool = False,) -> tuple[Hub, Server]: local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered)- server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).start()+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public)+ server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), public=is_chat_path if chat_public else None).start()if federate or advertise or join: hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps) for url in peers or []: diff --git a/src/pyrtan/js/pyrtan.js b/src/pyrtan/js/pyrtan.js index 802607f..17683aa 100644 --- a/src/pyrtan/js/pyrtan.js +++ b/src/pyrtan/js/pyrtan.js@@ -1204,7 +1204,8 @@cap.sources = this.sources(); const watches = Array.isArray(this.raw.watches) ? this.raw.watches : []; if (watches.length) cap.watches = watches.map((w) => { const kind = ["every", "file", "queue", "serial"].find((k) => k in w) || "?"; return { kind, what: kind === "every" ? String(w.every) : kind === "file" ? String(w.file) : kind === "queue" ? `${w.queue && w.queue.topic} at ${w.queue && w.queue.url}` : String(w.serial && w.serial.device) }; });- return Object.assign(cap, { license: this.license, language: languageDict(this.language), triggers: this.triggers.map((t) => t.pattern), inputs });+ const fixed = this.triggers.filter((t) => Object.keys(t.fixedInputs).length);+ return Object.assign(cap, { license: this.license, language: languageDict(this.language), triggers: this.triggers.map((t) => t.pattern) }, fixed.length ? { trigger_inputs: Object.fromEntries(fixed.map((t) => [t.pattern, t.fixedInputs])) } : {}, { inputs });} } diff --git a/src/pyrtan/js/theme.js b/src/pyrtan/js/theme.js index ec06a69..ded17ed 100644 --- a/src/pyrtan/js/theme.js +++ b/src/pyrtan/js/theme.js@@ -1,19 +1,23 @@-// The wordmark and its two colours. "Pyr" is the primary colour (buttons,+// The wordmark and its two colours. "pyr" is the primary colour (buttons,// links, the current tab); "tan" is the secondary (focus rings, hover edges,-// selected text). Click either half of the wordmark, right-click it, or press-// and hold it on a touch screen to pick that half's colour. The choice is kept-// in this browser only, under this origin, and applies to every Pyrtan page-// served from it.+// selected text), and its picker also sets the mode: light, dark, or auto to+// follow the system. Click either half of the wordmark, right-click it, or+// press and hold it on a touch screen to open that half's picker. The choice+// is kept in this browser only, under this origin, and applies to every+// Pyrtan page served from it.//-// A page gives the defaults as --pyr, --pyr-fg, --tan and --tan-fg on :root-// (light and dark), marks the halves with data-theme-part="pyr" and "tan",-// and loads this script in <head> so a saved choice applies before first paint.+// A page gives the defaults as --pyr, --pyr-fg, --tan and --tan-fg on :root,+// with its dark values both under prefers-color-scheme (for+// :root:not([data-theme=light])) and under :root[data-theme=dark]; marks the+// halves with data-theme-part="pyr" and "tan"; and loads this script in+// <head> so a saved choice applies before first paint.(() => { const KEY = "pyrtan.theme"; const PARTS = {- pyr: {label: "Pyr", role: "primary", presets: [["ember", "#c2410c"], ["crimson", "#c92a2a"], ["rose", "#c2255c"], ["violet", "#7048e8"], ["blue", "#2b5db8"], ["teal", "#0c8599"], ["green", "#2f9e44"], ["slate", "#495057"]]},+ pyr: {label: "pyr", role: "primary", presets: [["ember", "#c2410c"], ["crimson", "#c92a2a"], ["rose", "#c2255c"], ["violet", "#7048e8"], ["blue", "#2b5db8"], ["teal", "#0c8599"], ["green", "#2f9e44"], ["slate", "#495057"]]},tan: {label: "tan", role: "secondary", presets: [["tan", "#c19a6b"], ["sand", "#d2b48c"], ["copper", "#b87333"], ["clay", "#a0522d"], ["gold", "#d4a017"], ["khaki", "#b5a642"], ["sage", "#8a9a5b"], ["steel", "#7d8ea3"]]}, };+ const MODES = [["auto", "Auto"], ["light", "Light"], ["dark", "Dark"]];const root = document.documentElement; const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || "{}") || {}; } catch (e) { return {}; } }; const save = (t) => { try { if (Object.keys(t).length) localStorage.setItem(KEY, JSON.stringify(t)); else localStorage.removeItem(KEY); } catch (e) {} };@@ -28,6 +32,7 @@if (valid(t[part])) { root.style.setProperty("--" + part, t[part]); root.style.setProperty("--" + part + "-fg", ink(t[part])); } else { root.style.removeProperty("--" + part); root.style.removeProperty("--" + part + "-fg"); } }+ if (t.mode === "light" || t.mode === "dark") root.dataset.theme = t.mode; else delete root.dataset.theme;const meta = document.querySelector('meta[name="theme-color"]'); if (meta) { const c = getComputedStyle(root).getPropertyValue("--pyr").trim(); if (c) meta.content = c; } }@@ -36,6 +41,7 @@const style = document.createElement("style"); style.textContent = `+ :root[data-theme=light] { color-scheme:light } :root[data-theme=dark] { color-scheme:dark }[data-theme-part] { cursor:pointer; border-radius:4px; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; touch-action:manipulation } [data-theme-part]:hover { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px } [data-theme-part][aria-expanded=true] { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px }@@ -50,6 +56,10 @@.theme-pick input[type=color] { width:36px; height:28px; padding:0; border:1px solid var(--line, #e3e1da); border-radius:6px; background:none; cursor:pointer } .theme-pick .theme-reset { font:inherit; padding:6px 10px; border:1px solid var(--line, #e3e1da); border-radius:6px; background:var(--soft, #f1efe9); color:var(--fg, #1d1d1b); cursor:pointer } .theme-pick .theme-reset:disabled { opacity:.5; cursor:default }+ .theme-pick .theme-mode { display:flex; gap:4px; margin:10px 0 0; padding:3px; background:var(--soft, #f1efe9); border-radius:8px }+ .theme-pick .theme-mode[hidden] { display:none }+ .theme-pick .theme-mode button { flex:1; font:inherit; padding:5px 0; border:0; border-radius:6px; background:transparent; color:var(--fg, #1d1d1b); cursor:pointer }+ .theme-pick .theme-mode button[aria-pressed=true] { background:var(--card, #fff); box-shadow:0 1px 3px rgba(0,0,0,.2); font-weight:600 }.theme-pick .theme-hint { color:var(--mute, #6b6a64); font-size:12px; margin:8px 0 0 }`; document.head.appendChild(style);@@ -60,6 +70,10 @@if (color) theme[part] = color.toLowerCase(); else delete theme[part]; save(theme); apply(theme); render(); }+ function mode(m) {+ if (m === "light" || m === "dark") theme.mode = m; else delete theme.mode;+ save(theme); apply(theme); render();+ }function render() { if (!pick || !openPart) return; const p = PARTS[openPart], cur = current(openPart);@@ -68,15 +82,19 @@pick.querySelector(".theme-swatches").innerHTML = p.presets.map(([name, c]) => `<button type="button" data-color="${c}" style="background:${c}" title="${name}" aria-label="${name}" aria-pressed="${cur === c}"></button>`).join(""); pick.querySelector("input[type=color]").value = cur || getComputedStyle(root).getPropertyValue("--" + openPart).trim() || "#000000"; pick.querySelector(".theme-reset").disabled = !cur;+ const modes = pick.querySelector(".theme-mode"), on = theme.mode || "auto";+ modes.hidden = openPart !== "tan";+ for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.mode === on));} function build() { pick = document.createElement("div"); pick.className = "theme-pick"; pick.hidden = true; pick.setAttribute("role", "dialog");- pick.innerHTML = `<div class="theme-head"></div><div class="theme-swatches"></div><div class="theme-row"><label><input type="color"> Custom</label><button type="button" class="theme-reset">Default</button></div><div class="theme-hint">Kept in this browser only.</div>`;+ pick.innerHTML = `<div class="theme-head"></div><div class="theme-swatches"></div><div class="theme-row"><label><input type="color"> Custom</label><button type="button" class="theme-reset">Default</button></div><div class="theme-mode" role="group" aria-label="Mode">${MODES.map(([m, label]) => `<button type="button" data-mode="${m}">${label}</button>`).join("")}</div><div class="theme-hint">Kept in this browser only.</div>`;pick.addEventListener("click", (e) => { const b = e.target.closest("button[data-color]"); if (b) choose(openPart, b.dataset.color); else if (e.target.closest(".theme-reset")) choose(openPart, null);+ else if (e.target.closest("button[data-mode]")) mode(e.target.closest("button[data-mode]").dataset.mode);}); pick.querySelector("input[type=color]").addEventListener("input", (e) => choose(openPart, e.target.value)); document.body.appendChild(pick);@@ -129,6 +147,8 @@window.addEventListener("resize", () => { if (pick && !pick.hidden) place(); }); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();+ // Auto follows the system; the browser bar's colour follows with it.+ try { matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { apply(theme); render(); }); } catch (e) {}// Another tab on the same origin changed the colours. window.addEventListener("storage", (e) => { if (e.key === KEY) { theme = load(); apply(theme); render(); } }); })(); diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 8322368..de3b7c6 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -3,8 +3,9 @@ file hosted anywhere else (``pyrtan console --out``), where it is a hub ofits own until it joins one. It asks a sentence and shows the answer, the output and the trace; lists-what the swarm can answer; shows the hub's peers and their ``link``; and-lists the gaps. Everything it does goes through the public endpoints+what the swarm can answer; shows the hub's peers and their ``link``; lists+the gaps; and, on a hub started with ``--chat``, pairs this browser with a+random stranger or a bot (``/chat``, see :mod:`pyrtan.chat`). Everything it does goes through the public endpoints(``/api``, ``/health``, ``/capabilities``, ``/peers``, ``/gaps``, ``/intent``, ``/resume``), so it works the same against a hub and a service, and the same whether it was served by that hub or by a static@@ -50,7 +51,8 @@ PAGE = r"""<!doctype html><script>__THEME__</script> <style> :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 }:root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) } * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif }@@ -88,13 +90,22 @@ PAGE = r"""<!doctype html>.pyr { color:var(--pyr) } .tan { color:var(--tan) } :focus-visible { outline:2px solid var(--tan); outline-offset:2px } ::selection { background:var(--tan); color:var(--tan-fg) }+ .chat-log { height:min(60vh,480px); overflow-y:auto; padding:2px 2px 8px; overflow-wrap:anywhere }+ .chat-log .line { margin:6px 0 }+ .chat-log .sys { color:var(--mute); font-size:14px }+ .chat-log .who { font-weight:600; margin-right:6px }+ .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) }+ .chat-log details { font-size:13px; margin-top:2px }+ .typing-line { min-height:20px; color:var(--mute); font-size:14px; font-style:italic }+ #chat-say { flex-wrap:nowrap } #chat-say input { flex-basis:0 }+ select { font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }@media (max-width:560px) { table thead { display:none } table td { display:block; border-top:0 } table tr { display:block; border-top:1px solid var(--line); padding:6px 0 } } </style> </head> <body> <main> <header>- <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span> <small id="who">…</small></h1>+ <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span> <small id="who">…</small></h1><span class="mute" id="where"></span> </header> <p class="lede">A core set of expandable services that act on all forms of I/O through inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved.</p>@@ -124,6 +135,7 @@ PAGE = r"""<!doctype html><a href="#unanswered" data-tab="unanswered">Unanswered</a> <a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a> <a href="#member" data-tab="member" id="member-tab" hidden>Member</a>+ <a href="#chat" data-tab="chat" id="chat-tab" hidden>Chat</a><a href="#api" data-tab="api">API</a> </nav>@@ -215,6 +227,30 @@ PAGE = r"""<!doctype html></div> </section>+ <section id="tab-chat" hidden>+ <h2>Talk to a stranger</h2>+ <p class="mute" style="font-size:14px;margin-top:0">You are paired at random: with someone else on this hub or on a hub it follows, or with a bot. A bot is a set of trees on the network, not a person and not a model; each of its lines carries the path that produced it, and what it cannot answer it says so, with the tree that would. Nobody has a name and nothing is kept: the conversation is gone when either of you leaves. <span id="chat-count"></span></p>+ <div class="notice" id="chat-guest" hidden>This hub's lobby is open to anyone, and you are here without its token: you meet people, not bots. A bot runs the hub's trees, and the token is what guards them.</div>+ <div class="card" id="chat-off">+ <form class="row" id="chat-start" style="margin:0">+ <input type="text" id="chat-interests" placeholder="interests, optional: weather, news, music" autocomplete="off">+ <select id="chat-want" aria-label="who to meet"><option value="anyone">a stranger, or a bot</option><option value="people">people only</option><option value="bot">a bot</option></select>+ <button type="submit">Start</button>+ </form>+ </div>+ <div class="card" id="chat-on" hidden>+ <div class="chat-log" id="chat-log" aria-live="polite"></div>+ <div class="typing-line" id="chat-typing-line" aria-live="polite"></div>+ <form class="row" id="chat-say" style="margin:8px 0 0">+ <button type="button" class="quiet" id="chat-next" title="Esc">New</button>+ <input type="text" id="chat-text" placeholder="say something" autocomplete="off" maxlength="1000">+ <button type="submit">Send</button>+ </form>+ <div class="mute" style="font-size:13px">Stop (or Esc), twice: end this and meet someone new · <a href="#" id="chat-stop">leave the lobby</a></div>+ </div>+ <label class="mute" style="font-size:14px" id="chat-typing-opt"><input type="checkbox" id="chat-typing" checked> Typing indicator: see when the stranger is typing, and let them see when you are. Off, neither of you sees it.</label>+ </section>+<section id="tab-api" hidden> <h2>The same endpoints this page uses</h2> <div class="card">@@ -225,7 +261,9 @@ GET /capabilitiesGET /health GET /gaps GET /unanswered-GET /peers (hub only)</pre>+GET /peers (hub only)+POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat)+POST /chat/typing {"seat": "…", "typing": true}</pre><div class="mute" style="font-size:14px;margin-top:8px" id="api-help">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div> </div> </section>@@ -279,6 +317,7 @@ function showTab(name) {if (name === "unanswered") loadUnanswered(); if (name === "directory") loadDirectory(); if (name === "member") member.render();+ if (name === "chat") chat.count();} async function loadDirectory() {@@ -289,7 +328,7 @@ async function loadDirectory() {tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code>${h.via === "self" ? ' <span class="mute">(this hub)</span>' : ""}${h.kind === "member" ? ' <span class="mute">(member)</span>' : ""}<div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${h.seen ? new Date(h.seen * 1000).toLocaleString() : ""}</td>`; body.appendChild(tr); }- if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Follow a seed, or start this hub with <code>--listed</code>.</td></tr>`;+ if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Join the default network (<code>pyrtan join</code>), or start this hub with <code>--listed</code>.</td></tr>`;} window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1)));@@ -314,7 +353,7 @@ async function detect() {$("#connect-lede").textContent = "This Pyrtan needs a token:"; $("#connect-help").innerHTML = "Kept in this browser only. The token is the one the hub or service was started with (<code>--token</code> or <code>$PYRTAN_TOKEN</code>)."; $("#hub-token").value = token;- if (!token) problem("Enter the token to see what this Pyrtan can answer.");+ if (!token) problem(a.chat_public ? "Enter the token to see what this Pyrtan can answer, or open Chat: its lobby is open to anyone." : "Enter the token to see what this Pyrtan can answer.");} return a; }@@ -389,6 +428,15 @@ async function load() {$("#peers-tab").hidden = !isHub; $("#directory-tab").hidden = !(isHub && a.federation); $("#member-tab").hidden = !(isHub && a.federation);+ $("#chat-tab").hidden = !(isHub && a.chat);+ if (a.chat && a.chat_bots === false && token) {+ try { a.chat_bots = (await json("api")).chat_bots; } catch (e) {} // the first ask went without the token+ }+ if (a.chat && a.chat_bots === false) {+ // A public lobby, and no token here: people only, since a bot runs the hub's trees.+ $("#chat-want").innerHTML = `<option value="people">people only</option>`;+ $("#chat-guest").hidden = false;+ }member.hub = a.federation || null; if (member.hub) member.start(); $("#where").textContent = local ? "running in this browser" : (base || location.origin) + (a.auth === "bearer" ? " · token" : "");@@ -398,7 +446,10 @@ async function load() {$("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up` + (h.trees != null ? `, ${h.trees} trees` : "") : (h.trees ? `${h.trees.length} trees` : "")); } catch (e) { $("#who").textContent = "unreachable"; } for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin;- if (a.auth === "bearer" && !token) return;+ if (a.auth === "bearer" && !token) {+ if (a.chat_public && location.hash === "#chat") showTab("chat"); // the one tab open without the token+ return;+ }let c; try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; } problem(local && !(c.trees || []).length ? "No catalog answered and none was kept in this browser, so it holds no trees yet. Join a hub above, or try again online." : "", true);@@ -715,8 +766,111 @@ $("#member-listed").addEventListener("change", (e) => { member.me.listed = e.tar$("#kept").addEventListener("click", async (e) => { const a = e.target.closest("a[data-show]"); if (!a) return; e.preventDefault(); const b = await member.keptGet(a.dataset.show); alert(b ? JSON.stringify(b.data, null, 2).slice(0, 4000) : "not kept"); }); if ("serviceWorker" in navigator && (location.protocol === "https:" || ["localhost", "127.0.0.1"].includes(location.hostname))) { navigator.serviceWorker.register("sw.js").catch(() => {}); }+// ------------------------------------------------------------ chat+// A seat in the hub's lobby: start, then long-poll for events (the hub+// echoes our own lines too, so the log is the hub's word, in its order).+const chat = {+ seat: null, after: 0, gen: 0, state: null, partner: null, sure: 0,+ indicator: false, partnerTyping: false, sentTyping: false, sentAt: 0,+ pref() { try { return localStorage.getItem("pyrtan.chat.typing") !== "0"; } catch (e) { return true; } },+ async post(path, body, extra) {+ const r = await api(path, Object.assign({method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)}, extra || {}));+ if (r.status === 401) throw new Error("401: this Pyrtan needs a token");+ return r.json();+ },+ async count() {+ try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); $("#chat-typing-opt").hidden = s.typing !== true; } catch (e) {}+ },+ async start() {+ const interests = $("#chat-interests").value.split(/[,\s]+/).filter(Boolean);+ let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests, typing_indicator: $("#chat-typing").checked}); } catch (e) { problem(e.message, true); return; }+ if (!r.seat) { problem(r.say || "The lobby did not take a seat.", true); return; }+ problem(""); this.seat = r.seat; this.after = 0; $("#chat-log").innerHTML = "";+ $("#chat-off").hidden = true; $("#chat-on").hidden = false;+ this.take(r); this.loop(++this.gen); $("#chat-text").focus();+ },+ take(r) {+ for (const e of r.events || []) if (e.n > this.after) { this.render(e); this.after = e.n; }+ this.state = r.state; this.partner = r.partner;+ this.indicator = !!r.typing_indicator; this.partnerTyping = !!r.partner_typing;+ $("#chat-typing-line").textContent = this.partnerTyping ? (r.partner && r.partner.kind === "bot" ? "Bot is thinking…" : "Stranger is typing…") : "";+ $("#chat-text").disabled = r.state !== "talking";+ $("#chat-text").placeholder = r.state === "talking" ? (r.partner && r.partner.kind === "bot" ? "ask the bot something" : "say something") : r.state === "waiting" ? "waiting for someone…" : "press New for someone else";+ this.resetNext();+ },+ async loop(gen) {+ while (this.seat && gen === this.gen) {+ // Saying what is shown lets the hub answer as soon as the stranger starts or stops typing.+ const ask = {seat: this.seat, after: this.after, wait: 20}; if (this.indicator) ask.partner_typing = this.partnerTyping;+ let r; try { r = await this.post("chat/poll", ask); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; }+ if (gen !== this.gen) return;+ if (r.state === "left") return;+ if (r.reason === "no-seat") { this.line("sys", "This seat is gone (nobody polled it for a minute). Start again."); this.seat = null; this.take({state: "ended"}); return; }+ this.take(r);+ }+ },+ render(e) {+ if (e.kind === "system") return this.line("sys", e.text);+ const el = this.line(e.kind, e.text, {you: "You", stranger: "Stranger", bot: "Bot"}[e.kind] || e.kind);+ if (e.kind === "bot" && (e.trace || e.tree)) {+ const d = document.createElement("details");+ d.innerHTML = `<summary>${esc([e.status, e.tree, e.peer && e.peer !== "local" ? "from " + e.peer : ""].filter(Boolean).join(" · "))}</summary>${e.trace ? `<pre>${esc(renderTrace(e.trace))}</pre>` : ""}`;+ el.appendChild(d);+ }+ },+ line(kind, text, who) {+ const log = $("#chat-log"), stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40;+ const el = document.createElement("div"); el.className = "line " + kind;+ el.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + esc(text);+ log.appendChild(el); if (stick) log.scrollTop = log.scrollHeight;+ return el;+ },+ async say() {+ const text = $("#chat-text").value.trim(); if (!text || !this.seat) return;+ $("#chat-text").value = ""; this.sentTyping = false; // the line ends the typing, on the hub too+ try { const r = await this.post("chat/say", {seat: this.seat, text}); if (r.status !== "ok") this.line("sys", r.say || r.reason); } catch (e) { this.line("sys", e.message); }+ },+ typed(on) {+ // Typing goes out when it starts or stops, and again every two seconds while it goes on.+ if (!this.seat || !this.indicator || this.state !== "talking" || (this.partner && this.partner.kind === "bot")) return;+ const now = Date.now();+ if (on === this.sentTyping && (!on || now - this.sentAt < 2000)) return;+ this.sentTyping = on; this.sentAt = now;+ this.post("chat/typing", {seat: this.seat, typing: on}).catch(() => {});+ },+ async setIndicator(on) {+ try { localStorage.setItem("pyrtan.chat.typing", on ? "1" : "0"); } catch (e) {}+ if (!this.seat) return;+ this.sentTyping = false;+ try { this.take(await this.post("chat/settings", {seat: this.seat, typing_indicator: on, after: this.after})); } catch (e) { this.line("sys", e.message); }+ },+ resetNext() { this.sure = 0; $("#chat-next").textContent = this.state === "talking" ? "Stop" : "New"; },+ async next() {+ // Like Omegle: while talking, the first press asks, the second ends it and finds someone new.+ if (this.state === "talking" && !this.sure) { this.sure = 1; $("#chat-next").textContent = "Really?"; setTimeout(() => this.resetNext(), 3000); return; }+ if (!this.seat) return this.start();+ this.sentTyping = false;+ try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after, typing_indicator: $("#chat-typing").checked})); } catch (e) { this.line("sys", e.message); }+ },+ leave(keepalive) {+ if (!this.seat) return;+ const seat = this.seat; this.seat = null; this.gen++;+ this.post("chat/leave", {seat}, keepalive ? {keepalive: true} : {}).catch(() => {});+ },+};+$("#chat-start").addEventListener("submit", (e) => { e.preventDefault(); chat.start(); });+$("#chat-say").addEventListener("submit", (e) => { e.preventDefault(); chat.say(); });+$("#chat-text").addEventListener("input", () => chat.typed(!!$("#chat-text").value.trim()));+$("#chat-text").addEventListener("blur", () => chat.typed(false));+$("#chat-typing").checked = chat.pref();+$("#chat-typing").addEventListener("change", (e) => chat.setIndicator(e.target.checked));+$("#chat-next").addEventListener("click", () => chat.next());+$("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leave(); $("#chat-on").hidden = true; $("#chat-off").hidden = false; chat.count(); });+document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } });+window.addEventListener("pagehide", () => chat.leave(true));+// Forgetting the hub makes this browser its own hub again.-$("#forget").addEventListener("click", () => { store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });+$("#forget").addEventListener("click", () => { chat.leave(true); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });load(); </script> </body> diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index cfdbb03..4dcea81 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py@@ -279,7 +279,7 @@ def bearer_ok(header: str | None, token: str) -> bool:return scheme.lower() == "bearer" and hmac.compare_digest(value.strip().encode("utf-8"), token.encode("utf-8"))-def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False):+def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False, public: Callable[[str], bool] | None = None):token = token or None origins = [o.rstrip("/") for o in (cors or []) if o] any_origin = "*" in origins@@ -319,7 +319,7 @@ def make_handler(route: Route, quiet: bool = False, token: str | None = None, codef _authorised(self, path: str) -> bool: """True if the request may proceed; otherwise a 401 has been sent."""- if token is None or path in PUBLIC_PATHS or bearer_ok(self.headers.get("Authorization"), token):+ if token is None or path in PUBLIC_PATHS or bearer_ok(self.headers.get("Authorization"), token) or (public is not None and public(path)):return True if federated and signed_path(path) and self.headers.get("X-Pyrtan-Signature"): return True # the route verifies the signature@@ -391,10 +391,11 @@ class Server:"""A running HTTP server around a route function, for a service or a hub. ``token`` requires ``Authorization: Bearer`` on everything but the public- paths; ``cors`` lists the browser origins allowed to call (``*`` for any)."""+ paths, and any path ``public`` says yes to (a hub's open lobby); ``cors``+ lists the browser origins allowed to call (``*`` for any)."""- def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False) -> None:- self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated))+ def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False, public: Callable[[str], bool] | None = None) -> None:+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated, public))self.httpd.daemon_threads = True self.thread: threading.Thread | None = None diff --git a/src/pyrtan/tree.py b/src/pyrtan/tree.py index 53f1a59..5914d64 100644 --- a/src/pyrtan/tree.py +++ b/src/pyrtan/tree.py@@ -263,6 +263,8 @@ class Tree:"license": self.license, "language": self.language.to_dict(), "triggers": [t.pattern for t in self.triggers],+ # What a trigger fixes ("top story" means count = 1): a hub routes by these too.+ **({"trigger_inputs": {t.pattern: t.fixed_inputs for t in self.triggers if t.fixed_inputs}} if any(t.fixed_inputs for t in self.triggers) else {}),"inputs": { n: {k: v for k, v in {"type": s.type, "default": s.default, "required": s.required and s.default is None, "description": s.description}.items() if v not in (None, "", False)} for n, s in self.inputs.items() diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index 35b55e8..1e8c464 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -74,6 +74,9 @@ CASES: list[dict] = [{"intent": "is the modem on /dev/ttyUSB1 up"}, {"intent": "is the modem on /dev/ttyACM0 up"}, {"intent": "is the modem on /dev/ttyS0 up"},+ {"intent": "how are you today?"},+ {"intent": "are you a bot"},+ {"intent": "goodbye"},] diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 11305da..14af50e 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json@@ -112,6 +112,11 @@"name": "fixed", "sources": [], "tags": [],+ "trigger_inputs": {+ "the usual": {+ "what": "coffee"+ }+ },"triggers": [ "the usual" ],@@ -169,6 +174,11 @@"news", "read-only" ],+ "trigger_inputs": {+ "[what's the] top story on hacker news": {+ "count": 1+ }+ },"triggers": [ "[what's the] top story on hacker news", "top {count:int} stories on hacker news"@@ -444,6 +454,50 @@} ] },+ {+ "description": "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything.",+ "hosts": [],+ "inputs": {+ "topic": {+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "small-talk",+ "namespace": "pyrtan",+ "sources": [],+ "tags": [+ "chat",+ "read-only"+ ],+ "trigger_inputs": {+ "(bye|goodbye|see you|see ya)": {+ "topic": "bye"+ },+ "(who|what) are you": {+ "topic": "who"+ },+ "are you (a bot|human|real|a person)": {+ "topic": "who"+ },+ "how are you [doing|today]": {+ "topic": "how"+ },+ "what (can|do) you do": {+ "topic": "what"+ }+ },+ "triggers": [+ "how are you [doing|today]",+ "(who|what) are you",+ "what (can|do) you do",+ "are you (a bot|human|real|a person)",+ "(bye|goodbye|see you|see ya)"+ ],+ "version": 1+ },{ "description": "echo for tie-a", "hosts": [],@@ -8092,6 +8146,171 @@}, "intent": "is the modem on /dev/ttyS0 up", "tree": "modem-check"+ },+ {+ "expect": {+ "inputs": {+ "topic": "how"+ },+ "output": {+ "topic": "how"+ },+ "say": "Deterministic, thanks: the same question takes the same path every time.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "topic",+ "node": "intent",+ "observed": {+ "cleaned": "how are you today",+ "inputs": {+ "topic": "how"+ },+ "steps": [],+ "trigger": "how are you [doing|today]"+ },+ "summary": "'how are you today?' -> 'how are you today'; small-talk via 'how are you [doing|today]' (19 literal chars)"+ },+ {+ "chosen": "eq 'how'",+ "depth": 0,+ "kind": "decide",+ "next": "how",+ "node": "topic",+ "observed": "how",+ "summary": "inputs.topic = how"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "how",+ "summary": "Deterministic, thanks: the same question takes the same path every time."+ }+ ],+ "tree": "small-talk"+ },+ "tree": "small-talk"+ },+ "inputs": {+ "topic": "how"+ },+ "intent": "how are you today?",+ "tree": "small-talk"+ },+ {+ "expect": {+ "inputs": {+ "topic": "who"+ },+ "output": {+ "topic": "who"+ },+ "say": "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "topic",+ "node": "intent",+ "observed": {+ "cleaned": "are you a bot",+ "inputs": {+ "topic": "who"+ },+ "steps": [],+ "trigger": "are you (a bot|human|real|a person)"+ },+ "summary": "'are you a bot'; small-talk via 'are you (a bot|human|real|a person)' (26 literal chars)"+ },+ {+ "chosen": "eq 'who'",+ "depth": 0,+ "kind": "decide",+ "next": "who",+ "node": "topic",+ "observed": "who",+ "summary": "inputs.topic = who"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "who",+ "summary": "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it."+ }+ ],+ "tree": "small-talk"+ },+ "tree": "small-talk"+ },+ "inputs": {+ "topic": "who"+ },+ "intent": "are you a bot",+ "tree": "small-talk"+ },+ {+ "expect": {+ "inputs": {+ "topic": "bye"+ },+ "output": {+ "topic": "bye"+ },+ "say": "Bye. Press New to meet someone else.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "topic",+ "node": "intent",+ "observed": {+ "cleaned": "goodbye",+ "inputs": {+ "topic": "bye"+ },+ "steps": [],+ "trigger": "(bye|goodbye|see you|see ya)"+ },+ "summary": "'goodbye'; small-talk via '(bye|goodbye|see you|see ya)' (21 literal chars)"+ },+ {+ "chosen": "eq 'bye'",+ "depth": 0,+ "kind": "decide",+ "next": "bye",+ "node": "topic",+ "observed": "bye",+ "summary": "inputs.topic = bye"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "bye",+ "summary": "Bye. Press New to meet someone else."+ }+ ],+ "tree": "small-talk"+ },+ "tree": "small-talk"+ },+ "inputs": {+ "topic": "bye"+ },+ "intent": "goodbye",+ "tree": "small-talk"} ], "trees": {@@ -9157,6 +9376,121 @@} ] },+ "small-talk": {+ "description": "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything.",+ "inputs": {+ "topic": {+ "one_of": [+ "how",+ "who",+ "what",+ "bye"+ ],+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "small-talk",+ "namespace": "pyrtan",+ "nodes": {+ "bye": {+ "kind": "done",+ "output": {+ "topic": "bye"+ },+ "say": "Bye. Press New to meet someone else."+ },+ "how": {+ "kind": "done",+ "output": {+ "topic": "how"+ },+ "say": "Deterministic, thanks: the same question takes the same path every time."+ },+ "topic": {+ "branches": [+ {+ "then": "how",+ "when": {+ "eq": "how"+ }+ },+ {+ "then": "who",+ "when": {+ "eq": "who"+ }+ },+ {+ "then": "what",+ "when": {+ "eq": "what"+ }+ },+ {+ "then": "bye",+ "when": {+ "eq": "bye"+ }+ }+ ],+ "kind": "decide",+ "on": "inputs.topic"+ },+ "what": {+ "kind": "done",+ "output": {+ "topic": "what"+ },+ "say": "Only what my trees were written for. Ask something else and I'll say that nobody answers it yet, and which tree would."+ },+ "who": {+ "kind": "done",+ "output": {+ "topic": "who"+ },+ "say": "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it."+ }+ },+ "start": "topic",+ "tags": [+ "chat",+ "read-only"+ ],+ "triggers": [+ {+ "inputs": {+ "topic": "how"+ },+ "pattern": "how are you [doing|today]"+ },+ {+ "inputs": {+ "topic": "who"+ },+ "pattern": "(who|what) are you"+ },+ {+ "inputs": {+ "topic": "what"+ },+ "pattern": "what (can|do) you do"+ },+ {+ "inputs": {+ "topic": "who"+ },+ "pattern": "are you (a bot|human|real|a person)"+ },+ {+ "inputs": {+ "topic": "bye"+ },+ "pattern": "(bye|goodbye|see you|see ya)"+ }+ ],+ "version": 1+ },"weather": { "description": "Current weather for a place, from Open-Meteo.", "hosts": [ diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 3b56e8d..ee756ec 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py@@ -40,7 +40,7 @@ def served(catalog_dir):def test_build_index_describes_every_tree(catalog_dir): index = json.loads((catalog_dir / "index.json").read_text())- assert index["pyrtan-catalog"] == 1 and index["count"] == 10+ assert index["pyrtan-catalog"] == 1 and index["count"] == 11weather = next(e for e in index["trees"] if e["name"] == "weather") assert weather["path"] == "weather/weather.toml" and weather["license"] == "MIT" assert weather["sha256"] == hashlib.sha256((catalog_dir / "weather/weather.toml").read_bytes()).hexdigest()@@ -68,7 +68,7 @@ def test_build_index_leaves_out_what_does_not_belong(tmp_path):def test_search_and_find(served): cat = Catalog(served)- assert {e["name"] for e in cat.search("")} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check"}+ assert {e["name"] for e in cat.search("")} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk"}assert [e["name"] for e in cat.search("open-meteo")] == ["weather"] assert [e["name"] for e in cat.search("news stories")] == ["hacker-news"] assert cat.search("nothing like this") == []@@ -130,7 +130,7 @@ def test_not_a_catalog(served, tmp_path):def test_cli_index_search_pull(catalog_dir, served, tmp_path, capsys): out = tmp_path / "idx.json" assert main(["index", str(catalog_dir), "--out", str(out)]) == 0- assert json.loads(out.read_text())["count"] == 10+ assert json.loads(out.read_text())["count"] == 11assert main(["search", "weather", "--catalog", served]) == 0 assert "weather@2" in capsys.readouterr().out assert main(["search", "zzz", "--catalog", served]) == 1@@ -148,7 +148,7 @@ def test_index_writes_the_trees_as_json_for_the_browser(catalog_dir, tmp_path):assert main(["index", str(catalog_dir), "--copy", str(site)]) == 0 index = json.loads((site / "index.json").read_text()) bundle = json.loads((site / "trees.json").read_text())- assert bundle["pyrtan-trees"] == 1 and bundle["count"] == index["count"] == 10+ assert bundle["pyrtan-trees"] == 1 and bundle["count"] == index["count"] == len(index["trees"]) > 0assert [(t["id"], t["sha256"]) for t in bundle["trees"]] == [(e["id"], e["sha256"]) for e in index["trees"]] weather = next(t["tree"] for t in bundle["trees"] if t["id"].endswith("weather")) assert weather == load_tree(site / next(e["path"] for e in index["trees"] if e["id"].endswith("weather"))).raw@@ -276,7 +276,7 @@ def test_gate_and_publish(tmp_path, capsys):assert "placed" in out and "git checkout -b catalog/barry-lan-printer" in out assert main(["index", str(repo / "examples"), str(repo / "catalog"), "--copy", str(tmp_path / "site"), "--require-namespace"]) == 0 served = json.loads((tmp_path / "site" / "index.json").read_text())- assert {e["id"] for e in served["trees"]} == {f"pyrtan/{n}" for n in ("weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check")} | {"barry/lan-printer"}+ assert {e["id"] for e in served["trees"]} == {f"pyrtan/{n}" for n in ("weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk")} | {"barry/lan-printer"}assert main(["index", str(repo / "examples"), str(repo / "catalog")]) == 2 # two roots need --copy assert main(["search", "lan", "--catalog", str(tmp_path / "site")]) == 0 and "barry/lan-printer@3" in capsys.readouterr().out assert main(["pull", "lan-printer", "--catalog", str(tmp_path / "site"), "--dir", str(tmp_path / "pulled")]) == 0 diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..2808758 --- /dev/null +++ b/tests/test_chat.py@@ -0,0 +1,496 @@+"""The lobby: two strangers on one hub, a bot made of trees, strangers on+two hubs that follow each other, and what the lobby refuses."""++import io+import sys+import time++import pytest++from pyrtan import Registry, RecordedTransport+from pyrtan.chat import interests_of, sample+from pyrtan.cli import main+from pyrtan.hub import Hub+from pyrtan.service import Server, Service, http_json+from conftest import EXAMPLES, FIXTURES++GREETER = """+name = "greeter"+description = "asks for a name"+start = "have-name"++[[triggers]]+pattern = "greet me"++[inputs.name]+type = "str"+required = false++[nodes.have-name]+kind = "decide"+on = "inputs.name"+branches = [ { when = { exists = true }, then = "hello" }, { else = "ask" } ]++[nodes.ask]+kind = "ask"+prompt = "What is your name?"+into = "inputs.name"+next = "hello"++[nodes.hello]+kind = "done"+say = "Hello, {{ inputs.name }}."+"""+++def post(hub, path, body):+ return hub.route("POST", path, body)+++def start(hub, want="anyone", interests=()):+ status, out = post(hub, "/chat/start", {"want": want, "interests": list(interests)})+ assert status == 200, out+ return out+++def until(hub, seat, test, after=0, timeout=5.0):+ """Poll a seat until ``test(events, view)`` holds; every event seen so far."""+ events, deadline = [], time.time() + timeout+ while True:+ status, view = post(hub, "/chat/poll", {"seat": seat, "after": after, "wait": 0.2})+ assert status == 200, view+ events += view["events"]+ after = view["after"]+ if test(events, view):+ return events, view+ if time.time() > deadline:+ raise AssertionError(f"never: {events}")+++def texts(events, kind):+ return [e["text"] for e in events if e["kind"] == kind]+++@pytest.fixture+def examples_hub():+ svc = Service(Registry.from_paths([EXAMPLES]), name="alpha", transport=RecordedTransport.from_file(FIXTURES / "examples.json"))+ hub = Hub(name="alpha", chat=True, local=svc)+ yield hub+ hub.stop()+++def test_two_strangers_meet_talk_and_part():+ hub = Hub(name="h", chat=True)+ a = start(hub, "people", ["Music", "cats"])+ assert a["state"] == "waiting" and a["interests"] == ["music", "cats"]+ assert hub.route("GET", "/chat", None)[1]["waiting"] == 1+ b = start(hub, "anyone", ["music"])+ assert b["state"] == "talking" and b["partner"] == {"kind": "stranger", "hub": None}+ assert "You're now chatting with a random stranger. Say hi!" in texts(b["events"], "system")+ assert "You both like music." in texts(b["events"], "system")+ events, view = until(hub, a["seat"], lambda ev, v: v["state"] == "talking")+ assert "You both like music." in texts(events, "system")++ assert post(hub, "/chat/say", {"seat": a["seat"], "text": " hi there "})[0] == 200+ events, _ = until(hub, b["seat"], lambda ev, v: texts(ev, "stranger"), after=b["after"])+ assert texts(events, "stranger") == ["hi there"]+ events, _ = until(hub, a["seat"], lambda ev, v: texts(ev, "you"), after=view["after"])+ assert texts(events, "you") == ["hi there"] # your own line comes back in the hub's order++ # b moves on: a is told, and cannot talk into the void.+ status, nxt = post(hub, "/chat/next", {"seat": b["seat"]})+ assert status == 200 and nxt["state"] == "waiting"+ events, view = until(hub, a["seat"], lambda ev, v: v["state"] == "ended")+ assert "Stranger has disconnected." in texts(events, "system")+ assert post(hub, "/chat/say", {"seat": a["seat"], "text": "hello?"})[1]["reason"] == "not-talking"++ # Next pairs a with b again: the only other person here.+ status, again = post(hub, "/chat/next", {"seat": a["seat"]})+ assert again["state"] == "talking"+ assert post(hub, "/chat/leave", {"seat": a["seat"]})[1]["left"] is True+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat"+ events, _ = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=nxt["after"])+ assert texts(events, "system")[-1] == "Stranger has disconnected."+ assert hub.route("GET", "/chat", None)[1] == {"status": "ok", "waiting": 0, "talking": 0, "bots": 0, "hubs": 0, "typing": True}+++def test_a_bot_is_trees_and_says_how_it_answered(examples_hub):+ hub = examples_hub+ seat = start(hub, "bot", ["disk"])+ assert seat["state"] == "talking" and seat["partner"]["kind"] == "bot" and seat["partner"]["name"] == "alpha"+ intro = texts(seat["events"], "system")[-1]+ assert intro.startswith("You're now chatting with a bot: alpha, this hub's own trees.") and "not a model" in intro+ assert "Try “how full is the disk”." in intro # a sentence from the tree that shares the interest+ after = seat["after"]++ def bot_says(text):+ nonlocal after+ assert post(hub, "/chat/say", {"seat": seat["seat"], "text": text})[0] == 200+ events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=after)+ after = view["after"]+ return [e for e in events if e["kind"] == "bot"][-1]++ line = bot_says("what's the weather in Oslo")+ assert line["text"] == "Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h."+ assert line["status"] == "done" and line["tree"] == "weather" and line["peer"] == "local"+ assert line["trace"]["steps"][0]["kind"] == "intent" and line["output"]["sky"] == "raining"++ # A greeting asks the swarm nothing: the bot says who it is, and nothing is recorded.+ line = bot_says("hey!")+ assert line["reason"] == "greeting" and line["text"].startswith("I'm alpha, this hub's own trees.")+ line = bot_says("how are you?")+ assert line["tree"] == "small-talk" and line["text"].startswith("Deterministic")+ assert hub.unanswered.to_list() == []++ # Nobody answers: the bot says so, with the tree that would, and the swarm records it.+ line = bot_says("top 3 stories on lobsters")+ assert line["reason"] == "no-match" and 'pyrtan new top-stories-lobsters --from "top 3 stories on lobsters"' in line["text"]+ assert [u["text"] for u in hub.unanswered.to_list()] == ["top 3 stories on lobsters"]+++def test_a_bot_asks_and_the_next_line_is_the_answer(tmp_path):+ (tmp_path / "greeter.toml").write_text(GREETER)+ hub = Hub(name="g", chat=True, local=Service(Registry.from_paths([tmp_path]), name="g"))+ seat = start(hub, "bot")+ assert "Try “greet me”." in texts(seat["events"], "system")[-1]+ post(hub, "/chat/say", {"seat": seat["seat"], "text": "greet me"})+ events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=seat["after"])+ assert texts(events, "bot") == ["What is your name?"] and events[-1]["status"] == "asked"+ post(hub, "/chat/say", {"seat": seat["seat"], "text": "Ada"})+ events, _ = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=view["after"])+ assert texts(events, "bot") == ["Hello, Ada."] and events[-1]["status"] == "done"+++def test_anyone_meets_a_bot_after_a_while_and_people_never_do(examples_hub):+ hub = examples_hub+ hub.lobby.bot_after = 0.3+ people = start(hub, "people")+ _, view = post(hub, "/chat/poll", {"seat": people["seat"], "after": people["after"], "wait": 0.6})+ assert view["state"] == "waiting" and view["events"] == []+ post(hub, "/chat/leave", {"seat": people["seat"]})++ anyone = start(hub, "anyone")+ assert anyone["state"] == "waiting"+ # The long poll wakes by itself when the bot is due.+ t0 = time.time()+ _, view = post(hub, "/chat/poll", {"seat": anyone["seat"], "after": anyone["after"], "wait": 5})+ assert view["state"] == "talking" and view["partner"]["kind"] == "bot" and time.time() - t0 < 2+++def test_no_trees_no_bots():+ hub = Hub(name="empty", chat=True)+ hub.lobby.bot_after = 0+ bot = start(hub, "bot")+ assert bot["state"] == "ended" and "No bots here" in texts(bot["events"], "system")[-1]+ anyone = start(hub, "anyone")+ events, view = until(hub, anyone["seat"], lambda ev, v: "either" in " ".join(texts(ev, "system")))+ assert view["state"] == "waiting" and texts(events, "system").count("No bots here either; still looking for a person.") == 1+++def test_lines_are_short_and_not_too_fast():+ hub = Hub(name="h", chat=True)+ a, b = start(hub, "people"), start(hub, "people")+ say = lambda text: post(hub, "/chat/say", {"seat": a["seat"], "text": text})+ assert say("x" * 1001)[1]["reason"] == "too-long"+ assert say(" ")[0] == 400+ assert [say(f"line {i}")[0] for i in range(6)] == [200] * 5 + [429]+ assert post(hub, "/chat/start", {"want": "everyone"})[0] == 400+ assert post(hub, "/chat/say", {"seat": "nobody", "text": "hi"})[1]["reason"] == "no-seat"+++def test_a_seat_nobody_polls_is_let_go():+ hub = Hub(name="h", chat=True)+ hub.lobby.seat_idle = 0.3+ a, b = start(hub, "people"), start(hub, "people")+ events, view = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=b["after"])+ assert texts(events, "system") == ["Stranger has disconnected."]+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat"+++def test_interests_choose_whom_you_meet():+ hub = Hub(name="h", chat=True)+ cats = start(hub, "people", ["cats"])+ hub.lobby.seats[cats["seat"]].busy = True # as if asking another hub: two can wait at once+ dogs = start(hub, "people", ["dogs"])+ hub.lobby.seats[cats["seat"]].busy = False+ assert cats["state"] == dogs["state"] == "waiting"+ me = start(hub, "people", ["dogs", "chess"]) # at random among equals, but sharing a word goes first+ assert me["state"] == "talking" and "You both like dogs." in texts(me["events"], "system")+ assert post(hub, "/chat/poll", {"seat": cats["seat"]})[1]["state"] == "waiting"+ assert interests_of("Dogs, #chess,, a b c d e f") == ["dogs", "chess", "a", "b", "c"]+ assert sample({"triggers": ["[what's the] weather in {place}"], "inputs": {"place": {"default": "Oslo"}}}) == "what's the weather in Oslo"+ assert sample({"triggers": ["(who|what) are you [doing|today]"]}) == "who are you doing"+ assert sample({"triggers": ["is {host:host} up"], "inputs": {"host": {}}}) is None+++def test_trigger_inputs_travel_with_capabilities(examples_hub):+ # small-talk fixes its topic in each trigger; a hub must route with it.+ caps = examples_hub.capabilities()["trees"]+ talk = next(t for t in caps if t["nam
The diff is cut here; the rest is in the clone.