pyrtan / sourcev0.19.0 · main

Merge main (LAN discovery 0.16.0, browser trees, exchange rates) into identity without a shell; this is release 0.17.0

Claude · 2026-10-28 · 831c0f6c9ae0ab2b600dfb76755087a767fc17b4 · parents 4cfe4c9 019cb65

Main shipped 0.15.0 and 0.16.0 meanwhile, so this is 0.17.0, and the design note's sections for it say v0.17. One conflict: serve_hub's call in the CLI takes main's --lan beside the grants and the names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv

Changes

 CONTRIBUTING.md                           |    8 +
 README.md                                 |   44 +-
 _meta/federation.md                       |   43 +-
 _meta/identity.md                         |   10 +-
 _meta/tree-format.md                      |    3 +-
 deploy/README.md                          |   12 +-
 deploy/openmediavault/docker-compose.yml  |    5 +-
 examples/air-quality/air-quality.toml     |  108 +
 examples/books/books.toml                 |   66 +
 examples/dad-joke/dad-joke.toml           |   54 +
 examples/dictionary/define.toml           |   66 +
 examples/earthquakes/earthquakes.toml     |   63 +
 examples/exchange-rate/exchange-rate.toml |   87 +
 examples/hn-search/hn-search.toml         |   77 +
 examples/holidays/holidays.toml           |   65 +
 examples/iss/iss.toml                     |   82 +
 examples/packages/npm-package.toml        |   57 +
 examples/packages/pypi-package.toml       |   58 +
 examples/sun/sun.toml                     |   80 +
 examples/synonyms/synonyms.toml           |   62 +
 examples/wikipedia/wikipedia.toml         |   79 +
 pyproject.toml                            |    2 +-
 site/README.md                            |    8 +-
 site/rendezvous/network.json              |    2 +-
 src/pyrtan/__init__.py                    |    2 +-
 src/pyrtan/cli.py                         |   11 +-
 src/pyrtan/federation.py                  |  125 +-
 src/pyrtan/hub.py                         |   55 +-
 src/pyrtan/js/pyrtan.js                   |    8 +-
 src/pyrtan/lan.py                         |  210 ++
 src/pyrtan/nlp.py                         |   12 +-
 src/pyrtan/page.py                        |  142 +-
 src/pyrtan/template.py                    |   13 +-
 tests/conformance/make_vectors.py         |   33 +-
 tests/conformance/vectors.json            | 5611 +++++++++++++++++++++++++++++++++++++++++++++----
 tests/conftest.py                         |    5 +-
 tests/fixtures/examples.json              |  362 ++++
 tests/test_catalog.py                     |   15 +-
 tests/test_cli.py                         |   12 +-
 tests/test_examples.py                    |  108 +
 tests/test_federation.py                  |   50 +
 tests/test_lan.py                         |  143 ++
 tests/test_nlp.py                         |    6 +
 tests/test_service_hub.py                 |    5 +-
 tests/test_template.py                    |    4 +
 uv.lock                                   |    2 +-
 46 files changed, 7485 insertions(+), 590 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c90117c..31bacb7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,6 +24,14 @@ does not exist, write it; if one gives a wrong answer, the trace shows where.
