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 atjoin 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, seriallines, brokers, a browser to drive) or a site that does not let a page read it say so; for those, join a hub started with `--cors https://pyrtan.com` by giving its URL and token. Forget it and the@@ -272,15 +276,25 @@ keeper. Leaving a network (`pyrtan network leave`) cuts the links madein 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. Everyhour 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, thecatalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation-between hubs, networks (any hub starts one; pyrtan.com publishes the-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 aspart 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 cantherefore 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 thepublic 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 apublic 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 keydirectly. 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 ofclaims; 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 networkof 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: &pyrtanPYRTAN_EGRESS: any # Federation: once the hub is reachable from outside (a tunnel or a # port-forward with TLS), give it its public URL and it joins the- # default network pyrtan.com publishes, follows a 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 }}¤t=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` withno 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 askpyrtan 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 thelink'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 uuidfrom collections import deque from dataclasses import dataclass, field from pathlib import Path-from typing import Any, Callable+from typing import Any, Callable, Iterablefrom . import __version__ from .service import http_json@@ -98,7 +110,10 @@ from .store import BlobStore, StoreError, check_key, digest as blob_digestDEFAULT_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 outdef 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 ofone, 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 threadingfrom pathlib import Path import time from dataclasses import dataclass, field-from typing import Any+from typing import Any, Iterable, Iteratorfrom . 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_hubsfrom . 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 = triedif 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 themfor 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 beaconhub.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 sentenceconst 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 = Nonediff --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¤t=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¤t=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.