pyrtan / sourcev0.19.0 · main

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 at
join one, the browser is the hub. It holds the catalog's trees, reads the sentence and runs the tree in the page (`pyrtan.js`, the same runtime and the same reading as Python's), and keeps gaps and unanswered sentences
-while the page is open. Trees that need a box (files, programs, serial
+while the page is open. 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, serial
lines, brokers, a browser to drive) or a site that does not let a page read it say so; for those, join a hub started with `--cors https://pyrtan.com` by giving its URL and token. Forget it and the diff --git a/examples/air-quality/air-quality.toml b/examples/air-quality/air-quality.toml new file mode 100644 index 0000000..571f3f3 --- /dev/null +++ b/examples/air-quality/air-quality.toml
@@ -0,0 +1,108 @@
+# The air where a place is, now: the European Air Quality Index and the
+# fine particles behind it, from Open-Meteo. The index's own bands name it.
+
+name = "air-quality"
+version = 1
+namespace = "pyrtan"
+description = "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo."
+tags = ["weather", "health", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "air-quality-api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "[what's the] air quality (in|for) {place}"
+[[triggers]]
+pattern = "how is the air in {place}"
+[[triggers]]
+pattern = "(aqi|pollution) in {place}"
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "air" },
+ { else = "unknown-place" },
+]
+
+[nodes.air]
+kind = "observe"
+http = { method = "GET", url = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}&current=european_aqi,pm2_5,pm10" }
+into = "aq"
+extract = [
+ { into = "aqi", json = "json.current.european_aqi" },
+ { into = "pm25", json = "json.current.pm2_5" },
+ { into = "pm10", json = "json.current.pm10" },
+]
+next = "band"
+
+# The bands of the European Air Quality Index.
+[nodes.band]
+kind = "decide"
+on = "aqi"
+branches = [
+ { when = { type = "number", lte = 20 }, then = "good" },
+ { when = { lte = 40 }, then = "fair" },
+ { when = { lte = 60 }, then = "moderate" },
+ { when = { lte = 80 }, then = "poor" },
+ { when = { lte = 100 }, then = "very-poor" },
+ { when = { gt = 100 }, then = "extremely-poor" },
+ { else = "no-reading" },
+]
+
+[nodes.good]
+kind = "set"
+set = { band = "good" }
+next = "reply"
+[nodes.fair]
+kind = "set"
+set = { band = "fair" }
+next = "reply"
+[nodes.moderate]
+kind = "set"
+set = { band = "moderate" }
+next = "reply"
+[nodes.poor]
+kind = "set"
+set = { band = "poor" }
+next = "reply"
+[nodes.very-poor]
+kind = "set"
+set = { band = "very poor" }
+next = "reply"
+[nodes.extremely-poor]
+kind = "set"
+set = { band = "extremely poor" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}: air quality {{ state.band }} (European AQI {{ state.aqi }}), PM2.5 {{ state.pm25 }} µg/m³, PM10 {{ state.pm10 }} µg/m³."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", aqi = "{{ state.aqi }}", band = "{{ state.band }}", pm2_5 = "{{ state.pm25 }}", pm10 = "{{ state.pm10 }}" }
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[nodes.no-reading]
+kind = "fail"
+reason = "no-reading"
+say = "Open-Meteo found {{ state.name }} but gave no air quality reading (status {{ state.aq.status }})."
diff --git a/examples/books/books.toml b/examples/books/books.toml new file mode 100644 index 0000000..93a1f6f --- /dev/null +++ b/examples/books/books.toml
@@ -0,0 +1,66 @@
+# A book by its title or its author, from Open Library's search: the best
+# match, who wrote it and when it first came out.
+
+name = "books"
+version = 1
+namespace = "pyrtan"
+description = "A book from Open Library: the best match for a title or an author, who wrote it, and when it first came out."
+tags = ["reference", "books", "read-only"]
+license = "MIT"
+hosts = ["openlibrary.org"]
+start = "search"
+
+[[triggers]]
+pattern = "who wrote {title}"
+[[triggers]]
+pattern = "find the book {title}"
+[[triggers]]
+pattern = "books by {title}"
+
+[inputs.title]
+type = "str"
+description = "A title or an author, as in: The Left Hand of Darkness"
+
+[nodes.search]
+kind = "observe"
+http = { method = "GET", url = "https://openlibrary.org/search.json?q={{ inputs.title | url }}&limit=1&fields=title,author_name,first_publish_year,key" }
+into = "resp"
+extract = [
+ { into = "found", json = "json.numFound" },
+ { into = "book", json = "json.docs[0].title" },
+ { into = "author", json = "json.docs[0].author_name[0]" },
+ { into = "year", json = "json.docs[0].first_publish_year" },
+ { into = "key", json = "json.docs[0].key" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "book"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "no-book" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.book }}, by {{ state.author }} ({{ state.year }}). https://openlibrary.org{{ state.key }}"
+output = { title = "{{ state.book }}", author = "{{ state.author }}", first_published = "{{ state.year }}", url = "https://openlibrary.org{{ state.key }}", matches = "{{ state.found }}" }
+
+[nodes.no-book]
+kind = "fail"
+reason = "no-book"
+say = "Open Library has nothing matching {{ inputs.title }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Open Library answered {{ state.resp.status }}."
diff --git a/examples/dad-joke/dad-joke.toml b/examples/dad-joke/dad-joke.toml new file mode 100644 index 0000000..64c6910 --- /dev/null +++ b/examples/dad-joke/dad-joke.toml
@@ -0,0 +1,54 @@
+# A dad joke, from icanhazdadjoke.com. It answers JSON only when asked for
+# JSON alone, so this tree says so in its Accept header.
+
+name = "dad-joke"
+version = 1
+namespace = "pyrtan"
+description = "A random dad joke, from icanhazdadjoke.com."
+tags = ["fun", "read-only"]
+license = "MIT"
+hosts = ["icanhazdadjoke.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "tell me a [dad] joke"
+[[triggers]]
+pattern = "[a] dad joke"
+[[triggers]]
+pattern = "make me laugh"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://icanhazdadjoke.com/", headers = { Accept = "application/json" } }
+into = "resp"
+extract = [
+ { into = "joke", json = "json.joke" },
+ { into = "id", json = "json.id" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-joke" },
+ { else = "unexpected" },
+]
+
+[nodes.has-joke]
+kind = "decide"
+on = "joke"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.joke }}"
+output = { joke = "{{ state.joke }}", url = "https://icanhazdadjoke.com/j/{{ state.id }}" }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "icanhazdadjoke.com answered {{ state.resp.status }}."
diff --git a/examples/dictionary/define.toml b/examples/dictionary/define.toml new file mode 100644 index 0000000..2ea6f99 --- /dev/null +++ b/examples/dictionary/define.toml
@@ -0,0 +1,66 @@
+# The first definition of an English word, from the Free Dictionary API.
+
+name = "define"
+version = 1
+namespace = "pyrtan"
+description = "What an English word means: its first definition, part of speech and pronunciation."
+tags = ["reference", "language", "read-only"]
+license = "MIT"
+hosts = ["api.dictionaryapi.dev"]
+start = "fetch"
+
+[[triggers]]
+pattern = "define {word:word}"
+[[triggers]]
+pattern = "what does {word:word} mean"
+[[triggers]]
+pattern = "[the] (meaning|definition) of {word:word}"
+
+[inputs.word]
+type = "str"
+pattern = "^[A-Za-z][A-Za-z'\\-]*$"
+description = "One English word"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.dictionaryapi.dev/api/v2/entries/en/{{ inputs.word | lower | url }}" }
+into = "resp"
+extract = [
+ { into = "word", json = "json[0].word" },
+ { into = "phonetic", json = "json[0].phonetic" },
+ { into = "part", json = "json[0].meanings[0].partOfSpeech" },
+ { into = "definition", json = "json[0].meanings[0].definitions[0].definition" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-definition" },
+ { when = { eq = 404 }, then = "unknown-word" },
+ { else = "unexpected" },
+]
+
+[nodes.has-definition]
+kind = "decide"
+on = "definition"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unknown-word" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.word }} ({{ state.part }}): {{ state.definition }}"
+output = { word = "{{ state.word }}", phonetic = "{{ state.phonetic }}", part_of_speech = "{{ state.part }}", definition = "{{ state.definition }}" }
+
+[nodes.unknown-word]
+kind = "fail"
+reason = "unknown-word"
+say = "The dictionary has no entry for {{ inputs.word }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The dictionary answered {{ state.resp.status }}."
diff --git a/examples/earthquakes/earthquakes.toml b/examples/earthquakes/earthquakes.toml new file mode 100644 index 0000000..ce4a1ec --- /dev/null +++ b/examples/earthquakes/earthquakes.toml
@@ -0,0 +1,63 @@
+# Earthquakes of magnitude 4.5 and up in the past day, from the USGS feed:
+# how many, and the most recent one. The feed lists the newest first.
+
+name = "earthquakes"
+version = 1
+namespace = "pyrtan"
+description = "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest."
+tags = ["science", "read-only"]
+license = "MIT"
+hosts = ["earthquake.usgs.gov"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[any] [recent] earthquakes [today]"
+[[triggers]]
+pattern = "[was there an] earthquake today"
+[[triggers]]
+pattern = "latest earthquake"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson" }
+into = "resp"
+extract = [
+ { into = "count", json = "json.metadata.count" },
+ { into = "mag", json = "json.features[0].properties.mag" },
+ { into = "place", json = "json.features[0].properties.place" },
+ { into = "tsunami", json = "json.features[0].properties.tsunami" },
+ { into = "url", json = "json.features[0].properties.url" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "count"
+branches = [
+ { when = { eq = 0 }, then = "none" },
+ { when = { gt = 0 }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.count }} earthquake(s) of magnitude 4.5+ in the past day. The latest: M{{ state.mag }}, {{ state.place }}. {{ state.url }}"
+output = { count = "{{ state.count }}", latest = { magnitude = "{{ state.mag }}", place = "{{ state.place }}", tsunami = "{{ state.tsunami }}", url = "{{ state.url }}" } }
+
+[nodes.none]
+kind = "done"
+say = "No earthquakes of magnitude 4.5+ anywhere in the past day."
+output = { count = 0 }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The USGS feed answered {{ state.resp.status }}."
diff --git a/examples/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&current=european_aqi,pm2_5,pm10 -> 200; aqi=21, pm25=3.8, pm10=10.2"
+ },
+ {
+ "chosen": "lte 40",
+ "depth": 0,
+ "kind": "decide",
+ "next": "fair",
+ "node": "band",
+ "observed": 21,
+ "summary": "state.aqi = 21"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "fair",
+ "observed": {
+ "band": "fair"
+ },
+ "summary": "band=fair"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Oslo, Norway: air quality fair (European AQI 21), PM2.5 3.8 µg/m³, PM10 10.2 µg/m³."
+ }
+ ],
+ "tree": "air-quality"
+ },
+ "tree": "air-quality"
+ },
+ "inputs": {
+ "place": "Oslo"
+ },
+ "intent": "air quality in Oslo",
+ "tree": "air-quality"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Delhi"
+ },
+ "output": {
+ "aqi": 87,
+ "band": "very poor",
+ "country": "India",
+ "place": "Delhi",
+ "pm10": 118.3,
+ "pm2_5": 61.4
+ },
+ "say": "Delhi, India: air quality very poor (European AQI 87), PM2.5 61.4 µg/m³, PM10 118.3 µg/m³.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "how is the air in Delhi",
+ "inputs": {
+ "place": "Delhi"
+ },
+ "steps": [],
+ "trigger": "how is the air in {place}"
+ },
+ "summary": "'how is the air in Delhi'; air-quality via 'how is the air in {place}' (13 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "India",
+ "hit": {
+ "country": "India",
+ "latitude": 28.65,
+ "longitude": 77.23,
+ "name": "Delhi"
+ },
+ "lat": 28.65,
+ "lon": 77.23,
+ "name": "Delhi"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Delhi&count=1&language=en&format=json -> 200; hit={'name': 'Delhi', 'country': 'India', 'latitude': 28.65, 'longitude': 77.23}, lat=28.65, lon=77.23, name=Delhi, country=India"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "air",
+ "node": "found",
+ "observed": {
+ "country": "India",
+ "latitude": 28.65,
+ "longitude": 77.23,
+ "name": "Delhi"
+ },
+ "summary": "state.hit = {'name': 'Delhi', 'country': 'India', 'latitude': 28.65, 'longitude': 77.23}"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "band",
+ "node": "air",
+ "observed": {
+ "aqi": 87,
+ "pm10": 118.3,
+ "pm25": 61.4
+ },
+ "summary": "GET https://air-quality-api.open-meteo.com/v1/air-quality?latitude=28.65&longitude=77.23&current=european_aqi,pm2_5,pm10 -> 200; aqi=87, pm25=61.4, pm10=118.3"
+ },
+ {
+ "chosen": "lte 100",
+ "depth": 0,
+ "kind": "decide",
+ "next": "very-poor",
+ "node": "band",
+ "observed": 87,
+ "summary": "state.aqi = 87"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "very-poor",
+ "observed": {
+ "band": "very poor"
+ },
+ "summary": "band=very poor"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Delhi, India: air quality very poor (European AQI 87), PM2.5 61.4 µg/m³, PM10 118.3 µg/m³."
+ }
+ ],
+ "tree": "air-quality"
+ },
+ "tree": "air-quality"
+ },
+ "inputs": {
+ "place": "Delhi"
+ },
+ "intent": "how is the air in Delhi",
+ "tree": "air-quality"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "@types/node"
+ },
+ "output": {
+ "description": "TypeScript definitions for node",
+ "package": "@types/node",
+ "url": "https://www.npmjs.com/package/@types/node",
+ "version": "26.6.5"
+ },
+ "say": "@types/node 26.6.5 on npm: TypeScript definitions for node https://www.npmjs.com/package/@types/node",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "latest version of @types/node on npm",
+ "inputs": {
+ "package": "@types/node"
+ },
+ "steps": [],
+ "trigger": "[what's the] latest version of {package:word} on npm"
+ },
+ "summary": "'latest version of @types/node on npm'; npm-package via \"[what's the] latest version of {package:word} on npm\" (29 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "TypeScript definitions for node",
+ "version": "26.6.5"
+ },
+ "summary": "GET https://registry.npmjs.org/%40types%2Fnode/latest -> 200; version=26.6.5, about=TypeScript definitions for node"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "@types/node 26.6.5 on npm: TypeScript definitions for node https://www.npmjs.com/package/@types/node"
+ }
+ ],
+ "tree": "npm-package"
+ },
+ "tree": "npm-package"
+ },
+ "inputs": {
+ "package": "@types/node"
+ },
+ "intent": "latest version of @types/node on npm",
+ "tree": "npm-package"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "reason": "no-package",
+ "say": "npm has no package called nope-not-a-pkg-zz.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "npm package nope-not-a-pkg-zz",
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "steps": [],
+ "trigger": "npm (package|version) {package:word}"
+ },
+ "summary": "'npm package nope-not-a-pkg-zz'; npm-package via 'npm (package|version) {package:word}' (17 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": null,
+ "version": null
+ },
+ "summary": "GET https://registry.npmjs.org/nope-not-a-pkg-zz/latest -> 404; version=None, about=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-package",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-package",
+ "summary": "no-package: npm has no package called nope-not-a-pkg-zz."
+ }
+ ],
+ "tree": "npm-package"
+ },
+ "tree": "npm-package"
+ },
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "intent": "npm package nope-not-a-pkg-zz",
+ "tree": "npm-package"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "requests"
+ },
+ "output": {
+ "package": "requests",
+ "summary": "Python HTTP for Humans.",
+ "url": "https://pypi.org/project/requests/",
+ "version": "2.34.2"
+ },
+ "say": "requests 2.34.2 on PyPI: Python HTTP for Humans. https://pypi.org/project/requests/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "latest requests on pypi",
+ "inputs": {
+ "package": "requests"
+ },
+ "steps": [],
+ "trigger": "latest {package:word} on pypi"
+ },
+ "summary": "'latest requests on pypi'; pypi-package via 'latest {package:word} on pypi' (12 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "Python HTTP for Humans.",
+ "name": "requests",
+ "url": "https://pypi.org/project/requests/",
+ "version": "2.34.2"
+ },
+ "summary": "GET https://pypi.org/pypi/requests/json -> 200; name=requests, version=2.34.2, about=Python HTTP for Humans., url=https://pypi.org/project/requests/"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "requests 2.34.2 on PyPI: Python HTTP for Humans. https://pypi.org/project/requests/"
+ }
+ ],
+ "tree": "pypi-package"
+ },
+ "tree": "pypi-package"
+ },
+ "inputs": {
+ "package": "requests"
+ },
+ "intent": "latest requests on pypi",
+ "tree": "pypi-package"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "reason": "no-package",
+ "say": "PyPI has no project called nope-not-a-pkg-zz.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "pip version nope-not-a-pkg-zz",
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "steps": [],
+ "trigger": "(pypi|pip) (package|version) {package:word}"
+ },
+ "summary": "'pip version nope-not-a-pkg-zz'; pypi-package via '(pypi|pip) (package|version) {package:word}' (21 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": null,
+ "name": null,
+ "url": null,
+ "version": null
+ },
+ "summary": "GET https://pypi.org/pypi/nope-not-a-pkg-zz/json -> 404; name=None, version=None, about=None, url=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-package",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-package",
+ "summary": "no-package: PyPI has no project called nope-not-a-pkg-zz."
+ }
+ ],
+ "tree": "pypi-package"
+ },
+ "tree": "pypi-package"
+ },
+ "inputs": {
+ "package": "nope-not-a-pkg-zz"
+ },
+ "intent": "pip version nope-not-a-pkg-zz",
+ "tree": "pypi-package"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "count": 19,
+ "latest": {
+ "magnitude": 5.1,
+ "place": "7 km ENE of Pangyan, Philippines",
+ "tsunami": 0,
+ "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ }
+ },
+ "say": "19 earthquake(s) of magnitude 4.5+ in the past day. The latest: M5.1, 7 km ENE of Pangyan, Philippines. https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "any earthquakes today",
+ "inputs": {},
+ "steps": [],
+ "trigger": "[any] [recent] earthquakes [today]"
+ },
+ "summary": "'any earthquakes today'; earthquakes via '[any] [recent] earthquakes [today]' (25 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "count": 19,
+ "mag": 5.1,
+ "place": "7 km ENE of Pangyan, Philippines",
+ "tsunami": 0,
+ "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ },
+ "summary": "GET https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson -> 200; count=19, mag=5.1, place=7 km ENE of Pangyan, Philippines, tsunami=0, url=https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "gt 0",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": 19,
+ "summary": "state.count = 19"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "19 earthquake(s) of magnitude 4.5+ in the past day. The latest: M5.1, 7 km ENE of Pangyan, Philippines. https://earthquake.usgs.gov/earthquakes/eventpage/us7000ttkk"
+ }
+ ],
+ "tree": "earthquakes"
+ },
+ "tree": "earthquakes"
+ },
+ "inputs": {},
+ "intent": "any earthquakes today",
+ "tree": "earthquakes"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "altitude_km": 422.43868994227,
+ "latitude": -26.625662371377,
+ "longitude": -60.102704384113,
+ "map": "https://www.openstreetmap.org/?mlat=-26.625662371377&mlon=-60.102704384113&zoom=3",
+ "speed_kmh": 27577.69858816,
+ "visibility": "eclipsed"
+ },
+ "say": "The ISS is over -26.62, -60.10, 422 km up, moving at 27577 km/h, in the Earth's shadow.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "where is the iss",
+ "inputs": {},
+ "steps": [],
+ "trigger": "where is the (iss|space station) [now]"
+ },
+ "summary": "'where is the iss'; iss via 'where is the (iss|space station) [now]' (28 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "alt": 422.43868994227,
+ "lat": -26.625662371377,
+ "lat2": "-26.62",
+ "light": "eclipsed",
+ "lon": -60.102704384113,
+ "lon2": "-60.10",
+ "speed": 27577.69858816
+ },
+ "summary": "GET https://api.wheretheiss.at/v1/satellites/25544 -> 200; lat=-26.625662371377, lon=-60.102704384113, alt=422.43868994227, speed=27577.69858816, light=eclipsed, lat2=-26.62, lon2=-60.10"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "have-position",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "type 'number'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "light",
+ "node": "have-position",
+ "observed": -26.625662371377,
+ "summary": "state.lat = -26.625662371377"
+ },
+ {
+ "chosen": "eq 'eclipsed'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "in-shadow",
+ "node": "light",
+ "observed": "eclipsed",
+ "summary": "state.light = eclipsed"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "in-shadow",
+ "observed": {
+ "where": "in the Earth's shadow"
+ },
+ "summary": "where=in the Earth's shadow"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The ISS is over -26.62, -60.10, 422 km up, moving at 27577 km/h, in the Earth's shadow."
+ }
+ ],
+ "tree": "iss"
+ },
+ "tree": "iss"
+ },
+ "inputs": {},
+ "intent": "where is the iss",
+ "tree": "iss"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "query": "sqlite"
+ },
+ "output": {
+ "query": "sqlite",
+ "stories": [
+ {
+ "by": "phiresky",
+ "discussion": "https://news.ycombinator.com/item?id=27016630",
+ "points": 1812,
+ "title": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ },
+ {
+ "by": "dpeck",
+ "discussion": "https://news.ycombinator.com/item?id=31318708",
+ "points": 1353,
+ "title": "I'm all-in on server-side SQLite",
+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/"
+ },
+ {
+ "by": "ropbear",
+ "discussion": "https://news.ycombinator.com/item?id=49272832",
+ "points": 1223,
+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",
+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug"
+ }
+ ],
+ "total": 4632
+ },
+ "say": "4632 Hacker News stories match sqlite. Top: Hosting SQLite databases on GitHub Pages or any static file hoster https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "search hacker news for sqlite",
+ "inputs": {
+ "query": "sqlite"
+ },
+ "steps": [],
+ "trigger": "search hacker news for {query}"
+ },
+ "summary": "'search hacker news for sqlite'; hn-search via 'search hacker news for {query}' (19 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "first": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "first_url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/",
+ "hits": [
+ {
+ "author": "phiresky",
+ "objectID": "27016630",
+ "points": 1812,
+ "title": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ },
+ {
+ "author": "dpeck",
+ "objectID": "31318708",
+ "points": 1353,
+ "title": "I'm all-in on server-side SQLite",
+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/"
+ },
+ {
+ "author": "ropbear",
+ "objectID": "49272832",
+ "points": 1223,
+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",
+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug"
+ }
+ ],
+ "total": 4632
+ },
+ "summary": "GET https://hn.algolia.com/api/v1/search?query=sqlite&tags=story&hitsPerPage=3 -> 200; total=4632, first=Hosting SQLite databases on GitHub Pages or any static file hoster, first_url=https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/, hits=[{'title': 'Hosting SQLite databases on GitHub Pages or any static file hoster'…"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "each",
+ "node": "any",
+ "observed": [
+ {
+ "author": "phiresky",
+ "objectID": "27016630",
+ "points": 1812,
+ "title": "Hosting SQLite databases on GitHub Pages or any static file hoster",
+ "url": "https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ },
+ {
+ "author": "dpeck",
+ "objectID": "31318708",
+ "points": 1353,
+ "title": "I'm all-in on server-side SQLite",
+ "url": "https://fly.io/blog/all-in-on-sqlite-litestream/"
+ },
+ {
+ "author": "ropbear",
+ "objectID": "49272832",
+ "points": 1223,
+ "title": "Tracking down the 16-year-old WAL-reset SQLite bug",
+ "url": "https://tailscale.com/blog/sqlite-wal-reset-bug"
+ }
+ ],
+ "summary": "state.hits = [{'title': 'Hosting SQLite databases on GitHub Pages or any static file hoster'…"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 0,
+ "kind": "each",
+ "next": "reply",
+ "node": "each",
+ "summary": "3 item(s)"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "4632 Hacker News stories match sqlite. Top: Hosting SQLite databases on GitHub Pages or any static file hoster https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"
+ }
+ ],
+ "tree": "hn-search"
+ },
+ "tree": "hn-search"
+ },
+ "inputs": {
+ "query": "sqlite"
+ },
+ "intent": "search hacker news for sqlite",
+ "tree": "hn-search"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "query": "qqqzzzxxyyy"
+ },
+ "output": {
+ "query": "qqqzzzxxyyy",
+ "stories": [],
+ "total": 0
+ },
+ "say": "No Hacker News stories match qqqzzzxxyyy.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "search hacker news for qqqzzzxxyyy",
+ "inputs": {
+ "query": "qqqzzzxxyyy"
+ },
+ "steps": [],
+ "trigger": "search hacker news for {query}"
+ },
+ "summary": "'search hacker news for qqqzzzxxyyy'; hn-search via 'search hacker news for {query}' (19 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "first": null,
+ "first_url": null,
+ "hits": [],
+ "total": 0
+ },
+ "summary": "GET https://hn.algolia.com/api/v1/search?query=qqqzzzxxyyy&tags=story&hitsPerPage=3 -> 200; total=0, first=None, first_url=None, hits=[]"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "empty True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "nothing",
+ "node": "any",
+ "observed": [],
+ "summary": "state.hits = []"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "nothing",
+ "summary": "No Hacker News stories match qqqzzzxxyyy."
+ }
+ ],
+ "tree": "hn-search"
+ },
+ "tree": "hn-search"
+ },
+ "inputs": {
+ "query": "qqqzzzxxyyy"
+ },
+ "intent": "search hacker news for qqqzzzxxyyy",
+ "tree": "hn-search"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "title": "The Left Hand of Darkness"
+ },
+ "output": {
+ "author": "Ursula K. Le Guin",
+ "first_published": 1969,
+ "matches": 112,
+ "title": "The Left Hand of Darkness",
+ "url": "https://openlibrary.org/works/OL59800W"
+ },
+ "say": "The Left Hand of Darkness, by Ursula K. Le Guin (1969). https://openlibrary.org/works/OL59800W",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "who wrote The Left Hand of Darkness",
+ "inputs": {
+ "title": "The Left Hand of Darkness"
+ },
+ "steps": [],
+ "trigger": "who wrote {title}"
+ },
+ "summary": "'who wrote The Left Hand of Darkness'; books via 'who wrote {title}' (8 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "author": "Ursula K. Le Guin",
+ "book": "The Left Hand of Darkness",
+ "found": 112,
+ "key": "/works/OL59800W",
+ "year": 1969
+ },
+ "summary": "GET https://openlibrary.org/search.json?q=The%20Left%20Hand%20of%20Darkness&limit=1&fields=title,author_name,first_publish_year,key -> 200; found=112, book=The Left Hand of Darkness, author=Ursula K. Le Guin, year=1969, key=/works/OL59800W"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": "The Left Hand of Darkness",
+ "summary": "state.book = The Left Hand of Darkness"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The Left Hand of Darkness, by Ursula K. Le Guin (1969). https://openlibrary.org/works/OL59800W"
+ }
+ ],
+ "tree": "books"
+ },
+ "tree": "books"
+ },
+ "inputs": {
+ "title": "The Left Hand of Darkness"
+ },
+ "intent": "who wrote The Left Hand of Darkness",
+ "tree": "books"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "title": "qqqzzzxxyyy"
+ },
+ "reason": "no-book",
+ "say": "Open Library has nothing matching qqqzzzxxyyy.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "search",
+ "node": "intent",
+ "observed": {
+ "cleaned": "find the book qqqzzzxxyyy",
+ "inputs": {
+ "title": "qqqzzzxxyyy"
+ },
+ "steps": [],
+ "trigger": "find the book {title}"
+ },
+ "summary": "'find the book qqqzzzxxyyy'; books via 'find the book {title}' (11 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "search",
+ "observed": {
+ "author": null,
+ "book": null,
+ "found": 0,
+ "key": null,
+ "year": null
+ },
+ "summary": "GET https://openlibrary.org/search.json?q=qqqzzzxxyyy&limit=1&fields=title,author_name,first_publish_year,key -> 200; found=0, book=None, author=None, year=None, key=None"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-book",
+ "node": "any",
+ "summary": "state.book = <missing>"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-book",
+ "summary": "no-book: Open Library has nothing matching qqqzzzxxyyy."
+ }
+ ],
+ "tree": "books"
+ },
+ "tree": "books"
+ },
+ "inputs": {
+ "title": "qqqzzzxxyyy"
+ },
+ "intent": "find the book qqqzzzxxyyy",
+ "tree": "books"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "country": "no"
+ },
+ "output": {
+ "after": {
+ "date": "2026-12-26",
+ "name": "St. Stephen's Day"
+ },
+ "country": "NO",
+ "date": "2026-12-25",
+ "local_name": "Første juledag",
+ "name": "Christmas Day"
+ },
+ "say": "The next public holiday in NO is Christmas Day (Første juledag) on 2026-12-25.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "next holiday in no",
+ "inputs": {
+ "country": "no"
+ },
+ "steps": [],
+ "trigger": "[when is the] next [public|bank] holiday in {country:word}"
+ },
+ "summary": "'next holiday in no'; holidays via '[when is the] next [public|bank] holiday in {country:word}' (32 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "date": "2026-12-25",
+ "local": "Første juledag",
+ "name": "Christmas Day",
+ "then": "St. Stephen's Day",
+ "then_date": "2026-12-26"
+ },
+ "summary": "GET https://date.nager.at/api/v3/NextPublicHolidays/NO -> 200; date=2026-12-25, name=Christmas Day, local=Første juledag, then=St. Stephen's Day, then_date=2026-12-26"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": "2026-12-25",
+ "summary": "state.date = 2026-12-25"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The next public holiday in NO is Christmas Day (Første juledag) on 2026-12-25."
+ }
+ ],
+ "tree": "holidays"
+ },
+ "tree": "holidays"
+ },
+ "inputs": {
+ "country": "no"
+ },
+ "intent": "next holiday in no",
+ "tree": "holidays"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "country": "XX"
+ },
+ "reason": "unknown-country",
+ "say": "Nager.Date knows no country XX (it answered 500); use a two-letter code such as NO, US or DE.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "next holiday in XX",
+ "inputs": {
+ "country": "XX"
+ },
+ "steps": [],
+ "trigger": "[when is the] next [public|bank] holiday in {country:word}"
+ },
+ "summary": "'next holiday in XX'; holidays via '[when is the] next [public|bank] holiday in {country:word}' (32 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "date": null,
+ "local": null,
+ "name": null,
+ "then": null,
+ "then_date": null
+ },
+ "summary": "GET https://date.nager.at/api/v3/NextPublicHolidays/XX -> 500; date=None, name=None, local=None, then=None, then_date=None"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "unknown-country",
+ "node": "status",
+ "observed": 500,
+ "summary": "state.resp.status = 500"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "unknown-country",
+ "summary": "unknown-country: Nager.Date knows no country XX (it answered 500); use a two-letter code such as NO, US or DE."
+ }
+ ],
+ "tree": "holidays"
+ },
+ "tree": "holidays"
+ },
+ "inputs": {
+ "country": "XX"
+ },
+ "intent": "next holiday in XX",
+ "tree": "holidays"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "joke": "I am so good at sleeping I can do it with my eyes closed!",
+ "url": "https://icanhazdadjoke.com/j/oOKBsPZ0wc"
+ },
+ "say": "I am so good at sleeping I can do it with my eyes closed!",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "tell me a joke",
+ "inputs": {},
+ "steps": [],
+ "trigger": "tell me a [dad] joke"
+ },
+ "summary": "'tell me a joke'; dad-joke via 'tell me a [dad] joke' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "id": "oOKBsPZ0wc",
+ "joke": "I am so good at sleeping I can do it with my eyes closed!"
+ },
+ "summary": "GET https://icanhazdadjoke.com/ -> 200; joke=I am so good at sleeping I can do it with my eyes closed!, id=oOKBsPZ0wc"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-joke",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-joke",
+ "observed": "I am so good at sleeping I can do it with my eyes closed!",
+ "summary": "state.joke = I am so good at sleeping I can do it with my eyes closed!"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "I am so good at sleeping I can do it with my eyes closed!"
+ }
+ ],
+ "tree": "dad-joke"
+ },
+ "tree": "dad-joke"
+ },
+ "inputs": {},
+ "intent": "tell me a joke",
+ "tree": "dad-joke"
+ }
+ ],
+ "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 }}&current=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&current=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&current=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, ThreadingHTTPServer
import pytest
+from pyrtan import Registry
from 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 out
code, 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"})