A tree that reads a page should prefer an API or a feed when the site offers one, and should decide on the status code before it decides on the body.
+A tree whose every node is `http` against an API that lets other origins
+read it (it answers with `Access-Control-Allow-Origin`) also runs in a
+browser: the console at pyrtan.com runs it in the page, with no hub.
+`curl -sI -H "Origin: https://pyrtan.com" URL` shows whether it does.
+Keep to headers a page may send as they are (`Accept` is fine; a
+custom `User-Agent` is not): anything else makes the browser send a
+preflight request first, which many APIs do not answer.
+
A tree meant for the catalog at pyrtan.com carries a `license` line (an SPDX identifier; `MIT`, `Apache-2.0` or `CC0-1.0` are the usual choices) and a `namespace` line (who publishes it; the catalog id is diff --git a/README.md b/README.md index b80ab53..c8a6b9b 100644 --- a/README.md +++ b/README.md
@@ -162,10 +162,10 @@ 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; `--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 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; `--lan` finds hubs on the local network by multicast, with nobody to ask; `--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 join [INVITE] --at HUB` | Puts your hub in a network and follows a hub in it: the default network (built in), 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…". |
@@ -202,7 +202,11 @@ its `link`; and the gaps. The same console lives at
join one, the browser is the hub. It holds the catalog's trees, reads the sentence and runs the tree in the page (`pyrtan.js`, the same runtime and the same reading as Python's), and keeps gaps and unanswered sentences
-while the page is open. Trees that need a box (files, programs, serial
+while the page is open. Nineteen of the examples run there: weather,
+sunrise and sunset, air quality, Wikipedia, a dictionary, synonyms,
+exchange rates, Hacker News (top stories, an item, a search), books,
+npm and PyPI versions, GitHub releases, earthquakes, where the ISS is,
+public holidays, a dad joke, and small talk. Trees that need a box (files, programs, serial
lines, 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
@@ -272,15 +276,25 @@ keeper. Leaving a network (`pyrtan network leave`) cuts the links made
in it and forgets what was heard there. A follow made outside any network, and a member's, works as it always has.
-The **default network** is the one pyrtan.com publishes in
-`rendezvous/network.json`: `pyrtan`, with a secret that is public on
-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/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. pyrtan.com runs no hub: it is static files on GitHub Pages. Every
+The **default network** is built into pyrtan: `pyrtan`, with a secret
+that is public on 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 that accepts, trying what the hub already knows before
+asking anyone: the hubs it follows in the network, then the ones its
+directory has heard in it, then the hub list, then the snapshot, each
+fetched only if the ones before it did not do. So a hub that has been in
+the network once never needs pyrtan.com again; when nobody accepts, the
+hub is the network so far, and others join through it. `$PYRTAN_HUB_LIST`
+takes any number of sources (a hub list, any hub's `/directory`, a
+snapshot or a mirror of one), so pyrtan.com is one starting point among
+any others. On one LAN not even that is needed: hubs started `--lan`
+(with `--advertise http://<LAN address>:PORT`) announce themselves over
+UDP multicast that never passes a router, and `pyrtan join` tries the
+hubs heard there right after the ones it follows, so two boxes in one
+house find each other with the internet unplugged. pyrtan.com runs no hub: it is static files on GitHub Pages,
+and publishes the default network's name and secret only as a mirror. 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
@@ -667,11 +681,11 @@ site/ pyrtan.com: the landing page, the hub list, the catalog
## Status
-0.14.0. The core, the service, the hub, the command, the console, the
+0.17.0. The core, the service, the hub, the command, the console, the
catalog (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
-default), the gossiped directory, members (a browser or a phone as
+between hubs, networks (any hub starts one; the default one is built
+in; hubs on one LAN find each other by multicast), the gossiped directory, members (a browser or a phone as
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 diff --git a/_meta/federation.md b/_meta/federation.md index 8f5aef4..4cad130 100644 --- a/_meta/federation.md +++ b/_meta/federation.md
@@ -215,11 +215,16 @@ the visitor's browser.
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
+first hub that accepts, in this order, each fetched only if the ones
+before it did not do: the hubs it already follows in the network, the
+hubs its own directory heard in it (most recent first), the hub list,
+the snapshot. `pyrtan hub --join default` does the same on start, so a
+restart asks nobody. 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` names any number of sources,
+separated by spaces or commas, each a URL or a file: a hub list, any
+hub's `/directory`, a snapshot, or a mirror of one; `$PYRTAN_RENDEZVOUS`
+names one hub. pyrtan.com is one starting point, not the only one. (Until v0.13 this was
`seeds.json`, and pyrtan.com briefly ran a hub of its own on Fly.io; both are gone.)
@@ -240,6 +245,26 @@ 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 empty is if nobody has been up for a week. Nothing of ours polls: GitHub does.
+**On the LAN (v0.16).** Two boxes in one house should not need the
+internet to find each other. A hub started `--lan` says it is here on a
+UDP multicast group (`239.255.70.84:8770`, administratively scoped, TTL
+1, so it never passes a router): its id, its name and the public
+networks it is in. It says so on start, with a question every other hub
+on the LAN answers at once, and every thirty seconds after; what it
+hears it keeps for three minutes (`GET /lan`, operator). `pyrtan join`
+then tries the hubs heard on the LAN in that network right after the
+ones it already follows, before the directory, the hub list and the
+snapshot (`known_hubs`), so a hub started `--lan --join default` on a
+LAN with another such hub is in the network with it, with nothing on
+the internet asked. A beacon is a hint like a directory entry: anyone on
+the LAN can send one, nothing is routed because of it, and the follow it
+leads to is checked and signed like any other. It carries no secret and
+names only public networks. A hub announces only an id others can reach
+(`--advertise http://<LAN address>:PORT`; never `127.0.0.1` or
+`0.0.0.0`); without one it still asks and listens, and can join a hub
+it hears. In Docker, multicast needs `network_mode: host`. Standard
+library only: `src/pyrtan/lan.py`.
+
**What an entry is not.** A hub two follows away cannot verify who made an entry: there are no public keys in the standard library, and HMAC proves things only between two hubs that share a secret. So an entry is
@@ -286,8 +311,12 @@ naming its networks. The `directory` blob a hub's keepers hold is the
public part only. `pyrtan network leave` unfollows the hubs followed in the network, drops those that followed in it and forgets its entries.
-**The default network.** pyrtan.com publishes `rendezvous/network.json`:
-the name `pyrtan` and a secret that is public on purpose. A secret on a
+**The default network** is built in (v0.15): the name `pyrtan` and a
+secret that is public on purpose, so joining it reads nothing from
+anyone. pyrtan.com publishes the same two in `rendezvous/network.json`
+as a mirror, which a test holds equal to the built-in pair;
+`$PYRTAN_NETWORK` names another (a URL or a file of that shape), which
+is how the tests keep away from it. A secret on a
public page guards nothing, and is not meant to: it names the network, so the public directory is one network among others and a private one never mixes into it. What makes a stranger's hub harmless is what always diff --git a/_meta/identity.md b/_meta/identity.md index cc073f3..d859e83 100644 --- a/_meta/identity.md +++ b/_meta/identity.md
@@ -2,10 +2,10 @@
| | | |---|---|
-| **Status** | **Built, v0.14 and v0.15, in four steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key; and, in v0.15, all of it from the console with no shell, a root's encrypted backup (§5a), and names any network runs in any git repository (§6a). What is left is in §10, *Later*. |
+| **Status** | **Built, v0.14 and v0.17, in four steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key; and, in v0.17, all of it from the console with no shell, a root's encrypted backup (§5a), and names any network runs in any git repository (§6a). What is left is in §10, *Later*. |
| **Date** | 2026-10-27 | | **Decision** | A person (or a hub, or a device) is a public key. The identifier is that key, written so that anyone can check a signature from it without asking anybody. Domains, hubs and onion services are where an identity can be *reached* and what it can be *called*, never what it *is*: `pyr:<key>` is the identity, `pyr:<key>@host` says where to find it, and `alice@pyrtan.com` is a name a domain vouches for. pyrtan.com hands out names by pull request, as one name provider among any number. An identity grants nothing by itself: each hub keeps its own list of who may do what. |
-| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/names.py` (resolving, pins, `/.well-known/pyrtan/`, pyrtan.com's gate), `pyrtan id` in `src/pyrtan/cli.py`, `site/names/`, `site/gate.py`, `tests/test_identity.py`, `tests/test_names.py`. `src/pyrtan/grants.py` (who may do what on a hub), `src/pyrtan/hub.py`, `federation.py` and `service.py` (signed requests, `/allow`, `/identity`, members as identities), `src/pyrtan/js/identity.js` and `page.py` (the console's key), `tests/test_grants.py`, `tests/conformance/identity.json` and `identity.cjs`, `tests/browser/console_identity.cjs`; since v0.15, the console's Identity and Access tabs, `pyrtan id backup|restore|release`, `pyrtan names init|gate`, `pyrtan hub --names`, `tests/test_console_identity.py` and `tests/browser/console_gui.cjs`. |
+| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/names.py` (resolving, pins, `/.well-known/pyrtan/`, pyrtan.com's gate), `pyrtan id` in `src/pyrtan/cli.py`, `site/names/`, `site/gate.py`, `tests/test_identity.py`, `tests/test_names.py`. `src/pyrtan/grants.py` (who may do what on a hub), `src/pyrtan/hub.py`, `federation.py` and `service.py` (signed requests, `/allow`, `/identity`, members as identities), `src/pyrtan/js/identity.js` and `page.py` (the console's key), `tests/test_grants.py`, `tests/conformance/identity.json` and `identity.cjs`, `tests/browser/console_identity.cjs`; since v0.17, the console's Identity and Access tabs, `pyrtan id backup|restore|release`, `pyrtan names init|gate`, `pyrtan hub --names`, `tests/test_console_identity.py` and `tests/browser/console_gui.cjs`. |
## 0. The brief
@@ -199,7 +199,7 @@ shell. An unpaired key signs as itself, so a hub can also grant the key
directly. A QR code of the code and of the note, and picking the note up from the root's hub instead of pasting it, are later (§10).
-## 5a. A root in a browser, and its backup (v0.15)
+## 5a. A root in a browser, and its backup (v0.17)
The root was meant to live somewhere quiet: a file on one machine, or offline. That asks for a shell, and a shell turns people away. So the
@@ -326,7 +326,7 @@ wellknown DIR --domain D --out SITE` builds them from a directory of
claims; nothing about them is pyrtan.com's business. An operator can serve names from their own hub. An onion service can serve names to anyone who fetches over Tor.
-## 6a. Any network's names, in any git repository (v0.15)
+## 6a. Any network's names, in any git repository (v0.17)
pyrtan.com is one name provider. A network can be another: its names are a git repository on any host, served by any static host or by its own
@@ -556,7 +556,7 @@ hub that checks Alice with a shared secret can also forge Alice.
the console in Chromium end to end (a key, a note, a reload, an answer with no token, a trusted member). This is the step that replaces handing out the token.
-- **Fourth, built (v0.15): no shell, backups, any network's names.**
+- **Fourth, built (v0.17): no shell, backups, any network's names.**
`identity.py` seals and opens backups, and signs releases and handovers; `names.py` judges names owned by their keys, writes a names repository for GitHub, GitLab or anything, and a hub serves one; diff --git a/_meta/tree-format.md b/_meta/tree-format.md index 35b714a..fbf9aa3 100644 --- a/_meta/tree-format.md +++ b/_meta/tree-format.md
@@ -327,7 +327,8 @@ service was started with, for tokens). Paths take `.key`, `[0]`, `[-1]`,
`["key"]`. Filters: `url`, `json`, `int`, `float`, `str`, `lower`, `upper`, `strip`,
-`title`, `first`, `last`, `length`.
+`title`, `first`, `last`, `length`, `join` (a list as `a, b, c`, for a
+sentence).
A string that is exactly one placeholder keeps the value's type (a list stays a list). A placeholder that resolves to nothing fails the node, except diff --git a/deploy/README.md b/deploy/README.md index 73b5956..ce17c71 100644 --- a/deploy/README.md +++ b/deploy/README.md
@@ -70,10 +70,16 @@ 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 hub in it
-that answers (from pyrtan.com's hub list, then its snapshot),
+the default network (built in), follows the first hub in it that
+accepts (one it already knows, else 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
+follow URL --at <your hub> --token …` follows any other. With other hubs
+in the same house (a second NAS, a laptop running `pyrtan hub`), set
+`PYRTAN_LAN: "true"` too, with `network_mode: host` on the hub service
+and `PYRTAN_ADVERTISE` at the box's LAN address: the hubs find each
+other by multicast, and join through each other before asking
+pyrtan.com anything. For a network
of your own, `pyrtan network new home --at <your hub> --token …` prints an invite; a friend's hub joins with `pyrtan join <invite>`, or by adding it to its `PYRTAN_JOIN`. Following is one-directional trust: your diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index 6e502cc..007a065 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml
@@ -35,9 +35,12 @@ x-pyrtan: &pyrtan
PYRTAN_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 hub in it, and lists
+ # default network (built into pyrtan), 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_LAN: "true" finds the other hubs in the house by multicast,
+ # with nobody to ask; it needs `network_mode: host` on the hub and
+ # PYRTAN_ADVERTISE set to the box's LAN address (http://<nas-ip>:8700).
# PYRTAN_JOIN may also hold invites to networks of your own # (NAME:SECRET@HUB, from `pyrtan network new`), space-separated. # PYRTAN_ADVERTISE: "https://hub.example.net" diff --git a/examples/air-quality/air-quality.toml b/examples/air-quality/air-quality.toml new file mode 100644 index 0000000..571f3f3 --- /dev/null +++ b/examples/air-quality/air-quality.toml
@@ -0,0 +1,108 @@
+# The air where a place is, now: the European Air Quality Index and the
+# fine particles behind it, from Open-Meteo. The index's own bands name it.
+
+name = "air-quality"
+version = 1
+namespace = "pyrtan"
+description = "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo."
+tags = ["weather", "health", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "air-quality-api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "[what's the] air quality (in|for) {place}"
+[[triggers]]
+pattern = "how is the air in {place}"
+[[triggers]]
+pattern = "(aqi|pollution) in {place}"
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "air" },
+ { else = "unknown-place" },
+]
+
+[nodes.air]
+kind = "observe"
+http = { method = "GET", url = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}&current=european_aqi,pm2_5,pm10" }
+into = "aq"
+extract = [
+ { into = "aqi", json = "json.current.european_aqi" },
+ { into = "pm25", json = "json.current.pm2_5" },
+ { into = "pm10", json = "json.current.pm10" },
+]
+next = "band"
+
+# The bands of the European Air Quality Index.
+[nodes.band]
+kind = "decide"
+on = "aqi"
+branches = [
+ { when = { type = "number", lte = 20 }, then = "good" },
+ { when = { lte = 40 }, then = "fair" },
+ { when = { lte = 60 }, then = "moderate" },
+ { when = { lte = 80 }, then = "poor" },
+ { when = { lte = 100 }, then = "very-poor" },
+ { when = { gt = 100 }, then = "extremely-poor" },
+ { else = "no-reading" },
+]
+
+[nodes.good]
+kind = "set"
+set = { band = "good" }
+next = "reply"
+[nodes.fair]
+kind = "set"
+set = { band = "fair" }
+next = "reply"
+[nodes.moderate]
+kind = "set"
+set = { band = "moderate" }
+next = "reply"
+[nodes.poor]
+kind = "set"
+set = { band = "poor" }
+next = "reply"
+[nodes.very-poor]
+kind = "set"
+set = { band = "very poor" }
+next = "reply"
+[nodes.extremely-poor]
+kind = "set"
+set = { band = "extremely poor" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}: air quality {{ state.band }} (European AQI {{ state.aqi }}), PM2.5 {{ state.pm25 }} µg/m³, PM10 {{ state.pm10 }} µg/m³."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", aqi = "{{ state.aqi }}", band = "{{ state.band }}", pm2_5 = "{{ state.pm25 }}", pm10 = "{{ state.pm10 }}" }
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[nodes.no-reading]
+kind = "fail"
+reason = "no-reading"
+say = "Open-Meteo found {{ state.name }} but gave no air quality reading (status {{ state.aq.status }})."
diff --git a/examples/books/books.toml b/examples/books/books.toml new file mode 100644 index 0000000..93a1f6f --- /dev/null +++ b/examples/books/books.toml
@@ -0,0 +1,66 @@
+# A book by its title or its author, from Open Library's search: the best
+# match, who wrote it and when it first came out.
+
+name = "books"
+version = 1
+namespace = "pyrtan"
+description = "A book from Open Library: the best match for a title or an author, who wrote it, and when it first came out."
+tags = ["reference", "books", "read-only"]
+license = "MIT"
+hosts = ["openlibrary.org"]
+start = "search"
+
+[[triggers]]
+pattern = "who wrote {title}"
+[[triggers]]
+pattern = "find the book {title}"
+[[triggers]]
+pattern = "books by {title}"
+
+[inputs.title]
+type = "str"
+description = "A title or an author, as in: The Left Hand of Darkness"
+
+[nodes.search]
+kind = "observe"
+http = { method = "GET", url = "https://openlibrary.org/search.json?q={{ inputs.title | url }}&limit=1&fields=title,author_name,first_publish_year,key" }
+into = "resp"
+extract = [
+ { into = "found", json = "json.numFound" },
+ { into = "book", json = "json.docs[0].title" },
+ { into = "author", json = "json.docs[0].author_name[0]" },
+ { into = "year", json = "json.docs[0].first_publish_year" },
+ { into = "key", json = "json.docs[0].key" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "book"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "no-book" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.book }}, by {{ state.author }} ({{ state.year }}). https://openlibrary.org{{ state.key }}"
+output = { title = "{{ state.book }}", author = "{{ state.author }}", first_published = "{{ state.year }}", url = "https://openlibrary.org{{ state.key }}", matches = "{{ state.found }}" }
+
+[nodes.no-book]
+kind = "fail"
+reason = "no-book"
+say = "Open Library has nothing matching {{ inputs.title }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Open Library answered {{ state.resp.status }}."
diff --git a/examples/dad-joke/dad-joke.toml b/examples/dad-joke/dad-joke.toml new file mode 100644 index 0000000..64c6910 --- /dev/null +++ b/examples/dad-joke/dad-joke.toml
@@ -0,0 +1,54 @@
+# A dad joke, from icanhazdadjoke.com. It answers JSON only when asked for
+# JSON alone, so this tree says so in its Accept header.
+
+name = "dad-joke"
+version = 1
+namespace = "pyrtan"
+description = "A random dad joke, from icanhazdadjoke.com."
+tags = ["fun", "read-only"]
+license = "MIT"
+hosts = ["icanhazdadjoke.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "tell me a [dad] joke"
+[[triggers]]
+pattern = "[a] dad joke"
+[[triggers]]
+pattern = "make me laugh"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://icanhazdadjoke.com/", headers = { Accept = "application/json" } }
+into = "resp"
+extract = [
+ { into = "joke", json = "json.joke" },
+ { into = "id", json = "json.id" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-joke" },
+ { else = "unexpected" },
+]
+
+[nodes.has-joke]
+kind = "decide"
+on = "joke"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.joke }}"
+output = { joke = "{{ state.joke }}", url = "https://icanhazdadjoke.com/j/{{ state.id }}" }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "icanhazdadjoke.com answered {{ state.resp.status }}."
diff --git a/examples/dictionary/define.toml b/examples/dictionary/define.toml new file mode 100644 index 0000000..2ea6f99 --- /dev/null +++ b/examples/dictionary/define.toml
@@ -0,0 +1,66 @@
+# The first definition of an English word, from the Free Dictionary API.
+
+name = "define"
+version = 1
+namespace = "pyrtan"
+description = "What an English word means: its first definition, part of speech and pronunciation."
+tags = ["reference", "language", "read-only"]
+license = "MIT"
+hosts = ["api.dictionaryapi.dev"]
+start = "fetch"
+
+[[triggers]]
+pattern = "define {word:word}"
+[[triggers]]
+pattern = "what does {word:word} mean"
+[[triggers]]
+pattern = "[the] (meaning|definition) of {word:word}"
+
+[inputs.word]
+type = "str"
+pattern = "^[A-Za-z][A-Za-z'\\-]*$"
+description = "One English word"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.dictionaryapi.dev/api/v2/entries/en/{{ inputs.word | lower | url }}" }
+into = "resp"
+extract = [
+ { into = "word", json = "json[0].word" },
+ { into = "phonetic", json = "json[0].phonetic" },
+ { into = "part", json = "json[0].meanings[0].partOfSpeech" },
+ { into = "definition", json = "json[0].meanings[0].definitions[0].definition" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-definition" },
+ { when = { eq = 404 }, then = "unknown-word" },
+ { else = "unexpected" },
+]
+
+[nodes.has-definition]
+kind = "decide"
+on = "definition"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unknown-word" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.word }} ({{ state.part }}): {{ state.definition }}"
+output = { word = "{{ state.word }}", phonetic = "{{ state.phonetic }}", part_of_speech = "{{ state.part }}", definition = "{{ state.definition }}" }
+
+[nodes.unknown-word]
+kind = "fail"
+reason = "unknown-word"
+say = "The dictionary has no entry for {{ inputs.word }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The dictionary answered {{ state.resp.status }}."
diff --git a/examples/earthquakes/earthquakes.toml b/examples/earthquakes/earthquakes.toml new file mode 100644 index 0000000..ce4a1ec --- /dev/null +++ b/examples/earthquakes/earthquakes.toml
@@ -0,0 +1,63 @@
+# Earthquakes of magnitude 4.5 and up in the past day, from the USGS feed:
+# how many, and the most recent one. The feed lists the newest first.
+
+name = "earthquakes"
+version = 1
+namespace = "pyrtan"
+description = "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest."
+tags = ["science", "read-only"]
+license = "MIT"
+hosts = ["earthquake.usgs.gov"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[any] [recent] earthquakes [today]"
+[[triggers]]
+pattern = "[was there an] earthquake today"
+[[triggers]]
+pattern = "latest earthquake"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson" }
+into = "resp"
+extract = [
+ { into = "count", json = "json.metadata.count" },
+ { into = "mag", json = "json.features[0].properties.mag" },
+ { into = "place", json = "json.features[0].properties.place" },
+ { into = "tsunami", json = "json.features[0].properties.tsunami" },
+ { into = "url", json = "json.features[0].properties.url" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "count"
+branches = [
+ { when = { eq = 0 }, then = "none" },
+ { when = { gt = 0 }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.count }} earthquake(s) of magnitude 4.5+ in the past day. The latest: M{{ state.mag }}, {{ state.place }}. {{ state.url }}"
+output = { count = "{{ state.count }}", latest = { magnitude = "{{ state.mag }}", place = "{{ state.place }}", tsunami = "{{ state.tsunami }}", url = "{{ state.url }}" } }
+
+[nodes.none]
+kind = "done"
+say = "No earthquakes of magnitude 4.5+ anywhere in the past day."
+output = { count = 0 }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The USGS feed answered {{ state.resp.status }}."
diff --git a/examples/exchange-rate/exchange-rate.toml b/examples/exchange-rate/exchange-rate.toml new file mode 100644 index 0000000..1055216 --- /dev/null +++ b/examples/exchange-rate/exchange-rate.toml
@@ -0,0 +1,87 @@
+# Converting an amount between currencies at the European Central Bank's
+# reference rate, from Frankfurter. Asked for one currency, the API answers
+# with a table of one, so the value is read from it by its shape rather
+# than by the currency's name.
+
+name = "exchange-rate"
+version = 1
+namespace = "pyrtan"
+description = "An amount in another currency at today's European Central Bank reference rate, from Frankfurter."
+tags = ["money", "read-only"]
+license = "MIT"
+hosts = ["api.frankfurter.dev"]
+start = "fetch"
+
+[[triggers]]
+pattern = "convert {amount:number} {from:word} (to|into|in) {to:word}"
+[[triggers]]
+pattern = "how much is {amount:number} {from:word} in {to:word}"
+[[triggers]]
+pattern = "[the] exchange rate (from|for) {from:word} to {to:word}"
+[[triggers]]
+pattern = "{from:word} to {to:word} (rate|exchange rate)"
+
+# Kept as the digits read from the sentence, so 100 reads as 100, not 100.0.
+[inputs.amount]
+type = "str"
+default = "1"
+pattern = "^[0-9]+(\\.[0-9]+)?$"
+description = "How much of the first currency, as in: 100 or 2.5"
+
+[inputs.from]
+type = "str"
+pattern = "^[A-Za-z]{3}$"
+description = "A three-letter currency code, as in: USD"
+
+[inputs.to]
+type = "str"
+pattern = "^[A-Za-z]{3}$"
+description = "A three-letter currency code, as in: EUR"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}" }
+into = "resp"
+extract = [
+ { into = "value", regex = "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}" },
+ { into = "day", json = "json.date" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-rate" },
+ { when = { eq = 404 }, then = "unknown-currency" },
+ { when = { eq = 422 }, then = "same-currency" },
+ { else = "unexpected" },
+]
+
+[nodes.has-rate]
+kind = "decide"
+on = "value"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."
+output = { amount = "{{ inputs.amount | float }}", from = "{{ inputs.from | upper }}", to = "{{ inputs.to | upper }}", value = "{{ state.value | float }}", date = "{{ state.day }}" }
+
+[nodes.unknown-currency]
+kind = "fail"
+reason = "unknown-currency"
+say = "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."
+
+[nodes.same-currency]
+kind = "fail"
+reason = "same-currency"
+say = "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Frankfurter answered {{ state.resp.status }}."
diff --git a/examples/hn-search/hn-search.toml b/examples/hn-search/hn-search.toml new file mode 100644 index 0000000..91b39e7 --- /dev/null +++ b/examples/hn-search/hn-search.toml
@@ -0,0 +1,77 @@
+# Hacker News stories matching some words, through Algolia's HN Search
+# API: how many match, and the three best by its ranking.
+
+name = "hn-search"
+version = 1
+namespace = "pyrtan"
+description = "Hacker News stories about something, through HN Search: the top three matches."
+tags = ["news", "tech", "read-only"]
+license = "MIT"
+hosts = ["hn.algolia.com"]
+start = "search"
+
+[[triggers]]
+pattern = "search hacker news for {query}"
+[[triggers]]
+pattern = "hacker news stories about {query}"
+[[triggers]]
+pattern = "what does hacker news say about {query}"
+
+[inputs.query]
+type = "str"
+description = "Words to search story titles and text for"
+
+[nodes.search]
+kind = "observe"
+http = { method = "GET", url = "https://hn.algolia.com/api/v1/search?query={{ inputs.query | url }}&tags=story&hitsPerPage=3" }
+into = "resp"
+extract = [
+ { into = "total", json = "json.nbHits" },
+ { into = "first", json = "json.hits[0].title" },
+ { into = "first_url", json = "json.hits[0].url" },
+ { into = "hits", json = "json.hits" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "hits"
+branches = [
+ { when = { empty = true }, then = "nothing" },
+ { else = "each" },
+]
+
+[nodes.each]
+kind = "each"
+over = "hits"
+limit = 3
+start = "one"
+into = "stories"
+next = "reply"
+
+[nodes.one]
+kind = "done"
+output = { title = "{{ item.title }}", url = "{{ item.url }}", points = "{{ item.points }}", by = "{{ item.author }}", discussion = "https://news.ycombinator.com/item?id={{ item.objectID }}" }
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.total }} Hacker News stories match {{ inputs.query }}. Top: {{ state.first }} {{ state.first_url }}"
+output = { query = "{{ inputs.query }}", total = "{{ state.total }}", stories = "{{ state.stories }}" }
+
+[nodes.nothing]
+kind = "done"
+say = "No Hacker News stories match {{ inputs.query }}."
+output = { query = "{{ inputs.query }}", total = 0, stories = [] }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "HN Search answered {{ state.resp.status }}."
diff --git a/examples/holidays/holidays.toml b/examples/holidays/holidays.toml new file mode 100644 index 0000000..51614aa --- /dev/null +++ b/examples/holidays/holidays.toml
@@ -0,0 +1,65 @@
+# The next public holiday in a country, from Nager.Date. The country is its
+# two-letter ISO code; the API answers an unknown one with an error status.
+
+name = "holidays"
+version = 1
+namespace = "pyrtan"
+description = "The next public holiday in a country (by its two-letter code), from Nager.Date."
+tags = ["calendar", "read-only"]
+license = "MIT"
+hosts = ["date.nager.at"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[when is the] next [public|bank] holiday in {country:word}"
+[[triggers]]
+pattern = "[public|bank] holidays in {country:word}"
+
+[inputs.country]
+type = "str"
+pattern = "^[A-Za-z]{2}$"
+description = "A two-letter country code, as in: NO, US, DE"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://date.nager.at/api/v3/NextPublicHolidays/{{ inputs.country | upper }}" }
+into = "resp"
+extract = [
+ { into = "date", json = "json[0].date" },
+ { into = "name", json = "json[0].name" },
+ { into = "local", json = "json[0].localName" },
+ { into = "then", json = "json[1].name" },
+ { into = "then_date", json = "json[1].date" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unknown-country" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "date"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "none-listed" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "The next public holiday in {{ inputs.country | upper }} is {{ state.name }} ({{ state.local }}) on {{ state.date }}."
+output = { country = "{{ inputs.country | upper }}", date = "{{ state.date }}", name = "{{ state.name }}", local_name = "{{ state.local }}", after = { date = "{{ state.then_date }}", name = "{{ state.then }}" } }
+
+[nodes.unknown-country]
+kind = "fail"
+reason = "unknown-country"
+say = "Nager.Date knows no country {{ inputs.country | upper }} (it answered {{ state.resp.status }}); use a two-letter code such as NO, US or DE."
+
+[nodes.none-listed]
+kind = "fail"
+reason = "none-listed"
+say = "Nager.Date lists no coming public holidays for {{ inputs.country | upper }}."
diff --git a/examples/iss/iss.toml b/examples/iss/iss.toml new file mode 100644 index 0000000..bafdf13 --- /dev/null +++ b/examples/iss/iss.toml
@@ -0,0 +1,82 @@
+# Where the International Space Station is now, from wheretheiss.at
+# (25544 is its NORAD catalog number).
+
+name = "iss"
+version = 1
+namespace = "pyrtan"
+description = "Where the International Space Station is right now: latitude, longitude, altitude and speed."
+tags = ["science", "space", "read-only"]
+license = "MIT"
+hosts = ["api.wheretheiss.at"]
+start = "fetch"
+
+[[triggers]]
+pattern = "where is the (iss|space station) [now]"
+[[triggers]]
+pattern = "(iss|space station) (position|location)"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.wheretheiss.at/v1/satellites/25544" }
+into = "resp"
+# The API gives a dozen decimals; the words keep two (for a position, about a kilometre), the output all of them.
+extract = [
+ { into = "lat", json = "json.latitude" },
+ { into = "lon", json = "json.longitude" },
+ { into = "alt", json = "json.altitude" },
+ { into = "speed", json = "json.velocity" },
+ { into = "light", json = "json.visibility" },
+ { into = "lat2", regex = "\"latitude\":\\s*(-?\\d+(?:\\.\\d{1,2})?)" },
+ { into = "lon2", regex = "\"longitude\":\\s*(-?\\d+(?:\\.\\d{1,2})?)" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "have-position" },
+ { when = { eq = 429 }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.have-position]
+kind = "decide"
+on = "lat"
+branches = [
+ { when = { type = "number" }, then = "light" },
+ { else = "unexpected" },
+]
+
+[nodes.light]
+kind = "decide"
+on = "light"
+branches = [
+ { when = { eq = "daylight" }, then = "in-sun" },
+ { when = { eq = "eclipsed" }, then = "in-shadow" },
+ { else = "in-sun" },
+]
+
+[nodes.in-sun]
+kind = "set"
+set = { where = "in sunlight" }
+next = "reply"
+[nodes.in-shadow]
+kind = "set"
+set = { where = "in the Earth's shadow" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "The ISS is over {{ state.lat2 }}, {{ state.lon2 }}, {{ state.alt | int }} km up, moving at {{ state.speed | int }} km/h, {{ state.where }}."
+output = { latitude = "{{ state.lat }}", longitude = "{{ state.lon }}", altitude_km = "{{ state.alt }}", speed_kmh = "{{ state.speed }}", visibility = "{{ state.light }}", map = "https://www.openstreetmap.org/?mlat={{ state.lat }}&mlon={{ state.lon }}&zoom=3" }
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "wheretheiss.at is asking for fewer requests (429); try again in a moment."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "wheretheiss.at answered {{ state.resp.status }}."
diff --git a/examples/packages/npm-package.toml b/examples/packages/npm-package.toml new file mode 100644 index 0000000..f046d7c --- /dev/null +++ b/examples/packages/npm-package.toml
@@ -0,0 +1,57 @@
+# The latest published version of an npm package, from the registry.
+# Scoped names (@scope/name) are one path segment, so the slash is encoded.
+
+name = "npm-package"
+version = 1
+namespace = "pyrtan"
+description = "The latest version of an npm package, and what it says it is."
+tags = ["software", "javascript", "read-only"]
+license = "MIT"
+hosts = ["registry.npmjs.org"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest version of {package:word} on npm"
+[[triggers]]
+pattern = "latest {package:word} on npm"
+[[triggers]]
+pattern = "npm (package|version) {package:word}"
+
+[inputs.package]
+type = "str"
+pattern = "^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$"
+description = "A package name, as in: react or @types/node"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://registry.npmjs.org/{{ inputs.package | url }}/latest" }
+into = "resp"
+extract = [
+ { into = "version", json = "json.version" },
+ { into = "about", json = "json.description" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "no-package" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.package }} {{ state.version }} on npm: {{ state.about }} https://www.npmjs.com/package/{{ inputs.package }}"
+output = { package = "{{ inputs.package }}", version = "{{ state.version }}", description = "{{ state.about }}", url = "https://www.npmjs.com/package/{{ inputs.package }}" }
+
+[nodes.no-package]
+kind = "fail"
+reason = "no-package"
+say = "npm has no package called {{ inputs.package }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The npm registry answered {{ state.resp.status }}."
diff --git a/examples/packages/pypi-package.toml b/examples/packages/pypi-package.toml new file mode 100644 index 0000000..9810c7d --- /dev/null +++ b/examples/packages/pypi-package.toml
@@ -0,0 +1,58 @@
+# The latest released version of a Python package, from PyPI's JSON API.
+
+name = "pypi-package"
+version = 1
+namespace = "pyrtan"
+description = "The latest version of a Python package on PyPI, and what it says it is."
+tags = ["software", "python", "read-only"]
+license = "MIT"
+hosts = ["pypi.org"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest version of {package:word} on pypi"
+[[triggers]]
+pattern = "latest {package:word} on pypi"
+[[triggers]]
+pattern = "(pypi|pip) (package|version) {package:word}"
+
+[inputs.package]
+type = "str"
+pattern = "^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$"
+description = "A project name, as in: requests"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://pypi.org/pypi/{{ inputs.package | url }}/json" }
+into = "resp"
+extract = [
+ { into = "name", json = "json.info.name" },
+ { into = "version", json = "json.info.version" },
+ { into = "about", json = "json.info.summary" },
+ { into = "url", json = "json.info.package_url" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "no-package" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }} {{ state.version }} on PyPI: {{ state.about }} {{ state.url }}"
+output = { package = "{{ state.name }}", version = "{{ state.version }}", summary = "{{ state.about }}", url = "{{ state.url }}" }
+
+[nodes.no-package]
+kind = "fail"
+reason = "no-package"
+say = "PyPI has no project called {{ inputs.package }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "PyPI answered {{ state.resp.status }}."
diff --git a/examples/sun/sun.toml b/examples/sun/sun.toml new file mode 100644 index 0000000..64aabed --- /dev/null +++ b/examples/sun/sun.toml
@@ -0,0 +1,80 @@
+# Today's sunrise and sunset for a place, in the place's own time, from
+# Open-Meteo: geocode the name, then ask for the day's sun times with the
+# place's time zone.
+
+name = "sun"
+version = 1
+namespace = "pyrtan"
+description = "Today's sunrise and sunset for a place, in its local time, from Open-Meteo."
+tags = ["weather", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "[when is] (sunrise|sunset) in {place}"
+[[triggers]]
+pattern = "when does the sun (rise|set) in {place}"
+[[triggers]]
+pattern = "(sunrise|sun) times (in|for) {place}"
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "times" },
+ { else = "unknown-place" },
+]
+
+[nodes.times]
+kind = "observe"
+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&daily=sunrise,sunset&timezone=auto&forecast_days=1" }
+into = "fc"
+extract = [
+ { into = "day", json = "json.daily.time[0]" },
+ { into = "rise", regex = "T(\\d\\d:\\d\\d)", from = "json.daily.sunrise[0]" },
+ { into = "set", regex = "T(\\d\\d:\\d\\d)", from = "json.daily.sunset[0]" },
+ { into = "zone", json = "json.timezone" },
+]
+next = "have-times"
+
+# Above the polar circles the sun may not rise or set at all that day.
+[nodes.have-times]
+kind = "decide"
+on = "rise"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "no-times" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}, {{ state.day }}: sunrise {{ state.rise }}, sunset {{ state.set }} ({{ state.zone }})."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", date = "{{ state.day }}", sunrise = "{{ state.rise }}", sunset = "{{ state.set }}", timezone = "{{ state.zone }}" }
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[nodes.no-times]
+kind = "fail"
+reason = "no-times"
+say = "Open-Meteo gave no sunrise for {{ state.name }} today (status {{ state.fc.status }}); near the poles the sun may not rise or set at all."
diff --git a/examples/synonyms/synonyms.toml b/examples/synonyms/synonyms.toml new file mode 100644 index 0000000..5bbde12 --- /dev/null +++ b/examples/synonyms/synonyms.toml
@@ -0,0 +1,62 @@
+# Words that mean the same, from Datamuse, best first.
+
+name = "synonyms"
+version = 1
+namespace = "pyrtan"
+description = "Synonyms for an English word, best first, from Datamuse."
+tags = ["reference", "language", "read-only"]
+license = "MIT"
+hosts = ["api.datamuse.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "synonyms (for|of) {word:word}"
+[[triggers]]
+pattern = "(another|other) word for {word:word}"
+[[triggers]]
+pattern = "what is another word for {word:word}"
+
+[inputs.word]
+type = "str"
+pattern = "^[A-Za-z][A-Za-z'\\-]*$"
+description = "One English word"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.datamuse.com/words?rel_syn={{ inputs.word | lower | url }}&max=8" }
+into = "resp"
+extract = [
+ { into = "words", regex = "\"word\":\\s*\"([^\"]+)\"", all = true },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "words"
+branches = [
+ { when = { empty = true }, then = "none" },
+ { else = "reply" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "Synonyms for {{ inputs.word }}: {{ state.words | join }}."
+output = { word = "{{ inputs.word }}", synonyms = "{{ state.words }}" }
+
+[nodes.none]
+kind = "fail"
+reason = "no-synonyms"
+say = "Datamuse knows no synonyms for {{ inputs.word }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Datamuse answered {{ state.resp.status }}."
diff --git a/examples/wikipedia/wikipedia.toml b/examples/wikipedia/wikipedia.toml new file mode 100644 index 0000000..27fdb53 --- /dev/null +++ b/examples/wikipedia/wikipedia.toml
@@ -0,0 +1,79 @@
+# The opening paragraph of a Wikipedia article, from its REST API. A title
+# that leads to several articles is a disambiguation page: the tree says so
+# rather than reading out a list of meanings.
+
+name = "wikipedia"
+version = 1
+namespace = "pyrtan"
+description = "The summary of a Wikipedia article."
+tags = ["reference", "read-only"]
+license = "MIT"
+hosts = ["en.wikipedia.org"]
+start = "fetch"
+
+[[triggers]]
+pattern = "tell me about {topic}"
+[[triggers]]
+pattern = "who (is|was) {topic}"
+[[triggers]]
+pattern = "(wikipedia|wiki) {topic}"
+
+[inputs.topic]
+type = "str"
+description = "An article title, as in: Ursula K. Le Guin"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://en.wikipedia.org/api/rest_v1/page/summary/{{ inputs.topic | url }}" }
+into = "resp"
+extract = [
+ { into = "kind", json = "json.type" },
+ { into = "title", json = "json.title" },
+ { into = "summary", json = "json.extract" },
+ { into = "url", json = "json.content_urls.desktop.page" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "kind" },
+ { when = { eq = 404 }, then = "no-article" },
+ { when = { eq = 429 }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.kind]
+kind = "decide"
+on = "kind"
+branches = [
+ { when = { eq = "disambiguation" }, then = "ambiguous" },
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.title }}: {{ state.summary }} {{ state.url }}"
+output = { title = "{{ state.title }}", summary = "{{ state.summary }}", url = "{{ state.url }}" }
+
+[nodes.ambiguous]
+kind = "fail"
+reason = "ambiguous"
+say = "{{ state.title }} could mean several things on Wikipedia; say which: {{ state.url }}"
+
+[nodes.no-article]
+kind = "fail"
+reason = "no-article"
+say = "Wikipedia has no article called {{ inputs.topic }}."
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "Wikipedia is asking for fewer requests (429); try again in a minute."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Wikipedia answered {{ state.resp.status }}."
diff --git a/pyproject.toml b/pyproject.toml index ead4e43..b506f9e 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project] name = "pyrtan"
-version = "0.14.0"
+version = "0.17.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 9b71d24..ad69523 100644 --- a/site/README.md +++ b/site/README.md
@@ -10,7 +10,7 @@ static directory and publishes it.
| `/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 or as a key the hub grants `run`; its Identity tab makes, backs up and restores an identity, claims names and pairs devices, with no shell | `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/network.json` | the default network: its name, and a secret that is public on purpose; built into pyrtan, published here as a mirror (and for the snapshot) | `site/rendezvous/network.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 hubs carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |
@@ -64,9 +64,11 @@ reports` reads when given no hub.
`site/rendezvous/network.json` is the default network: the name `pyrtan` and a secret that is public on purpose (it names the network,
-it guards nothing; see `_meta/federation.md` §7a). `pyrtan join` with
+it guards nothing; see `_meta/federation.md` §7a). pyrtan has the same
+two values built in, and a test holds them equal; this file is a mirror,
+and what the snapshot keeps the network's part by. `pyrtan join` with
no invite, `pyrtan hub --join default` and `$PYRTAN_JOIN=default` put a
-hub in it.
+hub in it without reading it.
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` diff --git a/site/rendezvous/network.json b/site/rendezvous/network.json index ac2765c..2aaa4a0 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 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.",
+ "about": "The default network, as built into pyrtan; this file mirrors it (and the snapshot keeps the network's part by it). `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first hub that accepts: one it already knows, else one from hubs.json, else from the snapshot. 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/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index b69de05..7f35136 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -33,4 +33,4 @@ __all__ = [
"Response", ]
-__version__ = "0.14.0"
+__version__ = "0.17.0"
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 5369629..ab82606 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -12,6 +12,7 @@
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 hub --chat and keep a lobby: strangers and bots, Omegle-style
+ pyrtan hub --lan --join default and find the hubs on this LAN by multicast, with nobody to ask
pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: join the default network pyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following
@@ -169,7 +170,7 @@ def main(argv: list[str] | None = None) -> int:
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; 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("--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 built into pyrtan, 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)") p.add_argument("--state", metavar="FILE", default=os.environ.get("PYRTAN_STATE"), help="keep who this hub follows and is followed by, and the directory, in this file across restarts (default $PYRTAN_STATE)")
@@ -181,6 +182,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--names", metavar="DIR", default=os.environ.get("PYRTAN_NAMES"), help="serve the name claims in DIR (a checkout of a names repository, `pyrtan names init`) at /.well-known/pyrtan/, as this network's name provider (default $PYRTAN_NAMES)") p.add_argument("--names-domain", metavar="DOMAIN", default=os.environ.get("PYRTAN_NAMES_DOMAIN"), help="the domain the names are for (default $PYRTAN_NAMES_DOMAIN, else this hub's own host); the hub must be reachable at https://DOMAIN") p.add_argument("--grants", metavar="FILE", default=os.environ.get("PYRTAN_GRANTS"), help="keep the grants in this file (default $PYRTAN_GRANTS, else grants.json beside --state, else in memory)")
+ p.add_argument("--lan", action="store_true", default=os.environ.get("PYRTAN_LAN", "").lower() in ("1", "true", "yes"), help="find hubs on this local network with no one to ask: announce this hub over UDP multicast (TTL 1) and try the hubs heard there when joining a network; needs --advertise with a LAN address (default $PYRTAN_LAN)")
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)")
@@ -913,10 +915,10 @@ def cmd_hub(args: argparse.Namespace) -> int:
try: 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,
+ federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed or args.lan, 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, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public,
+ 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, lan=args.lan,
allow=args.allow, grants=args.grants, names=args.names, names_domain=args.names_domain, ) except IdentityError as e:
@@ -930,6 +932,9 @@ def cmd_hub(args: argparse.Namespace) -> int:
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 "")
+ if hub.lan is not None:
+ lan = hub.lan.view()
+ local += f", on the LAN ({lan['group']}): " + (f"cannot join it: {hub.lan.error}" if hub.lan.error else f"heard {len(lan['hubs'])} hub(s)" + ("" if lan["announcing"] else "; listening only, since this hub's id is not a LAN address (give --advertise http://<its LAN address>:PORT)"))
if len(hub.grants): local += f", granting {len(hub.grants)} identit{'y' if len(hub.grants) == 1 else 'ies'}" print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 44554c1..1f1bf74 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py
@@ -60,8 +60,20 @@ it is in that network. The directory heard over a link is kept with the
link's network and passed on only over links in the same one, so a private network's hubs never reach the public directory, and ``GET /directory`` shows them only to the operator. The default network
-(``default_network``, published at pyrtan.com) has a public secret: it
-names the network, it does not guard it, and nothing here needs it to.
+(``default_network``) has a public secret: it names the network, it does
+not guard it, and nothing here needs it to. Its name and secret are
+built in, so joining it asks nobody for them; pyrtan.com publishes the
+same two values as a mirror, not as the authority.
+
+**Joining** the default network tries what the hub already knows before
+anything else: the hubs it follows in it, then the hubs its directory
+has heard in it, most recent first. Only when none of those accepts does
+it read the hub list and then the snapshot, and only as far as it needs.
+So a hub that has been in the network once never needs pyrtan.com again,
+and any hub's public ``/directory`` or any mirror of the list will do as
+a starting point (``$PYRTAN_HUB_LIST``). A hub started ``--lan`` also
+hears the hubs on its own network say they are there (:mod:`pyrtan.lan`)
+and tries those right after the ones it follows: no internet needed.
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
@@ -90,7 +102,7 @@ import uuid
from collections import deque from dataclasses import dataclass, field from pathlib import Path
-from typing import Any, Callable
+from typing import Any, Callable, Iterable
from . import __version__ from .service import http_json
@@ -98,7 +110,10 @@ from .store import BlobStore, StoreError, check_key, digest as blob_digest
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"
+# The default network, built in: public on purpose, so it names the network
+# and guards nothing. site/rendezvous/network.json publishes the same values.
+DEFAULT_NETWORK_NAME = "pyrtan"
+DEFAULT_NETWORK_SECRET = "public-default-network-of-pyrtan-com"
NETWORK_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$") INVITE = re.compile(r"^([a-z0-9][a-z0-9-]{0,39}):([A-Za-z0-9_-]{16,128})(?:@(https?://\S+))?$") SIGNATURE_WINDOW = 300.0 # seconds a signed message stays valid
@@ -128,47 +143,51 @@ SIGNED_PATHS = ("/inbox", "/run", "/intent", "/resume", "/capabilities")
# ---------------------------------------------------------------- the hub 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_HUB_LIST") or DEFAULT_HUB_LIST
+def _read_json(source: str) -> Any:
+ """A URL or a file, as JSON; None when it cannot be had."""
try: if source.startswith(("http://", "https://")): status, body = http_json("GET", source, timeout=10.0)
- if status != 200:
- return []
- else:
- body = json.loads(Path(source).read_text(encoding="utf-8"))
+ return body if status == 200 else None
+ return json.loads(Path(source).read_text(encoding="utf-8"))
except Exception:
- return []
+ return None
+
+
+def hub_urls(body: Any) -> list[str]:
+ """The hubs a list names: ``{"hubs": ["https://…", …]}`` (a hub list)
+ or ``{"hubs": [{"id": "https://…", "seen": …}, …]}`` (a hub's
+ ``/directory``, or a snapshot), the latter most recently heard first."""
if not isinstance(body, dict) or not isinstance(body.get("hubs"), list): return []
- return [h.rstrip("/") for h in body["hubs"] if isinstance(h, str) and h.startswith(("http://", "https://"))]
+ rows = body["hubs"]
+ if all(isinstance(h, dict) for h in rows):
+ rows = sorted(rows, key=lambda h: -float(h.get("seen", 0) or 0))
+ urls = [h if isinstance(h, str) else h.get("id") if isinstance(h, dict) else None for h in rows]
+ return list(dict.fromkeys(u.rstrip("/") for u in urls if isinstance(u, str) and u.startswith(("http://", "https://"))))
+
+
+def rendezvous_hubs() -> list[str]:
+ """Hubs to try first, from ``$PYRTAN_RENDEZVOUS`` (one hub),
+ ``$PYRTAN_HUB_LIST`` (any number of URLs or files, separated by spaces
+ or commas: hub lists, a hub's ``/directory``, a snapshot or a mirror of
+ one, in order), or the hub list at pyrtan.com. Never raises: nothing
+ readable is an empty list."""
+ single = os.environ.get("PYRTAN_RENDEZVOUS")
+ if single:
+ return [single.rstrip("/")]
+ sources = os.environ.get("PYRTAN_HUB_LIST", "").replace(",", " ").split() or [DEFAULT_HUB_LIST]
+ out: list[str] = []
+ for source in sources:
+ out.extend(u for u in hub_urls(_read_json(source)) if u not in out)
+ return out
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 no listed hub answers."""
- source = os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT
- try:
- if source.startswith(("http://", "https://")):
- status, body = http_json("GET", source, timeout=10.0)
- if status != 200:
- return []
- else:
- body = json.loads(Path(source).read_text(encoding="utf-8"))
- except Exception:
- return []
- if not isinstance(body, dict) or not isinstance(body.get("hubs"), list):
- return []
- hubs = [h for h in body["hubs"] if isinstance(h, dict) and isinstance(h.get("id"), str) and h["id"].startswith(("http://", "https://"))]
- hubs.sort(key=lambda h: -float(h.get("seen", 0) or 0))
- return [h["id"].rstrip("/") for h in hubs]
+ return hub_urls(_read_json(os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT))
# ---------------------------------------------------------------- networks
@@ -221,20 +240,14 @@ def parse_invite(text: str) -> tuple[Network, str | None]:
def default_network() -> Network | None:
- """The public network pyrtan.com publishes (``$PYRTAN_NETWORK``
- overrides the URL or names a file). Its secret is public on purpose:
- it names the network rather than guarding it. None when unreadable."""
- source = os.environ.get("PYRTAN_NETWORK") or DEFAULT_NETWORK
- try:
- if source.startswith(("http://", "https://")):
- status, body = http_json("GET", source, timeout=10.0)
- if status != 200:
- return None
- else:
- body = json.loads(Path(source).read_text(encoding="utf-8"))
- except Exception:
- return None
- net = Network.from_state(body)
+ """The default network: built in, so finding it asks nobody. Its secret
+ is public on purpose: it names the network rather than guarding it.
+ ``$PYRTAN_NETWORK`` (a URL or a file shaped like pyrtan.com's
+ ``network.json``) names another; None when that cannot be read."""
+ source = os.environ.get("PYRTAN_NETWORK")
+ if not source:
+ return Network(DEFAULT_NETWORK_NAME, DEFAULT_NETWORK_SECRET, public=True)
+ net = Network.from_state(_read_json(source))
if net is not None: net.public = True return net
@@ -406,6 +419,7 @@ class Federation:
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.lan: Any = None # what the hub hears on the LAN, when started --lan (pyrtan.lan.Lan)
self.ask_timeout = ASK_TIMEOUT self.following: dict[str, Remote] = {} self.followers: dict[str, Remote] = {}
@@ -514,9 +528,22 @@ class Federation:
raise ValueError(f"this hub is already in a network named {name!r}") return self.add_network(Network(name, _secrets.token_urlsafe(24), public))
- def join(self, net: Network, hubs: list[str]) -> Remote | None:
+ def known_hubs(self, network: str) -> list[str]:
+ """The hubs this one already knows in a network (by id), to try
+ before asking anyone: the ones it follows in it, then the ones it
+ heard on the LAN say they are in it (``--lan``), then the ones its
+ directory heard in it, most recently heard first."""
+ now = time.time()
+ with self._lock:
+ followed = [r.url for r in self.following.values() if r.kind == "hub" and r.network == network and r.state == "accepted"]
+ heard = sorted((e for e in self.directory_entries.values() if e.get("network") == network and e.get("kind", "hub") == "hub" and now - e["seen"] < DIRECTORY_TTL), key=lambda e: -e["seen"])
+ nearby = self.lan.hubs(network) if self.lan is not None else []
+ return [u for u in dict.fromkeys(followed + nearby + [e["id"].rstrip("/") for e in heard]) if u != self.id]
+
+ def join(self, net: Network, hubs: Iterable[str]) -> Remote | None:
"""Be in ``net`` and follow the first of ``hubs`` that accepts a
- follow in it. None when none does: the hub is then a network of
+ follow in it (an iterable, so a caller can fetch more only when the
+ first ones fail). None when none does: the hub is then a network of
one, which others can join by following it.""" net = self.add_network(net) for url in hubs: diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 6bbe566..138200e 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py
@@ -63,11 +63,12 @@ import threading
from pathlib import Path import time from dataclasses import dataclass, field
-from typing import Any
+from typing import Any, Iterable, Iterator
from . import __version__ from .chat import Lobby, is_chat_path
-from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_hub, parse_invite, sign
+from .lan import Lan
+from .federation import MAX_HOPS, Federation, Remote, default_network, first_live_hub, parse_invite, rendezvous_hubs, sign, snapshot_hubs
from . import identity from .grants import Grants, ability_for, parse_may from .identity import IdentityError
@@ -157,6 +158,7 @@ class Hub:
self._audience = audience 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 only
+ self.lan: Lan | None = None # hubs heard on the local network (--lan)
self.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
@@ -337,8 +339,18 @@ class Hub:
self._thread = threading.Thread(target=loop, daemon=True) self._thread.start()
+ def on_lan(self) -> Lan:
+ """Announce this hub on the LAN and listen for others (``--lan``)."""
+ fed = self.federation
+ assert fed is not None, "a hub is federated before it goes on the LAN"
+ self.lan = Lan(lambda: {"id": fed.id, "name": self.name, "networks": sorted(n.id for n in fed.networks.values() if n.public)}).start()
+ fed.lan = self.lan
+ return self.lan
+
def stop(self) -> None: self._stop.set()
+ if self.lan is not None:
+ self.lan.stop()
if self.local is not None: self.local.service.stop()
@@ -604,6 +616,8 @@ class Hub:
if fed is not None: if method == "GET" and path == "/actor": return 200, fed.actor()
+ if method == "GET" and path == "/lan":
+ return (200, self.lan.view()) if self.lan is not None else (404, {"status": "error", "reason": "no-lan", "say": "This hub is not on the LAN; start it with --lan."})
if method == "GET" and path == "/directory": # A private network's part only to the operator (or to anyone, on a hub with no token). return 200, fed.directory(private=not self.token or operator)
@@ -736,7 +750,7 @@ class Hub:
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}"]
+ endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/lan", "/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, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "audience": self.audience, "grants": len(self.grants), "chat_bots": self.lobby is not None and self._trusted(headers, may), "endpoints": endpoints} if method == "POST" and path == "/register": url = body.get("url")
@@ -761,13 +775,26 @@ def _rank(peer: Local | Peer | Remote) -> int:
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 listed hub that answers is followed outside any network, as
- before networks."""
+ network (built in) when ``invite`` is empty or ``default``, and follow a
+ hub in it: for the default network, the hubs this one already knows in
+ it first, then the hub list, then the snapshot, each fetched only when
+ the ones before it did not do. When ``$PYRTAN_NETWORK`` names a default
+ network that cannot be read, the first listed hub that answers is
+ followed outside any network, as before networks."""
+ tried: list[str] = []
if invite in (None, "", "default"): net = default_network()
- hubs = bootstrap_candidates()
+ if net is not None:
+ known = net.id
+
+ def candidates() -> Iterator[str]:
+ for source in (lambda: fed.known_hubs(known), rendezvous_hubs, snapshot_hubs):
+ for url in source():
+ if url not in tried and url != fed.id:
+ tried.append(url)
+ yield url
+
+ hubs: Iterable[str] = candidates()
if net is None: url = first_live_hub() if url is None:
@@ -783,12 +810,13 @@ def join_network(fed: Federation, invite: Any) -> tuple[int, dict[str, Any]]:
except ValueError as e: return 400, {"status": "error", "reason": "bad-invite", "say": str(e)} hubs = [url] if url else []
+ tried.extend(h for h in hubs if h.rstrip("/") != fed.id)
try: remote = fed.join(net, hubs) except ValueError as e: return 400, {"status": "error", "reason": "bad-network", "say": str(e)} net = fed.networks[net.name]
- hubs = [h for h in hubs if h.rstrip("/") != fed.id]
+ hubs = tried
if remote is not None: say = f"in {net.name}, following {remote.url}" elif hubs:
@@ -826,6 +854,7 @@ def serve_hub(
report_gaps: bool = False, unanswered: str | Path | None = None, watch: bool = False,
+ lan: bool = False,
chat: bool = False, allow: list[str] | None = None, grants: str | None = None,
@@ -849,7 +878,7 @@ def serve_hub(
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, grants=granted, names=names, names_domain=names_domain) 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, keyed=True).start() hub._audience = (advertise or server.url).rstrip("/")
- if federate or advertise or join:
+ if federate or advertise or join or lan:
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 []: hub.add_peer(url)
@@ -858,8 +887,14 @@ def serve_hub(
hub.federation.follow(url) # type: ignore[union-attr] except ValueError as e: print(f"pyrtan hub: cannot follow {url}: {e}", flush=True)
+ if lan:
+ hub.on_lan()
+ if join and hub.lan is not None and hub.lan.error is None:
+ hub.lan.wait(1.5) # the hubs nearby answer the first question in that time: join one of them
for invite in join or []: _, answer = join_network(hub.federation, invite) # type: ignore[arg-type] print(f"pyrtan hub: join {answer.get('network') or invite.split(':', 1)[0]}: {answer.get('say')}", flush=True)
+ if join and hub.lan is not None:
+ hub.lan.send() # now in its networks: say so at once, not at the next beacon
hub.start_polling() return hub, server diff --git a/src/pyrtan/js/pyrtan.js b/src/pyrtan/js/pyrtan.js index 17683aa..897cf94 100644 --- a/src/pyrtan/js/pyrtan.js +++ b/src/pyrtan/js/pyrtan.js
@@ -122,6 +122,7 @@
return MISSING; } const pyQuote = (s) => encodeURIComponent(String(s)).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
+ const asText = (v) => (Array.isArray(v) || isObj(v) ? pyJson(v) : pyStr(v)); // a value as it reads inside a sentence
const pyStr = (v) => (typeof v === "string" ? v : isBool(v) ? (v ? "True" : "False") : v === null || v === undefined ? "None" : Array.isArray(v) || isObj(v) ? pyrepr(v) : String(v)); const FILTERS = { url: (v) => pyQuote(pyStr(v)),
@@ -135,6 +136,7 @@
first: (v) => ((Array.isArray(v) || typeof v === "string") && v.length ? v[0] : MISSING), last: (v) => ((Array.isArray(v) || typeof v === "string") && v.length ? v[v.length - 1] : MISSING), length: (v) => (Array.isArray(v) || typeof v === "string" ? v.length : isObj(v) ? Object.keys(v).length : MISSING),
+ join: (v) => (Array.isArray(v) ? v.map(asText).join(", ") : asText(v)),
title: (v) => pyStr(v).toLowerCase().replace(/(^|[^a-z0-9'])([a-z])/gi, (m, pre, c) => pre + c.toUpperCase()).replace(/'([a-z])/gi, (m, c) => "'" + c.toUpperCase()), }; function renderValue(expr, scope) {
@@ -159,8 +161,7 @@
return template.replace(PLACEHOLDER, (m, expr) => { const value = renderValue(expr, scope); if (isMissing(value)) { if (strict) throw new TemplateError(`${m} resolves to nothing`); return ""; }
- if (Array.isArray(value) || isObj(value)) return pyJson(value);
- return pyStr(value);
+ return asText(value);
}); } if (Array.isArray(template)) return template.map((t) => render(t, scope, strict));
@@ -587,7 +588,8 @@
} function trim(text) { text = text.replace(/^[^\p{L}\p{N}]+|[\s?!.,;:]+$/gu, "");
- text = text.replace(/\s*[,;!?]+\s*/g, " ");
+ // A comma between digits (1,000) is part of a number and stays.
+ text = text.replace(/\s*[,;!?]+\s*/g, (m, at, s) => (m === "," && /[0-9]/.test(s[at - 1] || "") && /[0-9]/.test(s[at + 1] || "") ? m : " "));
text = text.replace(/(?<=[A-Za-z])\.(?=\s|$)/g, ""); return text.replace(/\s+/gu, " ").trim(); } diff --git a/src/pyrtan/lan.py b/src/pyrtan/lan.py new file mode 100644 index 0000000..c1a4db7 --- /dev/null +++ b/src/pyrtan/lan.py
@@ -0,0 +1,210 @@
+"""The LAN: hubs on the same network find each other, with nobody to ask.
+
+A hub started ``--lan`` says it is here, on a UDP multicast group that
+never leaves the local network (TTL 1): its federation id (the URL other
+hubs reach it at), its name, and the public networks it is in. It says
+so when it starts, with a question that makes every other hub on the LAN
+answer at once, and again every thirty seconds. It keeps what it hears
+for three minutes. Joining a network then tries the hubs it heard on the
+LAN in that network right after the ones it already follows, before its
+directory, the hub list or the snapshot: two boxes in one house find
+each other with the internet unplugged.
+
+A beacon is a hint, exactly as a directory entry is: anyone on the LAN
+can send one, and nothing is routed to a hub because it said it was
+there. Following it is still the one act of trust, and the follow checks
+the hub's ``/actor`` and is signed with the network's secret like any
+other. Beacons carry no secret and no private network: only public
+network ids, so a private network's hubs are not named to the LAN.
+
+A hub announces itself only when its id is an address others can reach:
+not ``127.0.0.1``, ``localhost`` or ``0.0.0.0``. Give it one with
+``--advertise http://<its LAN address>:8700``. In Docker, multicast
+needs ``network_mode: host``.
+
+Standard library only. ``$PYRTAN_LAN_GROUP`` (``ADDRESS:PORT``) moves the
+group, which is how the tests keep to themselves.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import socket
+import struct
+import threading
+import time
+from typing import Any, Callable
+from urllib.parse import urlsplit
+
+GROUP = "239.255.70.84" # administratively scoped: stays on the site
+PORT = 8770
+EVERY = 30.0 # seconds between beacons
+HEARD_FOR = 180.0 # a hub not heard for this long is forgotten
+HEARD_MAX = 64 # hubs kept at most
+ANSWER_EVERY = 2.0 # a question is answered at most this often
+BEACON_MAX = 2048 # bytes
+UNREACHABLE = {"127.0.0.1", "localhost", "0.0.0.0", "::1", "::"}
+
+
+def group() -> tuple[str, int]:
+ """The multicast group, from ``$PYRTAN_LAN_GROUP`` or the default."""
+ text = os.environ.get("PYRTAN_LAN_GROUP", "")
+ host, _, port = text.rpartition(":")
+ try:
+ return (host, int(port)) if host and port else (GROUP, PORT)
+ except ValueError:
+ return GROUP, PORT
+
+
+def reachable(url: str) -> bool:
+ """Whether another box could reach a hub at ``url``."""
+ try:
+ parts = urlsplit(url)
+ except ValueError:
+ return False
+ return parts.scheme in ("http", "https") and bool(parts.hostname) and parts.hostname not in UNREACHABLE and not parts.hostname.startswith("127.")
+
+
+class Lan:
+ """Announce this hub on the LAN and keep what other hubs announce.
+
+ ``me`` returns what to say (``{"id", "name", "networks"}``), or None
+ to stay quiet; it is asked each time, so a hub that joins a network
+ says so in its next beacon."""
+
+ def __init__(self, me: Callable[[], dict[str, Any] | None], *, every: float = EVERY, address: tuple[str, int] | None = None) -> None:
+ self.me = me
+ self.every = every
+ self.address = address or group()
+ self.heard: dict[str, dict[str, Any]] = {}
+ self.error: str | None = None
+ self._answered = 0.0
+ self._lock = threading.Lock()
+ self._stop = threading.Event()
+ self._sock: socket.socket | None = None
+ self._threads: list[threading.Thread] = []
+
+ # ------------------------------------------------------------ what is said
+
+ def beacon(self, *, query: bool = False) -> bytes | None:
+ """What this hub says; None when it has nothing to say. A hub that
+ cannot be reached still asks who is there: a question with no id."""
+ me = self.me()
+ if not me or not reachable(str(me.get("id", ""))):
+ return json.dumps({"pyrtan-lan": 1, "query": True}).encode("utf-8") if query else None
+ body = {"pyrtan-lan": 1, "id": str(me["id"]).rstrip("/"), "name": str(me.get("name", ""))[:80], "networks": [n for n in me.get("networks", []) if isinstance(n, str)][:16]}
+ if query:
+ body["query"] = True
+ data = json.dumps(body, separators=(",", ":")).encode("utf-8")
+ return data if len(data) <= BEACON_MAX else None
+
+ def take(self, data: bytes, sender: str, now: float | None = None) -> bool | None:
+ """A datagram heard from ``sender``: True when it named a hub (and
+ False when it named this one); None when it was not a beacon. A
+ question in it is answered, at most every couple of seconds."""
+ now = time.time() if now is None else now
+ try:
+ body = json.loads(data[:BEACON_MAX].decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ if not isinstance(body, dict) or body.get("pyrtan-lan") != 1:
+ return None
+ me = self.me() or {}
+ hub = body["id"].rstrip("/") if isinstance(body.get("id"), str) else None
+ if hub == str(me.get("id", "")).rstrip("/"):
+ return False
+ if body.get("query") is True and now - self._answered >= ANSWER_EVERY:
+ self._answered = now
+ self.send()
+ if hub is None or not reachable(hub) or len(hub) > 200:
+ return None
+ networks = [n for n in body.get("networks", []) if isinstance(n, str) and len(n) <= 64][:16] if isinstance(body.get("networks"), list) else []
+ with self._lock:
+ self.heard[hub] = {"id": hub, "name": str(body.get("name", ""))[:80], "networks": networks, "from": sender, "seen": now}
+ if len(self.heard) > HEARD_MAX:
+ for old in sorted(self.heard.values(), key=lambda e: e["seen"])[: len(self.heard) - HEARD_MAX]:
+ self.heard.pop(old["id"], None)
+ return True
+
+ def hubs(self, network: str | None = None, now: float | None = None) -> list[str]:
+ """The hubs heard lately, most recent first; ``network`` keeps the
+ ones that said they are in it."""
+ now = time.time() if now is None else now
+ with self._lock:
+ fresh = [e for e in self.heard.values() if now - e["seen"] < HEARD_FOR and (network is None or network in e["networks"])]
+ return [e["id"] for e in sorted(fresh, key=lambda e: -e["seen"])]
+
+ def view(self) -> dict[str, Any]:
+ now = time.time()
+ with self._lock:
+ rows = [{**e, "age_s": int(now - e["seen"])} for e in self.heard.values() if now - e["seen"] < HEARD_FOR]
+ me = self.me() or {}
+ return {"group": f"{self.address[0]}:{self.address[1]}", "announcing": self.beacon() is not None, "id": me.get("id"), "error": self.error, "hubs": sorted(rows, key=lambda e: e["age_s"])}
+
+ # ------------------------------------------------------------ the socket
+
+ def send(self, *, query: bool = False) -> bool:
+ """Say this hub is here (``query``: and ask who else is)."""
+ data = self.beacon(query=query)
+ if data is None or self._sock is None:
+ return False
+ try:
+ self._sock.sendto(data, self.address)
+ return True
+ except OSError as e:
+ self.error = str(e)
+ return False
+
+ def start(self) -> "Lan":
+ """Join the group, ask who is there, and keep listening and
+ announcing in the background. A LAN that will not have it (no
+ multicast route, the port refused) leaves ``error`` set and the
+ hub running without it."""
+ host, port = self.address
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ if hasattr(socket, "SO_REUSEPORT"):
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # several hubs on one box share the port
+ s.bind(("", port))
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, struct.pack("4sl", socket.inet_aton(host), socket.INADDR_ANY))
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1) # never past the first router
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) # hubs on this same box hear it too
+ s.settimeout(1.0)
+ except OSError as e:
+ self.error = f"cannot join {host}:{port}: {e}"
+ return self
+ self._sock = s
+ self._threads = [threading.Thread(target=self._listen, daemon=True), threading.Thread(target=self._announce, daemon=True)]
+ for t in self._threads:
+ t.start()
+ return self
+
+ def wait(self, seconds: float) -> None:
+ """Give the answers to the first question a moment to arrive."""
+ self._stop.wait(seconds)
+
+ def _listen(self) -> None:
+ while not self._stop.is_set() and self._sock is not None:
+ try:
+ data, (sender, _) = self._sock.recvfrom(BEACON_MAX + 1)
+ except socket.timeout:
+ continue
+ except OSError:
+ return
+ self.take(data, sender)
+
+ def _announce(self) -> None:
+ self.send(query=True)
+ while not self._stop.wait(self.every):
+ self.send()
+
+ def stop(self) -> None:
+ self._stop.set()
+ if self._sock is not None:
+ try:
+ self._sock.close()
+ except OSError:
+ pass
+ self._sock = None
diff --git a/src/pyrtan/nlp.py b/src/pyrtan/nlp.py index 34e33f6..a2120b1 100644 --- a/src/pyrtan/nlp.py +++ b/src/pyrtan/nlp.py
@@ -92,6 +92,7 @@ DURATION_UNITS = {
"w": 604800, "wk": 604800, "wks": 604800, "week": 604800, "weeks": 604800, }
+DIGITS = "0123456789"
WORD = re.compile(r"[A-Za-z][A-Za-z'’]*|\d+(?:[.,]\d+)*|\S") HOST_RE = re.compile(r"^(?:https?://)?((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:/\S*)?$", re.IGNORECASE) URL_RE = re.compile(r"^https?://[^\s<>\"']+$", re.IGNORECASE)
@@ -113,13 +114,20 @@ def fold(text: str) -> str:
def trim(text: str) -> str: """Drop punctuation that carries nothing: around the whole sentence, and
- a comma or full stop between words."""
+ a comma or full stop between words. A comma between digits (1,000) is
+ part of a number and stays."""
text = re.sub(r"^[\s\W_]+|[\s?!.,;:]+$", "", text)
- text = re.sub(r"\s*[,;!?]+\s*", " ", text)
+ text = re.sub(r"\s*[,;!?]+\s*", lambda m: m.group(0) if _in_number(m) else " ", text)
text = re.sub(r"(?<=[A-Za-z])\.(?=\s|$)", "", text) return re.sub(r"\s+", " ", text).strip()
+def _in_number(m: re.Match[str]) -> bool:
+ s, i, j = m.string, m.start(), m.end()
+ return m.group(0) == "," and 0 < i and j < len(s) and s[i - 1] in DIGITS and s[j] in DIGITS
+
+
+
def strip_fillers(text: str, keep: frozenset[str] = frozenset()) -> tuple[str, list[str]]: """Greetings, politeness and request wrappers at either edge, repeatedly. A filler whose first word is in ``keep`` stays: a trigger somewhere diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 50e2687..1df1c40 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -96,6 +96,15 @@ PAGE = r"""<!doctype html>
.status { font-weight:600 } .done{color:var(--ok)} .failed,.error{color:var(--bad)} .asked{color:var(--ask)} .say { font-size:18px; margin:6px 0 } pre { margin:8px 0 0; padding:10px 12px; background:var(--bg); border:1px solid var(--line); border-radius:8px; overflow:auto; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap }
+ .suggest { position:relative; flex:1 1 180px; min-width:0 }
+ form.row .suggest input[type=text] { position:relative; width:100%; background:transparent }
+ .suggest .ghost { position:absolute; inset:0; padding:10px 12px; border:1px solid transparent; border-radius:8px; background:var(--card); white-space:pre; overflow:hidden; pointer-events:none }
+ .suggest .ghost .typed { visibility:hidden } .suggest .ghost .rest { color:var(--mute) }
+ .suggestions { position:absolute; z-index:10; left:0; right:0; top:calc(100% + 4px); list-style:none; margin:0; padding:4px 0; background:var(--card); border:1px solid var(--line); border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.12); max-height:320px; overflow-y:auto }
+ .suggestions li { padding:7px 12px; cursor:pointer; display:flex; gap:8px; align-items:baseline }
+ .suggestions li[aria-selected=true], .suggestions li:hover { background:var(--soft) }
+ .suggestions li b { font-weight:600 } .suggestions li .text { flex:1; min-width:0; overflow-wrap:anywhere }
+ .suggestions li .from { color:var(--mute); font-size:13px }
details summary { cursor:pointer; color:var(--mute) } ul.tries { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px } ul.tries li a { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--fg); text-decoration:none; font-size:14px }
@@ -164,7 +173,11 @@ PAGE = r"""<!doctype html>
<section id="tab-ask"> <form class="row" id="ask">
- <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off">
+ <div class="suggest">
+ <div class="ghost" id="ghost" aria-hidden="true"><span class="typed"></span><span class="rest"></span></div>
+ <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="both" aria-expanded="false" aria-controls="suggestions">
+ <ul class="suggestions" id="suggestions" role="listbox" aria-label="Suggestions" hidden></ul>
+ </div>
<button type="submit" id="go">Ask</button> </form> <div class="card" style="margin-bottom:24px">
@@ -397,6 +410,17 @@ const examples = {
"site-status": "is example.com up", "github-release": "latest release of astral-sh/uv", "hacker-news-item": "hacker news item 1",
+ "wikipedia": "tell me about Ada Lovelace",
+ "define": "what does serendipity mean",
+ "sun": "when is sunset in Oslo",
+ "air-quality": "air quality in Delhi",
+ "npm-package": "latest version of react on npm",
+ "pypi-package": "latest requests on pypi",
+ "hn-search": "search hacker news for sqlite",
+ "books": "who wrote The Left Hand of Darkness",
+ "holidays": "next holiday in US",
+ "exchange-rate": "convert 100 USD to EUR",
+ "synonyms": "synonyms for happy",
}; // Served by a Pyrtan, only its token is kept, under its own origin; served elsewhere, the hub URL and token. const KEY = "pyrtan.console:" + location.origin;
@@ -679,10 +703,18 @@ async function loadDirectory() {
} window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1)));
+// A trigger as a sentence: the first of each choice, an input's default or "…"
+// for its blank, and the optional words kept or dropped.
+function phrase(t, pat, optional) {
+ return pat.replace(/\[([^\]|]*)[^\]]*\]/g, optional ? "$1" : "").replace(/\(([^|)]*)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();
+}
function sample(t) { if (examples[t.name]) return examples[t.name];
- const pat = (t.triggers || [])[0]; if (!pat) return null;
- return pat.replace(/\[[^\]]*\]/g, "").replace(/\((\w+)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();
+ const pat = (t.triggers || [])[0]; return pat ? phrase(t, pat, false) : null;
+}
+// Every sentence a tree answers to, for the ask box to suggest; one that starts with a blank suggests nothing.
+function samples(t) {
+ return [examples[t.name], ...(t.triggers || []).map(p => phrase(t, p, true))].filter(s => s && !s.startsWith("…"));
} async function detect() {
@@ -805,6 +837,7 @@ async function load() {
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); const body = $("#caps tbody"); const tries = $("#tries"); body.innerHTML = ""; tries.innerHTML = "";
+ suggest.from((c.trees || []).filter(t => !local || (runsHere(t) && !anySite(t))));
for (const t of c.trees || []) { const tr = document.createElement("tr"); const here = !local ? "" : !runsHere(t) ? `<div class="gap">needs a hub: ${esc((t.sources||[]).filter(x => x !== "http").join(", "))}</div>` : anySite(t) ? '<div class="gap">any site; most will not let a page read them</div>' : "";
@@ -812,6 +845,9 @@ async function load() {
body.appendChild(tr); const s = sample(t); if (s && !s.includes("…") && (!local || (runsHere(t) && !anySite(t)))) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); } }
+ // The greyed suggestion in the empty box (Tab takes it) is one this swarm answers.
+ const first = tries.querySelector("a[data-q]"), offered = [...tries.querySelectorAll("a[data-q]")].map(a => a.dataset.q);
+ if (first && !offered.includes($("#text").placeholder)) $("#text").placeholder = first.dataset.q;
showTab((location.hash || "#ask").slice(1)); }
@@ -889,6 +925,7 @@ function renderTrace(tr) {
async function ask(snapshot, answer) { const text = $("#text").value.trim(); if (!text && !snapshot) return;
+ if (!snapshot) { suggest.remember(text); suggest.close(); }
$("#go").disabled = true; const t0 = performance.now(); let r;
@@ -921,6 +958,105 @@ async function ask(snapshot, answer) {
} $("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); });
+
+// ---------------------------------------------------------------- suggestions in the ask box
+// The sentences this swarm's trees answer to, and the ones asked in this
+// browser before. The best that starts with what is typed shows greyed after
+// it (Tab or → takes it; Tab in the empty box takes the placeholder), and up to
+// eight drop down under the box: ↑ ↓ pick, Enter asks, Esc closes. A sentence
+// with a blank ("latest release of …") fills the box up to the blank instead of asking.
+const ASKED_KEY = "pyrtan.console.asked";
+const suggest = {
+ pool: [], items: [], active: -1, typed: "", open: false,
+ input: $("#text"), list: $("#suggestions"),
+ recent() { try { const r = JSON.parse(localStorage.getItem(ASKED_KEY) || "[]"); return Array.isArray(r) ? r.filter(s => typeof s === "string") : []; } catch (e) { return []; } },
+ remember(text) {
+ const r = [text, ...this.recent().filter(s => s.toLowerCase() !== text.toLowerCase())].slice(0, 20);
+ try { localStorage.setItem(ASKED_KEY, JSON.stringify(r)); } catch (e) {}
+ },
+ from(trees) {
+ const seen = new Set(); this.pool = [];
+ for (const t of trees) for (const text of samples(t)) { const k = text.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.pool.push({text, tree: t.name}); } }
+ },
+ // Recent first, then the trees': those that start with what is typed, then those with every typed word starting one of theirs.
+ match(q) {
+ const low = q.toLowerCase(), words = low.split(/\s+/).filter(Boolean), seen = new Set(), out = [];
+ const add = (e) => { const k = e.text.toLowerCase(); if (!seen.has(k) && out.length < 8) { seen.add(k); out.push(e); } };
+ const all = [...this.recent().map(text => ({text, recent: true})), ...this.pool];
+ for (const e of all) if (e.text.toLowerCase().startsWith(low)) add(e);
+ for (const e of all) { const ws = e.text.toLowerCase().split(/\s+/); if (words.every(w => ws.some(x => x.startsWith(w)))) add(e); }
+ return out;
+ },
+ fill(e) { const i = e.text.indexOf("…"); return i < 0 ? e.text : e.text.slice(0, i); },
+ // The rest of the first suggestion that starts with what is typed, while the caret is at the end.
+ completion() {
+ const el = this.input, v = el.value, low = v.toLowerCase();
+ if (!v || document.activeElement !== el || el.selectionStart !== v.length || el.selectionEnd !== v.length) return "";
+ const e = this.items.find(e => { const f = this.fill(e); return f.length > v.length && f.toLowerCase().startsWith(low); });
+ return e ? this.fill(e).slice(v.length) : "";
+ },
+ ghost() {
+ const rest = this.input.scrollWidth <= this.input.clientWidth ? this.completion() : ""; // scrolled text would not line up
+ $("#ghost .typed").textContent = rest ? this.input.value : "";
+ $("#ghost .rest").textContent = rest;
+ },
+ render() {
+ const v = this.typed, low = v.toLowerCase();
+ this.list.innerHTML = "";
+ this.items.forEach((e, i) => {
+ const li = document.createElement("li");
+ li.id = "suggestion-" + i; li.setAttribute("role", "option"); li.setAttribute("aria-selected", String(i === this.active));
+ const head = v && e.text.toLowerCase().startsWith(low) ? v.length : v ? 0 : e.text.length; // what is typed plain, the rest bold
+ li.innerHTML = `<span class="text">${esc(e.text.slice(0, head))}<b>${esc(e.text.slice(head))}</b></span><span class="from">${e.recent ? "recent" : esc(e.tree)}</span>`;
+ li.addEventListener("click", () => this.choose(e));
+ this.list.appendChild(li);
+ });
+ const shown = this.open && this.items.length > 0;
+ this.list.hidden = !shown; this.input.setAttribute("aria-expanded", String(shown));
+ if (shown && this.active >= 0) this.input.setAttribute("aria-activedescendant", "suggestion-" + this.active); else this.input.removeAttribute("aria-activedescendant");
+ },
+ refresh() {
+ this.typed = this.input.value; this.active = -1;
+ this.items = this.open ? this.match(this.typed) : [];
+ this.render(); this.ghost();
+ },
+ close() { this.open = false; this.refresh(); },
+ choose(e) {
+ this.input.value = this.fill(e); this.input.focus();
+ if (e.text.includes("…")) { this.open = true; this.refresh(); } else ask();
+ },
+ key(ev) {
+ if (ev.isComposing || ev.ctrlKey || ev.altKey || ev.metaKey) return;
+ const shown = !this.list.hidden;
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
+ ev.preventDefault();
+ if (!shown) { this.open = true; this.refresh(); return; }
+ // -1 is what was typed: the list wraps through it, as a search box's does.
+ const n = this.items.length + 1;
+ this.active = ((this.active + 1 + (ev.key === "ArrowDown" ? 1 : -1)) % n + n) % n - 1;
+ this.input.value = this.active < 0 ? this.typed : this.fill(this.items[this.active]);
+ this.render(); this.ghost();
+ } else if ((ev.key === "Tab" && !ev.shiftKey) || ev.key === "ArrowRight") {
+ const v = this.input.value, rest = this.completion();
+ const take = rest ? v + rest : ev.key === "Tab" && !v ? this.input.placeholder : "";
+ if (!take) return; // nothing to complete: Tab moves on, → moves the caret
+ ev.preventDefault(); this.input.value = take; this.open = true; this.refresh();
+ } else if (ev.key === "Escape" && shown) {
+ ev.preventDefault(); this.input.value = this.typed; this.close();
+ } else if (ev.key === "Enter" && shown && this.active >= 0 && this.items[this.active].text.includes("…")) {
+ ev.preventDefault(); this.choose(this.items[this.active]);
+ }
+ },
+};
+suggest.input.addEventListener("input", () => { suggest.open = true; suggest.refresh(); });
+suggest.input.addEventListener("keydown", (e) => suggest.key(e));
+suggest.input.addEventListener("keyup", () => suggest.ghost());
+suggest.input.addEventListener("mousedown", () => { if (!suggest.open) { suggest.open = true; suggest.refresh(); } });
+suggest.input.addEventListener("click", () => suggest.ghost());
+suggest.input.addEventListener("focus", () => suggest.ghost());
+suggest.input.addEventListener("blur", () => suggest.close());
+suggest.list.addEventListener("mousedown", (e) => e.preventDefault()); // keep the box focused, so the click lands
+window.addEventListener("resize", () => suggest.ghost());
$("#tries").addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; location.hash = "#ask"; ask(); }); $("#connect-form").addEventListener("submit", (e) => { e.preventDefault(); diff --git a/src/pyrtan/template.py b/src/pyrtan/template.py index 8288ac0..4b55d69 100644 --- a/src/pyrtan/template.py +++ b/src/pyrtan/template.py
@@ -85,6 +85,13 @@ def _get_index(cur: Any, index: int) -> Any:
return MISSING
+def _text(value: Any) -> str:
+ """A value as it reads inside a sentence: a list or table as JSON, the rest as itself."""
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, ensure_ascii=False)
+ return str(value)
+
+
FILTERS = { "url": lambda v: urllib.parse.quote(str(v), safe=""), "json": lambda v: json.dumps(v, ensure_ascii=False),
@@ -98,6 +105,8 @@ FILTERS = {
"last": lambda v: v[-1] if isinstance(v, (list, tuple, str)) and v else MISSING, "length": lambda v: len(v) if hasattr(v, "__len__") else MISSING, "title": lambda v: str(v).title(),
+ # A list in a sentence: "a, b, c". Its items read as they would on their own.
+ "join": lambda v: ", ".join(_text(x) for x in v) if isinstance(v, (list, tuple)) else _text(v),
}
@@ -143,9 +152,7 @@ def render(template: Any, scope: dict[str, Any], *, strict: bool = True) -> Any:
if strict: raise TemplateError(f"{m.group(0)} resolves to nothing") return ""
- if isinstance(value, (dict, list)):
- return json.dumps(value, ensure_ascii=False)
- return str(value)
+ return _text(value)
return PLACEHOLDER.sub(sub, template) if isinstance(template, list): diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index 1e8c464..db73916 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py
@@ -77,6 +77,36 @@ CASES: list[dict] = [
{"intent": "how are you today?"}, {"intent": "are you a bot"}, {"intent": "goodbye"},
+ # Trees that run in a browser too.
+ {"intent": "tell me about Ursula K. Le Guin"},
+ {"intent": "wiki Mercury"},
+ {"intent": "who is Xyzzyplugh Notaperson"},
+ {"intent": "who was Alan Turing"},
+ {"intent": "what does serendipity mean"},
+ {"intent": "define zzzqx"},
+ {"intent": "when is sunset in Oslo"},
+ {"intent": "sunrise in Longyearbyen"},
+ {"intent": "air quality in Oslo"},
+ {"intent": "how is the air in Delhi"},
+ {"intent": "latest version of @types/node on npm"},
+ {"intent": "npm package nope-not-a-pkg-zz"},
+ {"intent": "latest requests on pypi"},
+ {"intent": "pip version nope-not-a-pkg-zz"},
+ {"intent": "any earthquakes today"},
+ {"intent": "where is the iss"},
+ {"intent": "search hacker news for sqlite"},
+ {"intent": "search hacker news for qqqzzzxxyyy"},
+ {"intent": "who wrote The Left Hand of Darkness"},
+ {"intent": "find the book qqqzzzxxyyy"},
+ {"intent": "next holiday in no"},
+ {"intent": "next holiday in XX"},
+ {"intent": "tell me a joke"},
+ {"intent": "convert 100 usd to eur"},
+ {"intent": "convert 1,000 EUR into JPY"},
+ {"intent": "usd to xyz rate"},
+ {"intent": "exchange rate from usd to usd"},
+ {"intent": "synonyms for happy"},
+ {"intent": "another word for qqqzzz"},
]
@@ -91,7 +121,7 @@ LANGUAGE_TREES: list[dict] = [
_echo("remind", ["remind me {when:date} at {at:time}", "remind me in {wait:duration}"], {"when": {"type": "str", "default": ""}, "at": {"type": "str", "default": ""}, "wait": {"type": "int", "default": 0}}), _echo("count", ["count to {n:number}", "add {x:float} and {y:int}"], {"n": {"type": "float", "default": 0}, "x": {"type": "float", "default": 0.0}, "y": {"type": "int", "default": 0}}), _echo("ping", ["ping {host:host}", "fetch {url:url}", "mail {to:email}"], {"host": {"type": "str", "default": ""}, "url": {"type": "str", "default": ""}, "to": {"type": "str", "default": ""}}),
- _echo("books", ["(find|search) books by {author}", "top story on book club"], {"author": {"type": "str", "default": ""}, "count": {"type": "int", "default": 3}},
+ _echo("library", ["(find|search) books by {author}", "top story on book club"], {"author": {"type": "str", "default": ""}, "count": {"type": "int", "default": 3}},
language={"synonyms": {"books": ["novels", "paperback books"], "Find": "look up"}, "typos": True}), _echo("tie-a", ["shout {x:word}"], {"x": {"type": "str", "default": ""}}), _echo("tie-b", ["shout {y:word}"], {"y": {"type": "str", "default": ""}}),
@@ -124,6 +154,7 @@ NLP = [
("clean", "Hey, can you please tell me what's up?"), ("clean", "“Curly” quotes — and dashes"), ("clean", "FULL width"), ("clean", "I'm sure it's a dozen"), ("clean", "one hundred and twenty three things"), ("clean", "a hundred and one"), ("clean", "Thanks"), ("clean", "well, um, okay pyrtan: weather."), ("clean", "St. Ives. Then Mr. Smith"),
+ ("clean", "convert 1,000.5 EUR, then 2, 3"), ("clean", "a,b and 1 ,2 and 3, 4"),
("singular", "stories"), ("singular", "boxes"), ("singular", "news"), ("singular", "glass"), ("singular", "cats"), ("singular", "bus"), ("plural", "story"), ("plural", "box"), ("plural", "day"), ("plural", "status"), ("parse_number", "twenty-one"), ("parse_number", "1,234"), ("parse_number", "-3.25"), ("parse_number", "lots"), diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 14af50e..801d22a 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json
@@ -8,36 +8,65 @@
"name": "this browser", "trees": [ {
- "description": "echo for books",
- "hosts": [],
+ "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "air-quality-api.open-meteo.com"
+ ],
"inputs": {
- "author": {
+ "place": {
+ "description": "A town or city name",
+ "required": true,
"type": "str"
- },
- "count": {
- "default": 3,
- "type": "int"
} },
- "language": {
- "synonyms": {
- "books": [
- "novels",
- "paperback books"
- ],
- "find": [
- "look up"
- ]
- },
- "typos": true
+ "language": {},
+ "license": "MIT",
+ "name": "air-quality",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "weather",
+ "health",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] air quality (in|for) {place}",
+ "how is the air in {place}",
+ "(aqi|pollution) in {place}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "A book from Open Library: the best match for a title or an author, who wrote it, and when it first came out.",
+ "hosts": [
+ "openlibrary.org"
+ ],
+ "inputs": {
+ "title": {
+ "description": "A title or an author, as in: The Left Hand of Darkness",
+ "required": true,
+ "type": "str"
+ }
},
- "license": "",
+ "language": {},
+ "license": "MIT",
"name": "books",
- "sources": [],
- "tags": [],
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "reference",
+ "books",
+ "read-only"
+ ],
"triggers": [
- "(find|search) books by {author}",
- "top story on book club"
+ "who wrote {title}",
+ "find the book {title}",
+ "books by {title}"
], "version": 1 },
@@ -66,6 +95,61 @@
], "version": 1 },
+ {
+ "description": "A random dad joke, from icanhazdadjoke.com.",
+ "hosts": [
+ "icanhazdadjoke.com"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "dad-joke",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "fun",
+ "read-only"
+ ],
+ "triggers": [
+ "tell me a [dad] joke",
+ "[a] dad joke",
+ "make me laugh"
+ ],
+ "version": 1
+ },
+ {
+ "description": "What an English word means: its first definition, part of speech and pronunciation.",
+ "hosts": [
+ "api.dictionaryapi.dev"
+ ],
+ "inputs": {
+ "word": {
+ "description": "One English word",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "define",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "reference",
+ "language",
+ "read-only"
+ ],
+ "triggers": [
+ "define {word:word}",
+ "what does {word:word} mean",
+ "[the] (meaning|definition) of {word:word}"
+ ],
+ "version": 1
+ },
{ "commands": [ "df"
@@ -98,6 +182,71 @@
], "version": 2 },
+ {
+ "description": "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest.",
+ "hosts": [
+ "earthquake.usgs.gov"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "earthquakes",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "science",
+ "read-only"
+ ],
+ "triggers": [
+ "[any] [recent] earthquakes [today]",
+ "[was there an] earthquake today",
+ "latest earthquake"
+ ],
+ "version": 1
+ },
+ {
+ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",
+ "hosts": [
+ "api.frankfurter.dev"
+ ],
+ "inputs": {
+ "amount": {
+ "default": "1",
+ "description": "How much of the first currency, as in: 100 or 2.5",
+ "type": "str"
+ },
+ "from": {
+ "description": "A three-letter currency code, as in: USD",
+ "required": true,
+ "type": "str"
+ },
+ "to": {
+ "description": "A three-letter currency code, as in: EUR",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "exchange-rate",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "money",
+ "read-only"
+ ],
+ "triggers": [
+ "convert {amount:number} {from:word} (to|into|in) {to:word}",
+ "how much is {amount:number} {from:word} in {to:word}",
+ "[the] exchange rate (from|for) {from:word} to {to:word}",
+ "{from:word} to {to:word} (rate|exchange rate)"
+ ],
+ "version": 1
+ },
{ "description": "echo for fixed", "hosts": [],
@@ -213,6 +362,124 @@
], "version": 2 },
+ {
+ "description": "Hacker News stories about something, through HN Search: the top three matches.",
+ "hosts": [
+ "hn.algolia.com"
+ ],
+ "inputs": {
+ "query": {
+ "description": "Words to search story titles and text for",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "hn-search",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "news",
+ "tech",
+ "read-only"
+ ],
+ "triggers": [
+ "search hacker news for {query}",
+ "hacker news stories about {query}",
+ "what does hacker news say about {query}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "The next public holiday in a country (by its two-letter code), from Nager.Date.",
+ "hosts": [
+ "date.nager.at"
+ ],
+ "inputs": {
+ "country": {
+ "description": "A two-letter country code, as in: NO, US, DE",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "holidays",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "calendar",
+ "read-only"
+ ],
+ "triggers": [
+ "[when is the] next [public|bank] holiday in {country:word}",
+ "[public|bank] holidays in {country:word}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "Where the International Space Station is right now: latitude, longitude, altitude and speed.",
+ "hosts": [
+ "api.wheretheiss.at"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "iss",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "science",
+ "space",
+ "read-only"
+ ],
+ "triggers": [
+ "where is the (iss|space station) [now]",
+ "(iss|space station) (position|location)"
+ ],
+ "version": 1
+ },
+ {
+ "description": "echo for library",
+ "hosts": [],
+ "inputs": {
+ "author": {
+ "type": "str"
+ },
+ "count": {
+ "default": 3,
+ "type": "int"
+ }
+ },
+ "language": {
+ "synonyms": {
+ "books": [
+ "novels",
+ "paperback books"
+ ],
+ "find": [
+ "look up"
+ ]
+ },
+ "typos": true
+ },
+ "license": "",
+ "name": "library",
+ "sources": [],
+ "tags": [],
+ "triggers": [
+ "(find|search) books by {author}",
+ "top story on book club"
+ ],
+ "version": 1
+ },
{ "description": "Error lines in the tail of a log file on this box.", "hosts": [],
@@ -278,6 +545,37 @@
], "version": 2 },
+ {
+ "description": "The latest version of an npm package, and what it says it is.",
+ "hosts": [
+ "registry.npmjs.org"
+ ],
+ "inputs": {
+ "package": {
+ "description": "A package name, as in: react or @types/node",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "npm-package",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "software",
+ "javascript",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] latest version of {package:word} on npm",
+ "latest {package:word} on npm",
+ "npm (package|version) {package:word}"
+ ],
+ "version": 1
+ },
{ "description": "asks, then answers", "hosts": [],
@@ -328,9 +626,40 @@
"version": 1 }, {
- "description": "echo for remind",
- "hosts": [],
- "inputs": {
+ "description": "The latest version of a Python package on PyPI, and what it says it is.",
+ "hosts": [
+ "pypi.org"
+ ],
+ "inputs": {
+ "package": {
+ "description": "A project name, as in: requests",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "pypi-package",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "software",
+ "python",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] latest version of {package:word} on pypi",
+ "latest {package:word} on pypi",
+ "(pypi|pip) (package|version) {package:word}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "echo for remind",
+ "hosts": [],
+ "inputs": {
"at": { "type": "str" },
@@ -498,6 +827,68 @@
], "version": 1 },
+ {
+ "description": "Today's sunrise and sunset for a place, in its local time, from Open-Meteo.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "sun",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "weather",
+ "read-only"
+ ],
+ "triggers": [
+ "[when is] (sunrise|sunset) in {place}",
+ "when does the sun (rise|set) in {place}",
+ "(sunrise|sun) times (in|for) {place}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "Synonyms for an English word, best first, from Datamuse.",
+ "hosts": [
+ "api.datamuse.com"
+ ],
+ "inputs": {
+ "word": {
+ "description": "One English word",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "synonyms",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "reference",
+ "language",
+ "read-only"
+ ],
+ "triggers": [
+ "synonyms (for|of) {word:word}",
+ "(another|other) word for {word:word}",
+ "what is another word for {word:word}"
+ ],
+ "version": 1
+ },
{ "description": "echo for tie-a", "hosts": [],
@@ -575,6 +966,36 @@
"how [hot|cold|warm] is it in {place}" ], "version": 2
+ },
+ {
+ "description": "The summary of a Wikipedia article.",
+ "hosts": [
+ "en.wikipedia.org"
+ ],
+ "inputs": {
+ "topic": {
+ "description": "An article title, as in: Ursula K. Le Guin",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "wikipedia",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "reference",
+ "read-only"
+ ],
+ "triggers": [
+ "tell me about {topic}",
+ "who (is|was) {topic}",
+ "(wikipedia|wiki) {topic}"
+ ],
+ "version": 1
} ] },
@@ -2353,7 +2774,7 @@
"version": 1 }, {
- "description": "echo for books",
+ "description": "echo for library",
"hosts": [], "inputs": { "author": {
@@ -2375,7 +2796,7 @@
}, "typos": true },
- "name": "books",
+ "name": "library",
"nodes": { "reply": { "kind": "done",
@@ -2607,6 +3028,22 @@
[] ] },
+ {
+ "fn": "clean",
+ "text": "convert 1,000.5 EUR, then 2, 3",
+ "value": [
+ "convert 1,000.5 EUR then 2 3",
+ []
+ ]
+ },
+ {
+ "fn": "clean",
+ "text": "a,b and 1 ,2 and 3, 4",
+ "value": [
+ "a b and 1 2 and 3 4",
+ []
+ ]
+ },
{ "fn": "singular", "text": "stories",
@@ -4558,18 +4995,51 @@
}, { "expect": {
- "cleaned": "count to 1 000.5",
- "considered": [],
- "matches": [],
+ "cleaned": "count to 1,000.5",
+ "considered": [
+ {
+ "inputs": {
+ "n": 1000.5
+ },
+ "notes": [
+ "n: '1,000.5' read as 1000.5"
+ ],
+ "rejected": false,
+ "specificity": 7,
+ "tree": "count",
+ "trigger": "count to {n:number}"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "n": 1000.5
+ },
+ "notes": [
+ "n: '1,000.5' read as 1000.5"
+ ],
+ "rejected": false,
+ "specificity": 7,
+ "tree": "count",
+ "trigger": "count to {n:number}"
+ }
+ ],
"now": "2026-10-25T12:00:00+00:00", "step": { "kind": "intent", "node": "intent", "observed": {
- "cleaned": "count to 1 000.5",
- "steps": []
+ "cleaned": "count to 1,000.5",
+ "inputs": {
+ "n": 1000.5
+ },
+ "notes": [
+ "n: '1,000.5' read as 1000.5"
+ ],
+ "steps": [],
+ "trigger": "count to {n:number}"
},
- "summary": "'count to 1,000.5' -> 'count to 1 000.5'; no trigger matched"
+ "summary": "'count to 1,000.5'; count via 'count to {n:number}' (7 literal chars); n: '1,000.5' read as 1000.5"
}, "steps": [], "text": "count to 1,000.5"
@@ -4997,7 +5467,7 @@
"notes": [], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5009,7 +5479,7 @@
"notes": [], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5025,7 +5495,7 @@
"steps": [], "trigger": "(find|search) books by {author}" },
- "summary": "'find books by Ursula Le Guin'; books via '(find|search) books by {author}' (17 literal chars)"
+ "summary": "'find books by Ursula Le Guin'; library via '(find|search) books by {author}' (17 literal chars)"
}, "steps": [], "text": "find books by Ursula Le Guin"
@@ -5043,7 +5513,7 @@
"notes": [], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5055,7 +5525,7 @@
"notes": [], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5071,7 +5541,7 @@
"steps": [], "trigger": "(find|search) books by {author}" },
- "summary": "'search novels by Le Guin'; books via '(find|search) books by {author}' (17 literal chars)"
+ "summary": "'search novels by Le Guin'; library via '(find|search) books by {author}' (17 literal chars)"
}, "steps": [], "text": "search novels by Le Guin"
@@ -5089,7 +5559,7 @@
"notes": [], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5101,7 +5571,7 @@
"notes": [], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5117,7 +5587,7 @@
"steps": [], "trigger": "(find|search) books by {author}" },
- "summary": "'look up paperback books by Le Guin'; books via '(find|search) books by {author}' (17 literal chars)"
+ "summary": "'look up paperback books by Le Guin'; library via '(find|search) books by {author}' (17 literal chars)"
}, "steps": [], "text": "look up paperback books by Le Guin"
@@ -5157,7 +5627,7 @@
], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5171,7 +5641,7 @@
], "rejected": false, "specificity": 17,
- "tree": "books",
+ "tree": "library",
"trigger": "(find|search) books by {author}" } ],
@@ -5190,7 +5660,7 @@
"steps": [], "trigger": "(find|search) books by {author}" },
- "summary": "'serch books by Le Guin'; books via '(find|search) books by {author}' (17 literal chars); typo: serch->search"
+ "summary": "'serch books by Le Guin'; library via '(find|search) books by {author}' (17 literal chars); typo: serch->search"
}, "steps": [], "text": "serch books by Le Guin"
@@ -5206,7 +5676,7 @@
"notes": [], "rejected": false, "specificity": 18,
- "tree": "books",
+ "tree": "library",
"trigger": "top story on book club" } ],
@@ -5216,7 +5686,7 @@
"notes": [], "rejected": false, "specificity": 18,
- "tree": "books",
+ "tree": "library",
"trigger": "top story on book club" } ],
@@ -5230,7 +5700,7 @@
"steps": [], "trigger": "top story on book club" },
- "summary": "'top stories on book club'; books via 'top story on book club' (18 literal chars)"
+ "summary": "'top stories on book club'; library via 'top story on book club' (18 literal chars)"
}, "steps": [], "text": "top stories on book club"
@@ -8311,233 +8781,3845 @@
}, "intent": "goodbye", "tree": "small-talk"
- }
- ],
- "trees": {
- "disk-space": {
- "commands": [
- "df"
- ],
- "description": "How full a filesystem is, from df on the box this tree runs on.",
- "inputs": {
- "mount": {
- "default": "/",
- "description": "A mount point, or any path on the filesystem to measure",
- "type": "str"
- }
- },
- "license": "MIT",
- "name": "disk-space",
- "namespace": "pyrtan",
- "nodes": {
- "df": {
- "extract": [
+ },
+ {
+ "expect": {
+ "inputs": {
+ "topic": "Ursula K Le Guin"
+ },
+ "output": {
+ "summary": "Ursula Kroeber Le Guin was an American author. She is best known for her works of speculative fiction.",
+ "title": "Ursula K. Le Guin",
+ "url": "https://en.wikipedia.org/wiki/Ursula_K._Le_Guin"
+ },
+ "say": "Ursula K. Le Guin: Ursula Kroeber Le Guin was an American author. She is best known for her works of speculative fiction. https://en.wikipedia.org/wiki/Ursula_K._Le_Guin",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
{
- "group": 1,
- "into": "percent",
- "regex": "(\\d+)%"
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "tell me about Ursula K Le Guin",
+ "inputs": {
+ "topic": "Ursula K Le Guin"
+ },
+ "steps": [],
+ "trigger": "tell me about {topic}"
+ },
+ "summary": "'tell me about Ursula K. Le Guin' -> 'tell me about Ursula K Le Guin'; wikipedia via 'tell me about {topic}' (11 literal chars)"
}, {
- "group": 1,
- "into": "mounted",
- "regex": "%\\s+(\\S+)\\s*$"
- }
- ],
- "into": "df",
- "kind": "observe",
- "next": "ran",
- "shell": {
- "args": [
- "-P",
- "{{ inputs.mount }}"
- ],
- "command": "df"
- }
- },
- "failed": {
- "kind": "fail",
- "reason": "df-failed",
- "say": "df could not measure {{ inputs.mount }}: {{ state.df.stderr | strip }}"
- },
- "fine": {
- "kind": "done",
- "output": {
- "mounted": "{{ state.mounted }}",
- "percent": "{{ state.pct }}"
- },
- "say": "{{ state.mounted }} is {{ state.pct }}% full."
- },
- "full": {
- "kind": "fail",
- "reason": "disk-full",
- "say": "{{ state.mounted }} is {{ state.pct }}% full."
- },
- "level": {
- "branches": [
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "kind": "standard",
+ "summary": "Ursula Kroeber Le Guin was an American author. She is best known for her works of speculative fiction.",
+ "title": "Ursula K. Le Guin",
+ "url": "https://en.wikipedia.org/wiki/Ursula_K._Le_Guin"
+ },
+ "summary": "GET https://en.wikipedia.org/api/rest_v1/page/summary/Ursula%20K%20Le%20Guin -> 200; kind=standard, title=Ursula K. Le Guin, summary=Ursula Kroeber Le Guin was an American author. She is best known for her works …, url=https://en.wikipedia.org/wiki/Ursula_K._Le_Guin"
+ },
{
- "then": "fine",
- "when": {
- "lt": 80
- }
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "kind",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
}, {
- "then": "tight",
- "when": {
- "lt": 95
- }
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "kind",
+ "observed": "standard",
+ "summary": "state.kind = standard"
}, {
- "else": "full"
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Ursula K. Le Guin: Ursula Kroeber Le Guin was an American author. She is best known for her works of speculative fiction. https://en.wikipedia.org/wiki/Ursula_K._Le_Guin"
} ],
- "kind": "decide",
- "on": "pct"
+ "tree": "wikipedia"
},
- "number": {
- "kind": "set",
- "next": "level",
+ "tree": "wikipedia"
+ },
+ "inputs": {
+ "topic": "Ursula K Le Guin"
+ },
+ "intent": "tell me about Ursula K. Le Guin",
+ "tree": "wikipedia"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "topic": "Mercury"
+ },
+ "reason": "ambiguous",
+ "say": "Mercury could mean several things on Wikipedia; say which: https://en.wikipedia.org/wiki/Mercury",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "wiki Mercury",
+ "inputs": {
+ "topic": "Mercury"
+ },
+ "steps": [],
+ "trigger": "(wikipedia|wiki) {topic}"
+ },
+ "summary": "'wiki Mercury'; wikipedia via '(wikipedia|wiki) {topic}' (13 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "kind": "disambiguation",
+ "summary": "Mercury most commonly refers to:Mercury (planet), the closest planet to the Sun\n",
+ "title": "Mercury",
+ "url": "https://en.wikipedia.org/wiki/Mercury"
+ },
+ "summary": "GET https://en.wikipedia.org/api/rest_v1/page/summary/Mercury -> 200; kind=disambiguation, title=Mercury, summary=Mercury most commonly refers to:Mercury (planet), the closest planet to the Sun\n, url=https://en.wikipedia.org/wiki/Mercury"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "kind",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "eq 'disambiguation'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "ambiguous",
+ "node": "kind",
+ "observed": "disambiguation",
+ "summary": "state.kind = disambiguation"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "ambiguous",
+ "summary": "ambiguous: Mercury could mean several things on Wikipedia; say which: https://en.wikipedia.org/wiki/Mercury"
+ }
+ ],
+ "tree": "wikipedia"
+ },
+ "tree": "wikipedia"
+ },
+ "inputs": {
+ "topic": "Mercury"
+ },
+ "intent": "wiki Mercury",
+ "tree": "wikipedia"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "topic": "Xyzzyplugh Notaperson"
+ },
+ "reason": "no-article",
+ "say": "Wikipedia has no article called Xyzzyplugh Notaperson.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "who is Xyzzyplugh Notaperson",
+ "inputs": {
+ "topic": "Xyzzyplugh Notaperson"
+ },
+ "steps": [],
+ "trigger": "who (is|was) {topic}"
+ },
+ "summary": "'who is Xyzzyplugh Notaperson'; wikipedia via 'who (is|was) {topic}' (8 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "kind": "Internal error",
+ "summary": null,
+ "title": null,
+ "url": null
+ },
+ "summary": "GET https://en.wikipedia.org/api/rest_v1/page/summary/Xyzzyplugh%20Notaperson -> 404; kind=Internal error, title=None, summary=None, url=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-article",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-article",
+ "summary": "no-article: Wikipedia has no article called Xyzzyplugh Notaperson."
+ }
+ ],
+ "tree": "wikipedia"
+ },
+ "tree": "wikipedia"
+ },
+ "inputs": {
+ "topic": "Xyzzyplugh Notaperson"
+ },
+ "intent": "who is Xyzzyplugh Notaperson",
+ "tree": "wikipedia"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "topic": "Alan Turing"
+ },
+ "reason": "rate-limited",
+ "say": "Wikipedia is asking for fewer requests (429); try again in a minute.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "who was Alan Turing",
+ "inputs": {
+ "topic": "Alan Turing"
+ },
+ "steps": [],
+ "trigger": "who (is|was) {topic}"
+ },
+ "summary": "'who was Alan Turing'; wikipedia via 'who (is|was) {topic}' (8 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "kind": null,
+ "summary": null,
+ "title": null,
+ "url": null
+ },
+ "summary": "GET https://en.wikipedia.org/api/rest_v1/page/summary/Alan%20Turing -> 429; kind=None, title=None, summary=None, url=None"
+ },
+ {
+ "chosen": "eq 429",
+ "depth": 0,
+ "kind": "decide",
+ "next": "rate-limited",
+ "node": "status",
+ "observed": 429,
+ "summary": "state.resp.status = 429"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "rate-limited",
+ "summary": "rate-limited: Wikipedia is asking for fewer requests (429); try again in a minute."
+ }
+ ],
+ "tree": "wikipedia"
+ },
+ "tree": "wikipedia"
+ },
+ "inputs": {
+ "topic": "Alan Turing"
+ },
+ "intent": "who was Alan Turing",
+ "tree": "wikipedia"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "word": "serendipity"
+ },
+ "output": {
+ "definition": "A combination of events which have come together by chance to make a surprisingly good or wonderful outcome.",
+ "part_of_speech": "noun",
+ "phonetic": "/ˌsɛ.ɹən.ˈdɪ.pɪ.ti/",
+ "word": "serendipity"
+ },
+ "say": "serendipity (noun): A combination of events which have come together by chance to make a surprisingly good or wonderful outcome.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "what does serendipity mean",
+ "inputs": {
+ "word": "serendipity"
+ },
+ "steps": [],
+ "trigger": "what does {word:word} mean"
+ },
+ "summary": "'what does serendipity mean'; define via 'what does {word:word} mean' (12 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "definition": "A combination of events which have come together by chance to make a surprisingly good or wonderful outcome.",
+ "part": "noun",
+ "phonetic": "/ˌsɛ.ɹən.ˈdɪ.pɪ.ti/",
+ "word": "serendipity"
+ },
+ "summary": "GET https://api.dictionaryapi.dev/api/v2/entries/en/serendipity -> 200; word=serendipity, phonetic=/ˌsɛ.ɹən.ˈdɪ.pɪ.ti/, part=noun, definition=A combination of events which have come together by chance to make a surprising…"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-definition",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-definition",
+ "observed": "A combination of events which have come together by chance to make a surprisingly good or wonderful outcome.",
+ "summary": "state.definition = A combination of events which have come together by chance to make a surprising…"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "serendipity (noun): A combination of events which have come together by chance to make a surprisingly good or wonderful outcome."
+ }
+ ],
+ "tree": "define"
+ },
+ "tree": "define"
+ },
+ "inputs": {
+ "word": "serendipity"
+ },
+ "intent": "what does serendipity mean",
+ "tree": "define"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "word": "zzzqx"
+ },
+ "reason": "unknown-word",
+ "say": "The dictionary has no entry for zzzqx.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "define zzzqx",
+ "inputs": {
+ "word": "zzzqx"
+ },
+ "steps": [],
+ "trigger": "define {word:word}"
+ },
+ "summary": "'define zzzqx'; define via 'define {word:word}' (6 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "definition": null,
+ "part": null,
+ "phonetic": null,
+ "word": null
+ },
+ "summary": "GET https://api.dictionaryapi.dev/api/v2/entries/en/zzzqx -> 404; word=None, phonetic=None, part=None, definition=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "unknown-word",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "unknown-word",
+ "summary": "unknown-word: The dictionary has no entry for zzzqx."
+ }
+ ],
+ "tree": "define"
+ },
+ "tree": "define"
+ },
+ "inputs": {
+ "word": "zzzqx"
+ },
+ "intent": "define zzzqx",
+ "tree": "define"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Oslo"
+ },
+ "output": {
+ "country": "Norway",
+ "date": "2026-10-28",
+ "place": "Oslo",
+ "sunrise": "07:28",
+ "sunset": "16:32",
+ "timezone": "Europe/Oslo"
+ },
+ "say": "Oslo, Norway, 2026-10-28: sunrise 07:28, sunset 16:32 (Europe/Oslo).",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "when is sunset in Oslo",
+ "inputs": {
+ "place": "Oslo"
+ },
+ "steps": [],
+ "trigger": "[when is] (sunrise|sunset) in {place}"
+ },
+ "summary": "'when is sunset in Oslo'; sun via '[when is] (sunrise|sunset) in {place}' (21 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Norway",
+ "hit": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "lat": 59.91,
+ "lon": 10.75,
+ "name": "Oslo"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo&count=1&language=en&format=json -> 200; hit={'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}, lat=59.91, lon=10.75, name=Oslo, country=Norway"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "times",
+ "node": "found",
+ "observed": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "summary": "state.hit = {'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "have-times",
+ "node": "times",
+ "observed": {
+ "day": "2026-10-28",
+ "rise": "07:28",
+ "set": "16:32",
+ "zone": "Europe/Oslo"
+ },
+ "summary": "GET https://api.open-meteo.com/v1/forecast?latitude=59.91&longitude=10.75&daily=sunrise,sunset&timezone=auto&forecast_days=1 -> 200; day=2026-10-28, rise=07:28, set=16:32, zone=Europe/Oslo"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "have-times",
+ "observed": "07:28",
+ "summary": "state.rise = 07:28"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Oslo, Norway, 2026-10-28: sunrise 07:28, sunset 16:32 (Europe/Oslo)."
+ }
+ ],
+ "tree": "sun"
+ },
+ "tree": "sun"
+ },
+ "inputs": {
+ "place": "Oslo"
+ },
+ "intent": "when is sunset in Oslo",
+ "tree": "sun"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Longyearbyen"
+ },
+ "reason": "no-times",
+ "say": "Open-Meteo gave no sunrise for Longyearbyen today (status 200); near the poles the sun may not rise or set at all.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "sunrise in Longyearbyen",
+ "inputs": {
+ "place": "Longyearbyen"
+ },
+ "steps": [],
+ "trigger": "[when is] (sunrise|sunset) in {place}"
+ },
+ "summary": "'sunrise in Longyearbyen'; sun via '[when is] (sunrise|sunset) in {place}' (21 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Svalbard and Jan Mayen",
+ "hit": {
+ "country": "Svalbard and Jan Mayen",
+ "latitude": 78.22,
+ "longitude": 15.65,
+ "name": "Longyearbyen"
+ },
+ "lat": 78.22,
+ "lon": 15.65,
+ "name": "Longyearbyen"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Longyearbyen&count=1&language=en&format=json -> 200; hit={'name': 'Longyearbyen', 'country': 'Svalbard and Jan Mayen', 'latitude': 78.22…, lat=78.22, lon=15.65, name=Longyearbyen, country=Svalbard and Jan Mayen"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "times",
+ "node": "found",
+ "observed": {
+ "country": "Svalbard and Jan Mayen",
+ "latitude": 78.22,
+ "longitude": 15.65,
+ "name": "Longyearbyen"
+ },
+ "summary": "state.hit = {'name': 'Longyearbyen', 'country': 'Svalbard and Jan Mayen', 'latitude': 78.22…"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "have-times",
+ "node": "times",
+ "observed": {
+ "day": "2026-12-21",
+ "rise": null,
+ "set": null,
+ "zone": "Arctic/Longyearbyen"
+ },
+ "summary": "GET https://api.open-meteo.com/v1/forecast?latitude=78.22&longitude=15.65&daily=sunrise,sunset&timezone=auto&forecast_days=1 -> 200; day=2026-12-21, rise=None, set=None, zone=Arctic/Longyearbyen"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-times",
+ "node": "have-times",
+ "summary": "state.rise = <missing>"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-times",
+ "summary": "no-times: Open-Meteo gave no sunrise for Longyearbyen today (status 200); near the poles the sun may not rise or set at all."
+ }
+ ],
+ "tree": "sun"
+ },
+ "tree": "sun"
+ },
+ "inputs": {
+ "place": "Longyearbyen"
+ },
+ "intent": "sunrise in Longyearbyen",
+ "tree": "sun"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Oslo"
+ },
+ "output": {
+ "aqi": 21,
+ "band": "fair",
+ "country": "Norway",
+ "place": "Oslo",
+ "pm10": 10.2,
+ "pm2_5": 3.8
+ },
+ "say": "Oslo, Norway: air quality fair (European AQI 21), PM2.5 3.8 µg/m³, PM10 10.2 µg/m³.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "air quality in Oslo",
+ "inputs": {
+ "place": "Oslo"
+ },
+ "steps": [],
+ "trigger": "[what's the] air quality (in|for) {place}"
+ },
+ "summary": "'air quality in Oslo'; air-quality via \"[what's the] air quality (in|for) {place}\" (24 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Norway",
+ "hit": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "lat": 59.91,
+ "lon": 10.75,
+ "name": "Oslo"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo&count=1&language=en&format=json -> 200; hit={'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}, lat=59.91, lon=10.75, name=Oslo, country=Norway"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "air",
+ "node": "found",
+ "observed": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "summary": "state.hit = {'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "band",
+ "node": "air",
+ "observed": {
+ "aqi": 21,
+ "pm10": 10.2,
+ "pm25": 3.8
+ },
+ "summary": "GET https://air-quality-api.open-meteo.com/v1/air-quality?latitude=59.91&longitude=10.75&current=european_aqi,pm2_5,pm10 -> 200; aqi=21, pm25=3.8, pm10=10.2"
+ },
+ {
+ "chosen": "lte 40",
+ "depth": 0,
+ "kind": "decide",
+ "next": "fair",
+ "node": "band",
+ "observed": 21,
+ "summary": "state.aqi = 21"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "fair",
+ "observed": {
+ "band": "fair"
+ },
+ "summary": "band=fair"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Oslo, Norway: air quality fair (European AQI 21), PM2.5 3.8 µg/m³, PM10 10.2 µg/m³."
+ }
+ ],
+ "tree": "air-quality"
+ },
+ "tree": "air-quality"
+ },
+ "inputs": {
+ "place": "Oslo"
+ },
+ "intent": "air quality in Oslo",
+ "tree": "air-quality"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Delhi"
+ },
+ "output": {
+ "aqi": 87,
+ "band": "very poor",
+ "country": "India",
+ "place": "Delhi",
+ "pm10": 118.3,
+ "pm2_5": 61.4
+ },
+ "say": "Delhi, India: air quality very poor (European AQI 87), PM2.5 61.4 µg/m³, PM10 118.3 µg/m³.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "how is the air in Delhi",
+ "inputs": {
+ "place": "Delhi"
+ },
+ "steps": [],
+ "trigger": "how is the air in {place}"
+ },
+ "summary": "'how is the air in Delhi'; air-quality via 'how is the air in {place}' (13 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "India",
+ "hit": {
+ "country": "India",
+ "latitude": 28.65,
+ "longitude": 77.23,
+ "name": "Delhi"
+ },
+ "lat": 28.65,
+ "lon": 77.23,
+ "name": "Delhi"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Delhi&count=1&language=en&format=json -> 200; hit={'name': 'Delhi', 'country': 'India', 'latitude': 28.65, 'longitude': 77.23}, lat=28.65, lon=77.23, name=Delhi, country=India"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "air",
+ "node": "found",
+ "observed": {
+ "country": "India",
+ "latitude": 28.65,
+ "longitude": 77.23,
+ "name": "Delhi"
+ },
+ "summary": "state.hit = {'name': 'Delhi', 'country': 'India', 'latitude': 28.65, 'longitude': 77.23}"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "band",
+ "node": "air",
+ "observed": {
+ "aqi": 87,
+ "pm10": 118.3,
+ "pm25": 61.4
+ },
+ "summary": "GET https://air-quality-api.open-meteo.com/v1/air-quality?latitude=28.65&longitude=77.23&current=european_aqi,pm2_5,pm10 -> 200; aqi=87, pm25=61.4, pm10=118.3"
+ },
+ {
+ "chosen": "lte 100",
+ "depth": 0,
+ "kind": "decide",
+ "next": "very-poor",
+ "node": "band",
+ "observed": 87,
+ "summary": "state.aqi = 87"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "very-poor",
+ "observed": {
+ "band": "very poor"
+ },
+ "summary": "band=very poor"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Delhi, India: air quality very poor (European AQI 87), PM2.5 61.4 µg/m³, PM10 118.3 µg/m³."
+ }
+ ],
+ "tree": "air-quality"
+ },
+ "tree": "air-quality"
+ },
+ "inputs": {
+ "place": "Delhi"
+ },
+ "intent": "how is the air in Delhi",
+ "tree": "air-quality"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "@types/node"
+ },
+ "output": {
+ "description": "TypeScript definitions for node",
+ "package": "@types/node",
+ "url": "https://www.npmjs.com/package/@types/node",
+ "version": "26.6.5"
+ },
+ "say": "@types/node 26.6.5 on npm: TypeScript definitions for node https://www.npmjs.com/package/@types/node",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "latest version of @types/node on npm",
+ "inputs": {
+ "package": "@types/node"
+ },
+ "steps": [],
+ "trigger": "[what's the] latest version of {package:word} on npm"
+ },
+ "summary": "'latest version of @types/node on npm'; npm-package via \"[what's the] latest version of {package:word} on npm\" (29 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "TypeScript definitions for node",
+ "version": "26.6.5"
+ },
+ "summary": "GET https://registry.npmjs.org/%40types%2Fnode/latest -> 200; version=26.6.5, about=TypeScript definitions for node"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "@types/node 26.6.5 on npm: TypeScript definitions for node https://www.npmjs.com/package/@types/node"
+ }
+ ],
+ "tree": "npm-package"
+ },
+ "tree": "npm-package"
+ },
+ "inputs": {
+ "package": "@types/node"
+ },
+ "intent": "latest version of @types/node on npm",
+ "tree": "npm-package"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "reason": "no-package",
+ "say": "npm has no package called nope-not-a-pkg-zz.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "npm package nope-not-a-pkg-zz",
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "steps": [],
+ "trigger": "npm (package|version) {package:word}"
+ },
+ "summary": "'npm package nope-not-a-pkg-zz'; npm-package via 'npm (package|version) {package:word}' (17 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": null,
+ "version": null
+ },
+ "summary": "GET https://registry.npmjs.org/nope-not-a-pkg-zz/latest -> 404; version=None, about=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-package",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-package",
+ "summary": "no-package: npm has no package called nope-not-a-pkg-zz."
+ }
+ ],
+ "tree": "npm-package"
+ },
+ "tree": "npm-package"
+ },
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "intent": "npm package nope-not-a-pkg-zz",
+ "tree": "npm-package"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "requests"
+ },
+ "output": {
+ "package": "requests",
+ "summary": "Python HTTP for Humans.",
+ "url": "https://pypi.org/project/requests/",
+ "version": "2.34.2"
+ },
+ "say": "requests 2.34.2 on PyPI: Python HTTP for Humans. https://pypi.org/project/requests/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "latest requests on pypi",
+ "inputs": {
+ "package": "requests"
+ },
+ "steps": [],
+ "trigger": "latest {package:word} on pypi"
+ },
+ "summary": "'latest requests on pypi'; pypi-package via 'latest {package:word} on pypi' (12 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "Python HTTP for Humans.",
+ "name": "requests",
+ "url": "https://pypi.org/project/requests/",
+ "version": "2.34.2"
+ },
+ "summary": "GET https://pypi.org/pypi/requests/json -> 200; name=requests, version=2.34.2, about=Python HTTP for Humans., url=https://pypi.org/project/requests/"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "requests 2.34.2 on PyPI: Python HTTP for Humans. https://pypi.org/project/requests/"
+ }
+ ],
+ "tree": "pypi-package"
+ },
+ "tree": "pypi-package"
+ },
+ "inputs": {
+ "package": "requests"
+ },
+ "intent": "latest requests on pypi",
+ "tree": "pypi-package"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "reason": "no-package",
+ "say": "PyPI has no project called nope-not-a-pkg-zz.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "pip version nope-not-a-pkg-zz",
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "steps": [],
+ "trigger": "(pypi|pip) (package|version) {package:word}"
+ },
+ "summary": "'pip version nope-not-a-pkg-zz'; pypi-package via '(pypi|pip) (package|version) {package:word}' (21 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": null,
+ "name": null,
+ "url": null,
+ "version": null
+ },
+ "summary": "GET https://pypi.org/pypi/nope-not-a-pkg-zz/json -> 404; name=None, version=None, about=None, url=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-package",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-package",
+ "summary": "no-package: PyPI has no project called nope-not-a-pkg-zz."
+ }
+ ],
+ "tree": "pypi-package"
+ },
+ "tree": "pypi-package"
+ },
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "intent": "pip version nope-not-a-pkg-zz",
+ "tree": "pypi-package"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "count": 19,
+ "latest": {
+ "magnitude": 5.1,
+ "place": "7 km ENE of Pangyan, Philippines",
+ "tsunami": 0,
+ "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ }
+ },
+ "say": "19 earthquake(s) of magnitude 4.5+ in the past day. The latest: M5.1, 7 km ENE of Pangyan, Philippines. https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "any earthquakes today",
+ "inputs": {},
+ "steps": [],
+ "trigger": "[any] [recent] earthquakes [today]"
+ },
+ "summary": "'any earthquakes today'; earthquakes via '[any] [recent] earthquakes [today]' (25 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "count": 19,
+ "mag": 5.1,
+ "place": "7 km ENE of Pangyan, Philippines",
+ "tsunami": 0,
+ "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ },
+ "summary": "GET https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson -> 200; count=19, mag=5.1, place=7 km ENE of Pangyan, Philippines, tsunami=0, url=https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "gt 0",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": 19,
+ "summary": "state.count = 19"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "19 earthquake(s) of magnitude 4.5+ in the past day. The latest: M5.1, 7 km ENE of Pangyan, Philippines. https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ }
+ ],
+ "tree": "earthquakes"
+ },
+ "tree": "earthquakes"
+ },
+ "inputs": {},
+ "intent": "any earthquakes today",
+ "tree": "earthquakes"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "altitude_km": 422.43868994227,
+ "latitude": -26.625662371377,
+ "longitude": -60.102704384113,
+ "map": "https://www.openstreetmap.org/?mlat=-26.625662371377&mlon=-60.102704384113&zoom=3",
+ "speed_kmh": 27577.69858816,
+ "visibility": "eclipsed"
+ },
+ "say": "The ISS is over -26.62, -60.10, 422 km up, moving at 27577 km/h, in the Earth's shadow.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "where is the iss",
+ "inputs": {},
+ "steps": [],
+ "trigger": "where is the (iss|space station) [now]"
+ },
+ "summary": "'where is the iss'; iss via 'where is the (iss|space station) [now]' (28 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "alt": 422.43868994227,
+ "lat": -26.625662371377,
+ "lat2": "-26.62",
+ "light": "eclipsed",
+ "lon": -60.102704384113,
+ "lon2": "-60.10",
+ "speed": 27577.69858816
+ },
+ "summary": "GET https://api.wheretheiss.at/v1/satellites/25544 -> 200; lat=-26.625662371377, lon=-60.102704384113, alt=422.43868994227, speed=27577.69858816, light=eclipsed, lat2=-26.62, lon2=-60.10"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "have-position",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "type 'number'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "light",
+ "node": "have-position",
+ "observed": -26.625662371377,
+ "summary": "state.lat = -26.625662371377"
+ },
+ {
+ "chosen": "eq 'eclipsed'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "in-shadow",
+ "node": "light",
+ "observed": "eclipsed",
+ "summary": "state.light = eclipsed"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "in-shadow",
+ "observed": {
+ "where": "in the Earth's shadow"
+ },
+ "summary": "where=in the Earth's shadow"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The ISS is over -26.62, -60.10, 422 km up, moving at 27577 km/h, in the Earth's shadow."
+ }
+ ],
+ "tree": "iss"
+ },
+ "tree": "iss"
+ },
+ "inputs": {},
+ "intent": "where is the iss",
+ "tree": "iss"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "query": "sqlite"
+ },
+ "output": {
+ "query": "sqlite",
+ "stories": [
+ {
+ "by": "phiresky",
+ "discussion": "https://news.ycombinator.com/item?id=27016630",
+ "points": 1812,
+ "title": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ },
+ {
+ "by": "dpeck",
+ "discussion": "https://news.ycombinator.com/item?id=31318708",
+ "points": 1353,
+ "title": "I'm all-in on server-side SQLite",
+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/"
+ },
+ {
+ "by": "ropbear",
+ "discussion": "https://news.ycombinator.com/item?id=49272832",
+ "points": 1223,
+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",
+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug"
+ }
+ ],
+ "total": 4632
+ },
+ "say": "4632 Hacker News stories match sqlite. Top: Hosting SQLite databases on GitHub Pages or any static file hoster https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "search hacker news for sqlite",
+ "inputs": {
+ "query": "sqlite"
+ },
+ "steps": [],
+ "trigger": "search hacker news for {query}"
+ },
+ "summary": "'search hacker news for sqlite'; hn-search via 'search hacker news for {query}' (19 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "first": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "first_url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/",
+ "hits": [
+ {
+ "author": "phiresky",
+ "objectID": "27016630",
+ "points": 1812,
+ "title": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ },
+ {
+ "author": "dpeck",
+ "objectID": "31318708",
+ "points": 1353,
+ "title": "I'm all-in on server-side SQLite",
+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/"
+ },
+ {
+ "author": "ropbear",
+ "objectID": "49272832",
+ "points": 1223,
+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",
+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug"
+ }
+ ],
+ "total": 4632
+ },
+ "summary": "GET https://hn.algolia.com/api/v1/search?query=sqlite&tags=story&hitsPerPage=3 -> 200; total=4632, first=Hosting SQLite databases on GitHub Pages or any static file hoster, first_url=https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/, hits=[{'title': 'Hosting SQLite databases on GitHub Pages or any static file hoster'…"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "each",
+ "node": "any",
+ "observed": [
+ {
+ "author": "phiresky",
+ "objectID": "27016630",
+ "points": 1812,
+ "title": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ },
+ {
+ "author": "dpeck",
+ "objectID": "31318708",
+ "points": 1353,
+ "title": "I'm all-in on server-side SQLite",
+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/"
+ },
+ {
+ "author": "ropbear",
+ "objectID": "49272832",
+ "points": 1223,
+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",
+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug"
+ }
+ ],
+ "summary": "state.hits = [{'title': 'Hosting SQLite databases on GitHub Pages or any static file hoster'…"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 0,
+ "kind": "each",
+ "next": "reply",
+ "node": "each",
+ "summary": "3 item(s)"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "4632 Hacker News stories match sqlite. Top: Hosting SQLite databases on GitHub Pages or any static file hoster https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ }
+ ],
+ "tree": "hn-search"
+ },
+ "tree": "hn-search"
+ },
+ "inputs": {
+ "query": "sqlite"
+ },
+ "intent": "search hacker news for sqlite",
+ "tree": "hn-search"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "query": "qqqzzzxxyyy"
+ },
+ "output": {
+ "query": "qqqzzzxxyyy",
+ "stories": [],
+ "total": 0
+ },
+ "say": "No Hacker News stories match qqqzzzxxyyy.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "search hacker news for qqqzzzxxyyy",
+ "inputs": {
+ "query": "qqqzzzxxyyy"
+ },
+ "steps": [],
+ "trigger": "search hacker news for {query}"
+ },
+ "summary": "'search hacker news for qqqzzzxxyyy'; hn-search via 'search hacker news for {query}' (19 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "first": null,
+ "first_url": null,
+ "hits": [],
+ "total": 0
+ },
+ "summary": "GET https://hn.algolia.com/api/v1/search?query=qqqzzzxxyyy&tags=story&hitsPerPage=3 -> 200; total=0, first=None, first_url=None, hits=[]"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "empty True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "nothing",
+ "node": "any",
+ "observed": [],
+ "summary": "state.hits = []"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "nothing",
+ "summary": "No Hacker News stories match qqqzzzxxyyy."
+ }
+ ],
+ "tree": "hn-search"
+ },
+ "tree": "hn-search"
+ },
+ "inputs": {
+ "query": "qqqzzzxxyyy"
+ },
+ "intent": "search hacker news for qqqzzzxxyyy",
+ "tree": "hn-search"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "title": "The Left Hand of Darkness"
+ },
+ "output": {
+ "author": "Ursula K. Le Guin",
+ "first_published": 1969,
+ "matches": 112,
+ "title": "The Left Hand of Darkness",
+ "url": "https://openlibrary.org/works/OL59800W"
+ },
+ "say": "The Left Hand of Darkness, by Ursula K. Le Guin (1969). https://openlibrary.org/works/OL59800W",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "who wrote The Left Hand of Darkness",
+ "inputs": {
+ "title": "The Left Hand of Darkness"
+ },
+ "steps": [],
+ "trigger": "who wrote {title}"
+ },
+ "summary": "'who wrote The Left Hand of Darkness'; books via 'who wrote {title}' (8 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "author": "Ursula K. Le Guin",
+ "book": "The Left Hand of Darkness",
+ "found": 112,
+ "key": "/works/OL59800W",
+ "year": 1969
+ },
+ "summary": "GET https://openlibrary.org/search.json?q=The%20Left%20Hand%20of%20Darkness&limit=1&fields=title,author_name,first_publish_year,key -> 200; found=112, book=The Left Hand of Darkness, author=Ursula K. Le Guin, year=1969, key=/works/OL59800W"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": "The Left Hand of Darkness",
+ "summary": "state.book = The Left Hand of Darkness"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The Left Hand of Darkness, by Ursula K. Le Guin (1969). https://openlibrary.org/works/OL59800W"
+ }
+ ],
+ "tree": "books"
+ },
+ "tree": "books"
+ },
+ "inputs": {
+ "title": "The Left Hand of Darkness"
+ },
+ "intent": "who wrote The Left Hand of Darkness",
+ "tree": "books"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "title": "qqqzzzxxyyy"
+ },
+ "reason": "no-book",
+ "say": "Open Library has nothing matching qqqzzzxxyyy.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "find the book qqqzzzxxyyy",
+ "inputs": {
+ "title": "qqqzzzxxyyy"
+ },
+ "steps": [],
+ "trigger": "find the book {title}"
+ },
+ "summary": "'find the book qqqzzzxxyyy'; books via 'find the book {title}' (11 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "author": null,
+ "book": null,
+ "found": 0,
+ "key": null,
+ "year": null
+ },
+ "summary": "GET https://openlibrary.org/search.json?q=qqqzzzxxyyy&limit=1&fields=title,author_name,first_publish_year,key -> 200; found=0, book=None, author=None, year=None, key=None"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-book",
+ "node": "any",
+ "summary": "state.book = <missing>"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-book",
+ "summary": "no-book: Open Library has nothing matching qqqzzzxxyyy."
+ }
+ ],
+ "tree": "books"
+ },
+ "tree": "books"
+ },
+ "inputs": {
+ "title": "qqqzzzxxyyy"
+ },
+ "intent": "find the book qqqzzzxxyyy",
+ "tree": "books"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "country": "no"
+ },
+ "output": {
+ "after": {
+ "date": "2026-12-26",
+ "name": "St. Stephen's Day"
+ },
+ "country": "NO",
+ "date": "2026-12-25",
+ "local_name": "Første juledag",
+ "name": "Christmas Day"
+ },
+ "say": "The next public holiday in NO is Christmas Day (Første juledag) on 2026-12-25.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "next holiday in no",
+ "inputs": {
+ "country": "no"
+ },
+ "steps": [],
+ "trigger": "[when is the] next [public|bank] holiday in {country:word}"
+ },
+ "summary": "'next holiday in no'; holidays via '[when is the] next [public|bank] holiday in {country:word}' (32 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "date": "2026-12-25",
+ "local": "Første juledag",
+ "name": "Christmas Day",
+ "then": "St. Stephen's Day",
+ "then_date": "2026-12-26"
+ },
+ "summary": "GET https://date.nager.at/api/v3/NextPublicHolidays/NO -> 200; date=2026-12-25, name=Christmas Day, local=Første juledag, then=St. Stephen's Day, then_date=2026-12-26"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": "2026-12-25",
+ "summary": "state.date = 2026-12-25"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The next public holiday in NO is Christmas Day (Første juledag) on 2026-12-25."
+ }
+ ],
+ "tree": "holidays"
+ },
+ "tree": "holidays"
+ },
+ "inputs": {
+ "country": "no"
+ },
+ "intent": "next holiday in no",
+ "tree": "holidays"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "country": "XX"
+ },
+ "reason": "unknown-country",
+ "say": "Nager.Date knows no country XX (it answered 500); use a two-letter code such as NO, US or DE.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "next holiday in XX",
+ "inputs": {
+ "country": "XX"
+ },
+ "steps": [],
+ "trigger": "[when is the] next [public|bank] holiday in {country:word}"
+ },
+ "summary": "'next holiday in XX'; holidays via '[when is the] next [public|bank] holiday in {country:word}' (32 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "date": null,
+ "local": null,
+ "name": null,
+ "then": null,
+ "then_date": null
+ },
+ "summary": "GET https://date.nager.at/api/v3/NextPublicHolidays/XX -> 500; date=None, name=None, local=None, then=None, then_date=None"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "unknown-country",
+ "node": "status",
+ "observed": 500,
+ "summary": "state.resp.status = 500"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "unknown-country",
+ "summary": "unknown-country: Nager.Date knows no country XX (it answered 500); use a two-letter code such as NO, US or DE."
+ }
+ ],
+ "tree": "holidays"
+ },
+ "tree": "holidays"
+ },
+ "inputs": {
+ "country": "XX"
+ },
+ "intent": "next holiday in XX",
+ "tree": "holidays"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "joke": "I am so good at sleeping I can do it with my eyes closed!",
+ "url": "https://icanhazdadjoke.com/j/oOKBsPZ0wc"
+ },
+ "say": "I am so good at sleeping I can do it with my eyes closed!",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "tell me a joke",
+ "inputs": {},
+ "steps": [],
+ "trigger": "tell me a [dad] joke"
+ },
+ "summary": "'tell me a joke'; dad-joke via 'tell me a [dad] joke' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "id": "oOKBsPZ0wc",
+ "joke": "I am so good at sleeping I can do it with my eyes closed!"
+ },
+ "summary": "GET https://icanhazdadjoke.com/ -> 200; joke=I am so good at sleeping I can do it with my eyes closed!, id=oOKBsPZ0wc"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-joke",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-joke",
+ "observed": "I am so good at sleeping I can do it with my eyes closed!",
+ "summary": "state.joke = I am so good at sleeping I can do it with my eyes closed!"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "I am so good at sleeping I can do it with my eyes closed!"
+ }
+ ],
+ "tree": "dad-joke"
+ },
+ "tree": "dad-joke"
+ },
+ "inputs": {},
+ "intent": "tell me a joke",
+ "tree": "dad-joke"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "amount": "100",
+ "from": "usd",
+ "to": "eur"
+ },
+ "output": {
+ "amount": 100.0,
+ "date": "2026-10-27",
+ "from": "USD",
+ "to": "EUR",
+ "value": 89.69
+ },
+ "say": "100 USD is 89.69 EUR at the ECB rate of 2026-10-27.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "convert 100 usd to eur",
+ "inputs": {
+ "amount": 100,
+ "from": "usd",
+ "to": "eur"
+ },
+ "steps": [],
+ "trigger": "convert {amount:number} {from:word} (to|into|in) {to:word}"
+ },
+ "summary": "'convert 100 usd to eur'; exchange-rate via 'convert {amount:number} {from:word} (to|into|in) {to:word}' (15 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "day": "2026-10-27",
+ "value": "89.69"
+ },
+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR&amount=100 -> 200; value=89.69, day=2026-10-27"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-rate",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-rate",
+ "observed": "89.69",
+ "summary": "state.value = 89.69"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "100 USD is 89.69 EUR at the ECB rate of 2026-10-27."
+ }
+ ],
+ "tree": "exchange-rate"
+ },
+ "tree": "exchange-rate"
+ },
+ "inputs": {
+ "amount": 100,
+ "from": "usd",
+ "to": "eur"
+ },
+ "intent": "convert 100 usd to eur",
+ "tree": "exchange-rate"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "amount": "1000",
+ "from": "EUR",
+ "to": "JPY"
+ },
+ "output": {
+ "amount": 1000.0,
+ "date": "2026-10-27",
+ "from": "EUR",
+ "to": "JPY",
+ "value": 175970.0
+ },
+ "say": "1000 EUR is 175970 JPY at the ECB rate of 2026-10-27.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "convert 1,000 EUR into JPY",
+ "inputs": {
+ "amount": 1000,
+ "from": "EUR",
+ "to": "JPY"
+ },
+ "notes": [
+ "amount: '1,000' read as 1000"
+ ],
+ "steps": [],
+ "trigger": "convert {amount:number} {from:word} (to|into|in) {to:word}"
+ },
+ "summary": "'convert 1,000 EUR into JPY'; exchange-rate via 'convert {amount:number} {from:word} (to|into|in) {to:word}' (15 literal chars); amount: '1,000' read as 1000"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "day": "2026-10-27",
+ "value": "175970"
+ },
+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=EUR&symbols=JPY&amount=1000 -> 200; value=175970, day=2026-10-27"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-rate",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-rate",
+ "observed": "175970",
+ "summary": "state.value = 175970"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "1000 EUR is 175970 JPY at the ECB rate of 2026-10-27."
+ }
+ ],
+ "tree": "exchange-rate"
+ },
+ "tree": "exchange-rate"
+ },
+ "inputs": {
+ "amount": 1000,
+ "from": "EUR",
+ "to": "JPY"
+ },
+ "intent": "convert 1,000 EUR into JPY",
+ "tree": "exchange-rate"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "amount": "1",
+ "from": "usd",
+ "to": "xyz"
+ },
+ "reason": "unknown-currency",
+ "say": "The ECB publishes no rate between USD and XYZ; it covers about thirty currencies, by their three-letter codes.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "usd to xyz rate",
+ "inputs": {
+ "from": "usd",
+ "to": "xyz"
+ },
+ "steps": [],
+ "trigger": "{from:word} to {to:word} (rate|exchange rate)"
+ },
+ "summary": "'usd to xyz rate'; exchange-rate via '{from:word} to {to:word} (rate|exchange rate)' (18 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "day": null,
+ "value": null
+ },
+ "summary": "GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=XYZ&amount=1 -> 404; value=None, day=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "unknown-currency",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "unknown-currency",
+ "summary": "unknown-currency: The ECB publishes no rate between USD and XYZ; it covers about thirty currencies, by their three-letter codes."
+ }
+ ],
+ "tree": "exchange-rate"
+ },
+ "tree": "exchange-rate"
+ },
+ "inputs": {
+ "from": "usd",
+ "to": "xyz"
+ },
+ "intent": "usd to xyz rate",
+ "tree": "exchange-rate"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "amount": "1",
+ "from": "usd",

The diff is cut here; the rest is in the clone.