Add twelve trees that run in a browser as well as on a service
Claude · 2026-10-28 · e590539fc8fd11afdaa4beafc74ded53b50d0c67 · parents d610257
The console at pyrtan.com runs a tree in the page only when every node is http against an API that lets other origins read it. Of the eleven examples, five did. These twelve each reach one such API, no key needed:
wikipedia a Wikipedia article's summary (refuses disambiguation pages) define an English word's first definition (dictionaryapi.dev) sun today's sunrise and sunset, in local time (Open-Meteo) air-quality the European AQI and its band, PM2.5, PM10 (Open-Meteo) npm-package the latest version of an npm package, scoped names too pypi-package the latest version of a PyPI project earthquakes M4.5+ earthquakes in the past day, and the latest (USGS) iss where the ISS is now, rounded in words, exact in output hn-search Hacker News stories matching some words (Algolia) books a book by title or author (Open Library) holidays the next public holiday by country code (Nager.Date) dad-joke a dad joke (icanhazdadjoke.com, Accept: application/json)
Each decides on the status code before the body, and its failures name themselves (not found, rate-limited, ambiguous, polar night). Every branch is recorded in tests/fixtures/examples.json and tested, and every tree is in the conformance runs, so pyrtan.js answers each sentence as the Python runtime does (229/229).
"tell me a joke" now has a tree, so the tests that used it as the sentence nobody answers use "tell me a riddle"; tests that listed or counted the examples read them from examples/ instead. The console gains sample sentences for the new trees, README says which examples run in a page, and CONTRIBUTING says how to tell whether a tree will.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jeoK8iFSW7tkCrzXLZ
Changes
CONTRIBUTING.md | 8 + README.md | 6 +- 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/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/wikipedia/wikipedia.toml | 79 + src/pyrtan/page.py | 9 + tests/conformance/make_vectors.py | 26 +- tests/conformance/vectors.json | 4583 ++++++++++++++++++++++++++++++++++++++++++++++++----- tests/fixtures/examples.json | 295 ++++ tests/test_catalog.py | 15 +- tests/test_cli.py | 12 +- tests/test_examples.py | 90 ++ tests/test_service_hub.py | 4 +- 22 files changed, 5520 insertions(+), 383 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 623515b..0bdccd0 100644 --- a/README.md +++ b/README.md@@ -201,7 +201,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. Seventeen of the examples run there: weather,+sunrise and sunset, air quality, Wikipedia, a dictionary, 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 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/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/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/src/pyrtan/page.py b/src/pyrtan/page.py index 492ef28..ab4027b 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -313,6 +313,15 @@ 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",}; // 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; diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index 1e8c464..22b4337 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -77,6 +77,30 @@ 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"},]@@ -91,7 +115,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": ""}}), diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 14af50e..1956998 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,30 @@], "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": "echo for fixed", "hosts": [],@@ -213,6 +321,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 +504,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": [],@@ -327,6 +584,37 @@], "version": 1 },+ {+ "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": [],@@ -499,12 +787,43 @@"version": 1 }, {- "description": "echo for tie-a",- "hosts": [],+ "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": {- "x": {- "type": "str"- }+ "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": "echo for tie-a",+ "hosts": [],+ "inputs": {+ "x": {+ "type": "str"+ }}, "language": {}, "license": "",@@ -575,6 +894,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 +2702,7 @@"version": 1 }, {- "description": "echo for books",+ "description": "echo for library","hosts": [], "inputs": { "author": {@@ -2375,7 +2724,7 @@}, "typos": true },- "name": "books",+ "name": "library","nodes": { "reply": { "kind": "done",@@ -4997,7 +5346,7 @@"notes": [], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5009,7 +5358,7 @@"notes": [], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5025,7 +5374,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 +5392,7 @@"notes": [], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5055,7 +5404,7 @@"notes": [], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5071,7 +5420,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 +5438,7 @@"notes": [], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5101,7 +5450,7 @@"notes": [], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5117,7 +5466,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 +5506,7 @@], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5171,7 +5520,7 @@], "rejected": false, "specificity": 17,- "tree": "books",+ "tree": "library","trigger": "(find|search) books by {author}" } ],@@ -5190,7 +5539,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 +5555,7 @@"notes": [], "rejected": false, "specificity": 18,- "tree": "books",+ "tree": "library","trigger": "top story on book club" } ],@@ -5216,7 +5565,7 @@"notes": [], "rejected": false, "specificity": 18,- "tree": "books",+ "tree": "library","trigger": "top story on book club" } ],@@ -5230,7 +5579,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,167 +8660,2774 @@}, "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": [- {- "group": 1,- "into": "percent",- "regex": "(\\d+)%"- },- {- "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."+ },+ {+ "expect": {+ "inputs": {+ "topic": "Ursula K Le Guin"},- "full": {- "kind": "fail",- "reason": "disk-full",- "say": "{{ state.mounted }} is {{ state.pct }}% full."+ "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"},- "level": {- "branches": [+ "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": [{- "then": "fine",- "when": {- "lt": 80- }+ "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)"}, {- "then": "tight",- "when": {- "lt": 95- }+ "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"}, {- "else": "full"- }- ],- "kind": "decide",- "on": "pct"- },- "number": {- "kind": "set",- "next": "level",- "set": {- "pct": "{{ state.percent | int }}"- }- },- "ran": {- "branches": [+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "kind",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"+ },{- "then": "number",- "when": {- "eq": true- }+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "kind",+ "observed": "standard",+ "summary": "state.kind = standard"}, {- "else": "failed"+ "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": "df.ok"+ "tree": "wikipedia"},- "tight": {- "kind": "done",- "output": {- "mounted": "{{ state.mounted }}",- "percent": "{{ state.pct }}"- },- "say": "{{ state.mounted }} is {{ state.pct }}% full; make room soon."- }+ "tree": "wikipedia"},- "start": "df",- "tags": [- "ops",- "box",- "read-only"- ],- "triggers": [- {- "pattern": "[how] full is the disk"- },- {- "pattern": "disk space"- },- {- "pattern": "disk space on {mount:word}"- }- ],- "version": 2- },- "github-release": {- "description": "The latest release of a GitHub repository.",- "hosts": [- "api.github.com"- ],"inputs": {- "repo": {- "description": "owner/name",- "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$",- "type": "str"- }+ "topic": "Ursula K Le Guin"},- "license": "MIT",- "name": "github-release",- "namespace": "pyrtan",- "nodes": {- "fetch": {- "extract": [+ "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": [{- "into": "tag",- "json": "json.tag_name"+ "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)"}, {- "into": "name",- "json": "json.name"+ "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"}, {- "into": "published",- "json": "json.published_at"+ "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"+ }+ ],+ "trees": {+ "air-quality": {+ "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": {+ "place": {+ "description": "A town or city name",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "air-quality",+ "namespace": "pyrtan",+ "nodes": {+ "air": {+ "extract": [+ {+ "into": "aqi",+ "json": "json.current.european_aqi"+ },+ {+ "into": "pm25",+ "json": "json.current.pm2_5"+ },+ {+ "into": "pm10",+ "json": "json.current.pm10"+ }+ ],+ "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",+ "kind": "observe",+ "next": "band"+ },+ "band": {+ "branches": [+ {+ "then": "good",+ "when": {+ "lte": 20,+ "type": "number"+ }+ },+ {+ "then": "fair",+ "when": {+ "lte": 40+ }+ },+ {+ "then": "moderate",+ "when": {+ "lte": 60+ }+ },+ {+ "then": "poor",+ "when": {+ "lte": 80+ }+ },+ {+ "then": "very-poor",+ "when": {+ "lte": 100+ }+ },+ {+ "then": "extremely-poor",+ "when": {+ "gt": 100+ }+ },+ {+ "else": "no-reading"+ }+ ],+ "kind": "decide",+ "on": "aqi"+ },+ "extremely-poor": {+ "kind": "set",+ "next": "reply",+ "set": {+ "band": "extremely poor"+ }+ },+ "fair": {+ "kind": "set",+ "next": "reply",+ "set": {+ "band": "fair"+ }+ },+ "found": {+ "branches": [+ {+ "then": "air",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unknown-place"+ }+ ],+ "kind": "decide",+ "on": "hit"+ },+ "geocode": {+ "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"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json"+ },+ "into": "geo",+ "kind": "observe",+ "next": "found"+ },+ "good": {+ "kind": "set",+ "next": "reply",+ "set": {+ "band": "good"+ }+ },+ "moderate": {+ "kind": "set",+ "next": "reply",+ "set": {+ "band": "moderate"+ }+ },+ "no-reading": {+ "kind": "fail",+ "reason": "no-reading",+ "say": "Open-Meteo found {{ state.name }} but gave no air quality reading (status {{ state.aq.status }})."+ },+ "poor": {+ "kind": "set",+ "next": "reply",+ "set": {+ "band": "poor"+ }+ },+ "reply": {+ "kind": "done",+ "output": {+ "aqi": "{{ state.aqi }}",+ "band": "{{ state.band }}",+ "country": "{{ state.country }}",+ "place": "{{ state.name }}",+ "pm10": "{{ state.pm10 }}",+ "pm2_5": "{{ state.pm25 }}"+ },+ "say": "{{ state.name }}, {{ state.country }}: air quality {{ state.band }} (European AQI {{ state.aqi }}), PM2.5 {{ state.pm25 }} µg/m³, PM10 {{ state.pm10 }} µg/m³."+ },+ "unknown-place": {+ "kind": "fail",+ "reason": "unknown-place",+ "say": "I could not find a place called {{ inputs.place }}."+ },+ "very-poor": {+ "kind": "set",+ "next": "reply",+ "set": {+ "band": "very poor"+ }+ }+ },+ "start": "geocode",+ "tags": [+ "weather",+ "health",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[what's the] air quality (in|for) {place}"+ },+ {+ "pattern": "how is the air in {place}"+ },+ {+ "pattern": "(aqi|pollution) in {place}"+ }+ ],+ "version": 1+ },+ "books": {+ "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",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "books",+ "namespace": "pyrtan",+ "nodes": {+ "any": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "no-book"+ }+ ],+ "kind": "decide",+ "on": "book"+ },+ "no-book": {+ "kind": "fail",+ "reason": "no-book",+ "say": "Open Library has nothing matching {{ inputs.title }}."+ },+ "reply": {+ "kind": "done",+ "output": {+ "author": "{{ state.author }}",+ "first_published": "{{ state.year }}",+ "matches": "{{ state.found }}",+ "title": "{{ state.book }}",+ "url": "https://openlibrary.org{{ state.key }}"+ },+ "say": "{{ state.book }}, by {{ state.author }} ({{ state.year }}). https://openlibrary.org{{ state.key }}"+ },+ "search": {+ "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"+ }+ ],+ "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",+ "kind": "observe",+ "next": "status"+ },+ "status": {+ "branches": [+ {+ "then": "any",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "Open Library answered {{ state.resp.status }}."+ }+ },+ "start": "search",+ "tags": [+ "reference",+ "books",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "who wrote {title}"+ },+ {+ "pattern": "find the book {title}"+ },+ {+ "pattern": "books by {title}"+ }+ ],+ "version": 1+ },+ "dad-joke": {+ "description": "A random dad joke, from icanhazdadjoke.com.",+ "hosts": [+ "icanhazdadjoke.com"+ ],+ "license": "MIT",+ "name": "dad-joke",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "joke",+ "json": "json.joke"+ },+ {+ "into": "id",+ "json": "json.id"+ }+ ],+ "http": {+ "headers": {+ "Accept": "application/json"+ },+ "method": "GET",+ "url": "https://icanhazdadjoke.com/"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "has-joke": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "joke"+ },+ "reply": {+ "kind": "done",+ "output": {+ "joke": "{{ state.joke }}",+ "url": "https://icanhazdadjoke.com/j/{{ state.id }}"+ },+ "say": "{{ state.joke }}"+ },+ "status": {+ "branches": [+ {+ "then": "has-joke",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "icanhazdadjoke.com answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "fun",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "tell me a [dad] joke"+ },+ {+ "pattern": "[a] dad joke"+ },+ {+ "pattern": "make me laugh"+ }+ ],+ "version": 1+ },+ "define": {+ "description": "What an English word means: its first definition, part of speech and pronunciation.",+ "hosts": [+ "api.dictionaryapi.dev"+ ],+ "inputs": {+ "word": {+ "description": "One English word",+ "pattern": "^[A-Za-z][A-Za-z'\\-]*$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "define",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "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"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.dictionaryapi.dev/api/v2/entries/en/{{ inputs.word | lower | url }}"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "has-definition": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unknown-word"+ }+ ],+ "kind": "decide",+ "on": "definition"+ },+ "reply": {+ "kind": "done",+ "output": {+ "definition": "{{ state.definition }}",+ "part_of_speech": "{{ state.part }}",+ "phonetic": "{{ state.phonetic }}",+ "word": "{{ state.word }}"+ },+ "say": "{{ state.word }} ({{ state.part }}): {{ state.definition }}"+ },+ "status": {+ "branches": [+ {+ "then": "has-definition",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "unknown-word",+ "when": {+ "eq": 404+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "The dictionary answered {{ state.resp.status }}."+ },+ "unknown-word": {+ "kind": "fail",+ "reason": "unknown-word",+ "say": "The dictionary has no entry for {{ inputs.word }}."+ }+ },+ "start": "fetch",+ "tags": [+ "reference",+ "language",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "define {word:word}"+ },+ {+ "pattern": "what does {word:word} mean"+ },+ {+ "pattern": "[the] (meaning|definition) of {word:word}"+ }+ ],+ "version": 1+ },+ "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": [+ {+ "group": 1,+ "into": "percent",+ "regex": "(\\d+)%"+ },+ {+ "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": [+ {+ "then": "fine",+ "when": {+ "lt": 80+ }+ },+ {+ "then": "tight",+ "when": {+ "lt": 95+ }+ },+ {+ "else": "full"+ }+ ],+ "kind": "decide",+ "on": "pct"+ },+ "number": {+ "kind": "set",+ "next": "level",+ "set": {+ "pct": "{{ state.percent | int }}"+ }+ },+ "ran": {+ "branches": [+ {+ "then": "number",+ "when": {+ "eq": true+ }+ },+ {+ "else": "failed"+ }+ ],+ "kind": "decide",+ "on": "df.ok"+ },+ "tight": {+ "kind": "done",+ "output": {+ "mounted": "{{ state.mounted }}",+ "percent": "{{ state.pct }}"+ },+ "say": "{{ state.mounted }} is {{ state.pct }}% full; make room soon."+ }+ },+ "start": "df",+ "tags": [+ "ops",+ "box",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[how] full is the disk"+ },+ {+ "pattern": "disk space"+ },+ {+ "pattern": "disk space on {mount:word}"+ }+ ],+ "version": 2+ },+ "earthquakes": {+ "description": "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest.",+ "hosts": [+ "earthquake.usgs.gov"+ ],+ "license": "MIT",+ "name": "earthquakes",+ "namespace": "pyrtan",+ "nodes": {+ "any": {+ "branches": [+ {+ "then": "none",+ "when": {+ "eq": 0+ }+ },+ {+ "then": "reply",+ "when": {+ "gt": 0+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "count"+ },+ "fetch": {+ "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"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "none": {+ "kind": "done",+ "output": {+ "count": 0+ },+ "say": "No earthquakes of magnitude 4.5+ anywhere in the past day."+ },+ "reply": {+ "kind": "done",+ "output": {+ "count": "{{ state.count }}",+ "latest": {+ "magnitude": "{{ state.mag }}",+ "place": "{{ state.place }}",+ "tsunami": "{{ state.tsunami }}",+ "url": "{{ state.url }}"+ }+ },+ "say": "{{ state.count }} earthquake(s) of magnitude 4.5+ in the past day. The latest: M{{ state.mag }}, {{ state.place }}. {{ state.url }}"+ },+ "status": {+ "branches": [+ {+ "then": "any",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "The USGS feed answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "science",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[any] [recent] earthquakes [today]"+ },+ {+ "pattern": "[was there an] earthquake today"+ },+ {+ "pattern": "latest earthquake"+ }+ ],+ "version": 1+ },+ "github-release": {+ "description": "The latest release of a GitHub repository.",+ "hosts": [+ "api.github.com"+ ],+ "inputs": {+ "repo": {+ "description": "owner/name",+ "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "github-release",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "tag",+ "json": "json.tag_name"+ },+ {+ "into": "name",+ "json": "json.name"+ },+ {+ "into": "published",+ "json": "json.published_at"}, { "into": "url",@@ -8487,101 +11443,355 @@"Accept": "application/vnd.github+json" }, "method": "GET",- "url": "https://api.github.com/repos/{{ inputs.repo }}/releases/latest"+ "url": "https://api.github.com/repos/{{ inputs.repo }}/releases/latest"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "none": {+ "kind": "fail",+ "reason": "no-release",+ "say": "{{ inputs.repo }} has no releases, or does not exist."+ },+ "rate-limited": {+ "kind": "fail",+ "reason": "rate-limited",+ "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."+ },+ "reply": {+ "kind": "done",+ "output": {+ "name": "{{ state.name }}",+ "prerelease": "{{ state.prerelease }}",+ "published": "{{ state.published }}",+ "repo": "{{ inputs.repo }}",+ "tag": "{{ state.tag }}",+ "url": "{{ state.url }}"+ },+ "say": "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"+ },+ "status": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "none",+ "when": {+ "eq": 404+ }+ },+ {+ "then": "rate-limited",+ "when": {+ "in": [+ 403,+ 429+ ]+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-response",+ "say": "GitHub answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "github",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[what's the] latest release of {repo:word}"+ },+ {+ "pattern": "latest {repo:word} release"+ }+ ],+ "version": 2+ },+ "hacker-news": {+ "description": "Top stories on Hacker News, from its public API.",+ "hosts": [+ "hacker-news.firebaseio.com"+ ],+ "inputs": {+ "count": {+ "default": 1,+ "description": "How many stories to return",+ "max": 30,+ "min": 1,+ "type": "int"+ }+ },+ "license": "MIT",+ "name": "hacker-news",+ "namespace": "pyrtan",+ "nodes": {+ "any": {+ "branches": [+ {+ "then": "nothing",+ "when": {+ "empty": true+ }+ },+ {+ "then": "stories",+ "when": {+ "type": "list"+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "ids"+ },+ "check": {+ "branches": [+ {+ "then": "any",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "hn-down",+ "when": {+ "gte": 500+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "ids_resp.status"+ },+ "fetch-ids": {+ "extract": [+ {+ "into": "ids",+ "json": "json"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://hacker-news.firebaseio.com/v0/topstories.json"+ },+ "into": "ids_resp",+ "kind": "observe",+ "next": "check"+ },+ "hn-down": {+ "kind": "fail",+ "reason": "upstream-down",+ "say": "Hacker News answered {{ state.ids_resp.status }}; try again later."+ },+ "nothing": {+ "kind": "fail",+ "reason": "no-stories",+ "say": "Hacker News returned an empty front page."+ },+ "reply": {+ "kind": "done",+ "output": {+ "stories": "{{ state.stories }}"+ },+ "say": "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"+ },+ "stories": {+ "into": "stories",+ "kind": "each",+ "limit": "{{ inputs.count }}",+ "next": "reply",+ "over": "ids",+ "tree": "hacker-news-item",+ "with": {+ "id": "{{ item }}"+ }+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-response",+ "say": "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."+ }+ },+ "start": "fetch-ids",+ "tags": [+ "news",+ "read-only"+ ],+ "triggers": [+ {+ "inputs": {+ "count": 1+ },+ "pattern": "[what's the] top story on hacker news"+ },+ {+ "pattern": "top {count:int} stories on hacker news"+ }+ ],+ "version": 2+ },+ "hacker-news-item": {+ "description": "One Hacker News item by id: title, url, score, author.",+ "hosts": [+ "hacker-news.firebaseio.com"+ ],+ "inputs": {+ "id": {+ "description": "The item id",+ "min": 1,+ "type": "int"+ }+ },+ "license": "MIT",+ "name": "hacker-news-item",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "title",+ "json": "json.title"+ },+ {+ "into": "url",+ "json": "json.url"+ },+ {+ "into": "score",+ "json": "json.score"+ },+ {+ "into": "by",+ "json": "json.by"+ },+ {+ "into": "type",+ "json": "json.type"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json"}, "into": "resp", "kind": "observe",- "next": "status"- },- "none": {- "kind": "fail",- "reason": "no-release",- "say": "{{ inputs.repo }} has no releases, or does not exist."- },- "rate-limited": {- "kind": "fail",- "reason": "rate-limited",- "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."- },- "reply": {- "kind": "done",- "output": {- "name": "{{ state.name }}",- "prerelease": "{{ state.prerelease }}",- "published": "{{ state.published }}",- "repo": "{{ inputs.repo }}",- "tag": "{{ state.tag }}",- "url": "{{ state.url }}"- },- "say": "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"+ "next": "kind"},- "status": {+ "has-url": {"branches": [ { "then": "reply", "when": {- "eq": 200+ "exists": true} }, {- "then": "none",+ "else": "self-post"+ }+ ],+ "kind": "decide",+ "on": "url"+ },+ "kind": {+ "branches": [+ {+ "then": "has-url","when": {- "eq": 404+ "eq": "story"} }, {- "then": "rate-limited",+ "then": "has-url","when": {- "in": [- 403,- 429- ]+ "eq": "job"} }, {- "else": "unexpected"+ "then": "missing",+ "when": {+ "exists": false+ }+ },+ {+ "else": "not-a-story"} ], "kind": "decide",- "on": "resp.status"+ "on": "type"},- "unexpected": {+ "missing": {"kind": "fail",- "reason": "unexpected-response",- "say": "GitHub answered {{ state.resp.status }}."+ "reason": "no-such-item",+ "say": "There is no Hacker News item {{ inputs.id }}."+ },+ "not-a-story": {+ "kind": "fail",+ "reason": "not-a-story",+ "say": "Item {{ inputs.id }} is a {{ state.type }}, not a story."+ },+ "reply": {+ "kind": "done",+ "output": {+ "by": "{{ state.by }}",+ "id": "{{ inputs.id }}",+ "score": "{{ state.score }}",+ "title": "{{ state.title }}",+ "url": "{{ state.url }}"+ },+ "say": "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"+ },+ "self-post": {+ "kind": "set",+ "next": "reply",+ "set": {+ "url": "https://news.ycombinator.com/item?id={{ inputs.id }}"+ }} }, "start": "fetch", "tags": [- "github",+ "news","read-only" ], "triggers": [ {- "pattern": "[what's the] latest release of {repo:word}"- },- {- "pattern": "latest {repo:word} release"+ "pattern": "hacker news item {id:int}"} ], "version": 2 },- "hacker-news": {- "description": "Top stories on Hacker News, from its public API.",+ "hn-search": {+ "description": "Hacker News stories about something, through HN Search: the top three matches.","hosts": [- "hacker-news.firebaseio.com"+ "hn.algolia.com"], "inputs": {- "count": {- "default": 1,- "description": "How many stories to return",- "max": 30,- "min": 1,- "type": "int"+ "query": {+ "description": "Words to search story titles and text for",+ "type": "str"} }, "license": "MIT",- "name": "hacker-news",+ "name": "hn-search","namespace": "pyrtan", "nodes": { "any": {@@ -8593,19 +11803,76 @@} }, {- "then": "stories",- "when": {- "type": "list"- }+ "else": "each"+ }+ ],+ "kind": "decide",+ "on": "hits"+ },+ "each": {+ "into": "stories",+ "kind": "each",+ "limit": 3,+ "next": "reply",+ "over": "hits",+ "start": "one"+ },+ "nothing": {+ "kind": "done",+ "output": {+ "query": "{{ inputs.query }}",+ "stories": [],+ "total": 0+ },+ "say": "No Hacker News stories match {{ inputs.query }}."+ },+ "one": {+ "kind": "done",+ "output": {+ "by": "{{ item.author }}",+ "discussion": "https://news.ycombinator.com/item?id={{ item.objectID }}",+ "points": "{{ item.points }}",+ "title": "{{ item.title }}",+ "url": "{{ item.url }}"+ }+ },+ "reply": {+ "kind": "done",+ "output": {+ "query": "{{ inputs.query }}",+ "stories": "{{ state.stories }}",+ "total": "{{ state.total }}"+ },+ "say": "{{ state.total }} Hacker News stories match {{ inputs.query }}. Top: {{ state.first }} {{ state.first_url }}"+ },+ "search": {+ "extract": [+ {+ "into": "total",+ "json": "json.nbHits"}, {- "else": "unexpected"+ "into": "first",+ "json": "json.hits[0].title"+ },+ {+ "into": "first_url",+ "json": "json.hits[0].url"+ },+ {+ "into": "hits",+ "json": "json.hits"} ],- "kind": "decide",- "on": "ids"+ "http": {+ "method": "GET",+ "url": "https://hn.algolia.com/api/v1/search?query={{ inputs.query | url }}&tags=story&hitsPerPage=3"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"},- "check": {+ "status": {"branches": [ { "then": "any",@@ -8614,214 +11881,311 @@} }, {- "then": "hn-down",+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "HN Search answered {{ state.resp.status }}."+ }+ },+ "start": "search",+ "tags": [+ "news",+ "tech",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "search hacker news for {query}"+ },+ {+ "pattern": "hacker news stories about {query}"+ },+ {+ "pattern": "what does hacker news say about {query}"+ }+ ],+ "version": 1+ },+ "holidays": {+ "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",+ "pattern": "^[A-Za-z]{2}$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "holidays",+ "namespace": "pyrtan",+ "nodes": {+ "any": {+ "branches": [+ {+ "then": "reply","when": {- "gte": 500+ "exists": true} }, {- "else": "unexpected"+ "else": "none-listed"} ], "kind": "decide",- "on": "ids_resp.status"+ "on": "date"},- "fetch-ids": {+ "fetch": {"extract": [ {- "into": "ids",- "json": "json"+ "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"} ], "http": { "method": "GET",- "url": "https://hacker-news.firebaseio.com/v0/topstories.json"+ "url": "https://date.nager.at/api/v3/NextPublicHolidays/{{ inputs.country | upper }}"},- "into": "ids_resp",+ "into": "resp","kind": "observe",- "next": "check"- },- "hn-down": {- "kind": "fail",- "reason": "upstream-down",- "say": "Hacker News answered {{ state.ids_resp.status }}; try again later."+ "next": "status"},- "nothing": {+ "none-listed": {"kind": "fail",- "reason": "no-stories",- "say": "Hacker News returned an empty front page."+ "reason": "none-listed",+ "say": "Nager.Date lists no coming public holidays for {{ inputs.country | upper }}."}, "reply": { "kind": "done", "output": {- "stories": "{{ state.stories }}"+ "after": {+ "date": "{{ state.then_date }}",+ "name": "{{ state.then }}"+ },+ "country": "{{ inputs.country | upper }}",+ "date": "{{ state.date }}",+ "local_name": "{{ state.local }}",+ "name": "{{ state.name }}"},- "say": "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"+ "say": "The next public holiday in {{ inputs.country | upper }} is {{ state.name }} ({{ state.local }}) on {{ state.date }}."},- "stories": {- "into": "stories",- "kind": "each",- "limit": "{{ inputs.count }}",- "next": "reply",- "over": "ids",- "tree": "hacker-news-item",- "with": {- "id": "{{ item }}"- }+ "status": {+ "branches": [+ {+ "then": "any",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unknown-country"+ }+ ],+ "kind": "decide",+ "on": "resp.status"},- "unexpected": {+ "unknown-country": {"kind": "fail",- "reason": "unexpected-response",- "say": "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."+ "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."} },- "start": "fetch-ids",+ "start": "fetch","tags": [- "news",+ "calendar","read-only" ], "triggers": [ {- "inputs": {- "count": 1- },- "pattern": "[what's the] top story on hacker news"+ "pattern": "[when is the] next [public|bank] holiday in {country:word}"}, {- "pattern": "top {count:int} stories on hacker news"+ "pattern": "[public|bank] holidays in {country:word}"} ],- "version": 2+ "version": 1},- "hacker-news-item": {- "description": "One Hacker News item by id: title, url, score, author.",+ "iss": {+ "description": "Where the International Space Station is right now: latitude, longitude, altitude and speed.","hosts": [- "hacker-news.firebaseio.com"+ "api.wheretheiss.at"],- "inputs": {- "id": {- "description": "The item id",- "min": 1,- "type": "int"- }- },"license": "MIT",- "name": "hacker-news-item",+ "name": "iss","namespace": "pyrtan", "nodes": { "fetch": { "extract": [ {- "into": "title",- "json": "json.title"+ "into": "lat",+ "json": "json.latitude"}, {- "into": "url",- "json": "json.url"+ "into": "lon",+ "json": "json.longitude"}, {- "into": "score",- "json": "json.score"+ "into": "alt",+ "json": "json.altitude"}, {- "into": "by",- "json": "json.by"+ "into": "speed",+ "json": "json.velocity"}, {- "into": "type",- "json": "json.type"+ "into": "light",+ "json": "json.visibility"+ },+ {+ "into": "lat2",+ "regex": "\"latitude\":\\s*(-?\\d+(?:\\.\\d{1,2})?)"+ },+ {+ "into": "lon2",+ "regex": "\"longitude\":\\s*(-?\\d+(?:\\.\\d{1,2})?)"} ], "http": { "method": "GET",- "url": "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json"+ "url": "https://api.wheretheiss.at/v1/satellites/25544"}, "into": "resp", "kind": "observe",- "next": "kind"+ "next": "status"},- "has-url": {+ "have-position": {"branches": [ {- "then": "reply",+ "then": "light","when": {- "exists": true+ "type": "number"} }, {- "else": "self-post"+ "else": "unexpected"} ], "kind": "decide",- "on": "url"+ "on": "lat"},- "kind": {+ "in-shadow": {+ "kind": "set",+ "next": "reply",+ "set": {+ "where": "in the Earth's shadow"+ }+ },+ "in-sun": {+ "kind": "set",+ "next": "reply",+ "set": {+ "where": "in sunlight"+ }+ },+ "light": {"branches": [ {- "then": "has-url",- "when": {- "eq": "story"- }- },- {- "then": "has-url",+ "then": "in-sun","when": {- "eq": "job"+ "eq": "daylight"} }, {- "then": "missing",+ "then": "in-shadow","when": {- "exists": false+ "eq": "eclipsed"} }, {- "else": "not-a-story"+ "else": "in-sun"} ], "kind": "decide",- "on": "type"- },- "missing": {- "kind": "fail",- "reason": "no-such-item",- "say": "There is no Hacker News item {{ inputs.id }}."+ "on": "light"},- "not-a-story": {+ "rate-limited": {"kind": "fail",- "reason": "not-a-story",- "say": "Item {{ inputs.id }} is a {{ state.type }}, not a story."+ "reason": "rate-limited",+ "say": "wheretheiss.at is asking for fewer requests (429); try again in a moment."}, "reply": { "kind": "done", "output": {- "by": "{{ state.by }}",- "id": "{{ inputs.id }}",- "score": "{{ state.score }}",- "title": "{{ state.title }}",- "url": "{{ state.url }}"+ "altitude_km": "{{ state.alt }}",+ "latitude": "{{ state.lat }}",+ "longitude": "{{ state.lon }}",+ "map": "https://www.openstreetmap.org/?mlat={{ state.lat }}&mlon={{ state.lon }}&zoom=3",+ "speed_kmh": "{{ state.speed }}",+ "visibility": "{{ state.light }}"},- "say": "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"+ "say": "The ISS is over {{ state.lat2 }}, {{ state.lon2 }}, {{ state.alt | int }} km up, moving at {{ state.speed | int }} km/h, {{ state.where }}."},- "self-post": {- "kind": "set",- "next": "reply",- "set": {- "url": "https://news.ycombinator.com/item?id={{ inputs.id }}"- }+ "status": {+ "branches": [+ {+ "then": "have-position",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "rate-limited",+ "when": {+ "eq": 429+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "wheretheiss.at answered {{ state.resp.status }}."} }, "start": "fetch", "tags": [- "news",+ "science",+ "space","read-only" ], "triggers": [ {- "pattern": "hacker news item {id:int}"+ "pattern": "where is the (iss|space station) [now]"+ },+ {+ "pattern": "(iss|space station) (position|location)"} ],- "version": 2+ "version": 1}, "log-errors": { "description": "Error lines in the tail of a log file on this box.",@@ -8999,33 +12363,233 @@"reason": "modem-error", "say": "The modem on {{ inputs.device }} answered ERROR." },- "odd": {+ "odd": {+ "kind": "fail",+ "reason": "odd-answer",+ "say": "{{ inputs.device }} answered {{ state.s.body | strip }} to AT."+ },+ "yes": {+ "kind": "done",+ "output": {+ "device": "{{ inputs.device }}"+ },+ "say": "The modem on {{ inputs.device }} is there."+ }+ },+ "start": "at",+ "tags": [+ "hardware",+ "serial"+ ],+ "triggers": [+ {+ "pattern": "is the modem up"+ },+ {+ "pattern": "is the modem on {device:word} up"+ }+ ],+ "version": 2+ },+ "npm-package": {+ "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",+ "pattern": "^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "npm-package",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "version",+ "json": "json.version"+ },+ {+ "into": "about",+ "json": "json.description"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://registry.npmjs.org/{{ inputs.package | url }}/latest"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "no-package": {+ "kind": "fail",+ "reason": "no-package",+ "say": "npm has no package called {{ inputs.package }}."+ },+ "reply": {+ "kind": "done",+ "output": {+ "description": "{{ state.about }}",+ "package": "{{ inputs.package }}",+ "url": "https://www.npmjs.com/package/{{ inputs.package }}",+ "version": "{{ state.version }}"+ },+ "say": "{{ inputs.package }} {{ state.version }} on npm: {{ state.about }} https://www.npmjs.com/package/{{ inputs.package }}"+ },+ "status": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "no-package",+ "when": {+ "eq": 404+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "The npm registry answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "software",+ "javascript",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[what's the] latest version of {package:word} on npm"+ },+ {+ "pattern": "latest {package:word} on npm"+ },+ {+ "pattern": "npm (package|version) {package:word}"+ }+ ],+ "version": 1+ },+ "pypi-package": {+ "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",+ "pattern": "^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "pypi-package",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "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"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://pypi.org/pypi/{{ inputs.package | url }}/json"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "no-package": {+ "kind": "fail",+ "reason": "no-package",+ "say": "PyPI has no project called {{ inputs.package }}."+ },+ "reply": {+ "kind": "done",+ "output": {+ "package": "{{ state.name }}",+ "summary": "{{ state.about }}",+ "url": "{{ state.url }}",+ "version": "{{ state.version }}"+ },+ "say": "{{ state.name }} {{ state.version }} on PyPI: {{ state.about }} {{ state.url }}"+ },+ "status": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "no-package",+ "when": {+ "eq": 404+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {"kind": "fail",- "reason": "odd-answer",- "say": "{{ inputs.device }} answered {{ state.s.body | strip }} to AT."- },- "yes": {- "kind": "done",- "output": {- "device": "{{ inputs.device }}"- },- "say": "The modem on {{ inputs.device }} is there."+ "reason": "unexpected-status",+ "say": "PyPI answered {{ state.resp.status }}."} },- "start": "at",+ "start": "fetch","tags": [- "hardware",- "serial"+ "software",+ "python",+ "read-only"], "triggers": [ {- "pattern": "is the modem up"+ "pattern": "[what's the] latest version of {package:word} on pypi"}, {- "pattern": "is the modem on {device:word} up"+ "pattern": "latest {package:word} on pypi"+ },+ {+ "pattern": "(pypi|pip) (package|version) {package:word}"} ],- "version": 2+ "version": 1}, "rendered-headline": { "description": "The main heading of a page that needs a script to render, through a real browser.",@@ -9491,6 +13055,153 @@], "version": 1 },+ "sun": {+ "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",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "sun",+ "namespace": "pyrtan",+ "nodes": {+ "found": {+ "branches": [+ {+ "then": "times",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unknown-place"+ }+ ],+ "kind": "decide",+ "on": "hit"+ },+ "geocode": {+ "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"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json"+ },+ "into": "geo",+ "kind": "observe",+ "next": "found"+ },+ "have-times": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "no-times"+ }+ ],+ "kind": "decide",+ "on": "rise"+ },+ "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."+ },+ "reply": {+ "kind": "done",+ "output": {+ "country": "{{ state.country }}",+ "date": "{{ state.day }}",+ "place": "{{ state.name }}",+ "sunrise": "{{ state.rise }}",+ "sunset": "{{ state.set }}",+ "timezone": "{{ state.zone }}"+ },+ "say": "{{ state.name }}, {{ state.country }}, {{ state.day }}: sunrise {{ state.rise }}, sunset {{ state.set }} ({{ state.zone }})."+ },+ "times": {+ "extract": [+ {+ "into": "day",+ "json": "json.daily.time[0]"+ },+ {+ "from": "json.daily.sunrise[0]",+ "into": "rise",+ "regex": "T(\\d\\d:\\d\\d)"+ },+ {+ "from": "json.daily.sunset[0]",+ "into": "set",+ "regex": "T(\\d\\d:\\d\\d)"+ },+ {+ "into": "zone",+ "json": "json.timezone"+ }+ ],+ "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",+ "kind": "observe",+ "next": "have-times"+ },+ "unknown-place": {+ "kind": "fail",+ "reason": "unknown-place",+ "say": "I could not find a place called {{ inputs.place }}."+ }+ },+ "start": "geocode",+ "tags": [+ "weather",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[when is] (sunrise|sunset) in {place}"+ },+ {+ "pattern": "when does the sun (rise|set) in {place}"+ },+ {+ "pattern": "(sunrise|sun) times (in|for) {place}"+ }+ ],+ "version": 1+ },"weather": { "description": "Current weather for a place, from Open-Meteo.", "hosts": [@@ -9783,6 +13494,144 @@} ], "version": 2+ },+ "wikipedia": {+ "description": "The summary of a Wikipedia article.",+ "hosts": [+ "en.wikipedia.org"+ ],+ "inputs": {+ "topic": {+ "description": "An article title, as in: Ursula K. Le Guin",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "wikipedia",+ "namespace": "pyrtan",+ "nodes": {+ "ambiguous": {+ "kind": "fail",+ "reason": "ambiguous",+ "say": "{{ state.title }} could mean several things on Wikipedia; say which: {{ state.url }}"+ },+ "fetch": {+ "extract": [+ {+ "into": "kind",+ "json": "json.type"+ },+ {+ "into": "title",+ "json": "json.title"+ },+ {+ "into": "summary",+ "json": "json.extract"+ },+ {+ "into": "url",+ "json": "json.content_urls.desktop.page"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://en.wikipedia.org/api/rest_v1/page/summary/{{ inputs.topic | url }}"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "kind": {+ "branches": [+ {+ "then": "ambiguous",+ "when": {+ "eq": "disambiguation"+ }+ },+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "kind"+ },+ "no-article": {+ "kind": "fail",+ "reason": "no-article",+ "say": "Wikipedia has no article called {{ inputs.topic }}."+ },+ "rate-limited": {+ "kind": "fail",+ "reason": "rate-limited",+ "say": "Wikipedia is asking for fewer requests (429); try again in a minute."+ },+ "reply": {+ "kind": "done",+ "output": {+ "summary": "{{ state.summary }}",+ "title": "{{ state.title }}",+ "url": "{{ state.url }}"+ },+ "say": "{{ state.title }}: {{ state.summary }} {{ state.url }}"+ },+ "status": {+ "branches": [+ {+ "then": "kind",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "no-article",+ "when": {+ "eq": 404+ }+ },+ {+ "then": "rate-limited",+ "when": {+ "eq": 429+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "Wikipedia answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "reference",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "tell me about {topic}"+ },+ {+ "pattern": "who (is|was) {topic}"+ },+ {+ "pattern": "(wikipedia|wiki) {topic}"+ }+ ],+ "version": 1} } } diff --git a/tests/fixtures/examples.json b/tests/fixtures/examples.json index 53969bd..458d526 100644 --- a/tests/fixtures/examples.json +++ b/tests/fixtures/examples.json@@ -312,5 +312,300 @@"lines": [ "+CME ERROR: 10" ]+ },+ "GET https://en.wikipedia.org/api/rest_v1/page/summary/Ursula%20K%20Le%20Guin": {+ "status": 200,+ "body": {+ "type": "standard",+ "title": "Ursula K. Le Guin",+ "extract": "Ursula Kroeber Le Guin was an American author. She is best known for her works of speculative fiction.",+ "content_urls": {+ "desktop": {+ "page": "https://en.wikipedia.org/wiki/Ursula_K._Le_Guin"+ }+ }+ }+ },+ "GET https://en.wikipedia.org/api/rest_v1/page/summary/Mercury": {+ "status": 200,+ "body": {+ "type": "disambiguation",+ "title": "Mercury",+ "extract": "Mercury most commonly refers to:Mercury (planet), the closest planet to the Sun\n",+ "content_urls": {+ "desktop": {+ "page": "https://en.wikipedia.org/wiki/Mercury"+ }+ }+ }+ },+ "GET https://en.wikipedia.org/api/rest_v1/page/summary/Xyzzyplugh%20Notaperson": {+ "status": 404,+ "body": {+ "status": 404,+ "type": "Internal error"+ }+ },+ "GET https://en.wikipedia.org/api/rest_v1/page/summary/Alan%20Turing": {+ "status": 429,+ "body": "You are making too many requests to the API."+ },+ "GET https://api.dictionaryapi.dev/api/v2/entries/en/serendipity": {+ "status": 200,+ "body": [+ {+ "word": "serendipity",+ "phonetic": "/ˌsɛ.ɹən.ˈdɪ.pɪ.ti/",+ "meanings": [+ {+ "partOfSpeech": "noun",+ "definitions": [+ {+ "definition": "A combination of events which have come together by chance to make a surprisingly good or wonderful outcome."+ }+ ]+ }+ ]+ }+ ]+ },+ "GET https://api.dictionaryapi.dev/api/v2/entries/en/zzzqx": {+ "status": 404,+ "body": {+ "title": "No Definitions Found"+ }+ },+ "GET https://api.open-meteo.com/v1/forecast?latitude=59.91&longitude=10.75&daily=sunrise,sunset&timezone=auto&forecast_days=1": {+ "status": 200,+ "body": {+ "timezone": "Europe/Oslo",+ "daily": {+ "time": [+ "2026-10-28"+ ],+ "sunrise": [+ "2026-10-28T07:28"+ ],+ "sunset": [+ "2026-10-28T16:32"+ ]+ }+ }+ },+ "GET https://api.open-meteo.com/v1/forecast?latitude=78.22&longitude=15.65&daily=sunrise,sunset&timezone=auto&forecast_days=1": {+ "status": 200,+ "body": {+ "timezone": "Arctic/Longyearbyen",+ "daily": {+ "time": [+ "2026-12-21"+ ],+ "sunrise": [+ null+ ],+ "sunset": [+ null+ ]+ }+ }+ },+ "GET https://geocoding-api.open-meteo.com/v1/search?name=Longyearbyen&count=1&language=en&format=json": {+ "status": 200,+ "body": {+ "results": [+ {+ "name": "Longyearbyen",+ "country": "Svalbard and Jan Mayen",+ "latitude": 78.22,+ "longitude": 15.65+ }+ ]+ }+ },+ "GET https://air-quality-api.open-meteo.com/v1/air-quality?latitude=59.91&longitude=10.75¤t=european_aqi,pm2_5,pm10": {+ "status": 200,+ "body": {+ "current": {+ "time": "2026-10-28T00:00",+ "interval": 3600,+ "european_aqi": 21,+ "pm2_5": 3.8,+ "pm10": 10.2+ }+ }+ },+ "GET https://geocoding-api.open-meteo.com/v1/search?name=Delhi&count=1&language=en&format=json": {+ "status": 200,+ "body": {+ "results": [+ {+ "name": "Delhi",+ "country": "India",+ "latitude": 28.65,+ "longitude": 77.23+ }+ ]+ }+ },+ "GET https://air-quality-api.open-meteo.com/v1/air-quality?latitude=28.65&longitude=77.23¤t=european_aqi,pm2_5,pm10": {+ "status": 200,+ "body": {+ "current": {+ "european_aqi": 87,+ "pm2_5": 61.4,+ "pm10": 118.3+ }+ }+ },+ "GET https://registry.npmjs.org/%40types%2Fnode/latest": {+ "status": 200,+ "body": {+ "name": "@types/node",+ "version": "26.6.5",+ "description": "TypeScript definitions for node"+ }+ },+ "GET https://registry.npmjs.org/nope-not-a-pkg-zz/latest": {+ "status": 404,+ "body": "Not Found"+ },+ "GET https://pypi.org/pypi/requests/json": {+ "status": 200,+ "body": {+ "info": {+ "name": "requests",+ "version": "2.34.2",+ "summary": "Python HTTP for Humans.",+ "package_url": "https://pypi.org/project/requests/"+ }+ }+ },+ "GET https://pypi.org/pypi/nope-not-a-pkg-zz/json": {+ "status": 404,+ "body": {+ "message": "Not Found"+ }+ },+ "GET https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson": {+ "status": 200,+ "body": {+ "metadata": {+ "count": 19+ },+ "features": [+ {+ "properties": {+ "mag": 5.1,+ "place": "7 km ENE of Pangyan, Philippines",+ "tsunami": 0,+ "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"+ }+ },+ {+ "properties": {+ "mag": 5.4,+ "place": "197 km WSW of Merizo Village, Guam",+ "tsunami": 0,+ "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttke"+ }+ }+ ]+ }+ },+ "GET https://api.wheretheiss.at/v1/satellites/25544": {+ "status": 200,+ "body": {+ "latitude": -26.625662371377,+ "longitude": -60.102704384113,+ "altitude": 422.43868994227,+ "velocity": 27577.69858816,+ "visibility": "eclipsed"+ }+ },+ "GET https://hn.algolia.com/api/v1/search?query=sqlite&tags=story&hitsPerPage=3": {+ "status": 200,+ "body": {+ "nbHits": 4632,+ "hits": [+ {+ "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/",+ "points": 1812,+ "author": "phiresky",+ "objectID": "27016630"+ },+ {+ "title": "I'm all-in on server-side SQLite",+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/",+ "points": 1353,+ "author": "dpeck",+ "objectID": "31318708"+ },+ {+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug",+ "points": 1223,+ "author": "ropbear",+ "objectID": "49272832"+ }+ ]+ }+ },+ "GET https://hn.algolia.com/api/v1/search?query=qqqzzzxxyyy&tags=story&hitsPerPage=3": {+ "status": 200,+ "body": {+ "nbHits": 0,+ "hits": []+ }+ },+ "GET https://openlibrary.org/search.json?q=The%20Left%20Hand%20of%20Darkness&limit=1&fields=title,author_name,first_publish_year,key": {+ "status": 200,+ "body": {+ "numFound": 112,+ "docs": [+ {+ "author_name": [+ "Ursula K. Le Guin"+ ],+ "first_publish_year": 1969,+ "key": "/works/OL59800W",+ "title": "The Left Hand of Darkness"+ }+ ]+ }+ },+ "GET https://openlibrary.org/search.json?q=qqqzzzxxyyy&limit=1&fields=title,author_name,first_publish_year,key": {+ "status": 200,+ "body": {+ "numFound": 0,+ "docs": []+ }+ },+ "GET https://date.nager.at/api/v3/NextPublicHolidays/NO": {+ "status": 200,+ "body": [+ {+ "date": "2026-12-25",+ "localName": "Første juledag",+ "name": "Christmas Day"+ },+ {+ "date": "2026-12-26",+ "localName": "Andre juledag",+ "name": "St. Stephen's Day"+ }+ ]+ },+ "GET https://date.nager.at/api/v3/NextPublicHolidays/XX": {+ "status": 500,+ "body": ""+ },+ "GET https://icanhazdadjoke.com/": {+ "status": 200,+ "body": {+ "id": "oOKBsPZ0wc",+ "joke": "I am so good at sleeping I can do it with my eyes closed!",+ "status": 200+ }} } diff --git a/tests/test_catalog.py b/tests/test_catalog.py index ee756ec..0d61f5a 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py@@ -10,11 +10,14 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServerimport pytest+from pyrtan import Registryfrom pyrtan.catalog import Catalog, CatalogError, build_index from pyrtan.cli import main from pyrtan.tree import load_tree from conftest import EXAMPLES+EXAMPLE_NAMES = set(Registry.from_paths([EXAMPLES]).names())+@pytest.fixture def catalog_dir(tmp_path):@@ -40,7 +43,7 @@ def served(catalog_dir):def test_build_index_describes_every_tree(catalog_dir): index = json.loads((catalog_dir / "index.json").read_text())- assert index["pyrtan-catalog"] == 1 and index["count"] == 11+ assert index["pyrtan-catalog"] == 1 and index["count"] == len(EXAMPLE_NAMES)weather = next(e for e in index["trees"] if e["name"] == "weather") assert weather["path"] == "weather/weather.toml" and weather["license"] == "MIT" assert weather["sha256"] == hashlib.sha256((catalog_dir / "weather/weather.toml").read_bytes()).hexdigest()@@ -68,9 +71,9 @@ def test_build_index_leaves_out_what_does_not_belong(tmp_path):def test_search_and_find(served): cat = Catalog(served)- assert {e["name"] for e in cat.search("")} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk"}- assert [e["name"] for e in cat.search("open-meteo")] == ["weather"]- assert [e["name"] for e in cat.search("news stories")] == ["hacker-news"]+ assert {e["name"] for e in cat.search("")} == EXAMPLE_NAMES+ assert [e["name"] for e in cat.search("open-meteo")] == ["air-quality", "sun", "weather"]+ assert [e["name"] for e in cat.search("news stories")] == ["hacker-news", "hn-search"]assert cat.search("nothing like this") == [] assert cat.find("weather")["version"] == 2 assert cat.find("weather@2")["name"] == "weather"@@ -130,7 +133,7 @@ def test_not_a_catalog(served, tmp_path):def test_cli_index_search_pull(catalog_dir, served, tmp_path, capsys): out = tmp_path / "idx.json" assert main(["index", str(catalog_dir), "--out", str(out)]) == 0- assert json.loads(out.read_text())["count"] == 11+ assert json.loads(out.read_text())["count"] == len(EXAMPLE_NAMES)assert main(["search", "weather", "--catalog", served]) == 0 assert "weather@2" in capsys.readouterr().out assert main(["search", "zzz", "--catalog", served]) == 1@@ -276,7 +279,7 @@ def test_gate_and_publish(tmp_path, capsys):assert "placed" in out and "git checkout -b catalog/barry-lan-printer" in out assert main(["index", str(repo / "examples"), str(repo / "catalog"), "--copy", str(tmp_path / "site"), "--require-namespace"]) == 0 served = json.loads((tmp_path / "site" / "index.json").read_text())- assert {e["id"] for e in served["trees"]} == {f"pyrtan/{n}" for n in ("weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk")} | {"barry/lan-printer"}+ assert {e["id"] for e in served["trees"]} == {f"pyrtan/{n}" for n in EXAMPLE_NAMES} | {"barry/lan-printer"}assert main(["index", str(repo / "examples"), str(repo / "catalog")]) == 2 # two roots need --copy assert main(["search", "lan", "--catalog", str(tmp_path / "site")]) == 0 and "barry/lan-printer@3" in capsys.readouterr().out assert main(["pull", "lan-printer", "--catalog", str(tmp_path / "site"), "--dir", str(tmp_path / "pulled")]) == 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 685bb11..c770744 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py@@ -74,10 +74,10 @@ def test_new_from_a_sentence(tmp_path, capsys):def test_run_no_match_proposes_a_tree(capsys):- code, out, _ = run(capsys, "run", "tell me a joke", "--trees", str(EXAMPLES), "--replay", str(FIXTURES / "examples.json"), "--json")+ code, out, _ = run(capsys, "run", "tell me a riddle", "--trees", str(EXAMPLES), "--replay", str(FIXTURES / "examples.json"), "--json")assert code == 3 data = json.loads(out)- assert data["reason"] == "no-match" and data["output"]["proposal"]["name"] == "joke"+ assert data["reason"] == "no-match" and data["output"]["proposal"]["name"] == "riddle"def test_gaps_needs_a_target(capsys):@@ -96,13 +96,13 @@ def test_unanswered_lists_and_scaffolds(tmp_path, capsys):assert code == 0 and "nothing unanswered" in out http_json("POST", f"{server.url}/intent", {"text": "ping example.org"}) http_json("POST", f"{server.url}/intent", {"text": "ping example.org"})- http_json("POST", f"{server.url}/intent", {"text": "tell me a joke"})+ http_json("POST", f"{server.url}/intent", {"text": "tell me a riddle"})code, out, _ = run(capsys, "unanswered", "--at", server.url)- assert code == 0 and out.startswith("'ping example.org' x2") and "would be: host-ping answering ping {host:host}" in out and "pyrtan new joke --from" in out+ assert code == 0 and out.startswith("'ping example.org' x2") and "would be: host-ping answering ping {host:host}" in out and "pyrtan new riddle --from" in outcode, out, _ = run(capsys, "unanswered", "--at", server.url, "--json")- assert [e["text"] for e in json.loads(out)["unanswered"]] == ["ping example.org", "tell me a joke"]+ assert [e["text"] for e in json.loads(out)["unanswered"]] == ["ping example.org", "tell me a riddle"]code, out, _ = run(capsys, "unanswered", "--at", server.url, "--scaffold", str(tmp_path))- assert code == 0 and "wrote" in out and (tmp_path / "host-ping.toml").exists() and (tmp_path / "joke.toml").exists()+ assert code == 0 and "wrote" in out and (tmp_path / "host-ping.toml").exists() and (tmp_path / "riddle.toml").exists()assert run(capsys, "check", str(tmp_path))[0] == 0 code, out, _ = run(capsys, "unanswered", "--at", server.url, "--scaffold", str(tmp_path)) assert "kept" in out and "wrote" not in out diff --git a/tests/test_examples.py b/tests/test_examples.py index bef6706..ee5e384 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py@@ -146,3 +146,93 @@ def test_capabilities_name_the_sources_and_lists(examples):assert caps["disk-space"]["sources"] == ["shell"] and caps["disk-space"]["commands"] == ["df"] and "paths" not in caps["disk-space"] assert caps["room-temperature"]["sources"] == ["queue"] and caps["room-temperature"]["topics"] == ["home/+/temperature"] assert caps["weather"]["sources"] == ["http"] and caps["modem-check"]["devices"] == ["/dev/ttyUSB*", "/dev/ttyACM*"]+++# ---------------------------------------------------------------- trees that run in a browser too+# Each reaches only an API that lets a page on another origin read it (CORS),+# so the console at pyrtan.com runs them in the page, with no hub.+++def _path(out):+ return [s.node for s in out.trace.steps if s.kind != "intent"]+++def test_wikipedia_summary_and_its_refusals(runner):+ out = runner.run_intent("tell me about Ursula K. Le Guin")+ assert out.status == "done" and out.inputs == {"topic": "Ursula K Le Guin"}+ assert out.say.startswith("Ursula K. Le Guin: Ursula Kroeber Le Guin was an American author.") and out.output["url"] == "https://en.wikipedia.org/wiki/Ursula_K._Le_Guin"+ out = runner.run_intent("wiki Mercury")+ assert out.reason == "ambiguous" and _path(out) == ["fetch", "status", "kind", "ambiguous"]+ assert runner.run_intent("who is Xyzzyplugh Notaperson").reason == "no-article"+ assert runner.run_intent("who was Alan Turing").reason == "rate-limited"+++def test_define(runner):+ out = runner.run_intent("what does serendipity mean")+ assert out.status == "done" and out.say.startswith("serendipity (noun): A combination of events")+ assert out.output["phonetic"] == "/ˌsɛ.ɹən.ˈdɪ.pɪ.ti/"+ assert runner.run_intent("define zzzqx").reason == "unknown-word"+++def test_sun_times_in_local_time(runner):+ out = runner.run_intent("when is sunset in Oslo")+ assert out.say == "Oslo, Norway, 2026-10-28: sunrise 07:28, sunset 16:32 (Europe/Oslo)."+ out = runner.run_intent("sunrise in Longyearbyen") # polar night: no sunrise at all+ assert out.reason == "no-times" and _path(out)[-1] == "no-times"+ assert runner.run_intent("sun times for Nowhereville").reason == "unknown-place"+++def test_air_quality_bands(runner):+ out = runner.run_intent("air quality in Oslo")+ assert out.say == "Oslo, Norway: air quality fair (European AQI 21), PM2.5 3.8 µg/m³, PM10 10.2 µg/m³."+ out = runner.run_intent("how is the air in Delhi")+ assert out.output["band"] == "very poor" and [s.chosen for s in out.trace.steps if s.node == "band"] == ["lte 100"]+++def test_package_versions(runner):+ out = runner.run_intent("latest version of @types/node on npm")+ assert out.status == "done" and out.output == {"package": "@types/node", "version": "26.6.5", "description": "TypeScript definitions for node", "url": "https://www.npmjs.com/package/@types/node"}+ assert runner.run_intent("npm package nope-not-a-pkg-zz").reason == "no-package"+ out = runner.run_intent("latest requests on pypi")+ assert out.say == "requests 2.34.2 on PyPI: Python HTTP for Humans. https://pypi.org/project/requests/"+ assert runner.run_intent("pip version nope-not-a-pkg-zz").reason == "no-package"+++def test_earthquakes(runner):+ out = runner.run_intent("any earthquakes today")+ assert out.output["count"] == 19 and out.output["latest"]["place"] == "7 km ENE of Pangyan, Philippines"+ assert out.say.startswith("19 earthquake(s) of magnitude 4.5+ in the past day. The latest: M5.1")+++def test_iss_rounds_its_words_not_its_output(runner):+ out = runner.run_intent("where is the iss")+ assert out.say == "The ISS is over -26.62, -60.10, 422 km up, moving at 27577 km/h, in the Earth's shadow."+ assert out.output["latitude"] == -26.625662371377 and _path(out)[-2:] == ["in-shadow", "reply"]+++def test_hn_search(runner):+ out = runner.run_intent("search hacker news for sqlite")+ assert out.output["total"] == 4632 and len(out.output["stories"]) == 3+ assert out.output["stories"][0]["discussion"] == "https://news.ycombinator.com/item?id=27016630"+ out = runner.run_intent("search hacker news for qqqzzzxxyyy")+ assert out.status == "done" and out.output == {"query": "qqqzzzxxyyy", "total": 0, "stories": []}+++def test_books(runner):+ out = runner.run_intent("who wrote The Left Hand of Darkness")+ assert out.say == "The Left Hand of Darkness, by Ursula K. Le Guin (1969). https://openlibrary.org/works/OL59800W"+ assert runner.run_intent("find the book qqqzzzxxyyy").reason == "no-book"+++def test_holidays_by_country_code(runner):+ out = runner.run_intent("next holiday in no")+ assert out.say == "The next public holiday in NO is Christmas Day (Første juledag) on 2026-12-25."+ assert out.output["after"] == {"date": "2026-12-26", "name": "St. Stephen's Day"}+ assert runner.run_intent("next holiday in XX").reason == "unknown-country"+ assert runner.run_intent("next holiday in Norway").reason == "bad-input"+++def test_dad_joke_asks_for_json(runner, recorded):+ out = runner.run_intent("tell me a joke")+ assert out.status == "done" and out.say == "I am so good at sleeping I can do it with my eyes closed!"+ assert runner.registry.get("dad-joke").nodes["fetch"].get("http")["headers"] == {"Accept": "application/json"}diff --git a/tests/test_service_hub.py b/tests/test_service_hub.py index a0e6bc7..be9e41f 100644 --- a/tests/test_service_hub.py +++ b/tests/test_service_hub.py@@ -156,10 +156,10 @@ def test_swarm_of_two_behind_a_hub(tmp_path):assert status == 200 and reg["peer"]["link"] == "up" _, health = http_json("GET", f"{hub_srv.url}/health")- assert health["peers"] == 2 and health["up"] == 2 and health["trees"] == 12+ assert health["peers"] == 2 and health["up"] == 2 and health["trees"] == len(reg_a) + 1 # the examples, and picnic_, caps = http_json("GET", f"{hub_srv.url}/capabilities")- assert {t["name"] for t in caps["trees"]} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "picnic", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk"}+ assert {t["name"] for t in caps["trees"]} == set(reg_a.names()) | {"picnic"}# Routed by the hub to B, which delegates back through the hub to A. _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "picnic in Oslo"})