Merge pull request #28 from b-i-t-8/claude/great-heisenberg-o6ocah
b-i-t-8 · 2026-10-27 · aa2f4d2043e17c77d656a2bce406990810ba64b6 · parents f346bb4 e590539
Add twelve trees that run in a browser as well as on a service
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 6fcbf5e..d153781 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 9700a83..32042e8 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -326,6 +326,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 965c14f..a961fa2 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"})