Add seventeen more trees that run in a browser
Claude · 2026-10-28 · f19cc5967e97da2eb6213a9e62fa46e16e71b700 · parents 2df3d68
Every example now runs in a page but the six that reach a box or any site at all (disk-space, log-errors, modem-check, room-temperature, rendered-headline, site-status). Each new tree calls one API that lets other origins read it, needs no key, and decides on the status code before the body:
forecast tomorrow's weather: sky, low and high, chance of rain uv-index UV now and today's highest, with the WHO band local-time the time in a place (Open-Meteo's geocoder gives the zone, timeapi.io the clock) elevation how high a place is (the geocoder carries it) postal-code a postcode's place (Zippopotam.us; a bare ZIP is US) wayback the Wayback Machine's latest copy of a site rhymes rhymes from Datamuse, joined into a sentence github-repo a repository's description, stars, forks, open issues go-module the latest version of a Go module (proxy.golang.org) homebrew a Homebrew formula's stable version pokemon a Pokémon's number and types (types joined) my-ip the public address requests come from (ipify) advice a piece of advice (Advice Slip) cat-fact a fact about cats (catfact.ninja) next-launch the next rocket launch, flown ones left out (Launch Library) space-news the three latest spaceflight articles apod NASA's Astronomy Picture of the Day (DEMO_KEY)
Every branch is recorded in tests/fixtures/examples.json and tested. GitHub's and NASA's 200s are written by hand from their documented shapes (this sandbox's proxy blocks api.github.com, and NASA's demo key was spent from its address); the Open-Meteo forecast and UV answers likewise, as Open-Meteo was rate-limiting the sandbox. Conformance covers every new sentence: 264/264.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jeoK8iFSW7tkCrzXLZ
Changes
README.md | 14 +- examples/advice/advice.toml | 48 + examples/apod/apod.toml | 53 + examples/cat-fact/cat-fact.toml | 48 + examples/elevation/elevation.toml | 67 + examples/forecast/forecast.toml | 128 + examples/github-repo/github-repo.toml | 69 + examples/go-module/go-module.toml | 54 + examples/homebrew/homebrew.toml | 57 + examples/local-time/local-time.toml | 78 + examples/my-ip/my-ip.toml | 49 + examples/next-launch/next-launch.toml | 66 + examples/pokemon/pokemon.toml | 60 + examples/postal-code/postal-code.toml | 65 + examples/rhymes/rhymes.toml | 60 + examples/space-news/space-news.toml | 71 + examples/uv-index/uv-index.toml | 103 + examples/wayback/wayback.toml | 63 + src/pyrtan/page.py | 11 + tests/conformance/make_vectors.py | 27 + tests/conformance/vectors.json | 7015 ++++++++++++++++++++++++++++++++++++++++++++--------- tests/fixtures/examples.json | 344 +++ tests/test_catalog.py | 3 +- tests/test_examples.py | 76 + 24 files changed, 7538 insertions(+), 1091 deletions(-)
diff --git a/README.md b/README.md index 750f4aa..316cd97 100644 --- a/README.md +++ b/README.md@@ -201,11 +201,15 @@ its `link`; and the gaps. The same console lives atjoin one, the browser is the hub. It holds the catalog's trees, reads the sentence and runs the tree in the page (`pyrtan.js`, the same runtime and the same reading as Python's), and keeps gaps and unanswered sentences-while the page is open. Nineteen of the examples run there: weather,-sunrise and sunset, air quality, Wikipedia, a dictionary, synonyms,-exchange rates, Hacker News (top stories, an item, a search), books,-npm and PyPI versions, GitHub releases, earthquakes, where the ISS is,-public holidays, a dad joke, and small talk. Trees that need a box (files, programs, serial+while the page is open. Every example runs there but six: weather+today and tomorrow, sunrise, UV and air quality, local time, elevation,+postcodes, Wikipedia, words (definitions, synonyms, rhymes), exchange+rates, Hacker News, GitHub, packages (npm, PyPI, Go, Homebrew), books,+earthquakes, the ISS, launches, space news, NASA's picture of the day,+holidays, the Wayback Machine, Pokémon, your IP, jokes, advice and cat+facts. The six that need a hub reach a box or any site at all:+`disk-space`, `log-errors`, `modem-check`, `room-temperature`,+`rendered-headline` and `site-status`. Trees that need a box (files, programs, seriallines, brokers, a browser to drive) or a site that does not let a page read it say so; for those, join a hub started with `--cors https://pyrtan.com` by giving its URL and token. Forget it and the diff --git a/examples/advice/advice.toml b/examples/advice/advice.toml new file mode 100644 index 0000000..fbb4118 --- /dev/null +++ b/examples/advice/advice.toml@@ -0,0 +1,48 @@+# A piece of advice, from the Advice Slip API.++name = "advice"+version = 1+namespace = "pyrtan"+description = "A random piece of advice, from the Advice Slip API."+tags = ["fun", "read-only"]+license = "MIT"+hosts = ["api.adviceslip.com"]+start = "fetch"++[[triggers]]+pattern = "give me [some] advice"+[[triggers]]+pattern = "[any] advice"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.adviceslip.com/advice" }+into = "resp"+extract = [{ into = "advice", json = "json.slip.advice" }, { into = "id", json = "json.slip.id" }]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "has-advice" },+ { else = "unexpected" },+]++[nodes.has-advice]+kind = "decide"+on = "advice"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ state.advice }}"+output = { advice = "{{ state.advice }}", id = "{{ state.id }}" }++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "The Advice Slip API answered {{ state.resp.status }}."diff --git a/examples/apod/apod.toml b/examples/apod/apod.toml new file mode 100644 index 0000000..b2a82e6 --- /dev/null +++ b/examples/apod/apod.toml@@ -0,0 +1,53 @@+# NASA's Astronomy Picture of the Day. The public DEMO_KEY allows thirty+# requests an hour from one address, which is plenty for one person.++name = "apod"+version = 1+namespace = "pyrtan"+description = "NASA's Astronomy Picture of the Day: its title, and the picture or video."+tags = ["space", "science", "read-only"]+license = "MIT"+hosts = ["api.nasa.gov"]+start = "fetch"++[[triggers]]+pattern = "astronomy picture of the day"+[[triggers]]+pattern = "[nasa] (apod|space picture) [of the day|today]"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY" }+into = "resp"+extract = [+ { into = "title", json = "json.title" },+ { into = "day", json = "json.date" },+ { into = "media", json = "json.url" },+ { into = "kind", json = "json.media_type" },+ { into = "about", json = "json.explanation" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 429 }, then = "rate-limited" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "Astronomy Picture of the Day, {{ state.day }}: {{ state.title }}. {{ state.media }}"+output = { date = "{{ state.day }}", title = "{{ state.title }}", media_type = "{{ state.kind }}", url = "{{ state.media }}", explanation = "{{ state.about }}", page = "https://apod.nasa.gov/apod/astropix.html" }++[nodes.rate-limited]+kind = "fail"+reason = "rate-limited"+say = "NASA's demo key has used up its hour from this address; try again later, or see https://apod.nasa.gov/apod/astropix.html"++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "NASA's API answered {{ state.resp.status }}."diff --git a/examples/cat-fact/cat-fact.toml b/examples/cat-fact/cat-fact.toml new file mode 100644 index 0000000..3a08235 --- /dev/null +++ b/examples/cat-fact/cat-fact.toml@@ -0,0 +1,48 @@+# A fact about cats, from catfact.ninja.++name = "cat-fact"+version = 1+namespace = "pyrtan"+description = "A random fact about cats, from catfact.ninja."+tags = ["fun", "animals", "read-only"]+license = "MIT"+hosts = ["catfact.ninja"]+start = "fetch"++[[triggers]]+pattern = "[tell me a] cat fact"+[[triggers]]+pattern = "tell me something about cats"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://catfact.ninja/fact" }+into = "resp"+extract = [{ into = "fact", json = "json.fact" }]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "has-fact" },+ { else = "unexpected" },+]++[nodes.has-fact]+kind = "decide"+on = "fact"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ state.fact }}"+output = { fact = "{{ state.fact }}" }++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "catfact.ninja answered {{ state.resp.status }}."diff --git a/examples/elevation/elevation.toml b/examples/elevation/elevation.toml new file mode 100644 index 0000000..58d177e --- /dev/null +++ b/examples/elevation/elevation.toml@@ -0,0 +1,67 @@+# How high a place is above the sea, from Open-Meteo's geocoder, which+# carries each place's elevation with its name.++name = "elevation"+version = 1+namespace = "pyrtan"+description = "How high a place is above sea level, from Open-Meteo's geocoder."+tags = ["geography", "read-only"]+license = "MIT"+hosts = ["geocoding-api.open-meteo.com"]+start = "geocode"++[[triggers]]+pattern = "how high (is|above sea level is) {place}"+[[triggers]]+pattern = "[the] (elevation|altitude) of {place}"++[nodes.reply]+kind = "done"+say = "{{ state.name }}, {{ state.country }} is {{ state.height | int }} m above sea level."+output = { place = "{{ state.name }}", country = "{{ state.country }}", elevation_m = "{{ state.height }}" }++[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" },+ { into = "zone", json = "json.results[0].timezone" },+ { into = "height", json = "json.results[0].elevation" },+]+next = "found"++[nodes.found]+kind = "decide"+on = "hit"+branches = [+ { when = { exists = true }, then = "has-height" },+ { else = "unknown-place" },+]++[nodes.unknown-place]+kind = "fail"+reason = "unknown-place"+say = "I could not find a place called {{ inputs.place }}."++[inputs.place]+type = "str"+description = "A town or city name"+++[nodes.has-height]+kind = "decide"+on = "height"+branches = [+ { when = { type = "number" }, then = "reply" },+ { else = "no-height" },+]++[nodes.no-height]+kind = "fail"+reason = "no-height"+say = "Open-Meteo knows {{ state.name }} but not how high it is."diff --git a/examples/forecast/forecast.toml b/examples/forecast/forecast.toml new file mode 100644 index 0000000..9ab4204 --- /dev/null +++ b/examples/forecast/forecast.toml@@ -0,0 +1,128 @@+# Tomorrow's weather for a place, from Open-Meteo: the sky, the high and+# low, and the chance of rain, in the place's own day.++name = "forecast"+version = 1+namespace = "pyrtan"+description = "Tomorrow's weather for a place: sky, high and low, and chance of rain, from Open-Meteo."+tags = ["weather", "read-only"]+license = "MIT"+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]+start = "geocode"++[[triggers]]+pattern = "[what's the] weather (in|for) {place} tomorrow"+[[triggers]]+pattern = "[what's the] weather tomorrow (in|for) {place}"+[[triggers]]+pattern = "will it rain tomorrow in {place}"++[nodes.tomorrow]+kind = "observe"+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2" }+into = "fc"+extract = [+ { into = "day", json = "json.daily.time[1]" },+ { into = "code", json = "json.daily.weather_code[1]" },+ { into = "high", json = "json.daily.temperature_2m_max[1]" },+ { into = "low", json = "json.daily.temperature_2m_min[1]" },+ { into = "rain", json = "json.daily.precipitation_probability_max[1]" },+ { into = "unit", json = "json.daily_units.temperature_2m_max" },+]+next = "have-day"++[nodes.have-day]+kind = "decide"+on = "high"+branches = [+ { when = { type = "number" }, then = "sky" },+ { else = "no-forecast" },+]++# WMO weather codes, folded into words, as the weather tree does.+[nodes.sky]+kind = "decide"+on = "code"+branches = [+ { when = { eq = 0 }, then = "clear" },+ { when = { between = [1, 3] }, then = "cloudy" },+ { when = { between = [45, 48] }, then = "fog" },+ { when = { between = [51, 67] }, then = "rain" },+ { when = { between = [71, 77] }, then = "snow" },+ { when = { between = [80, 82] }, then = "rain" },+ { when = { between = [85, 86] }, then = "snow" },+ { when = { between = [95, 99] }, then = "storm" },+ { else = "unknown-sky" },+]++[nodes.clear]+kind = "set"+set = { sky = "clear" }+next = "reply"+[nodes.cloudy]+kind = "set"+set = { sky = "cloudy" }+next = "reply"+[nodes.fog]+kind = "set"+set = { sky = "foggy" }+next = "reply"+[nodes.rain]+kind = "set"+set = { sky = "rain" }+next = "reply"+[nodes.snow]+kind = "set"+set = { sky = "snow" }+next = "reply"+[nodes.storm]+kind = "set"+set = { sky = "storms" }+next = "reply"+[nodes.unknown-sky]+kind = "set"+set = { sky = "weather code {{ state.code }}" }+next = "reply"++[nodes.reply]+kind = "done"+say = "{{ state.name }}, {{ state.country }}, tomorrow ({{ state.day }}): {{ state.sky }}, {{ state.low }} to {{ state.high }}{{ state.unit }}, {{ state.rain }}% chance of rain."+output = { place = "{{ state.name }}", country = "{{ state.country }}", date = "{{ state.day }}", sky = "{{ state.sky }}", low = "{{ state.low }}", high = "{{ state.high }}", unit = "{{ state.unit }}", rain_chance = "{{ state.rain }}" }++[nodes.no-forecast]+kind = "fail"+reason = "no-forecast"+say = "Open-Meteo found {{ state.name }} but gave no forecast for tomorrow (status {{ state.fc.status }})."++[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" },+ { into = "zone", json = "json.results[0].timezone" },+ { into = "height", json = "json.results[0].elevation" },+]+next = "found"++[nodes.found]+kind = "decide"+on = "hit"+branches = [+ { when = { exists = true }, then = "tomorrow" },+ { else = "unknown-place" },+]++[nodes.unknown-place]+kind = "fail"+reason = "unknown-place"+say = "I could not find a place called {{ inputs.place }}."++[inputs.place]+type = "str"+description = "A town or city name"+diff --git a/examples/github-repo/github-repo.toml b/examples/github-repo/github-repo.toml new file mode 100644 index 0000000..cf1484c --- /dev/null +++ b/examples/github-repo/github-repo.toml@@ -0,0 +1,69 @@+# A GitHub repository at a glance: what it is, its stars, forks and open+# issues, from GitHub's REST API (no token: sixty requests an hour).++name = "github-repo"+version = 1+namespace = "pyrtan"+description = "A GitHub repository at a glance: description, language, stars, forks, open issues."+tags = ["github", "software", "read-only"]+license = "MIT"+hosts = ["api.github.com"]+start = "fetch"++[[triggers]]+pattern = "how many stars does {repo:word} have"+[[triggers]]+pattern = "github (repo|repository) {repo:word}"+[[triggers]]+pattern = "tell me about the {repo:word} repo"++[inputs.repo]+type = "str"+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"+description = "owner/name, as in: astral-sh/uv"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}", headers = { Accept = "application/vnd.github+json" } }+into = "resp"+extract = [+ { into = "full", json = "json.full_name" },+ { into = "about", json = "json.description" },+ { into = "language", json = "json.language" },+ { into = "stars", json = "json.stargazers_count" },+ { into = "forks", json = "json.forks_count" },+ { into = "issues", json = "json.open_issues_count" },+ { into = "url", json = "json.html_url" },+ { into = "archived", json = "json.archived" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 404 }, then = "no-repo" },+ { when = { in = [403, 429] }, then = "rate-limited" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ state.full }}: {{ state.about }} ({{ state.language }}) · {{ state.stars }} stars, {{ state.forks }} forks, {{ state.issues }} open issues. {{ state.url }}"+output = { repo = "{{ state.full }}", description = "{{ state.about }}", language = "{{ state.language }}", stars = "{{ state.stars }}", forks = "{{ state.forks }}", open_issues = "{{ state.issues }}", archived = "{{ state.archived }}", url = "{{ state.url }}" }++[nodes.no-repo]+kind = "fail"+reason = "no-repo"+say = "GitHub has no public repository {{ inputs.repo }}."++[nodes.rate-limited]+kind = "fail"+reason = "rate-limited"+say = "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "GitHub answered {{ state.resp.status }}."diff --git a/examples/go-module/go-module.toml b/examples/go-module/go-module.toml new file mode 100644 index 0000000..3c79b65 --- /dev/null +++ b/examples/go-module/go-module.toml@@ -0,0 +1,54 @@+# The latest version of a Go module, from the Go module proxy.++name = "go-module"+version = 1+namespace = "pyrtan"+description = "The latest version of a Go module, and when it was published, from proxy.golang.org."+tags = ["software", "go", "read-only"]+license = "MIT"+hosts = ["proxy.golang.org"]+start = "fetch"++[[triggers]]+pattern = "[what's the] latest version of {module:word} on go"+[[triggers]]+pattern = "go (module|version) {module:word}"++[inputs.module]+type = "str"+pattern = "^[a-z0-9.\\-]+(/[A-Za-z0-9._~\\-]+)+$"+description = "A module path, as in: github.com/gorilla/mux"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://proxy.golang.org/{{ inputs.module | lower }}/@latest" }+into = "resp"+extract = [+ { into = "version", json = "json.Version" },+ { into = "day", regex = "^(\\d{4}-\\d\\d-\\d\\d)", from = "json.Time" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { in = [404, 410] }, then = "no-module" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ inputs.module }} {{ state.version }}, published {{ state.day }}. https://pkg.go.dev/{{ inputs.module }}"+output = { module = "{{ inputs.module }}", version = "{{ state.version }}", published = "{{ state.day }}", url = "https://pkg.go.dev/{{ inputs.module }}" }++[nodes.no-module]+kind = "fail"+reason = "no-module"+say = "The Go module proxy has no module {{ inputs.module }}."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "The Go module proxy answered {{ state.resp.status }}."diff --git a/examples/homebrew/homebrew.toml b/examples/homebrew/homebrew.toml new file mode 100644 index 0000000..93326f2 --- /dev/null +++ b/examples/homebrew/homebrew.toml@@ -0,0 +1,57 @@+# A Homebrew formula: what it is and its current stable version, from+# formulae.brew.sh.++name = "homebrew"+version = 1+namespace = "pyrtan"+description = "A Homebrew formula's current stable version and what it is, from formulae.brew.sh."+tags = ["software", "macos", "read-only"]+license = "MIT"+hosts = ["formulae.brew.sh"]+start = "fetch"++[[triggers]]+pattern = "[what's the] latest version of {formula:word} on homebrew"+[[triggers]]+pattern = "(brew|homebrew) (formula|version) {formula:word}"++[inputs.formula]+type = "str"+pattern = "^[a-z0-9][a-z0-9@+._\\-]*$"+description = "A formula name, as in: wget"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://formulae.brew.sh/api/formula/{{ inputs.formula | lower | url }}.json" }+into = "resp"+extract = [+ { into = "name", json = "json.name" },+ { into = "version", json = "json.versions.stable" },+ { into = "about", json = "json.desc" },+ { into = "home", json = "json.homepage" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 404 }, then = "no-formula" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ state.name }} {{ state.version }} on Homebrew: {{ state.about }}. {{ state.home }}"+output = { formula = "{{ state.name }}", version = "{{ state.version }}", description = "{{ state.about }}", homepage = "{{ state.home }}", install = "brew install {{ state.name }}" }++[nodes.no-formula]+kind = "fail"+reason = "no-formula"+say = "Homebrew has no formula called {{ inputs.formula }}."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "formulae.brew.sh answered {{ state.resp.status }}."diff --git a/examples/local-time/local-time.toml b/examples/local-time/local-time.toml new file mode 100644 index 0000000..4d24cb5 --- /dev/null +++ b/examples/local-time/local-time.toml@@ -0,0 +1,78 @@+# The time now in a place: its time zone from Open-Meteo's geocoder, the+# clock in that zone from timeapi.io.++name = "local-time"+version = 1+namespace = "pyrtan"+description = "What time it is now in a place, and its time zone."+tags = ["time", "read-only"]+license = "MIT"+hosts = ["geocoding-api.open-meteo.com", "timeapi.io"]+start = "geocode"++[[triggers]]+pattern = "what time is it in {place}"+[[triggers]]+pattern = "[the] (time|local time) in {place}"++[nodes.clock]+kind = "observe"+http = { method = "GET", url = "https://timeapi.io/api/time/current/zone?timeZone={{ state.zone | url }}" }+into = "resp"+extract = [+ { into = "time", json = "json.time" },+ { into = "weekday", json = "json.dayOfWeek" },+ { into = "date", regex = "^(\\d{4}-\\d\\d-\\d\\d)", from = "json.dateTime" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { else = "no-clock" },+]++[nodes.reply]+kind = "done"+say = "It is {{ state.time }} on {{ state.weekday }} {{ state.date }} in {{ state.name }}, {{ state.country }} ({{ state.zone }})."+output = { place = "{{ state.name }}", country = "{{ state.country }}", time = "{{ state.time }}", date = "{{ state.date }}", weekday = "{{ state.weekday }}", timezone = "{{ state.zone }}" }++[nodes.no-clock]+kind = "fail"+reason = "no-clock"+say = "timeapi.io gave no time for {{ state.zone }} (status {{ state.resp.status }})."++[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" },+ { into = "zone", json = "json.results[0].timezone" },+ { into = "height", json = "json.results[0].elevation" },+]+next = "found"++[nodes.found]+kind = "decide"+on = "hit"+branches = [+ { when = { exists = true }, then = "clock" },+ { else = "unknown-place" },+]++[nodes.unknown-place]+kind = "fail"+reason = "unknown-place"+say = "I could not find a place called {{ inputs.place }}."++[inputs.place]+type = "str"+description = "A town or city name"+diff --git a/examples/my-ip/my-ip.toml b/examples/my-ip/my-ip.toml new file mode 100644 index 0000000..80e714c --- /dev/null +++ b/examples/my-ip/my-ip.toml@@ -0,0 +1,49 @@+# The public IP address this tree's requests come from, from ipify. In a+# browser that is the visitor's; on a service, the service's.++name = "my-ip"+version = 1+namespace = "pyrtan"+description = "The public IP address requests from here come from, from ipify."+tags = ["network", "read-only"]+license = "MIT"+hosts = ["api.ipify.org"]+start = "fetch"++[[triggers]]+pattern = "what is my [public] ip [address]"+[[triggers]]+pattern = "my [public] ip [address]"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.ipify.org?format=json" }+into = "resp"+extract = [{ into = "ip", json = "json.ip" }]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "has-ip" },+ { else = "unexpected" },+]++[nodes.has-ip]+kind = "decide"+on = "ip"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "Your public IP address is {{ state.ip }}."+output = { ip = "{{ state.ip }}" }++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "ipify answered {{ state.resp.status }}."diff --git a/examples/next-launch/next-launch.toml b/examples/next-launch/next-launch.toml new file mode 100644 index 0000000..5f124d0 --- /dev/null +++ b/examples/next-launch/next-launch.toml@@ -0,0 +1,66 @@+# The next rocket launch anywhere, from The Space Devs' Launch Library:+# what flies, when (UTC), and whether it is go. Launches that already flew+# are left out. Without a key the API allows fifteen requests an hour.++name = "next-launch"+version = 1+namespace = "pyrtan"+description = "The next rocket launch anywhere, when, and its status, from the Launch Library."+tags = ["space", "read-only"]+license = "MIT"+hosts = ["ll.thespacedevs.com"]+start = "fetch"++[[triggers]]+pattern = "[when is the] next (launch|rocket launch)"+[[triggers]]+pattern = "upcoming (launches|rocket launches)"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=1&mode=list&hide_recent_previous=true" }+into = "resp"+extract = [+ { into = "name", json = "json.results[0].name" },+ { into = "net", json = "json.results[0].net" },+ { into = "state", json = "json.results[0].status.name" },+ { into = "count", json = "json.count" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "any" },+ { when = { eq = 429 }, then = "rate-limited" },+ { else = "unexpected" },+]++[nodes.any]+kind = "decide"+on = "name"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "none" },+]++[nodes.reply]+kind = "done"+say = "Next launch: {{ state.name }}, {{ state.net }} UTC ({{ state.state }}). {{ state.count }} launches are scheduled."+output = { name = "{{ state.name }}", net_utc = "{{ state.net }}", status = "{{ state.state }}", upcoming = "{{ state.count }}" }++[nodes.none]+kind = "done"+say = "The Launch Library lists no upcoming launches."+output = { upcoming = 0 }++[nodes.rate-limited]+kind = "fail"+reason = "rate-limited"+say = "The Launch Library allows fifteen requests an hour without a key; try again later."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "The Launch Library answered {{ state.resp.status }}."diff --git a/examples/pokemon/pokemon.toml b/examples/pokemon/pokemon.toml new file mode 100644 index 0000000..528bf2a --- /dev/null +++ b/examples/pokemon/pokemon.toml@@ -0,0 +1,60 @@+# A Pokémon's number, types, height and weight, from PokéAPI. The API+# gives height in decimetres and weight in hectograms.++name = "pokemon"+version = 1+namespace = "pyrtan"+description = "A Pokémon's number, types, height and weight, from PokéAPI."+tags = ["fun", "games", "read-only"]+license = "MIT"+hosts = ["pokeapi.co"]+start = "fetch"++[[triggers]]+pattern = "pokemon {name:word}"+[[triggers]]+pattern = "what type is {name:word}"+[[triggers]]+pattern = "tell me about the pokemon {name:word}"++[inputs.name]+type = "str"+pattern = "^[A-Za-z0-9\\-]+$"+description = "A Pokémon's name or number, as in: pikachu"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://pokeapi.co/api/v2/pokemon/{{ inputs.name | lower | url }}" }+into = "resp"+extract = [+ { into = "id", json = "json.id" },+ { into = "name", json = "json.name" },+ { into = "types", regex = "\"type\":\\s*\\{\\s*\"name\":\\s*\"([a-z]+)\"", all = true },+ { into = "dm", json = "json.height" },+ { into = "hg", json = "json.weight" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 404 }, then = "no-pokemon" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "#{{ state.id }} {{ state.name | title }}, {{ state.types | join }} type."+output = { number = "{{ state.id }}", name = "{{ state.name }}", types = "{{ state.types }}", height_dm = "{{ state.dm }}", weight_hg = "{{ state.hg }}" }++[nodes.no-pokemon]+kind = "fail"+reason = "no-pokemon"+say = "PokéAPI knows no Pokémon called {{ inputs.name }}."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "PokéAPI answered {{ state.resp.status }}."diff --git a/examples/postal-code/postal-code.toml b/examples/postal-code/postal-code.toml new file mode 100644 index 0000000..3ae0cc5 --- /dev/null +++ b/examples/postal-code/postal-code.toml@@ -0,0 +1,65 @@+# Where a postal code is, from Zippopotam.us: the place, its state or+# region, and where it lies. A US ZIP code needs no country.++name = "postal-code"+version = 1+namespace = "pyrtan"+description = "The place a postal code belongs to, from Zippopotam.us (US ZIP codes, or a code in another country)."+tags = ["geography", "read-only"]+license = "MIT"+hosts = ["api.zippopotam.us"]+start = "fetch"++[[triggers]]+pattern = "where is zip [code] {code:word}"+inputs = { country = "us" }+[[triggers]]+pattern = "[where is] (postcode|postal code) {code:word} in {country:word}"++[inputs.code]+type = "str"+pattern = "^[A-Za-z0-9-]{2,10}$"+description = "A postal code, as in: 90210"++[inputs.country]+type = "str"+default = "us"+pattern = "^[A-Za-z]{2}$"+description = "A two-letter country code, as in: US, DE, GB"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.zippopotam.us/{{ inputs.country | lower }}/{{ inputs.code | url }}" }+into = "resp"+extract = [+ { into = "place", json = "json.places[0][\"place name\"]" },+ { into = "region", json = "json.places[0].state" },+ { into = "country_name", json = "json.country" },+ { into = "lat", json = "json.places[0].latitude" },+ { into = "lon", json = "json.places[0].longitude" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 404 }, then = "unknown-code" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ inputs.code | upper }} is {{ state.place }}, {{ state.region }}, {{ state.country_name }}."+output = { code = "{{ inputs.code | upper }}", place = "{{ state.place }}", region = "{{ state.region }}", country = "{{ state.country_name }}", latitude = "{{ state.lat | float }}", longitude = "{{ state.lon | float }}" }++[nodes.unknown-code]+kind = "fail"+reason = "unknown-code"+say = "Zippopotam.us knows no postal code {{ inputs.code | upper }} in {{ inputs.country | upper }}."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "Zippopotam.us answered {{ state.resp.status }}."diff --git a/examples/rhymes/rhymes.toml b/examples/rhymes/rhymes.toml new file mode 100644 index 0000000..41c7cbd --- /dev/null +++ b/examples/rhymes/rhymes.toml@@ -0,0 +1,60 @@+# Words that rhyme, from Datamuse, most common first.++name = "rhymes"+version = 1+namespace = "pyrtan"+description = "Words that rhyme with a word, from Datamuse."+tags = ["language", "read-only"]+license = "MIT"+hosts = ["api.datamuse.com"]+start = "fetch"++[[triggers]]+pattern = "what rhymes with {word:word}"+[[triggers]]+pattern = "(rhymes|words that rhyme) (for|with) {word:word}"++[inputs.word]+type = "str"+pattern = "^[A-Za-z][A-Za-z'\\-]*$"+description = "One English word"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.datamuse.com/words?rel_rhy={{ inputs.word | lower | url }}&max=10" }+into = "resp"+extract = [+ { into = "words", regex = "\"word\":\\s*\"([^\"]+)\"", all = true },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "any" },+ { else = "unexpected" },+]++[nodes.any]+kind = "decide"+on = "words"+branches = [+ { when = { empty = true }, then = "none" },+ { else = "reply" },+]++[nodes.reply]+kind = "done"+say = "Rhymes with {{ inputs.word }}: {{ state.words | join }}."+output = { word = "{{ inputs.word }}", rhymes = "{{ state.words }}" }++[nodes.none]+kind = "fail"+reason = "no-rhymes"+say = "Datamuse knows nothing that rhymes with {{ inputs.word }}."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "Datamuse answered {{ state.resp.status }}."diff --git a/examples/space-news/space-news.toml b/examples/space-news/space-news.toml new file mode 100644 index 0000000..398dddd --- /dev/null +++ b/examples/space-news/space-news.toml@@ -0,0 +1,71 @@+# The latest spaceflight news, from the Spaceflight News API: the newest+# three articles across the sites it follows.++name = "space-news"+version = 1+namespace = "pyrtan"+description = "The three latest spaceflight news articles, from the Spaceflight News API."+tags = ["space", "news", "read-only"]+license = "MIT"+hosts = ["api.spaceflightnewsapi.net"]+start = "fetch"++[[triggers]]+pattern = "[latest] (space|spaceflight) news"+[[triggers]]+pattern = "what's new in space"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.spaceflightnewsapi.net/v4/articles/?limit=3" }+into = "resp"+extract = [+ { into = "articles", json = "json.results" },+ { into = "title", json = "json.results[0].title" },+ { into = "site", json = "json.results[0].news_site" },+ { into = "url", json = "json.results[0].url" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "any" },+ { else = "unexpected" },+]++[nodes.any]+kind = "decide"+on = "articles"+branches = [+ { when = { empty = true }, then = "none" },+ { else = "each" },+]++[nodes.each]+kind = "each"+over = "articles"+limit = 3+start = "one"+into = "stories"+next = "reply"++[nodes.one]+kind = "done"+output = { title = "{{ item.title }}", site = "{{ item.news_site }}", url = "{{ item.url }}", published = "{{ item.published_at }}" }++[nodes.reply]+kind = "done"+say = "Latest in space: {{ state.title }} ({{ state.site }}) {{ state.url }}"+output = { stories = "{{ state.stories }}" }++[nodes.none]+kind = "done"+say = "The Spaceflight News API has no articles right now."+output = { stories = [] }++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "The Spaceflight News API answered {{ state.resp.status }}."diff --git a/examples/uv-index/uv-index.toml b/examples/uv-index/uv-index.toml new file mode 100644 index 0000000..3031775 --- /dev/null +++ b/examples/uv-index/uv-index.toml@@ -0,0 +1,103 @@+# The ultraviolet index for a place, now and at its highest today, from+# Open-Meteo, with the WHO's band for it.++name = "uv-index"+version = 1+namespace = "pyrtan"+description = "The UV index for a place, now and today's highest, with its WHO band, from Open-Meteo."+tags = ["weather", "health", "read-only"]+license = "MIT"+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]+start = "geocode"++[[triggers]]+pattern = "[what's the] (uv|uv index) in {place}"+[[triggers]]+pattern = "do i need sunscreen in {place}"++[nodes.uv]+kind = "observe"+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}¤t=uv_index&daily=uv_index_max&timezone=auto&forecast_days=1" }+into = "fc"+extract = [+ { into = "now", json = "json.current.uv_index" },+ { into = "max", json = "json.daily.uv_index_max[0]" },+]+next = "band"++# The WHO's UV index bands, for today's highest.+[nodes.band]+kind = "decide"+on = "max"+branches = [+ { when = { type = "number", lt = 3 }, then = "low" },+ { when = { lt = 6 }, then = "moderate" },+ { when = { lt = 8 }, then = "high" },+ { when = { lt = 11 }, then = "very-high" },+ { when = { gte = 11 }, then = "extreme" },+ { else = "no-reading" },+]++[nodes.low]+kind = "set"+set = { band = "low", advice = "no protection needed" }+next = "reply"+[nodes.moderate]+kind = "set"+set = { band = "moderate", advice = "sunscreen and a hat around midday" }+next = "reply"+[nodes.high]+kind = "set"+set = { band = "high", advice = "sunscreen, a hat, and shade at midday" }+next = "reply"+[nodes.very-high]+kind = "set"+set = { band = "very high", advice = "avoid the midday sun" }+next = "reply"+[nodes.extreme]+kind = "set"+set = { band = "extreme", advice = "stay out of the midday sun" }+next = "reply"++[nodes.reply]+kind = "done"+say = "{{ state.name }}, {{ state.country }}: UV index {{ state.now }} now, up to {{ state.max }} today ({{ state.band }}): {{ state.advice }}."+output = { place = "{{ state.name }}", country = "{{ state.country }}", uv_now = "{{ state.now }}", uv_max = "{{ state.max }}", band = "{{ state.band }}" }++[nodes.no-reading]+kind = "fail"+reason = "no-reading"+say = "Open-Meteo found {{ state.name }} but gave no UV index (status {{ state.fc.status }})."++[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" },+ { into = "zone", json = "json.results[0].timezone" },+ { into = "height", json = "json.results[0].elevation" },+]+next = "found"++[nodes.found]+kind = "decide"+on = "hit"+branches = [+ { when = { exists = true }, then = "uv" },+ { else = "unknown-place" },+]++[nodes.unknown-place]+kind = "fail"+reason = "unknown-place"+say = "I could not find a place called {{ inputs.place }}."++[inputs.place]+type = "str"+description = "A town or city name"+diff --git a/examples/wayback/wayback.toml b/examples/wayback/wayback.toml new file mode 100644 index 0000000..6a71ebc --- /dev/null +++ b/examples/wayback/wayback.toml@@ -0,0 +1,63 @@+# Whether the Internet Archive's Wayback Machine has a copy of a site, and+# a link to its most recent one.++name = "wayback"+version = 1+namespace = "pyrtan"+description = "The Wayback Machine's latest copy of a site, if it has one."+tags = ["web", "read-only"]+license = "MIT"+hosts = ["archive.org"]+start = "fetch"++[[triggers]]+pattern = "is {site:host} archived"+[[triggers]]+pattern = "wayback [machine] {site:host}"+[[triggers]]+pattern = "[the] archived copy of {site:host}"++[inputs.site]+type = "str"+description = "A host name, as in: example.com"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://archive.org/wayback/available?url={{ inputs.site | url }}" }+into = "resp"+extract = [+ { into = "copy", json = "json.archived_snapshots.closest.url" },+ { into = "stamp", json = "json.archived_snapshots.closest.timestamp" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "any" },+ { else = "unexpected" },+]++[nodes.any]+kind = "decide"+on = "copy"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "none" },+]++[nodes.reply]+kind = "done"+say = "The Wayback Machine has {{ inputs.site }}. Its latest copy: {{ state.copy }}"+output = { site = "{{ inputs.site }}", url = "{{ state.copy }}", timestamp = "{{ state.stamp }}" }++[nodes.none]+kind = "done"+say = "The Wayback Machine has no copy of {{ inputs.site }}."+output = { site = "{{ inputs.site }}", url = "" }++[nodes.unexpected]+kind = "fail"+reason = "unexpected-status"+say = "The Wayback Machine answered {{ state.resp.status }}."diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 1bc0256..252f6e8 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -337,6 +337,17 @@ const examples = {"holidays": "next holiday in US", "exchange-rate": "convert 100 USD to EUR", "synonyms": "synonyms for happy",+ "forecast": "weather in Oslo tomorrow",+ "uv-index": "uv index in Sydney",+ "local-time": "what time is it in Tokyo",+ "elevation": "how high is Quito",+ "postal-code": "where is zip 90210",+ "wayback": "is example.com archived",+ "rhymes": "what rhymes with moon",+ "github-repo": "how many stars does astral-sh/uv have",+ "go-module": "latest version of github.com/gorilla/mux on go",+ "homebrew": "brew formula wget",+ "pokemon": "pokemon pikachu",}; // 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 db73916..dcc218e 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -107,6 +107,33 @@ CASES: list[dict] = [{"intent": "exchange rate from usd to usd"}, {"intent": "synonyms for happy"}, {"intent": "another word for qqqzzz"},+ {"intent": "weather in Oslo tomorrow"},+ {"intent": "uv index in Sydney"},+ {"intent": "what time is it in La Paz"},+ {"intent": "how high is La Paz"},+ {"intent": "how high is Oslo"},+ {"intent": "where is zip 90210"},+ {"intent": "postcode SW1A in GB"},+ {"intent": "where is zip 00000"},+ {"intent": "is example.com archived"},+ {"intent": "is nothing-here-qqzz.example archived"},+ {"intent": "what rhymes with cat"},+ {"intent": "what rhymes with qqzzx"},+ {"intent": "how many stars does astral-sh/uv have"},+ {"intent": "github repo b-i-t-8/nothing-here"},+ {"intent": "latest version of github.com/gorilla/mux on go"},+ {"intent": "go module github.com/nope/nope-qq"},+ {"intent": "brew formula wget"},+ {"intent": "brew formula nope-qqzz"},+ {"intent": "pokemon pikachu"},+ {"intent": "what type is charizard"},+ {"intent": "pokemon notapokemonqq"},+ {"intent": "what is my ip"},+ {"intent": "give me some advice"},+ {"intent": "tell me a cat fact"},+ {"intent": "when is the next launch"},+ {"intent": "space news"},+ {"intent": "astronomy picture of the day"},] diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 801d22a..8b412b0 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json@@ -7,6 +7,29 @@"expect": { "name": "this browser", "trees": [+ {+ "description": "A random piece of advice, from the Advice Slip API.",+ "hosts": [+ "api.adviceslip.com"+ ],+ "inputs": {},+ "language": {},+ "license": "MIT",+ "name": "advice",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "fun",+ "read-only"+ ],+ "triggers": [+ "give me [some] advice",+ "[any] advice"+ ],+ "version": 1+ },{ "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.", "hosts": [@@ -39,6 +62,30 @@], "version": 1 },+ {+ "description": "NASA's Astronomy Picture of the Day: its title, and the picture or video.",+ "hosts": [+ "api.nasa.gov"+ ],+ "inputs": {},+ "language": {},+ "license": "MIT",+ "name": "apod",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "space",+ "science",+ "read-only"+ ],+ "triggers": [+ "astronomy picture of the day",+ "[nasa] (apod|space picture) [of the day|today]"+ ],+ "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": [@@ -70,6 +117,30 @@], "version": 1 },+ {+ "description": "A random fact about cats, from catfact.ninja.",+ "hosts": [+ "catfact.ninja"+ ],+ "inputs": {},+ "language": {},+ "license": "MIT",+ "name": "cat-fact",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "fun",+ "animals",+ "read-only"+ ],+ "triggers": [+ "[tell me a] cat fact",+ "tell me something about cats"+ ],+ "version": 1+ },{ "description": "echo for count", "hosts": [],@@ -206,6 +277,35 @@], "version": 1 },+ {+ "description": "How high a place is above sea level, from Open-Meteo's geocoder.",+ "hosts": [+ "geocoding-api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "elevation",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "geography",+ "read-only"+ ],+ "triggers": [+ "how high (is|above sea level is) {place}",+ "[the] (elevation|altitude) of {place}"+ ],+ "version": 1+ },{ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.", "hosts": [@@ -271,6 +371,37 @@], "version": 1 },+ {+ "description": "Tomorrow's weather for a place: sky, high and low, and chance of rain, from Open-Meteo.",+ "hosts": [+ "geocoding-api.open-meteo.com",+ "api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "forecast",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "weather",+ "read-only"+ ],+ "triggers": [+ "[what's the] weather (in|for) {place} tomorrow",+ "[what's the] weather tomorrow (in|for) {place}",+ "will it rain tomorrow in {place}"+ ],+ "version": 1+ },{ "description": "The latest release of a GitHub repository.", "hosts": [@@ -300,6 +431,67 @@], "version": 2 },+ {+ "description": "A GitHub repository at a glance: description, language, stars, forks, open issues.",+ "hosts": [+ "api.github.com"+ ],+ "inputs": {+ "repo": {+ "description": "owner/name, as in: astral-sh/uv",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "github-repo",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "github",+ "software",+ "read-only"+ ],+ "triggers": [+ "how many stars does {repo:word} have",+ "github (repo|repository) {repo:word}",+ "tell me about the {repo:word} repo"+ ],+ "version": 1+ },+ {+ "description": "The latest version of a Go module, and when it was published, from proxy.golang.org.",+ "hosts": [+ "proxy.golang.org"+ ],+ "inputs": {+ "module": {+ "description": "A module path, as in: github.com/gorilla/mux",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "go-module",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "software",+ "go",+ "read-only"+ ],+ "triggers": [+ "[what's the] latest version of {module:word} on go",+ "go (module|version) {module:word}"+ ],+ "version": 1+ },{ "description": "Top stories on Hacker News, from its public API.", "hosts": [@@ -422,6 +614,36 @@], "version": 1 },+ {+ "description": "A Homebrew formula's current stable version and what it is, from formulae.brew.sh.",+ "hosts": [+ "formulae.brew.sh"+ ],+ "inputs": {+ "formula": {+ "description": "A formula name, as in: wget",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "homebrew",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "software",+ "macos",+ "read-only"+ ],+ "triggers": [+ "[what's the] latest version of {formula:word} on homebrew",+ "(brew|homebrew) (formula|version) {formula:word}"+ ],+ "version": 1+ },{ "description": "Where the International Space Station is right now: latitude, longitude, altitude and speed.", "hosts": [@@ -480,6 +702,36 @@], "version": 1 },+ {+ "description": "What time it is now in a place, and its time zone.",+ "hosts": [+ "geocoding-api.open-meteo.com",+ "timeapi.io"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "local-time",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "time",+ "read-only"+ ],+ "triggers": [+ "what time is it in {place}",+ "[the] (time|local time) in {place}"+ ],+ "version": 1+ },{ "description": "Error lines in the tail of a log file on this box.", "hosts": [],@@ -545,6 +797,52 @@], "version": 2 },+ {+ "description": "The public IP address requests from here come from, from ipify.",+ "hosts": [+ "api.ipify.org"+ ],+ "inputs": {},+ "language": {},+ "license": "MIT",+ "name": "my-ip",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "network",+ "read-only"+ ],+ "triggers": [+ "what is my [public] ip [address]",+ "my [public] ip [address]"+ ],+ "version": 1+ },+ {+ "description": "The next rocket launch anywhere, when, and its status, from the Launch Library.",+ "hosts": [+ "ll.thespacedevs.com"+ ],+ "inputs": {},+ "language": {},+ "license": "MIT",+ "name": "next-launch",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "space",+ "read-only"+ ],+ "triggers": [+ "[when is the] next (launch|rocket launch)",+ "upcoming (launches|rocket launches)"+ ],+ "version": 1+ },{ "description": "The latest version of an npm package, and what it says it is.", "hosts": [@@ -626,53 +924,123 @@"version": 1 }, {- "description": "The latest version of a Python package on PyPI, and what it says it is.",+ "description": "A Pokémon's number, types, height and weight, from PokéAPI.","hosts": [- "pypi.org"+ "pokeapi.co"], "inputs": {- "package": {- "description": "A project name, as in: requests",+ "name": {+ "description": "A Pokémon's name or number, as in: pikachu","required": true, "type": "str" } }, "language": {}, "license": "MIT",- "name": "pypi-package",+ "name": "pokemon","namespace": "pyrtan", "sources": [ "http" ], "tags": [- "software",- "python",+ "fun",+ "games","read-only" ], "triggers": [- "[what's the] latest version of {package:word} on pypi",- "latest {package:word} on pypi",- "(pypi|pip) (package|version) {package:word}"+ "pokemon {name:word}",+ "what type is {name:word}",+ "tell me about the pokemon {name:word}"], "version": 1 }, {- "description": "echo for remind",- "hosts": [],+ "description": "The place a postal code belongs to, from Zippopotam.us (US ZIP codes, or a code in another country).",+ "hosts": [+ "api.zippopotam.us"+ ],"inputs": {- "at": {+ "code": {+ "description": "A postal code, as in: 90210",+ "required": true,"type": "str" },- "wait": {- "type": "int"- },- "when": {+ "country": {+ "default": "us",+ "description": "A two-letter country code, as in: US, DE, GB","type": "str" } }, "language": {},- "license": "",- "name": "remind",+ "license": "MIT",+ "name": "postal-code",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "geography",+ "read-only"+ ],+ "trigger_inputs": {+ "where is zip [code] {code:word}": {+ "country": "us"+ }+ },+ "triggers": [+ "where is zip [code] {code:word}",+ "[where is] (postcode|postal code) {code:word} in {country:word}"+ ],+ "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": [],+ "inputs": {+ "at": {+ "type": "str"+ },+ "wait": {+ "type": "int"+ },+ "when": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "remind","sources": [], "tags": [], "triggers": [@@ -710,6 +1078,35 @@], "version": 2 },+ {+ "description": "Words that rhyme with a word, from Datamuse.",+ "hosts": [+ "api.datamuse.com"+ ],+ "inputs": {+ "word": {+ "description": "One English word",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "rhymes",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "language",+ "read-only"+ ],+ "triggers": [+ "what rhymes with {word:word}",+ "(rhymes|words that rhyme) (for|with) {word:word}"+ ],+ "version": 1+ },{ "description": "The temperature a room's sensor last published, over MQTT.", "hosts": [@@ -827,6 +1224,30 @@], "version": 1 },+ {+ "description": "The three latest spaceflight news articles, from the Spaceflight News API.",+ "hosts": [+ "api.spaceflightnewsapi.net"+ ],+ "inputs": {},+ "language": {},+ "license": "MIT",+ "name": "space-news",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "space",+ "news",+ "read-only"+ ],+ "triggers": [+ "[latest] (space|spaceflight) news",+ "what's new in space"+ ],+ "version": 1+ },{ "description": "Today's sunrise and sunset for a place, in its local time, from Open-Meteo.", "hosts": [@@ -925,6 +1346,67 @@], "version": 1 },+ {+ "description": "The UV index for a place, now and today's highest, with its WHO band, from Open-Meteo.",+ "hosts": [+ "geocoding-api.open-meteo.com",+ "api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "uv-index",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "weather",+ "health",+ "read-only"+ ],+ "triggers": [+ "[what's the] (uv|uv index) in {place}",+ "do i need sunscreen in {place}"+ ],+ "version": 1+ },+ {+ "description": "The Wayback Machine's latest copy of a site, if it has one.",+ "hosts": [+ "archive.org"+ ],+ "inputs": {+ "site": {+ "description": "A host name, as in: example.com",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "wayback",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "web",+ "read-only"+ ],+ "triggers": [+ "is {site:host} archived",+ "wayback [machine] {site:host}",+ "[the] archived copy of {site:host}"+ ],+ "version": 1+ },{ "description": "Current weather for a place, from Open-Meteo.", "hosts": [@@ -11207,112 +11689,3616 @@}, "intent": "another word for qqqzzz", "tree": "synonyms"- }- ],- "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": [+ },+ {+ "expect": {+ "inputs": {+ "place": "Oslo"+ },+ "output": {+ "country": "Norway",+ "date": "2026-10-29",+ "high": 9.4,+ "low": 5.9,+ "place": "Oslo",+ "rain_chance": 80,+ "sky": "rain",+ "unit": "°C"+ },+ "say": "Oslo, Norway, tomorrow (2026-10-29): rain, 5.9 to 9.4°C, 80% chance of rain.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [{- "into": "aqi",- "json": "json.current.european_aqi"+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "weather in Oslo tomorrow",+ "inputs": {+ "place": "Oslo"+ },+ "steps": [],+ "trigger": "[what's the] weather (in|for) {place} tomorrow"+ },+ "summary": "'weather in Oslo tomorrow'; forecast via \"[what's the] weather (in|for) {place} tomorrow\" (29 literal chars)"}, {- "into": "pm25",- "json": "json.current.pm2_5"+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": "Norway",+ "height": null,+ "hit": {+ "country": "Norway",+ "latitude": 59.91,+ "longitude": 10.75,+ "name": "Oslo"+ },+ "lat": 59.91,+ "lon": 10.75,+ "name": "Oslo",+ "zone": null+ },+ "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, zone=None, height=None"}, {- "into": "pm10",- "json": "json.current.pm10"- }- ],- "http": {- "method": "GET",- "url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}¤t=european_aqi,pm2_5,pm10"- },- "into": "aq",- "kind": "observe",- "next": "band"- },- "band": {- "branches": [+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "tomorrow",+ "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}"+ },{- "then": "good",- "when": {- "lte": 20,- "type": "number"- }+ "depth": 0,+ "kind": "observe",+ "next": "have-day",+ "node": "tomorrow",+ "observed": {+ "code": 61,+ "day": "2026-10-29",+ "high": 9.4,+ "low": 5.9,+ "rain": 80,+ "unit": "°C"+ },+ "summary": "GET https://api.open-meteo.com/v1/forecast?latitude=59.91&longitude=10.75&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2 -> 200; day=2026-10-29, code=61, high=9.4, low=5.9, rain=80, unit=°C"+ },+ {+ "chosen": "type 'number'",+ "depth": 0,+ "kind": "decide",+ "next": "sky",+ "node": "have-day",+ "observed": 9.4,+ "summary": "state.high = 9.4"+ },+ {+ "chosen": "between [51, 67]",+ "depth": 0,+ "kind": "decide",+ "next": "rain",+ "node": "sky",+ "observed": 61,+ "summary": "state.code = 61"+ },+ {+ "depth": 0,+ "kind": "set",+ "next": "reply",+ "node": "rain",+ "observed": {+ "sky": "rain"+ },+ "summary": "sky=rain"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Oslo, Norway, tomorrow (2026-10-29): rain, 5.9 to 9.4°C, 80% chance of rain."+ }+ ],+ "tree": "forecast"+ },+ "tree": "forecast"+ },+ "inputs": {+ "place": "Oslo"+ },+ "intent": "weather in Oslo tomorrow",+ "tree": "forecast"+ },+ {+ "expect": {+ "inputs": {+ "place": "Sydney"+ },+ "output": {+ "band": "extreme",+ "country": "Australia",+ "place": "Sydney",+ "uv_max": 11.2,+ "uv_now": 6.3+ },+ "say": "Sydney, Australia: UV index 6.3 now, up to 11.2 today (extreme): stay out of the midday sun.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "uv index in Sydney",+ "inputs": {+ "place": "Sydney"+ },+ "steps": [],+ "trigger": "[what's the] (uv|uv index) in {place}"+ },+ "summary": "'uv index in Sydney'; uv-index via \"[what's the] (uv|uv index) in {place}\" (20 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": "Australia",+ "height": 58.0,+ "hit": {+ "country": "Australia",+ "elevation": 58.0,+ "latitude": -33.86785,+ "longitude": 151.20732,+ "name": "Sydney",+ "timezone": "Australia/Sydney"+ },+ "lat": -33.86785,+ "lon": 151.20732,+ "name": "Sydney",+ "zone": "Australia/Sydney"+ },+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Sydney&count=1&language=en&format=json -> 200; hit={'name': 'Sydney', 'country': 'Australia', 'latitude': -33.86785, 'longitude': …, lat=-33.86785, lon=151.20732, name=Sydney, country=Australia, zone=Australia/Sydney, height=58.0"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "uv",+ "node": "found",+ "observed": {+ "country": "Australia",+ "elevation": 58.0,+ "latitude": -33.86785,+ "longitude": 151.20732,+ "name": "Sydney",+ "timezone": "Australia/Sydney"+ },+ "summary": "state.hit = {'name': 'Sydney', 'country': 'Australia', 'latitude': -33.86785, 'longitude': …"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "band",+ "node": "uv",+ "observed": {+ "max": 11.2,+ "now": 6.3+ },+ "summary": "GET https://api.open-meteo.com/v1/forecast?latitude=-33.86785&longitude=151.20732¤t=uv_index&daily=uv_index_max&timezone=auto&forecast_days=1 -> 200; now=6.3, max=11.2"+ },+ {+ "chosen": "gte 11",+ "depth": 0,+ "kind": "decide",+ "next": "extreme",+ "node": "band",+ "observed": 11.2,+ "summary": "state.max = 11.2"+ },+ {+ "depth": 0,+ "kind": "set",+ "next": "reply",+ "node": "extreme",+ "observed": {+ "advice": "stay out of the midday sun",+ "band": "extreme"+ },+ "summary": "band=extreme, advice=stay out of the midday sun"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Sydney, Australia: UV index 6.3 now, up to 11.2 today (extreme): stay out of the midday sun."+ }+ ],+ "tree": "uv-index"+ },+ "tree": "uv-index"+ },+ "inputs": {+ "place": "Sydney"+ },+ "intent": "uv index in Sydney",+ "tree": "uv-index"+ },+ {+ "expect": {+ "inputs": {+ "place": "La Paz"+ },+ "output": {+ "country": "Mexico",+ "date": "2026-10-27",+ "place": "La Paz",+ "time": "17:50",+ "timezone": "America/Mazatlan",+ "weekday": "Tuesday"+ },+ "say": "It is 17:50 on Tuesday 2026-10-27 in La Paz, Mexico (America/Mazatlan).",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "what time is it in La Paz",+ "inputs": {+ "place": "La Paz"+ },+ "steps": [],+ "trigger": "what time is it in {place}"+ },+ "summary": "'what time is it in La Paz'; local-time via 'what time is it in {place}' (14 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": "Mexico",+ "height": 33.0,+ "hit": {+ "country": "Mexico",+ "elevation": 33.0,+ "latitude": 24.14231,+ "longitude": -110.31316,+ "name": "La Paz",+ "timezone": "America/Mazatlan"+ },+ "lat": 24.14231,+ "lon": -110.31316,+ "name": "La Paz",+ "zone": "America/Mazatlan"+ },+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=La%20Paz&count=1&language=en&format=json -> 200; hit={'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…, lat=24.14231, lon=-110.31316, name=La Paz, country=Mexico, zone=America/Mazatlan, height=33.0"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "clock",+ "node": "found",+ "observed": {+ "country": "Mexico",+ "elevation": 33.0,+ "latitude": 24.14231,+ "longitude": -110.31316,+ "name": "La Paz",+ "timezone": "America/Mazatlan"+ },+ "summary": "state.hit = {'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "clock",+ "observed": {+ "date": "2026-10-27",+ "time": "17:50",+ "weekday": "Tuesday"+ },+ "summary": "GET https://timeapi.io/api/time/current/zone?timeZone=America%2FMazatlan -> 200; time=17:50, weekday=Tuesday, date=2026-10-27"+ },+ {+ "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": "It is 17:50 on Tuesday 2026-10-27 in La Paz, Mexico (America/Mazatlan)."+ }+ ],+ "tree": "local-time"+ },+ "tree": "local-time"+ },+ "inputs": {+ "place": "La Paz"+ },+ "intent": "what time is it in La Paz",+ "tree": "local-time"+ },+ {+ "expect": {+ "inputs": {+ "place": "La Paz"+ },+ "output": {+ "country": "Mexico",+ "elevation_m": 33.0,+ "place": "La Paz"+ },+ "say": "La Paz, Mexico is 33 m above sea level.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "how high is La Paz",+ "inputs": {+ "place": "La Paz"+ },+ "steps": [],+ "trigger": "how high (is|above sea level is) {place}"+ },+ "summary": "'how high is La Paz'; elevation via 'how high (is|above sea level is) {place}' (24 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": "Mexico",+ "height": 33.0,+ "hit": {+ "country": "Mexico",+ "elevation": 33.0,+ "latitude": 24.14231,+ "longitude": -110.31316,+ "name": "La Paz",+ "timezone": "America/Mazatlan"+ },+ "lat": 24.14231,+ "lon": -110.31316,+ "name": "La Paz",+ "zone": "America/Mazatlan"+ },+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=La%20Paz&count=1&language=en&format=json -> 200; hit={'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…, lat=24.14231, lon=-110.31316, name=La Paz, country=Mexico, zone=America/Mazatlan, height=33.0"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "has-height",+ "node": "found",+ "observed": {+ "country": "Mexico",+ "elevation": 33.0,+ "latitude": 24.14231,+ "longitude": -110.31316,+ "name": "La Paz",+ "timezone": "America/Mazatlan"+ },+ "summary": "state.hit = {'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…"+ },+ {+ "chosen": "type 'number'",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "has-height",+ "observed": 33.0,+ "summary": "state.height = 33.0"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "La Paz, Mexico is 33 m above sea level."+ }+ ],+ "tree": "elevation"+ },+ "tree": "elevation"+ },+ "inputs": {+ "place": "La Paz"+ },+ "intent": "how high is La Paz",+ "tree": "elevation"+ },+ {+ "expect": {+ "inputs": {+ "place": "Oslo"+ },+ "reason": "no-height",+ "say": "Open-Meteo knows Oslo but not how high it is.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "how high is Oslo",+ "inputs": {+ "place": "Oslo"+ },+ "steps": [],+ "trigger": "how high (is|above sea level is) {place}"+ },+ "summary": "'how high is Oslo'; elevation via 'how high (is|above sea level is) {place}' (24 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": "Norway",+ "height": null,+ "hit": {+ "country": "Norway",+ "latitude": 59.91,+ "longitude": 10.75,+ "name": "Oslo"+ },+ "lat": 59.91,+ "lon": 10.75,+ "name": "Oslo",+ "zone": null+ },+ "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, zone=None, height=None"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "has-height",+ "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}"+ },+ {+ "chosen": "else",+ "depth": 0,+ "kind": "decide",+ "next": "no-height",+ "node": "has-height",+ "summary": "state.height = <missing>"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "no-height",+ "summary": "no-height: Open-Meteo knows Oslo but not how high it is."+ }+ ],+ "tree": "elevation"+ },+ "tree": "elevation"+ },+ "inputs": {+ "place": "Oslo"+ },+ "intent": "how high is Oslo",+ "tree": "elevation"+ },+ {+ "expect": {+ "inputs": {+ "code": "90210",+ "country": "us"+ },+ "output": {+ "code": "90210",+ "country": "United States",+ "latitude": 34.0901,+ "longitude": -118.4065,+ "place": "Beverly Hills",+ "region": "California"+ },+ "say": "90210 is Beverly Hills, California, United States.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "where is zip 90210",+ "inputs": {+ "code": "90210",+ "country": "us"+ },+ "steps": [],+ "trigger": "where is zip [code] {code:word}"+ },+ "summary": "'where is zip 90210'; postal-code via 'where is zip [code] {code:word}' (14 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "country_name": "United States",+ "lat": "34.0901",+ "lon": "-118.4065",+ "place": "Beverly Hills",+ "region": "California"+ },+ "summary": "GET https://api.zippopotam.us/us/90210 -> 200; place=Beverly Hills, region=California, country_name=United States, lat=34.0901, lon=-118.4065"+ },+ {+ "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": "90210 is Beverly Hills, California, United States."+ }+ ],+ "tree": "postal-code"+ },+ "tree": "postal-code"+ },+ "inputs": {+ "code": "90210",+ "country": "us"+ },+ "intent": "where is zip 90210",+ "tree": "postal-code"+ },+ {+ "expect": {+ "inputs": {+ "code": "SW1A",+ "country": "GB"+ },+ "output": {+ "code": "SW1A",+ "country": "Great Britain",+ "latitude": 51.4841,+ "longitude": -0.1966,+ "place": "Westminster Abbey",+ "region": "England"+ },+ "say": "SW1A is Westminster Abbey, England, Great Britain.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "postcode SW1A in GB",+ "inputs": {+ "code": "SW1A",+ "country": "GB"+ },+ "steps": [],+ "trigger": "[where is] (postcode|postal code) {code:word} in {country:word}"+ },+ "summary": "'postcode SW1A in GB'; postal-code via '[where is] (postcode|postal code) {code:word} in {country:word}' (27 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "country_name": "Great Britain",+ "lat": "51.4841",+ "lon": "-0.1966",+ "place": "Westminster Abbey",+ "region": "England"+ },+ "summary": "GET https://api.zippopotam.us/gb/SW1A -> 200; place=Westminster Abbey, region=England, country_name=Great Britain, lat=51.4841, lon=-0.1966"+ },+ {+ "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": "SW1A is Westminster Abbey, England, Great Britain."+ }+ ],+ "tree": "postal-code"+ },+ "tree": "postal-code"+ },+ "inputs": {+ "code": "SW1A",+ "country": "GB"+ },+ "intent": "postcode SW1A in GB",+ "tree": "postal-code"+ },+ {+ "expect": {+ "inputs": {+ "code": "00000",+ "country": "us"+ },+ "reason": "unknown-code",+ "say": "Zippopotam.us knows no postal code 00000 in US.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "where is zip 00000",+ "inputs": {+ "code": "00000",+ "country": "us"+ },+ "steps": [],+ "trigger": "where is zip [code] {code:word}"+ },+ "summary": "'where is zip 00000'; postal-code via 'where is zip [code] {code:word}' (14 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "country_name": null,+ "lat": null,+ "lon": null,+ "place": null,+ "region": null+ },+ "summary": "GET https://api.zippopotam.us/us/00000 -> 404; place=None, region=None, country_name=None, lat=None, lon=None"+ },+ {+ "chosen": "eq 404",+ "depth": 0,+ "kind": "decide",+ "next": "unknown-code",+ "node": "status",+ "observed": 404,+ "summary": "state.resp.status = 404"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "unknown-code",+ "summary": "unknown-code: Zippopotam.us knows no postal code 00000 in US."+ }+ ],+ "tree": "postal-code"+ },+ "tree": "postal-code"+ },+ "inputs": {+ "code": "00000",+ "country": "us"+ },+ "intent": "where is zip 00000",+ "tree": "postal-code"+ },+ {+ "expect": {+ "inputs": {+ "site": "example.com"+ },+ "output": {+ "site": "example.com",+ "timestamp": "20261027205234",+ "url": "http://web.archive.org/web/20261027205234/https://example.com/"+ },+ "say": "The Wayback Machine has example.com. Its latest copy: http://web.archive.org/web/20261027205234/https://example.com/",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "is example.com archived",+ "inputs": {+ "site": "example.com"+ },+ "steps": [],+ "trigger": "is {site:host} archived"+ },+ "summary": "'is example.com archived'; wayback via 'is {site:host} archived' (10 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "copy": "http://web.archive.org/web/20261027205234/https://example.com/",+ "stamp": "20261027205234"+ },+ "summary": "GET https://archive.org/wayback/available?url=example.com -> 200; copy=http://web.archive.org/web/20261027205234/https://example.com/, stamp=20261027205234"+ },+ {+ "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": "http://web.archive.org/web/20261027205234/https://example.com/",+ "summary": "state.copy = http://web.archive.org/web/20261027205234/https://example.com/"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "The Wayback Machine has example.com. Its latest copy: http://web.archive.org/web/20261027205234/https://example.com/"+ }+ ],+ "tree": "wayback"+ },+ "tree": "wayback"+ },+ "inputs": {+ "site": "example.com"+ },+ "intent": "is example.com archived",+ "tree": "wayback"+ },+ {+ "expect": {+ "inputs": {+ "site": "nothing-here-qqzz.example"+ },+ "output": {+ "site": "nothing-here-qqzz.example",+ "url": ""+ },+ "say": "The Wayback Machine has no copy of nothing-here-qqzz.example.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "is nothing-here-qqzz.example archived",+ "inputs": {+ "site": "nothing-here-qqzz.example"+ },+ "steps": [],+ "trigger": "is {site:host} archived"+ },+ "summary": "'is nothing-here-qqzz.example archived'; wayback via 'is {site:host} archived' (10 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "copy": null,+ "stamp": null+ },+ "summary": "GET https://archive.org/wayback/available?url=nothing-here-qqzz.example -> 200; copy=None, stamp=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": "none",+ "node": "any",+ "summary": "state.copy = <missing>"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "none",+ "summary": "The Wayback Machine has no copy of nothing-here-qqzz.example."+ }+ ],+ "tree": "wayback"+ },+ "tree": "wayback"+ },+ "inputs": {+ "site": "nothing-here-qqzz.example"+ },+ "intent": "is nothing-here-qqzz.example archived",+ "tree": "wayback"+ },+ {+ "expect": {+ "inputs": {+ "word": "cat"+ },+ "output": {+ "rhymes": [+ "flat",+ "caveat",+ "brat",+ "pat",+ "combat",+ "slat",+ "that",+ "rat",+ "diplomat",+ "fat"+ ],+ "word": "cat"+ },+ "say": "Rhymes with cat: flat, caveat, brat, pat, combat, slat, that, rat, diplomat, fat.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "what rhymes with cat",+ "inputs": {+ "word": "cat"+ },+ "steps": [],+ "trigger": "what rhymes with {word:word}"+ },+ "summary": "'what rhymes with cat'; rhymes via 'what rhymes with {word:word}' (14 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "words": [+ "flat",+ "caveat",+ "brat",+ "pat",+ "combat",+ "slat",+ "that",+ "rat",+ "diplomat",+ "fat"+ ]+ },+ "summary": "GET https://api.datamuse.com/words?rel_rhy=cat&max=10 -> 200; words=['flat', 'caveat', 'brat', 'pat', 'combat', 'slat', 'that', 'rat', 'diplomat', …"+ },+ {+ "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": "reply",+ "node": "any",+ "observed": [+ "flat",+ "caveat",+ "brat",+ "pat",+ "combat",+ "slat",+ "that",+ "rat",+ "diplomat",+ "fat"+ ],+ "summary": "state.words = ['flat', 'caveat', 'brat', 'pat', 'combat', 'slat', 'that', 'rat', 'diplomat', …"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Rhymes with cat: flat, caveat, brat, pat, combat, slat, that, rat, diplomat, fat."+ }+ ],+ "tree": "rhymes"+ },+ "tree": "rhymes"+ },+ "inputs": {+ "word": "cat"+ },+ "intent": "what rhymes with cat",+ "tree": "rhymes"+ },+ {+ "expect": {+ "inputs": {+ "word": "qqzzx"+ },+ "reason": "no-rhymes",+ "say": "Datamuse knows nothing that rhymes with qqzzx.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "what rhymes with qqzzx",+ "inputs": {+ "word": "qqzzx"+ },+ "steps": [],+ "trigger": "what rhymes with {word:word}"+ },+ "summary": "'what rhymes with qqzzx'; rhymes via 'what rhymes with {word:word}' (14 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "words": []+ },+ "summary": "GET https://api.datamuse.com/words?rel_rhy=qqzzx&max=10 -> 200; words=[]"+ },+ {+ "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": "none",+ "node": "any",+ "observed": [],+ "summary": "state.words = []"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "none",+ "summary": "no-rhymes: Datamuse knows nothing that rhymes with qqzzx."+ }+ ],+ "tree": "rhymes"+ },+ "tree": "rhymes"+ },+ "inputs": {+ "word": "qqzzx"+ },+ "intent": "what rhymes with qqzzx",+ "tree": "rhymes"+ },+ {+ "expect": {+ "inputs": {+ "repo": "astral-sh/uv"+ },+ "output": {+ "archived": false,+ "description": "An extremely fast Python package and project manager, written in Rust.",+ "forks": 2210,+ "language": "Rust",+ "open_issues": 2004,+ "repo": "astral-sh/uv",+ "stars": 71234,+ "url": "https://github.com/astral-sh/uv"+ },+ "say": "astral-sh/uv: An extremely fast Python package and project manager, written in Rust. (Rust) · 71234 stars, 2210 forks, 2004 open issues. https://github.com/astral-sh/uv",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "how many stars does astral-sh/uv have",+ "inputs": {+ "repo": "astral-sh/uv"+ },+ "steps": [],+ "trigger": "how many stars does {repo:word} have"+ },+ "summary": "'how many stars does astral-sh/uv have'; github-repo via 'how many stars does {repo:word} have' (20 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "about": "An extremely fast Python package and project manager, written in Rust.",+ "archived": false,+ "forks": 2210,+ "full": "astral-sh/uv",+ "issues": 2004,+ "language": "Rust",+ "stars": 71234,+ "url": "https://github.com/astral-sh/uv"+ },+ "summary": "GET https://api.github.com/repos/astral-sh/uv -> 200; full=astral-sh/uv, about=An extremely fast Python package and project manager, written in Rust., language=Rust, stars=71234, forks=2210, issues=2004, url=https://github.com/astral-sh/uv, archived=False"+ },+ {+ "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": "astral-sh/uv: An extremely fast Python package and project manager, written in Rust. (Rust) · 71234 stars, 2210 forks, 2004 open issues. https://github.com/astral-sh/uv"+ }+ ],+ "tree": "github-repo"+ },+ "tree": "github-repo"+ },+ "inputs": {+ "repo": "astral-sh/uv"+ },+ "intent": "how many stars does astral-sh/uv have",+ "tree": "github-repo"+ },+ {+ "expect": {+ "inputs": {+ "repo": "b-i-t-8/nothing-here"+ },+ "reason": "no-repo",+ "say": "GitHub has no public repository b-i-t-8/nothing-here.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "github repo b-i-t-8/nothing-here",+ "inputs": {+ "repo": "b-i-t-8/nothing-here"+ },+ "steps": [],+ "trigger": "github (repo|repository) {repo:word}"+ },+ "summary": "'github repo b-i-t-8/nothing-here'; github-repo via 'github (repo|repository) {repo:word}' (20 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "about": null,+ "archived": null,+ "forks": null,+ "full": null,+ "issues": null,+ "language": null,+ "stars": null,+ "url": null+ },+ "summary": "GET https://api.github.com/repos/b-i-t-8/nothing-here -> 404; full=None, about=None, language=None, stars=None, forks=None, issues=None, url=None, archived=None"+ },+ {+ "chosen": "eq 404",+ "depth": 0,+ "kind": "decide",+ "next": "no-repo",+ "node": "status",+ "observed": 404,+ "summary": "state.resp.status = 404"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "no-repo",+ "summary": "no-repo: GitHub has no public repository b-i-t-8/nothing-here."+ }+ ],+ "tree": "github-repo"+ },+ "tree": "github-repo"+ },+ "inputs": {+ "repo": "b-i-t-8/nothing-here"+ },+ "intent": "github repo b-i-t-8/nothing-here",+ "tree": "github-repo"+ },+ {+ "expect": {+ "inputs": {+ "module": "github.com/gorilla/mux"+ },+ "output": {+ "module": "github.com/gorilla/mux",+ "published": "2023-10-18",+ "url": "https://pkg.go.dev/github.com/gorilla/mux",+ "version": "v1.8.1"+ },+ "say": "github.com/gorilla/mux v1.8.1, published 2023-10-18. https://pkg.go.dev/github.com/gorilla/mux",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "latest version of github.com/gorilla/mux on go",+ "inputs": {+ "module": "github.com/gorilla/mux"+ },+ "steps": [],+ "trigger": "[what's the] latest version of {module:word} on go"+ },+ "summary": "'latest version of github.com/gorilla/mux on go'; go-module via \"[what's the] latest version of {module:word} on go\" (28 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "day": "2023-10-18",+ "version": "v1.8.1"+ },+ "summary": "GET https://proxy.golang.org/github.com/gorilla/mux/@latest -> 200; version=v1.8.1, day=2023-10-18"+ },+ {+ "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": "github.com/gorilla/mux v1.8.1, published 2023-10-18. https://pkg.go.dev/github.com/gorilla/mux"+ }+ ],+ "tree": "go-module"+ },+ "tree": "go-module"+ },+ "inputs": {+ "module": "github.com/gorilla/mux"+ },+ "intent": "latest version of github.com/gorilla/mux on go",+ "tree": "go-module"+ },+ {+ "expect": {+ "inputs": {+ "module": "github.com/nope/nope-qq"+ },+ "reason": "no-module",+ "say": "The Go module proxy has no module github.com/nope/nope-qq.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "go module github.com/nope/nope-qq",+ "inputs": {+ "module": "github.com/nope/nope-qq"+ },+ "steps": [],+ "trigger": "go (module|version) {module:word}"+ },+ "summary": "'go module github.com/nope/nope-qq'; go-module via 'go (module|version) {module:word}' (15 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "day": null,+ "version": null+ },+ "summary": "GET https://proxy.golang.org/github.com/nope/nope-qq/@latest -> 404; version=None, day=None"+ },+ {+ "chosen": "in [404, 410]",+ "depth": 0,+ "kind": "decide",+ "next": "no-module",+ "node": "status",+ "observed": 404,+ "summary": "state.resp.status = 404"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "no-module",+ "summary": "no-module: The Go module proxy has no module github.com/nope/nope-qq."+ }+ ],+ "tree": "go-module"+ },+ "tree": "go-module"+ },+ "inputs": {+ "module": "github.com/nope/nope-qq"+ },+ "intent": "go module github.com/nope/nope-qq",+ "tree": "go-module"+ },+ {+ "expect": {+ "inputs": {+ "formula": "wget"+ },+ "output": {+ "description": "Internet file retriever",+ "formula": "wget",+ "homepage": "https://www.gnu.org/software/wget/",+ "install": "brew install wget",+ "version": "1.25.0"+ },+ "say": "wget 1.25.0 on Homebrew: Internet file retriever. https://www.gnu.org/software/wget/",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "brew formula wget",+ "inputs": {+ "formula": "wget"+ },+ "steps": [],+ "trigger": "(brew|homebrew) (formula|version) {formula:word}"+ },+ "summary": "'brew formula wget'; homebrew via '(brew|homebrew) (formula|version) {formula:word}' (26 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "about": "Internet file retriever",+ "home": "https://www.gnu.org/software/wget/",+ "name": "wget",+ "version": "1.25.0"+ },+ "summary": "GET https://formulae.brew.sh/api/formula/wget.json -> 200; name=wget, version=1.25.0, about=Internet file retriever, home=https://www.gnu.org/software/wget/"+ },+ {+ "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": "wget 1.25.0 on Homebrew: Internet file retriever. https://www.gnu.org/software/wget/"+ }+ ],+ "tree": "homebrew"+ },+ "tree": "homebrew"+ },+ "inputs": {+ "formula": "wget"+ },+ "intent": "brew formula wget",+ "tree": "homebrew"+ },+ {+ "expect": {+ "inputs": {+ "formula": "nope-qqzz"+ },+ "reason": "no-formula",+ "say": "Homebrew has no formula called nope-qqzz.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "brew formula nope-qqzz",+ "inputs": {+ "formula": "nope-qqzz"+ },+ "steps": [],+ "trigger": "(brew|homebrew) (formula|version) {formula:word}"+ },+ "summary": "'brew formula nope-qqzz'; homebrew via '(brew|homebrew) (formula|version) {formula:word}' (26 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "about": null,+ "home": null,+ "name": null,+ "version": null+ },+ "summary": "GET https://formulae.brew.sh/api/formula/nope-qqzz.json -> 404; name=None, version=None, about=None, home=None"+ },+ {+ "chosen": "eq 404",+ "depth": 0,+ "kind": "decide",+ "next": "no-formula",+ "node": "status",+ "observed": 404,+ "summary": "state.resp.status = 404"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "no-formula",+ "summary": "no-formula: Homebrew has no formula called nope-qqzz."+ }+ ],+ "tree": "homebrew"+ },+ "tree": "homebrew"+ },+ "inputs": {+ "formula": "nope-qqzz"+ },+ "intent": "brew formula nope-qqzz",+ "tree": "homebrew"+ },+ {+ "expect": {+ "inputs": {+ "name": "pikachu"+ },+ "output": {+ "height_dm": 4,+ "name": "pikachu",+ "number": 25,+ "types": [+ "electric"+ ],+ "weight_hg": 60+ },+ "say": "#25 Pikachu, electric type.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "pokemon pikachu",+ "inputs": {+ "name": "pikachu"+ },+ "steps": [],+ "trigger": "pokemon {name:word}"+ },+ "summary": "'pokemon pikachu'; pokemon via 'pokemon {name:word}' (7 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "dm": 4,+ "hg": 60,+ "id": 25,+ "name": "pikachu",+ "types": [+ "electric"+ ]+ },+ "summary": "GET https://pokeapi.co/api/v2/pokemon/pikachu -> 200; id=25, name=pikachu, types=['electric'], dm=4, hg=60"+ },+ {+ "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": "#25 Pikachu, electric type."+ }+ ],+ "tree": "pokemon"+ },+ "tree": "pokemon"+ },+ "inputs": {+ "name": "pikachu"+ },+ "intent": "pokemon pikachu",+ "tree": "pokemon"+ },+ {+ "expect": {+ "inputs": {+ "name": "charizard"+ },+ "output": {+ "height_dm": 17,+ "name": "charizard",+ "number": 6,+ "types": [+ "fire",+ "flying"+ ],+ "weight_hg": 905+ },+ "say": "#6 Charizard, fire, flying type.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "what type is charizard",+ "inputs": {+ "name": "charizard"+ },+ "steps": [],+ "trigger": "what type is {name:word}"+ },+ "summary": "'what type is charizard'; pokemon via 'what type is {name:word}' (10 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "dm": 17,+ "hg": 905,+ "id": 6,+ "name": "charizard",+ "types": [+ "fire",+ "flying"+ ]+ },+ "summary": "GET https://pokeapi.co/api/v2/pokemon/charizard -> 200; id=6, name=charizard, types=['fire', 'flying'], dm=17, hg=905"+ },+ {+ "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": "#6 Charizard, fire, flying type."+ }+ ],+ "tree": "pokemon"+ },+ "tree": "pokemon"+ },+ "inputs": {+ "name": "charizard"+ },+ "intent": "what type is charizard",+ "tree": "pokemon"+ },+ {+ "expect": {+ "inputs": {+ "name": "notapokemonqq"+ },+ "reason": "no-pokemon",+ "say": "PokéAPI knows no Pokémon called notapokemonqq.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "pokemon notapokemonqq",+ "inputs": {+ "name": "notapokemonqq"+ },+ "steps": [],+ "trigger": "pokemon {name:word}"+ },+ "summary": "'pokemon notapokemonqq'; pokemon via 'pokemon {name:word}' (7 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "dm": null,+ "hg": null,+ "id": null,+ "name": null,+ "types": []+ },+ "summary": "GET https://pokeapi.co/api/v2/pokemon/notapokemonqq -> 404; id=None, name=None, types=[], dm=None, hg=None"+ },+ {+ "chosen": "eq 404",+ "depth": 0,+ "kind": "decide",+ "next": "no-pokemon",+ "node": "status",+ "observed": 404,+ "summary": "state.resp.status = 404"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "no-pokemon",+ "summary": "no-pokemon: PokéAPI knows no Pokémon called notapokemonqq."+ }+ ],+ "tree": "pokemon"+ },+ "tree": "pokemon"+ },+ "inputs": {+ "name": "notapokemonqq"+ },+ "intent": "pokemon notapokemonqq",+ "tree": "pokemon"+ },+ {+ "expect": {+ "inputs": {},+ "output": {+ "ip": "203.0.113.7"+ },+ "say": "Your public IP address is 203.0.113.7.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "what is my ip",+ "inputs": {},+ "steps": [],+ "trigger": "what is my [public] ip [address]"+ },+ "summary": "'what is my ip'; my-ip via 'what is my [public] ip [address]' (23 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "ip": "203.0.113.7"+ },+ "summary": "GET https://api.ipify.org?format=json -> 200; ip=203.0.113.7"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "has-ip",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "has-ip",+ "observed": "203.0.113.7",+ "summary": "state.ip = 203.0.113.7"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Your public IP address is 203.0.113.7."+ }+ ],+ "tree": "my-ip"+ },+ "tree": "my-ip"+ },+ "inputs": {},+ "intent": "what is my ip",+ "tree": "my-ip"+ },+ {+ "expect": {+ "inputs": {},+ "output": {+ "advice": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",+ "id": 66+ },+ "say": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "give me some advice",+ "inputs": {},+ "steps": [],+ "trigger": "give me [some] advice"+ },+ "summary": "'give me some advice'; advice via 'give me [some] advice' (16 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "advice": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",+ "id": 66+ },+ "summary": "GET https://api.adviceslip.com/advice -> 200; advice=Take time once in a while to look up at the stars for at least 5 minutes, in or…, id=66"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "has-advice",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "has-advice",+ "observed": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",+ "summary": "state.advice = Take time once in a while to look up at the stars for at least 5 minutes, in or…"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance."+ }+ ],+ "tree": "advice"+ },+ "tree": "advice"+ },+ "inputs": {},+ "intent": "give me some advice",+ "tree": "advice"+ },+ {+ "expect": {+ "inputs": {},+ "output": {+ "fact": "A cat has more bones than a human; humans have 206, and the cat - 230."+ },+ "say": "A cat has more bones than a human; humans have 206, and the cat - 230.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "tell me a cat fact",+ "inputs": {},+ "steps": [],+ "trigger": "[tell me a] cat fact"+ },+ "summary": "'tell me a cat fact'; cat-fact via '[tell me a] cat fact' (14 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "fact": "A cat has more bones than a human; humans have 206, and the cat - 230."+ },+ "summary": "GET https://catfact.ninja/fact -> 200; fact=A cat has more bones than a human; humans have 206, and the cat - 230."+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "has-fact",+ "node": "status",+ "observed": 200,+ "summary": "state.resp.status = 200"+ },+ {+ "chosen": "exists True",+ "depth": 0,+ "kind": "decide",+ "next": "reply",+ "node": "has-fact",+ "observed": "A cat has more bones than a human; humans have 206, and the cat - 230.",+ "summary": "state.fact = A cat has more bones than a human; humans have 206, and the cat - 230."+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "A cat has more bones than a human; humans have 206, and the cat - 230."+ }+ ],+ "tree": "cat-fact"+ },+ "tree": "cat-fact"+ },+ "inputs": {},+ "intent": "tell me a cat fact",+ "tree": "cat-fact"+ },+ {+ "expect": {+ "inputs": {},+ "output": {+ "name": "Falcon 9 Block 5 | Starlink Group 15-18",+ "net_utc": "2026-10-30T04:02:00Z",+ "status": "Go for Launch",+ "upcoming": 474+ },+ "say": "Next launch: Falcon 9 Block 5 | Starlink Group 15-18, 2026-10-30T04:02:00Z UTC (Go for Launch). 474 launches are scheduled.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "when is the next launch",+ "inputs": {},+ "steps": [],+ "trigger": "[when is the] next (launch|rocket launch)"+ },+ "summary": "'when is the next launch'; next-launch via '[when is the] next (launch|rocket launch)' (31 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "count": 474,+ "name": "Falcon 9 Block 5 | Starlink Group 15-18",+ "net": "2026-10-30T04:02:00Z",+ "state": "Go for Launch"+ },+ "summary": "GET https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=1&mode=list&hide_recent_previous=true -> 200; name=Falcon 9 Block 5 | Starlink Group 15-18, net=2026-10-30T04:02:00Z, state=Go for Launch, count=474"+ },+ {+ "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": "Falcon 9 Block 5 | Starlink Group 15-18",+ "summary": "state.name = Falcon 9 Block 5 | Starlink Group 15-18"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Next launch: Falcon 9 Block 5 | Starlink Group 15-18, 2026-10-30T04:02:00Z UTC (Go for Launch). 474 launches are scheduled."+ }+ ],+ "tree": "next-launch"+ },+ "tree": "next-launch"+ },+ "inputs": {},+ "intent": "when is the next launch",+ "tree": "next-launch"+ },+ {+ "expect": {+ "inputs": {},+ "output": {+ "stories": [+ {+ "published": "2026-10-27T20:21:30Z",+ "site": "SpaceNews",+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"+ },+ {+ "published": "2026-10-27T19:27:22Z",+ "site": "NASA",+ "title": "NASA Invites Media to Crew Completion of Yearlong Mars Simulation",+ "url": "https://www.nasa.gov/news-release/nasa-invites-media-to-crew-completion-of-yearlong-mars-simulation/"+ },+ {+ "published": "2026-10-27T17:58:57Z",+ "site": "NASA",+ "title": "NASA’s Neo Surveyor Sunshade",+ "url": "https://www.nasa.gov/image-article/nasas-neo-surveyor-sunshade/"+ }+ ]+ },+ "say": "Latest in space: Ukrainian startup, GomSpace test satellite for future communications network (SpaceNews) https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "space news",+ "inputs": {},+ "steps": [],+ "trigger": "[latest] (space|spaceflight) news"+ },+ "summary": "'space news'; space-news via '[latest] (space|spaceflight) news' (26 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "articles": [+ {+ "news_site": "SpaceNews",+ "published_at": "2026-10-27T20:21:30Z",+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"+ },+ {+ "news_site": "NASA",+ "published_at": "2026-10-27T19:27:22Z",+ "title": "NASA Invites Media to Crew Completion of Yearlong Mars Simulation",+ "url": "https://www.nasa.gov/news-release/nasa-invites-media-to-crew-completion-of-yearlong-mars-simulation/"+ },+ {+ "news_site": "NASA",+ "published_at": "2026-10-27T17:58:57Z",+ "title": "NASA’s Neo Surveyor Sunshade",+ "url": "https://www.nasa.gov/image-article/nasas-neo-surveyor-sunshade/"+ }+ ],+ "site": "SpaceNews",+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"+ },+ "summary": "GET https://api.spaceflightnewsapi.net/v4/articles/?limit=3 -> 200; articles=[{'title': 'Ukrainian startup, GomSpace test satellite for future communication…, title=Ukrainian startup, GomSpace test satellite for future communications network, site=SpaceNews, url=https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-comm…"+ },+ {+ "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": [+ {+ "news_site": "SpaceNews",+ "published_at": "2026-10-27T20:21:30Z",+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"+ },+ {+ "news_site": "NASA",+ "published_at": "2026-10-27T19:27:22Z",+ "title": "NASA Invites Media to Crew Completion of Yearlong Mars Simulation",+ "url": "https://www.nasa.gov/news-release/nasa-invites-media-to-crew-completion-of-yearlong-mars-simulation/"+ },+ {+ "news_site": "NASA",+ "published_at": "2026-10-27T17:58:57Z",+ "title": "NASA’s Neo Surveyor Sunshade",+ "url": "https://www.nasa.gov/image-article/nasas-neo-surveyor-sunshade/"+ }+ ],+ "summary": "state.articles = [{'title': 'Ukrainian startup, GomSpace test satellite for future communication…"+ },+ {+ "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": "Latest in space: Ukrainian startup, GomSpace test satellite for future communications network (SpaceNews) https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"+ }+ ],+ "tree": "space-news"+ },+ "tree": "space-news"+ },+ "inputs": {},+ "intent": "space news",+ "tree": "space-news"+ },+ {+ "expect": {+ "inputs": {},+ "output": {+ "date": "2026-10-28",+ "explanation": "Sometimes it's night on the ground but day in the air.",+ "media_type": "image",+ "page": "https://apod.nasa.gov/apod/astropix.html",+ "title": "Day and Night over the Clouds",+ "url": "https://apod.nasa.gov/apod/image/2610/example_1024.jpg"+ },+ "say": "Astronomy Picture of the Day, 2026-10-28: Day and Night over the Clouds. https://apod.nasa.gov/apod/image/2610/example_1024.jpg",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch",+ "node": "intent",+ "observed": {+ "cleaned": "astronomy picture of the day",+ "inputs": {},+ "steps": [],+ "trigger": "astronomy picture of the day"+ },+ "summary": "'astronomy picture of the day'; apod via 'astronomy picture of the day' (24 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "fetch",+ "observed": {+ "about": "Sometimes it's night on the ground but day in the air.",+ "day": "2026-10-28",+ "kind": "image",+ "media": "https://apod.nasa.gov/apod/image/2610/example_1024.jpg",+ "title": "Day and Night over the Clouds"+ },+ "summary": "GET https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY -> 200; title=Day and Night over the Clouds, day=2026-10-28, media=https://apod.nasa.gov/apod/image/2610/example_1024.jpg, kind=image, about=Sometimes it's night on the ground but day in the air."+ },+ {+ "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": "Astronomy Picture of the Day, 2026-10-28: Day and Night over the Clouds. https://apod.nasa.gov/apod/image/2610/example_1024.jpg"+ }+ ],+ "tree": "apod"+ },+ "tree": "apod"+ },+ "inputs": {},+ "intent": "astronomy picture of the day",+ "tree": "apod"+ }+ ],+ "trees": {+ "advice": {+ "description": "A random piece of advice, from the Advice Slip API.",+ "hosts": [+ "api.adviceslip.com"+ ],+ "license": "MIT",+ "name": "advice",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "advice",+ "json": "json.slip.advice"+ },+ {+ "into": "id",+ "json": "json.slip.id"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.adviceslip.com/advice"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "has-advice": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "advice"+ },+ "reply": {+ "kind": "done",+ "output": {+ "advice": "{{ state.advice }}",+ "id": "{{ state.id }}"+ },+ "say": "{{ state.advice }}"+ },+ "status": {+ "branches": [+ {+ "then": "has-advice",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "The Advice Slip API answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "fun",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "give me [some] advice"+ },+ {+ "pattern": "[any] advice"+ }+ ],+ "version": 1+ },+ "air-quality": {+ "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.",+ "hosts": [+ "geocoding-api.open-meteo.com",+ "air-quality-api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "air-quality",+ "namespace": "pyrtan",+ "nodes": {+ "air": {+ "extract": [+ {+ "into": "aqi",+ "json": "json.current.european_aqi"+ },+ {+ "into": "pm25",+ "json": "json.current.pm2_5"+ },+ {+ "into": "pm10",+ "json": "json.current.pm10"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}¤t=european_aqi,pm2_5,pm10"+ },+ "into": "aq",+ "kind": "observe",+ "next": "band"+ },+ "band": {+ "branches": [+ {+ "then": "good",+ "when": {+ "lte": 20,+ "type": "number"+ }}, { "then": "fair", "when": {- "lte": 40+ "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+ },+ "apod": {+ "description": "NASA's Astronomy Picture of the Day: its title, and the picture or video.",+ "hosts": [+ "api.nasa.gov"+ ],+ "license": "MIT",+ "name": "apod",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "title",+ "json": "json.title"+ },+ {+ "into": "day",+ "json": "json.date"+ },+ {+ "into": "media",+ "json": "json.url"+ },+ {+ "into": "kind",+ "json": "json.media_type"+ },+ {+ "into": "about",+ "json": "json.explanation"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "rate-limited": {+ "kind": "fail",+ "reason": "rate-limited",+ "say": "NASA's demo key has used up its hour from this address; try again later, or see https://apod.nasa.gov/apod/astropix.html"+ },+ "reply": {+ "kind": "done",+ "output": {+ "date": "{{ state.day }}",+ "explanation": "{{ state.about }}",+ "media_type": "{{ state.kind }}",+ "page": "https://apod.nasa.gov/apod/astropix.html",+ "title": "{{ state.title }}",+ "url": "{{ state.media }}"+ },+ "say": "Astronomy Picture of the Day, {{ state.day }}: {{ state.title }}. {{ state.media }}"+ },+ "status": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "eq": 200+ }+ },+ {+ "then": "rate-limited",+ "when": {+ "eq": 429+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "NASA's API answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "space",+ "science",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "astronomy picture of the day"+ },+ {+ "pattern": "[nasa] (apod|space picture) [of the day|today]"+ }+ ],+ "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+ },+ "cat-fact": {+ "description": "A random fact about cats, from catfact.ninja.",+ "hosts": [+ "catfact.ninja"+ ],+ "license": "MIT",+ "name": "cat-fact",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "fact",+ "json": "json.fact"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://catfact.ninja/fact"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "has-fact": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "fact"+ },+ "reply": {+ "kind": "done",+ "output": {+ "fact": "{{ state.fact }}"+ },+ "say": "{{ state.fact }}"+ },+ "status": {+ "branches": [+ {+ "then": "has-fact",+ "when": {+ "eq": 200+ }+ },+ {+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "catfact.ninja answered {{ state.resp.status }}."+ }+ },+ "start": "fetch",+ "tags": [+ "fun",+ "animals",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[tell me a] cat fact"+ },+ {+ "pattern": "tell me something about cats"+ }+ ],+ "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+ },+ "elevation": {+ "description": "How high a place is above sea level, from Open-Meteo's geocoder.",+ "hosts": [+ "geocoding-api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "elevation",+ "namespace": "pyrtan",+ "nodes": {+ "found": {+ "branches": [+ {+ "then": "has-height",+ "when": {+ "exists": true} }, {- "then": "moderate",+ "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"+ },+ {+ "into": "zone",+ "json": "json.results[0].timezone"+ },+ {+ "into": "height",+ "json": "json.results[0].elevation"+ }+ ],+ "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"+ },+ "has-height": {+ "branches": [+ {+ "then": "reply","when": {- "lte": 60+ "type": "number"} }, {- "then": "poor",+ "else": "no-height"+ }+ ],+ "kind": "decide",+ "on": "height"+ },+ "no-height": {+ "kind": "fail",+ "reason": "no-height",+ "say": "Open-Meteo knows {{ state.name }} but not how high it is."+ },+ "reply": {+ "kind": "done",+ "output": {+ "country": "{{ state.country }}",+ "elevation_m": "{{ state.height }}",+ "place": "{{ state.name }}"+ },+ "say": "{{ state.name }}, {{ state.country }} is {{ state.height | int }} m above sea level."+ },+ "unknown-place": {+ "kind": "fail",+ "reason": "unknown-place",+ "say": "I could not find a place called {{ inputs.place }}."+ }+ },+ "start": "geocode",+ "tags": [+ "geography",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "how high (is|above sea level is) {place}"+ },+ {+ "pattern": "[the] (elevation|altitude) of {place}"+ }+ ],+ "version": 1+ },+ "exchange-rate": {+ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",+ "hosts": [+ "api.frankfurter.dev"+ ],+ "inputs": {+ "amount": {+ "default": "1",+ "description": "How much of the first currency, as in: 100 or 2.5",+ "pattern": "^[0-9]+(\\.[0-9]+)?$",+ "type": "str"+ },+ "from": {+ "description": "A three-letter currency code, as in: USD",+ "pattern": "^[A-Za-z]{3}$",+ "type": "str"+ },+ "to": {+ "description": "A three-letter currency code, as in: EUR",+ "pattern": "^[A-Za-z]{3}$",+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "exchange-rate",+ "namespace": "pyrtan",+ "nodes": {+ "fetch": {+ "extract": [+ {+ "into": "value",+ "regex": "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}"+ },+ {+ "into": "day",+ "json": "json.date"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "has-rate": {+ "branches": [+ {+ "then": "reply","when": {- "lte": 80+ "exists": true} }, {- "then": "very-poor",+ "else": "unexpected"+ }+ ],+ "kind": "decide",+ "on": "value"+ },+ "reply": {+ "kind": "done",+ "output": {+ "amount": "{{ inputs.amount | float }}",+ "date": "{{ state.day }}",+ "from": "{{ inputs.from | upper }}",+ "to": "{{ inputs.to | upper }}",+ "value": "{{ state.value | float }}"+ },+ "say": "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."+ },+ "same-currency": {+ "kind": "fail",+ "reason": "same-currency",+ "say": "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."+ },+ "status": {+ "branches": [+ {+ "then": "has-rate","when": {- "lte": 100+ "eq": 200} }, {- "then": "extremely-poor",+ "then": "unknown-currency","when": {- "gt": 100+ "eq": 404} }, {- "else": "no-reading"+ "then": "same-currency",+ "when": {+ "eq": 422+ }+ },+ {+ "else": "unexpected"} ], "kind": "decide",- "on": "aqi"+ "on": "resp.status"+ },+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-status",+ "say": "Frankfurter answered {{ state.resp.status }}."+ },+ "unknown-currency": {+ "kind": "fail",+ "reason": "unknown-currency",+ "say": "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."+ }+ },+ "start": "fetch",+ "tags": [+ "money",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "convert {amount:number} {from:word} (to|into|in) {to:word}"+ },+ {+ "pattern": "how much is {amount:number} {from:word} in {to:word}"+ },+ {+ "pattern": "[the] exchange rate (from|for) {from:word} to {to:word}"+ },+ {+ "pattern": "{from:word} to {to:word} (rate|exchange rate)"+ }+ ],+ "version": 1+ },+ "forecast": {+ "description": "Tomorrow's weather for a place: sky, high and low, and chance of rain, 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": "forecast",+ "namespace": "pyrtan",+ "nodes": {+ "clear": {+ "kind": "set",+ "next": "reply",+ "set": {+ "sky": "clear"+ }},- "extremely-poor": {+ "cloudy": {"kind": "set", "next": "reply", "set": {- "band": "extremely poor"+ "sky": "cloudy"} },- "fair": {+ "fog": {"kind": "set", "next": "reply", "set": {- "band": "fair"+ "sky": "foggy"} }, "found": { "branches": [ {- "then": "air",+ "then": "tomorrow","when": { "exists": true }@@ -11345,6 +15331,14 @@{ "into": "country", "json": "json.results[0].country"+ },+ {+ "into": "zone",+ "json": "json.results[0].timezone"+ },+ {+ "into": "height",+ "json": "json.results[0].elevation"} ], "http": {@@ -11355,161 +15349,437 @@"kind": "observe", "next": "found" },- "good": {+ "have-day": {+ "branches": [+ {+ "then": "sky",+ "when": {+ "type": "number"+ }+ },+ {+ "else": "no-forecast"+ }+ ],+ "kind": "decide",+ "on": "high"+ },+ "no-forecast": {+ "kind": "fail",+ "reason": "no-forecast",+ "say": "Open-Meteo found {{ state.name }} but gave no forecast for tomorrow (status {{ state.fc.status }})."+ },+ "rain": {"kind": "set", "next": "reply", "set": {- "band": "good"+ "sky": "rain"} },- "moderate": {+ "reply": {+ "kind": "done",+ "output": {+ "country": "{{ state.country }}",+ "date": "{{ state.day }}",+ "high": "{{ state.high }}",+ "low": "{{ state.low }}",+ "place": "{{ state.name }}",+ "rain_chance": "{{ state.rain }}",+ "sky": "{{ state.sky }}",+ "unit": "{{ state.unit }}"+ },+ "say": "{{ state.name }}, {{ state.country }}, tomorrow ({{ state.day }}): {{ state.sky }}, {{ state.low }} to {{ state.high }}{{ state.unit }}, {{ state.rain }}% chance of rain."+ },+ "sky": {+ "branches": [+ {+ "then": "clear",+ "when": {+ "eq": 0+ }+ },+ {+ "then": "cloudy",+ "when": {+ "between": [+ 1,+ 3+ ]+ }+ },+ {+ "then": "fog",+ "when": {+ "between": [+ 45,+ 48+ ]+ }+ },+ {+ "then": "rain",+ "when": {+ "between": [+ 51,+ 67+ ]+ }+ },+ {+ "then": "snow",+ "when": {+ "between": [+ 71,+ 77+ ]+ }+ },+ {+ "then": "rain",+ "when": {+ "between": [+ 80,+ 82+ ]+ }+ },+ {+ "then": "snow",+ "when": {+ "between": [+ 85,+ 86+ ]+ }+ },+ {+ "then": "storm",+ "when": {+ "between": [+ 95,+ 99+ ]+ }+ },+ {+ "else": "unknown-sky"+ }+ ],+ "kind": "decide",+ "on": "code"+ },+ "snow": {"kind": "set", "next": "reply", "set": {- "band": "moderate"+ "sky": "snow"} },- "no-reading": {+ "storm": {+ "kind": "set",+ "next": "reply",+ "set": {+ "sky": "storms"+ }+ },+ "tomorrow": {+ "extract": [+ {+ "into": "day",+ "json": "json.daily.time[1]"+ },+ {+ "into": "code",+ "json": "json.daily.weather_code[1]"+ },+ {+ "into": "high",+ "json": "json.daily.temperature_2m_max[1]"+ },+ {+ "into": "low",+ "json": "json.daily.temperature_2m_min[1]"+ },+ {+ "into": "rain",+ "json": "json.daily.precipitation_probability_max[1]"+ },+ {+ "into": "unit",+ "json": "json.daily_units.temperature_2m_max"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2"+ },+ "into": "fc",+ "kind": "observe",+ "next": "have-day"+ },+ "unknown-place": {"kind": "fail",- "reason": "no-reading",- "say": "Open-Meteo found {{ state.name }} but gave no air quality reading (status {{ state.aq.status }})."+ "reason": "unknown-place",+ "say": "I could not find a place called {{ inputs.place }}."},- "poor": {+ "unknown-sky": {"kind": "set", "next": "reply", "set": {- "band": "poor"+ "sky": "weather code {{ state.code }}"}+ }+ },+ "start": "geocode",+ "tags": [+ "weather",+ "read-only"+ ],+ "triggers": [+ {+ "pattern": "[what's the] weather (in|for) {place} tomorrow"},- "reply": {- "kind": "done",- "output": {- "aqi": "{{ state.aqi }}",- "band": "{{ state.band }}",- "country": "{{ state.country }}",- "place": "{{ state.name }}",- "pm10": "{{ state.pm10 }}",- "pm2_5": "{{ state.pm25 }}"+ {+ "pattern": "[what's the] weather tomorrow (in|for) {place}"+ },+ {+ "pattern": "will it rain tomorrow in {place}"+ }+ ],+ "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",+ "json": "json.html_url"+ },+ {+ "into": "prerelease",+ "json": "json.prerelease"+ }+ ],+ "http": {+ "headers": {+ "Accept": "application/vnd.github+json"+ },+ "method": "GET",+ "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": "{{ state.name }}, {{ state.country }}: air quality {{ state.band }} (European AQI {{ state.aqi }}), PM2.5 {{ state.pm25 }} µg/m³, PM10 {{ state.pm10 }} µg/m³."+ "say": "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"},- "unknown-place": {- "kind": "fail",- "reason": "unknown-place",- "say": "I could not find a place called {{ inputs.place }}."+ "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"},- "very-poor": {- "kind": "set",- "next": "reply",- "set": {- "band": "very poor"- }+ "unexpected": {+ "kind": "fail",+ "reason": "unexpected-response",+ "say": "GitHub answered {{ state.resp.status }}."} },- "start": "geocode",+ "start": "fetch","tags": [- "weather",- "health",+ "github","read-only" ], "triggers": [ {- "pattern": "[what's the] air quality (in|for) {place}"- },- {- "pattern": "how is the air in {place}"+ "pattern": "[what's the] latest release of {repo:word}"}, {- "pattern": "(aqi|pollution) in {place}"+ "pattern": "latest {repo:word} release"} ],- "version": 1+ "version": 2},- "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.",+ "github-repo": {+ "description": "A GitHub repository at a glance: description, language, stars, forks, open issues.","hosts": [- "openlibrary.org"+ "api.github.com"], "inputs": {- "title": {- "description": "A title or an author, as in: The Left Hand of Darkness",+ "repo": {+ "description": "owner/name, as in: astral-sh/uv",+ "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$","type": "str" } }, "license": "MIT",- "name": "books",+ "name": "github-repo","namespace": "pyrtan", "nodes": {- "any": {- "branches": [+ "fetch": {+ "extract": [{- "then": "reply",- "when": {- "exists": true- }+ "into": "full",+ "json": "json.full_name"}, {- "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": "about",+ "json": "json.description"+ },{- "into": "found",- "json": "json.numFound"+ "into": "language",+ "json": "json.language"}, {- "into": "book",- "json": "json.docs[0].title"+ "into": "stars",+ "json": "json.stargazers_count"}, {- "into": "author",- "json": "json.docs[0].author_name[0]"+ "into": "forks",+ "json": "json.forks_count"}, {- "into": "year",- "json": "json.docs[0].first_publish_year"+ "into": "issues",+ "json": "json.open_issues_count"}, {- "into": "key",- "json": "json.docs[0].key"+ "into": "url",+ "json": "json.html_url"+ },+ {+ "into": "archived",+ "json": "json.archived"} ], "http": {+ "headers": {+ "Accept": "application/vnd.github+json"+ },"method": "GET",- "url": "https://openlibrary.org/search.json?q={{ inputs.title | url }}&limit=1&fields=title,author_name,first_publish_year,key"+ "url": "https://api.github.com/repos/{{ inputs.repo }}"}, "into": "resp", "kind": "observe", "next": "status" },+ "no-repo": {+ "kind": "fail",+ "reason": "no-repo",+ "say": "GitHub has no public repository {{ inputs.repo }}."+ },+ "rate-limited": {+ "kind": "fail",+ "reason": "rate-limited",+ "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."+ },+ "reply": {+ "kind": "done",+ "output": {+ "archived": "{{ state.archived }}",+ "description": "{{ state.about }}",+ "forks": "{{ state.forks }}",+ "language": "{{ state.language }}",+ "open_issues": "{{ state.issues }}",+ "repo": "{{ state.full }}",+ "stars": "{{ state.stars }}",+ "url": "{{ state.url }}"+ },+ "say": "{{ state.full }}: {{ state.about }} ({{ state.language }}) · {{ state.stars }} stars, {{ state.forks }} forks, {{ state.issues }} open issues. {{ state.url }}"+ },"status": { "branches": [ {- "then": "any",+ "then": "reply","when": { "eq": 200 } },+ {+ "then": "no-repo",+ "when": {+ "eq": 404+ }+ },+ {+ "then": "rate-limited",+ "when": {+ "in": [+ 403,+ 429+ ]+ }+ },{ "else": "unexpected" }@@ -11520,90 +15790,96 @@"unexpected": { "kind": "fail", "reason": "unexpected-status",- "say": "Open Library answered {{ state.resp.status }}."+ "say": "GitHub answered {{ state.resp.status }}."} },- "start": "search",+ "start": "fetch","tags": [- "reference",- "books",+ "github",+ "software","read-only" ], "triggers": [ {- "pattern": "who wrote {title}"+ "pattern": "how many stars does {repo:word} have"}, {- "pattern": "find the book {title}"+ "pattern": "github (repo|repository) {repo:word}"}, {- "pattern": "books by {title}"+ "pattern": "tell me about the {repo:word} repo"} ], "version": 1 },- "dad-joke": {- "description": "A random dad joke, from icanhazdadjoke.com.",+ "go-module": {+ "description": "The latest version of a Go module, and when it was published, from proxy.golang.org.","hosts": [- "icanhazdadjoke.com"+ "proxy.golang.org"],+ "inputs": {+ "module": {+ "description": "A module path, as in: github.com/gorilla/mux",+ "pattern": "^[a-z0-9.\\-]+(/[A-Za-z0-9._~\\-]+)+$",+ "type": "str"+ }+ },"license": "MIT",- "name": "dad-joke",+ "name": "go-module","namespace": "pyrtan", "nodes": { "fetch": { "extract": [ {- "into": "joke",- "json": "json.joke"+ "into": "version",+ "json": "json.Version"}, {- "into": "id",- "json": "json.id"+ "from": "json.Time",+ "into": "day",+ "regex": "^(\\d{4}-\\d\\d-\\d\\d)"} ], "http": {- "headers": {- "Accept": "application/json"- },"method": "GET",- "url": "https://icanhazdadjoke.com/"+ "url": "https://proxy.golang.org/{{ inputs.module | lower }}/@latest"}, "into": "resp", "kind": "observe", "next": "status" },- "has-joke": {- "branches": [- {- "then": "reply",- "when": {- "exists": true- }- },- {- "else": "unexpected"- }- ],- "kind": "decide",- "on": "joke"+ "no-module": {+ "kind": "fail",+ "reason": "no-module",+ "say": "The Go module proxy has no module {{ inputs.module }}."}, "reply": { "kind": "done", "output": {- "joke": "{{ state.joke }}",- "url": "https://icanhazdadjoke.com/j/{{ state.id }}"+ "module": "{{ inputs.module }}",+ "published": "{{ state.day }}",+ "url": "https://pkg.go.dev/{{ inputs.module }}",+ "version": "{{ state.version }}"},- "say": "{{ state.joke }}"+ "say": "{{ inputs.module }} {{ state.version }}, published {{ state.day }}. https://pkg.go.dev/{{ inputs.module }}"}, "status": { "branches": [ {- "then": "has-joke",+ "then": "reply","when": { "eq": 200 } },+ {+ "then": "no-module",+ "when": {+ "in": [+ 404,+ 410+ ]+ }+ },{ "else": "unexpected" }@@ -11614,107 +15890,76 @@"unexpected": { "kind": "fail", "reason": "unexpected-status",- "say": "icanhazdadjoke.com answered {{ state.resp.status }}."+ "say": "The Go module proxy answered {{ state.resp.status }}."} }, "start": "fetch", "tags": [- "fun",+ "software",+ "go","read-only" ], "triggers": [ {- "pattern": "tell me a [dad] joke"- },- {- "pattern": "[a] dad joke"+ "pattern": "[what's the] latest version of {module:word} on go"}, {- "pattern": "make me laugh"+ "pattern": "go (module|version) {module:word}"} ], "version": 1 },- "define": {- "description": "What an English word means: its first definition, part of speech and pronunciation.",+ "hacker-news": {+ "description": "Top stories on Hacker News, from its public API.","hosts": [- "api.dictionaryapi.dev"+ "hacker-news.firebaseio.com"], "inputs": {- "word": {- "description": "One English word",- "pattern": "^[A-Za-z][A-Za-z'\\-]*$",- "type": "str"+ "count": {+ "default": 1,+ "description": "How many stories to return",+ "max": 30,+ "min": 1,+ "type": "int"} }, "license": "MIT",- "name": "define",+ "name": "hacker-news","namespace": "pyrtan", "nodes": {- "fetch": {- "extract": [- {- "into": "word",- "json": "json[0].word"- },- {- "into": "phonetic",- "json": "json[0].phonetic"- },+ "any": {+ "branches": [{- "into": "part",- "json": "json[0].meanings[0].partOfSpeech"+ "then": "nothing",+ "when": {+ "empty": true+ }}, {- "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",+ "then": "stories","when": {- "exists": true+ "type": "list"} }, {- "else": "unknown-word"+ "else": "unexpected"} ], "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 }}"+ "on": "ids"},- "status": {+ "check": {"branches": [ {- "then": "has-definition",+ "then": "any","when": { "eq": 200 } }, {- "then": "unknown-word",+ "then": "hn-down","when": {- "eq": 404+ "gte": 500} }, {@@ -11722,248 +15967,298 @@} ], "kind": "decide",- "on": "resp.status"+ "on": "ids_resp.status"},- "unexpected": {+ "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": "unexpected-status",- "say": "The dictionary answered {{ state.resp.status }}."+ "reason": "upstream-down",+ "say": "Hacker News answered {{ state.ids_resp.status }}; try again later."},- "unknown-word": {+ "nothing": {"kind": "fail",- "reason": "unknown-word",- "say": "The dictionary has no entry for {{ inputs.word }}."+ "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",+ "start": "fetch-ids","tags": [- "reference",- "language",+ "news","read-only" ], "triggers": [ {- "pattern": "define {word:word}"- },- {- "pattern": "what does {word:word} mean"+ "inputs": {+ "count": 1+ },+ "pattern": "[what's the] top story on hacker news"}, {- "pattern": "[the] (meaning|definition) of {word:word}"+ "pattern": "top {count:int} stories on hacker news"} ],- "version": 1+ "version": 2},- "disk-space": {- "commands": [- "df"+ "hacker-news-item": {+ "description": "One Hacker News item by id: title, url, score, author.",+ "hosts": [+ "hacker-news.firebaseio.com"],- "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"+ "id": {+ "description": "The item id",+ "min": 1,+ "type": "int"} }, "license": "MIT",- "name": "disk-space",+ "name": "hacker-news-item","namespace": "pyrtan", "nodes": {- "df": {+ "fetch": {"extract": [ {- "group": 1,- "into": "percent",- "regex": "(\\d+)%"+ "into": "title",+ "json": "json.title"}, {- "group": 1,- "into": "mounted",- "regex": "%\\s+(\\S+)\\s*$"+ "into": "url",+ "json": "json.url"+ },+ {+ "into": "score",+ "json": "json.score"+ },+ {+ "into": "by",+ "json": "json.by"+ },+ {+ "into": "type",+ "json": "json.type"} ],- "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 }}"+ "http": {+ "method": "GET",+ "url": "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json"},- "say": "{{ state.mounted }} is {{ state.pct }}% full."- },- "full": {- "kind": "fail",- "reason": "disk-full",- "say": "{{ state.mounted }} is {{ state.pct }}% full."+ "into": "resp",+ "kind": "observe",+ "next": "kind"},- "level": {+ "has-url": {"branches": [ {- "then": "fine",- "when": {- "lt": 80- }- },- {- "then": "tight",+ "then": "reply","when": {- "lt": 95+ "exists": true} }, {- "else": "full"+ "else": "self-post"} ], "kind": "decide",- "on": "pct"- },- "number": {- "kind": "set",- "next": "level",- "set": {- "pct": "{{ state.percent | int }}"- }+ "on": "url"},- "ran": {+ "kind": {"branches": [ {- "then": "number",+ "then": "has-url","when": {- "eq": true+ "eq": "story"} }, {- "else": "failed"+ "then": "has-url",+ "when": {+ "eq": "job"+ }+ },+ {+ "then": "missing",+ "when": {+ "exists": false+ }+ },+ {+ "else": "not-a-story"} ], "kind": "decide",- "on": "df.ok"+ "on": "type"},- "tight": {+ "missing": {+ "kind": "fail",+ "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": {- "mounted": "{{ state.mounted }}",- "percent": "{{ state.pct }}"+ "by": "{{ state.by }}",+ "id": "{{ inputs.id }}",+ "score": "{{ state.score }}",+ "title": "{{ state.title }}",+ "url": "{{ state.url }}"},- "say": "{{ state.mounted }} is {{ state.pct }}% full; make room soon."+ "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": "df",+ "start": "fetch","tags": [- "ops",- "box",+ "news","read-only" ], "triggers": [ {- "pattern": "[how] full is the disk"- },- {- "pattern": "disk space"- },- {- "pattern": "disk space on {mount:word}"+ "pattern": "hacker news item {id:int}"} ], "version": 2 },- "earthquakes": {- "description": "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest.",+ "hn-search": {+ "description": "Hacker News stories about something, through HN Search: the top three matches.","hosts": [- "earthquake.usgs.gov"+ "hn.algolia.com"],+ "inputs": {+ "query": {+ "description": "Words to search story titles and text for",+ "type": "str"+ }+ },"license": "MIT",- "name": "earthquakes",+ "name": "hn-search","namespace": "pyrtan", "nodes": { "any": { "branches": [ {- "then": "none",- "when": {- "eq": 0- }- },- {- "then": "reply",+ "then": "nothing","when": {- "gt": 0+ "empty": true} }, {- "else": "unexpected"+ "else": "each"} ], "kind": "decide",- "on": "count"+ "on": "hits"},- "fetch": {+ "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": "count",- "json": "json.metadata.count"- },- {- "into": "mag",- "json": "json.features[0].properties.mag"+ "into": "total",+ "json": "json.nbHits"}, {- "into": "place",- "json": "json.features[0].properties.place"+ "into": "first",+ "json": "json.hits[0].title"}, {- "into": "tsunami",- "json": "json.features[0].properties.tsunami"+ "into": "first_url",+ "json": "json.hits[0].url"}, {- "into": "url",- "json": "json.features[0].properties.url"+ "into": "hits",+ "json": "json.hits"} ], "http": { "method": "GET",- "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson"+ "url": "https://hn.algolia.com/api/v1/search?query={{ inputs.query | url }}&tags=story&hitsPerPage=3"}, "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": [ {@@ -11982,234 +16277,203 @@"unexpected": { "kind": "fail", "reason": "unexpected-status",- "say": "The USGS feed answered {{ state.resp.status }}."+ "say": "HN Search answered {{ state.resp.status }}."} },- "start": "fetch",+ "start": "search","tags": [- "science",+ "news",+ "tech","read-only" ], "triggers": [ {- "pattern": "[any] [recent] earthquakes [today]"+ "pattern": "search hacker news for {query}"}, {- "pattern": "[was there an] earthquake today"+ "pattern": "hacker news stories about {query}"}, {- "pattern": "latest earthquake"+ "pattern": "what does hacker news say about {query}"} ], "version": 1 },- "exchange-rate": {- "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",+ "holidays": {+ "description": "The next public holiday in a country (by its two-letter code), from Nager.Date.","hosts": [- "api.frankfurter.dev"+ "date.nager.at"], "inputs": {- "amount": {- "default": "1",- "description": "How much of the first currency, as in: 100 or 2.5",- "pattern": "^[0-9]+(\\.[0-9]+)?$",- "type": "str"- },- "from": {- "description": "A three-letter currency code, as in: USD",- "pattern": "^[A-Za-z]{3}$",- "type": "str"- },- "to": {- "description": "A three-letter currency code, as in: EUR",- "pattern": "^[A-Za-z]{3}$",+ "country": {+ "description": "A two-letter country code, as in: NO, US, DE",+ "pattern": "^[A-Za-z]{2}$","type": "str" } }, "license": "MIT",- "name": "exchange-rate",+ "name": "holidays","namespace": "pyrtan", "nodes": {+ "any": {+ "branches": [+ {+ "then": "reply",+ "when": {+ "exists": true+ }+ },+ {+ "else": "none-listed"+ }+ ],+ "kind": "decide",+ "on": "date"+ },"fetch": { "extract": [ {- "into": "value",- "regex": "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}"+ "into": "date",+ "json": "json[0].date"}, {- "into": "day",- "json": "json.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://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}"+ "url": "https://date.nager.at/api/v3/NextPublicHolidays/{{ inputs.country | upper }}"}, "into": "resp", "kind": "observe", "next": "status" },- "has-rate": {- "branches": [- {- "then": "reply",- "when": {- "exists": true- }- },- {- "else": "unexpected"- }- ],- "kind": "decide",- "on": "value"+ "none-listed": {+ "kind": "fail",+ "reason": "none-listed",+ "say": "Nager.Date lists no coming public holidays for {{ inputs.country | upper }}."}, "reply": { "kind": "done", "output": {- "amount": "{{ inputs.amount | float }}",- "date": "{{ state.day }}",- "from": "{{ inputs.from | upper }}",- "to": "{{ inputs.to | upper }}",- "value": "{{ state.value | float }}"+ "after": {+ "date": "{{ state.then_date }}",+ "name": "{{ state.then }}"+ },+ "country": "{{ inputs.country | upper }}",+ "date": "{{ state.date }}",+ "local_name": "{{ state.local }}",+ "name": "{{ state.name }}"},- "say": "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."- },- "same-currency": {- "kind": "fail",- "reason": "same-currency",- "say": "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."+ "say": "The next public holiday in {{ inputs.country | upper }} is {{ state.name }} ({{ state.local }}) on {{ state.date }}."}, "status": { "branches": [ {- "then": "has-rate",- "when": {- "eq": 200- }- },- {- "then": "unknown-currency",- "when": {- "eq": 404- }- },- {- "then": "same-currency",+ "then": "any","when": {- "eq": 422+ "eq": 200} }, {- "else": "unexpected"+ "else": "unknown-country"} ], "kind": "decide", "on": "resp.status" },- "unexpected": {- "kind": "fail",- "reason": "unexpected-status",- "say": "Frankfurter answered {{ state.resp.status }}."- },- "unknown-currency": {+ "unknown-country": {"kind": "fail",- "reason": "unknown-currency",- "say": "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."+ "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", "tags": [- "money",+ "calendar","read-only" ], "triggers": [ {- "pattern": "convert {amount:number} {from:word} (to|into|in) {to:word}"- },- {- "pattern": "how much is {amount:number} {from:word} in {to:word}"- },- {- "pattern": "[the] exchange rate (from|for) {from:word} to {to:word}"+ "pattern": "[when is the] next [public|bank] holiday in {country:word}"}, {- "pattern": "{from:word} to {to:word} (rate|exchange rate)"+ "pattern": "[public|bank] holidays in {country:word}"} ], "version": 1 },- "github-release": {- "description": "The latest release of a GitHub repository.",+ "homebrew": {+ "description": "A Homebrew formula's current stable version and what it is, from formulae.brew.sh.","hosts": [- "api.github.com"+ "formulae.brew.sh"], "inputs": {- "repo": {- "description": "owner/name",- "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$",+ "formula": {+ "description": "A formula name, as in: wget",+ "pattern": "^[a-z0-9][a-z0-9@+._\\-]*$","type": "str" } }, "license": "MIT",- "name": "github-release",+ "name": "homebrew","namespace": "pyrtan", "nodes": { "fetch": { "extract": [- {- "into": "tag",- "json": "json.tag_name"- },{ "into": "name", "json": "json.name" }, {- "into": "published",- "json": "json.published_at"+ "into": "version",+ "json": "json.versions.stable"}, {- "into": "url",- "json": "json.html_url"+ "into": "about",+ "json": "json.desc"}, {- "into": "prerelease",- "json": "json.prerelease"+ "into": "home",+ "json": "json.homepage"} ], "http": {- "headers": {- "Accept": "application/vnd.github+json"- },"method": "GET",- "url": "https://api.github.com/repos/{{ inputs.repo }}/releases/latest"+ "url": "https://formulae.brew.sh/api/formula/{{ inputs.formula | lower | url }}.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": {+ "no-formula": {"kind": "fail",- "reason": "rate-limited",- "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."+ "reason": "no-formula",+ "say": "Homebrew has no formula called {{ inputs.formula }}."}, "reply": { "kind": "done", "output": {- "name": "{{ state.name }}",- "prerelease": "{{ state.prerelease }}",- "published": "{{ state.published }}",- "repo": "{{ inputs.repo }}",- "tag": "{{ state.tag }}",- "url": "{{ state.url }}"+ "description": "{{ state.about }}",+ "formula": "{{ state.name }}",+ "homepage": "{{ state.home }}",+ "install": "brew install {{ state.name }}",+ "version": "{{ state.version }}"},- "say": "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"+ "say": "{{ state.name }} {{ state.version }} on Homebrew: {{ state.about }}. {{ state.home }}"}, "status": { "branches": [@@ -12220,20 +16484,11 @@} }, {- "then": "none",+ "then": "no-formula","when": { "eq": 404 } },- {- "then": "rate-limited",- "when": {- "in": [- 403,- 429- ]- }- },{ "else": "unexpected" }@@ -12243,55 +16498,80 @@}, "unexpected": { "kind": "fail",- "reason": "unexpected-response",- "say": "GitHub answered {{ state.resp.status }}."+ "reason": "unexpected-status",+ "say": "formulae.brew.sh answered {{ state.resp.status }}."} }, "start": "fetch", "tags": [- "github",+ "software",+ "macos","read-only" ], "triggers": [ {- "pattern": "[what's the] latest release of {repo:word}"+ "pattern": "[what's the] latest version of {formula:word} on homebrew"}, {- "pattern": "latest {repo:word} release"+ "pattern": "(brew|homebrew) (formula|version) {formula:word}"} ],- "version": 2+ "version": 1},- "hacker-news": {- "description": "Top stories on Hacker News, from its public API.",+ "iss": {+ "description": "Where the International Space Station is right now: latitude, longitude, altitude and speed.","hosts": [- "hacker-news.firebaseio.com"+ "api.wheretheiss.at"],- "inputs": {- "count": {- "default": 1,- "description": "How many stories to return",- "max": 30,- "min": 1,- "type": "int"- }- },"license": "MIT",- "name": "hacker-news",+ "name": "iss","namespace": "pyrtan", "nodes": {- "any": {- "branches": [+ "fetch": {+ "extract": [{- "then": "nothing",- "when": {- "empty": true- }+ "into": "lat",+ "json": "json.latitude"}, {- "then": "stories",+ "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})?)"+ }+ ],+ "http": {+ "method": "GET",+ "url": "https://api.wheretheiss.at/v1/satellites/25544"+ },+ "into": "resp",+ "kind": "observe",+ "next": "status"+ },+ "have-position": {+ "branches": [+ {+ "then": "light","when": {- "type": "list"+ "type": "number"} }, {@@ -12299,595 +16579,613 @@} ], "kind": "decide",- "on": "ids"+ "on": "lat"},- "check": {+ "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": "any",+ "then": "in-sun","when": {- "eq": 200+ "eq": "daylight"} }, {- "then": "hn-down",+ "then": "in-shadow","when": {- "gte": 500+ "eq": "eclipsed"} }, {- "else": "unexpected"+ "else": "in-sun"} ], "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."+ "on": "light"},- "nothing": {+ "rate-limited": {"kind": "fail",- "reason": "no-stories",- "say": "Hacker News returned an empty front page."+ "reason": "rate-limited",+ "say": "wheretheiss.at is asking for fewer requests (429); try again in a moment."}, "reply": { "kind": "done", "output": {- "stories": "{{ state.stories }}"+ "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": "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"+ "say": "The ISS is over {{ state.lat2 }}, {{ state.lon2 }}, {{ state.alt | int }} km up, moving at {{ state.speed | int }} km/h, {{ state.where }}."},- "stories": {- "into": "stories",- "kind": "each",- "limit": "{{ inputs.count }}",- "next": "reply",- "over": "ids",- "tree": "hacker-news-item",- "with": {- "id": "{{ item }}"- }+ "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-response",- "say": "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."+ "reason": "unexpected-status",+ "say": "wheretheiss.at answered {{ state.resp.status }}."} },- "start": "fetch-ids",+ "start": "fetch","tags": [- "news",+ "science",+ "space","read-only" ], "triggers": [ {- "inputs": {- "count": 1- },- "pattern": "[what's the] top story on hacker news"+ "pattern": "where is the (iss|space station) [now]"}, {- "pattern": "top {count:int} stories on hacker news"+ "pattern": "(iss|space station) (position|location)"} ],- "version": 2+ "version": 1},- "hacker-news-item": {- "description": "One Hacker News item by id: title, url, score, author.",+ "local-time": {+ "description": "What time it is now in a place, and its time zone.","hosts": [- "hacker-news.firebaseio.com"+ "geocoding-api.open-meteo.com",+ "timeapi.io"], "inputs": {- "id": {- "description": "The item id",- "min": 1,- "type": "int"+ "place": {+ "description": "A town or city name",+ "type": "str"} }, "license": "MIT",- "name": "hacker-news-item",+ "name": "local-time","namespace": "pyrtan", "nodes": {- "fetch": {+ "clock": {"extract": [ {- "into": "title",- "json": "json.title"- },- {- "into": "url",- "json": "json.url"- },- {- "into": "score",- "json": "json.score"+ "into": "time",+ "json": "json.time"}, {- "into": "by",- "json": "json.by"+ "into": "weekday",+ "json": "json.dayOfWeek"}, {- "into": "type",- "json": "json.type"+ "from": "json.dateTime",+ "into": "date",+ "regex": "^(\\d{4}-\\d\\d-\\d\\d)"} ], "http": { "method": "GET",- "url": "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json"+ "url": "https://timeapi.io/api/time/current/zone?timeZone={{ state.zone | url }}"}, "into": "resp", "kind": "observe",- "next": "kind"+ "next": "status"},- "has-url": {+ "found": {"branches": [ {- "then": "reply",+ "then": "clock","when": { "exists": true } }, {- "else": "self-post"+ "else": "unknown-place"} ], "kind": "decide",- "on": "url"+ "on": "hit"},- "kind": {- "branches": [+ "geocode": {+ "extract": [{- "then": "has-url",- "when": {- "eq": "story"- }+ "into": "hit",+ "json": "json.results[0]"}, {- "then": "has-url",- "when": {- "eq": "job"- }+ "into": "lat",+ "json": "json.results[0].latitude"}, {- "then": "missing",- "when": {- "exists": false- }+ "into": "lon",+ "json": "json.results[0].longitude"}, {- "else": "not-a-story"+ "into": "name",+ "json": "json.results[0].name"+ },+ {+ "into": "country",+ "json": "json.results[0].country"+ },+ {+ "into": "zone",+ "json": "json.results[0].timezone"+ },+ {+ "into": "height",+ "json": "json.results[0].elevation"} ],- "kind": "decide",- "on": "type"- },- "missing": {- "kind": "fail",- "reason": "no-such-item",- "say": "There is no Hacker News item {{ inputs.id }}."+ "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"},- "not-a-story": {+ "no-clock": {"kind": "fail",- "reason": "not-a-story",- "say": "Item {{ inputs.id }} is a {{ state.type }}, not a story."+ "reason": "no-clock",+ "say": "timeapi.io gave no time for {{ state.zone }} (status {{ state.resp.status }})."}, "reply": { "kind": "done", "output": {- "by": "{{ state.by }}",- "id": "{{ inputs.id }}",- "score": "{{ state.score }}",- "title": "{{ state.title }}",- "url": "{{ state.url }}"+ "country": "{{ state.country }}",+ "date": "{{ state.date }}",+ "place": "{{ state.name }}",+ "time": "{{ state.time }}",+ "timezone": "{{ state.zone }}",+ "weekday": "{{ state.weekday }}"},- "say": "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"+ "say": "It is {{ state.time }} on {{ state.weekday }} {{ state.date }} in {{ state.name }}, {{ state.country }} ({{ state.zone }})."},- "self-post": {- "kind": "set",- "next": "reply",- "set": {- "url": "https://news.ycombi
The diff is cut here; the rest is in the clone.