Merge main (the console as a hub of its own, dates in our own words) into identity
Claude · 2026-10-28 · ff12eb13aa294b5f6a43154173bcf1252e1653ae · parents f4565a8 0a4767f
Conflicts were additive. The console's api() answers from the in-page LocalHub when the browser is its own hub and signs with its key only when talking to a real hub that names an audience; the Identity tab stays hidden on the LocalHub, which names none. The console loads its key before it loads anything else, and keeps main's forget, which makes the browser its own hub again. The conformance test keeps main's new totals beside the identity vectors; the README and site README keep main's console and index wording with identity's rows.
Checked: the full suite (the Chromium console test included), and the static console (pyrtan console --out) served on its own: a hub of its own with the catalog's trees, the Identity tab hidden, no page errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
README.md | 15 +- _meta/members.md | 9 +- _meta/platform.md | 31 +- site/README.md | 4 +- site/catalog/index.html | 8 +- site/index.html | 45 +- src/pyrtan/catalog.py | 20 +- src/pyrtan/cli.py | 9 +- src/pyrtan/js/pyrtan.js | 742 +++++++- src/pyrtan/nlp.py | 20 +- src/pyrtan/page.py | 101 +- tests/conformance/make_vectors.py | 173 +- tests/conformance/run.cjs | 55 +- tests/conformance/vectors.json | 5932 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- tests/test_catalog.py | 13 + tests/test_conformance.py | 8 +- 16 files changed, 7082 insertions(+), 103 deletions(-)
diff --git a/README.md b/README.md index c7c970a..623515b 100644 --- a/README.md +++ b/README.md@@ -174,8 +174,8 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).| `pyrtan new --from "…"` | Scaffolds a tree file from a sentence: numbers and hosts in it become slots, and the file passes `pyrtan check`. `pyrtan new NAME` scaffolds from a name. | | `pyrtan search WORDS` | Finds trees in a catalog (default: the one at pyrtan.com). | | `pyrtan pull NAME[@VERSION]` | Fetches a tree from a catalog into your trees folder, verified against the catalog's index. |-| `pyrtan index DIR` | Builds a catalog `index.json` from a directory of trees; what a catalog's CI runs. |-| `pyrtan console --out FILE` | Writes the console as one static HTML file, to host anywhere. |+| `pyrtan index DIR` | Builds a catalog `index.json` from a directory of trees, and `trees.json` beside it (the same trees as JSON, for the console in a browser); what a catalog's CI runs. |+| `pyrtan console --out FILE` | Writes the console as one static HTML file, to host anywhere; there the browser is a hub of its own until it joins one. || `pyrtan id new` | Makes your identity: a root key and the signed document hubs will hold. `id device add pyr:…` delegates to a device key, `id revoke` takes it back, `id route add HOST` says where you can be reached, `id name alice@domain` signs a name claim for a domain to serve (`--into` a checkout for a name at pyrtan.com), `id check` checks an identifier or any of those documents, `id show` shows them. | | `pyrtan allow WHO --may ABILITIES --at HUB` | Grants an identity (`pyr:…`, or a name the hub resolves and pins) abilities on your hub: `ask`, `chat`, `run`, `operate`. `allowed` lists the grants; `disallow` takes them back. Every command that calls a hub takes `--sign` to sign as your identity instead of (or beside) `--token`. | | `pyrtan id resolve NAME@DOMAIN` | What identity a name is, checked against the key's own signature and pinned the first time; a changed answer is refused until `--repin`. `id pins` lists the pins; `id wellknown DIR --domain D` builds `/.well-known/pyrtan/` for a domain to serve names. |@@ -197,8 +197,15 @@ pyrtan ask "top 3 stories on hacker news"Open <http://127.0.0.1:8700/> for the console: a sentence box, the answer, the output and the trace; everything the swarm can answer; each peer and its `link`; and the gaps. The same console lives at-<https://pyrtan.com/console/> and can be pointed at any hub started with-`--cors https://pyrtan.com`. The wordmark is two colours, **pyr** the+<https://pyrtan.com/console/>, and there it needs no hub at all: until you+join one, the browser is the hub. It holds the catalog's trees, reads the+sentence and runs the tree in the page (`pyrtan.js`, the same runtime and+the same reading as Python's), and keeps gaps and unanswered sentences+while the page is open. Trees that need a box (files, programs, serial+lines, brokers, a browser to drive) or a site that does not let a page+read it say so; for those, join a hub started with+`--cors https://pyrtan.com` by giving its URL and token. Forget it and the+browser is its own hub again. The wordmark is two colours, **pyr** theprimary and **tan** the secondary: click either half, right-click it, or press and hold it on a phone to pick that half's colour; tan's picker also sets light, dark or auto mode. The choice is kept in the browser. To run the swarm on a NAS or any Docker host, diff --git a/_meta/members.md b/_meta/members.md index 4f376cb..69bbba3 100644 --- a/_meta/members.md +++ b/_meta/members.md@@ -146,9 +146,12 @@ the recorded responses with a fixed clock and writes `vectors.json`;`tests/conformance/run.cjs` runs the same trees and inputs through `pyrtan.js` and compares status, say, reason, output, path and gaps; `tests/test_conformance.py` runs both and fails when the vectors are-stale. Three differences are documented in the file's header: `float`-accepts any number, CSS selection is the browser's `DOMParser` plus a-`:contains` shim, and a member never reads sentences (the hub does).+stale. The differences are documented in the file's header. Sentences+are read the same way too: the language rules, the triggers and the+proposal for an unanswered sentence are ported and in the vectors, so a+member reads among the trees it holds before it asks its hub, and a+browser that has joined no hub is a hub of its own+([`platform.md`](platform.md) §2.5).**Trust.** A member that follows with the hub's bearer token on the `Follow` is recorded `trusted`; one without is a keeper. Trust is what diff --git a/_meta/platform.md b/_meta/platform.md index c7bdffa..ae3c331 100644 --- a/_meta/platform.md +++ b/_meta/platform.md@@ -16,7 +16,8 @@ self-hosted one.** The console, the command, the catalog client and anyfuture integration speak only the public API (`/api`, `/health`, `/capabilities`, `/peers`, `/intent`, `/run`, `/resume`, `/gaps`), with a bearer token when the operator set one. Nothing hosted gets a private-endpoint.+endpoint. A browser that has joined no hub answers the same API itself+(§2.5), so the console cannot tell that apart either.The consequence for the business: what people pay for lives *around* the runtime, not in it. The runtime stays whole and AGPL; the paid product is@@ -147,10 +148,30 @@ operator's, per hub, and off by default.One HTML file, no build, no external assets. Served by a hub or service at `/` and `/console`, it talks to that origin. Written out by `pyrtan console --out` and hosted anywhere, it finds out on load that its-own origin is not a Pyrtan (`/api` does not answer as one), shows a-connect box for a hub URL and a token, keeps them in local storage, and-sends the token as a bearer. A hub that requires a token and serves the-console itself shows the same box with only the token field.+own origin is not a Pyrtan (`/api` does not answer as one), and then the+browser is the hub, for as long as the page is open: `pyrtan.js`'s+`LocalHub` answers `/api`, `/health`, `/capabilities`, `/intent`, `/run`,+`/resume`, `/gaps`, `/unanswered` and `/watches` inside the page, with the+shapes a service answers them with, and the console calls it through the+same function it calls a hub through. It holds the catalog's trees, read+from the catalog's `trees.json` (`../catalog/` beside the console, else+pyrtan.com's, else the copy the browser kept last time; `?catalog=URL`+names another), reads sentences with the same rules as the Python+runtime (the conformance vectors cover the reading, the value parsers,+the proposals and a run of requests against a service), runs the trees,+and keeps gaps and unanswered sentences in memory. What a page cannot+reach it reports instead of attempting: a `file`, `shell`, `serial`,+`queue` or `browser` node answers `source-unsupported`, and a site that+does not let a page read it fails as `transport` with that reason; the+Trees tab marks both, and "Try one" offers only trees that can answer.+Nothing in it is sent anywhere but the requests the trees make.++Joining is explicit: a hub URL and a token in the connect box, kept in+local storage, the token sent as a bearer (and `?hub=URL` for a link).+From then on everything goes to that hub, as before. "Forget" drops it,+and the browser is its own hub again. A hub that requires a token and+serves the console itself shows the same box with only the token+field.Tabs: Ask (sentence, answer, output, trace, and a pointer when the run met a gap), Trees (capabilities, with a link to the catalog), Peers (hub only; diff --git a/site/README.md b/site/README.md index b53df58..21fcf95 100644 --- a/site/README.md +++ b/site/README.md@@ -7,8 +7,8 @@ static directory and publishes it.| Path | What | Built from | | :-- | :-- | :-- | | `/` | the landing page, with the live directory read from the listed hubs in the visitor's browser | `site/index.html` |-| `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it); point it at any hub started with `--cors https://pyrtan.com`; its Member tab joins that hub, and runs its trees when joined with the token or as a key the hub grants `run`; its Identity tab holds that key | `pyrtan console --out` |-| `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, and `index.json` the command reads | `pyrtan index examples catalog --copy`, through `site/gate.py` |+| `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it). Until it joins a hub the browser is one: it holds the catalog's trees (`/catalog/trees.json`) and runs them in the page. To join any hub started with `--cors https://pyrtan.com`, give its URL and token; its Member tab then joins that hub, and runs its trees when joined with the token or as a key the hub grants `run`; its Identity tab holds that key | `pyrtan console --out` |+| `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, `index.json` the command reads, and `trees.json`, the same trees as JSON for the console | `pyrtan index examples catalog --copy`, through `site/gate.py` || `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` | | `/rendezvous/hubs.json` | the hub list: hubs operators added by pull request, which the snapshot asks first and `pyrtan join` tries first | `site/rendezvous/hubs.json` | diff --git a/site/catalog/index.html b/site/catalog/index.html index 7e0cbe5..9198bed 100644 --- a/site/catalog/index.html +++ b/site/catalog/index.html@@ -4,7 +4,7 @@<meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pyrtan catalog</title>-<meta name="description" content="Trees you can pull into your own Pyrtan swarm, checked and indexed from the repository.">+<meta name="description" content="Ready-made Pyrtan trees you can download and run."><meta name="theme-color" content="#c2410c"> <script src="../theme.js"></script> <style>@@ -32,10 +32,10 @@<body> <main> <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span> catalog</h1>- <p class="lede">Trees you can pull into your own swarm. Each is one TOML file, checked by the repository's tests, with the licence its author chose, under the namespace of whoever published it. <code>pyrtan pull NAMESPACE/NAME</code> verifies the file against <a href="index.json">index.json</a> before writing it. Where hubs that opted in have met a value a tree had no branch for, the <a href="../rendezvous/reports.json">gap reports</a> say so beside it.</p>+ <p class="lede">Ready-made trees you can download and run. Each one is a single tested TOML file, under its author's licence. <code>pyrtan pull NAMESPACE/NAME</code> checks the file against <a href="index.json">index.json</a> before saving it. Where hubs have hit a case a tree doesn't cover, its <a href="../rendezvous/reports.json">gap reports</a> are shown beside it.</p><input type="search" id="q" placeholder="filter: weather, github, read-only…" autocomplete="off"> <div id="list"><p class="mute">Loading index.json…</p></div>- <footer>To publish a tree: <code>pyrtan publish my-tree.toml --into <checkout> --owner <your GitHub login></code>, then open a pull request; the gate checks it, a maintainer merges, and it is here on the next build. <a href="../">pyrtan.com</a> · <a href="../source/blob/catalog/README.md.html">how</a> · <a href="../source/">source</a></footer>+ <footer>To publish a tree, run <code>pyrtan publish my-tree.toml --into <checkout> --owner <your GitHub login></code> and open a pull request. Once it passes the checks and is merged, it shows up here. <a href="../">pyrtan.com</a> · <a href="../source/blob/catalog/README.md.html">how</a> · <a href="../source/">source</a></footer></main> <script> const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));@@ -50,7 +50,7 @@ function render() {const reach = (t.hosts||[]).concat((t.sources||[]).filter(s => s !== "http")); const mine = reports.filter(r => String(r.id).split("@")[0] === id); const seen = mine.reduce((n, r) => n + (r.count||1), 0), hubs = Math.max(0, ...mine.map(r => r.reporters||1));- const gaps = mine.length ? `<details><summary class="mute">${seen} gap${seen === 1 ? "" : "s"} reported by ${hubs} hub${hubs === 1 ? "" : "s"}: a value no branch covered</summary>${mine.map(r => `<div><code>${esc(r.node)}</code> ${esc(r.on)} = <code>${esc(JSON.stringify(r.observed))}</code> <span class="mute">×${esc(r.count||1)}${r.id.includes("@") ? " · v" + esc(r.id.split("@")[1]) : ""}</span></div>`).join("")}</details>` : "";+ const gaps = mine.length ? `<details><summary class="mute">${seen} gap${seen === 1 ? "" : "s"} reported by ${hubs} hub${hubs === 1 ? "" : "s"}: cases this tree doesn't cover</summary>${mine.map(r => `<div><code>${esc(r.node)}</code> ${esc(r.on)} = <code>${esc(JSON.stringify(r.observed))}</code> <span class="mute">×${esc(r.count||1)}${r.id.includes("@") ? " · v" + esc(r.id.split("@")[1]) : ""}</span></div>`).join("")}</details>` : "";const d = document.createElement("div"); d.className = "card"; d.innerHTML = `<h2><code>${esc(id)}</code><small>v${esc(t.version)} · ${esc(t.license||"no licence")}${reach.length ? " · " + esc(reach.join(", ")) : ""}</small></h2><div class="mute">${esc(t.description||"")}</div>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}<pre>pyrtan pull ${esc(id)}</pre>${gaps}<div class="mute"><a href="${esc(t.path)}">${esc(t.path)}</a> · sha256 ${esc((t.sha256||"").slice(0, 12))}…</div>`; list.appendChild(d); diff --git a/site/index.html b/site/index.html index ba6af81..a14e89b 100644 --- a/site/index.html +++ b/site/index.html@@ -4,7 +4,7 @@<meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pyrtan</title>-<meta name="description" content="Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees. No model in the loop.">+<meta name="description" content="Open-source automation built on decision trees you can read. It answers like an assistant, with no AI model inside."><meta name="theme-color" content="#c2410c"> <script src="theme.js"></script> <style>@@ -42,13 +42,13 @@<body> <main> <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span></h1>- <p class="lede">Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees. It answers like an assistant. Inside, there is no model.</p>+ <p class="lede">Open-source automation built on decision trees you can read. Ask it something and it answers like an assistant, but there is no AI model inside.</p><div class="actions"> <a class="primary" href="console/">Open the console</a> <a href="catalog/">Browse the catalog</a>- <a href="#directory">The directory</a>- <a href="source/">Browse the source</a>+ <a href="#directory">See the hubs</a>+ <a href="source/">Read the source</a></div> <pre>$ pyrtan run "what's the weather in Oslo" --trees examples@@ -59,14 +59,14 @@ trace: weathersky (decide) [between [51, 67]]: state.code = 61 -> rain reply (done): Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.</pre>- <h2>The trade</h2>- <p>A model generalises to sentences nobody wrote a tree for; Pyrtan does not, and says so. In return every run is deterministic, costs nothing, works offline against recorded responses, cannot reach a host its author did not list, and can always explain itself: the trace names each observation and the condition that chose each branch.</p>+ <h2>Why trees instead of a model</h2>+ <p>An AI model can handle questions nobody planned for. Pyrtan can't, and it tells you so. In return, every answer is repeatable and free, works offline, only reaches the sites you allow, and shows exactly how it was reached.</p><div class="grid">- <div class="card"><h3>Trees</h3><p>One TOML file: the sentences it answers to, the hosts it may touch, what it observes, how it decides. No code, no loops, no model.</p></div>- <div class="card"><h3>Swarm</h3><p>Services hold trees; a hub routes between them and shows each peer's <code>link</code>. One process or many, the callers never change.</p></div>- <div class="card"><h3>Gaps</h3><p>A decision that meets a value no branch covers is recorded, not guessed at. The list of gaps is the list of what to write next.</p></div>- <div class="card"><h3>Catalog</h3><p>Trees others wrote, as static files anyone can mirror. <code>pyrtan pull NAME</code> verifies the file against the index before writing it.</p></div>+ <div class="card"><h3>Trees</h3><p>A tree is one TOML file: the questions it answers, the sites it may reach, and how it decides. No code to write.</p></div>+ <div class="card"><h3>Swarm</h3><p>Services run trees. A hub connects them and shows which ones are up. Run one process or many; nothing else changes.</p></div>+ <div class="card"><h3>Gaps</h3><p>When a tree meets a case it doesn't cover, Pyrtan writes it down instead of guessing. That list tells you what to add next.</p></div>+ <div class="card"><h3>Catalog</h3><p>Trees other people wrote. <code>pyrtan pull NAME</code> downloads one and checks it before saving it.</p></div></div> <h2>Install</h2>@@ -74,22 +74,23 @@ trace: weatherpyrtan search weather pyrtan pull weather --dir trees pyrtan serve trees --port 8701 # then open http://127.0.0.1:8701/</pre>- <p>Python 3.12 or later, no runtime dependencies. Runs on a laptop, a NAS or a container; <a href="https://github.com/b-i-t-8/pyrtan/blob/main/deploy/README.md">deployment notes</a>.</p>+ <p>Needs Python 3.12 or later and nothing else. Runs on a laptop, a NAS or a container. See the <a href="https://github.com/b-i-t-8/pyrtan/blob/main/deploy/README.md">deployment notes</a>.</p><h2>The console</h2>- <p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p>+ <p>Every hub has a web console at <code>/</code>. You can also use it here at <a href="console/">pyrtan.com/console</a>: start your hub with <code>--token</code> and <code>--cors https://pyrtan.com</code>, then connect to it from anywhere. Your browser talks to your hub directly; nothing passes through this site.</p><h2 id="directory">The directory</h2>- <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. pyrtan.com runs no hub of its own: it is static pages. This page asks the hubs on its <a href="rendezvous/hubs.json">hub list</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>- <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the hubs…</td></tr></tbody></table></div>+ <p>Hubs find each other without a central server. Start a hub with <code>--listed</code> and it tells the hubs it is connected to, which pass the word on. Below are the hubs in the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> uses. You can also start a private network of your own; its hubs don't show up here.</p>+ <p>This list comes live from the hubs on the <a href="rendezvous/hubs.json">hub list</a>. If none of them answer, you see the <a href="rendezvous/directory.json">hourly snapshot</a> instead. To use a hub, connect to it from your own.</p>+ <div class="card"><table id="hubs"><thead><tr><th>Status</th><th>Hub</th><th>Trees</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the hubs…</td></tr></tbody></table></div><pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS-pyrtan join --at https://your-hub --token … # be in the default network-pyrtan network new home --at https://your-hub --token … # or start your own: prints its invite-pyrtan directory # who the swarm knows</pre>- <p>To be on the hub list, add your hub's URL to <code>site/rendezvous/hubs.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every hub it can reach.</p>+pyrtan join --at https://your-hub --token … # join the default network+pyrtan network new home --at https://your-hub --token … # or start your own; prints an invite+pyrtan directory # list the hubs you know of</pre>+ <p>To add your hub to the hub list, open a pull request adding its URL to <code>site/rendezvous/hubs.json</code>. It doesn't need to be online all the time.</p><h2>Hosting</h2>- <p>Self-hosting is the product. A hosted option, running the same open-source hub per customer with secrets, retention, scheduled re-runs and alerts around it, is planned; it will speak the same API, so a swarm can move either way without changing a caller.</p>+ <p>Self-hosting comes first. A hosted version is planned: the same open-source hub, run for you, with secrets, scheduled runs and alerts. It will use the same API, so you can move between the two at any time.</p><footer>Pyrtan v__VERSION__. Trees you write are your own.</footer> </main>@@ -113,11 +114,11 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">let source = answered ? `live from ${answered} listed hub${answered > 1 ? "s" : ""}` : ""; if (!answered) { // Nobody on the list answered (or the list is empty): the hourly snapshot.- const why = listed.length ? "no listed hub answered from this browser" : "no hubs listed yet";+ const why = listed.length ? "no listed hub answered" : "no hubs listed yet";try { const snap = await (await fetch("rendezvous/directory.json")).json(); for (const h of snap.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h);- source = `${why}; snapshot as of ${snap.as_of || "?"}`;+ source = `${why}; showing the snapshot from ${snap.as_of || "?"}`;} catch (e) { note.textContent = `${why[0].toUpperCase() + why.slice(1)}, and there is no snapshot yet.`; return; } } const now = Date.now() / 1000;@@ -130,7 +131,7 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`; body.appendChild(tr); }- if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">${esc(source)}: nobody heard of yet. Add your hub to the hub list.</td></tr>`;+ if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">${esc(source)}: no hubs yet. Add yours to the hub list.</td></tr>`;})(); </script> </body> diff --git a/src/pyrtan/catalog.py b/src/pyrtan/catalog.py index e0ff540..75d71b8 100644 --- a/src/pyrtan/catalog.py +++ b/src/pyrtan/catalog.py@@ -16,7 +16,15 @@ on every merge."sources": ["http"], "triggers": [...], "license": "MIT", "path": "pyrtan/weather.toml", "sha256": "...", "size": 1234}]}-``path`` is relative to the index. A tree's ``id`` is ``namespace/name``:+``path`` is relative to the index. Beside it, ``trees.json`` carries every+tree the index lists as JSON (the TOML's own table), for a runtime that+reads JSON and not TOML: the console in a browser, which runs the+catalog's trees itself until it joins a hub::++ {"pyrtan-trees": 1, "generated": "...", "count": 1,+ "trees": [{"id": "pyrtan/weather", "sha256": "...", "tree": {...}}]}++``sha256`` is the TOML file's, as in the index. A tree's ``id`` is ``namespace/name``:the namespace is who published it (the tree's own ``namespace`` line), so two people can each have a ``weather``; ``pyrtan pull weather`` takes the one there is and asks when there are two, ``pyrtan pull barry/weather``@@ -163,6 +171,16 @@ def build_index(root: str | Path | list[str | Path], *, require_license: bool =return index, problems+def build_bundle(index: dict[str, Any], base: str | Path) -> dict[str, Any]:+ """``trees.json`` for an index whose files are under ``base``: each tree's+ table as JSON, in the index's order, with the file's ``sha256``."""+ trees = []+ for entry in index.get("trees", []):+ path = Path(base) / entry["path"]+ trees.append({"id": entry["id"], "sha256": entry["sha256"], "tree": load_tree(path).raw})+ return {"pyrtan-trees": FORMAT, "generated": index.get("generated"), "count": len(trees), "trees": trees}++# ---------------------------------------------------------------- reading diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 34e20c4..957dbbb 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -48,7 +48,7 @@ from pathlib import Pathfrom typing import Any from . import __version__-from .catalog import Catalog, CatalogError, build_index, catalog_url+from .catalog import Catalog, CatalogError, build_bundle, build_index, catalog_urlfrom .federation import bootstrap_candidates from .grow import Proposal, propose from .identity import ABILITIES, KINDS, IdentityError@@ -1269,7 +1269,10 @@ def cmd_index(args: argparse.Namespace) -> int:out = Path(args.out) if args.out else (Path(args.copy) if args.copy else roots[0]) / "index.json" out.parent.mkdir(parents=True, exist_ok=True) out.write_text(text, encoding="utf-8")- print(f"wrote {out}: {index['count']} tree(s)" + (f", copied to {args.copy}" if args.copy else "") + (f", {len(problems)} left out" if problems else ""))+ # The same trees as JSON, for the console in a browser (it reads JSON, not TOML).+ bundle = build_bundle(index, Path(args.copy) if args.copy else roots[0])+ (out.parent / "trees.json").write_text(json.dumps(bundle, ensure_ascii=False, separators=(",", ":"), default=str) + "\n", encoding="utf-8")+ print(f"wrote {out} and trees.json beside it: {index['count']} tree(s)" + (f", copied to {args.copy}" if args.copy else "") + (f", {len(problems)} left out" if problems else ""))return 1 if problems else 0@@ -1307,7 +1310,7 @@ def cmd_console(args: argparse.Namespace) -> int:(out.parent / "manifest.webmanifest").write_text(MANIFEST, encoding="utf-8") (out.parent / "sw.js").write_text(SERVICE_WORKER, encoding="utf-8") (out.parent / "pyrtan.js").write_text(PYRTAN_JS, encoding="utf-8")- print(f"wrote {out}, manifest.webmanifest, sw.js and pyrtan.js beside it; serve the folder from any static host, and point it at a hub with --cors set for that host")+ print(f"wrote {out}, manifest.webmanifest, sw.js and pyrtan.js beside it; serve the folder from any static host: there the browser is a hub of its own, with a catalog's trees.json (../catalog/ beside it, else pyrtan.com's), until it joins a hub started with --cors for that host")return 0 diff --git a/src/pyrtan/js/pyrtan.js b/src/pyrtan/js/pyrtan.js index 13502dd..17683aa 100644 --- a/src/pyrtan/js/pyrtan.js +++ b/src/pyrtan/js/pyrtan.js@@ -12,8 +12,13 @@* subset, not the host's DOM, so a page reads the same everywhere; named * character references beyond the common ones are left as written. * - Regular expressions are the host's; the subset trees use is common.- * - There is no intent matching here: a member runs the tree its hub- * already chose. `delegate` goes to the resolver (the hub).+ * - Sentences are read here as there (nlp.py, intent.py and the proposal+ * from grow.py): a runner reads among the trees it holds and asks its+ * resolver (a member's hub) only when none answers. Python's \d takes+ * any script's digits; here a digit is 0-9 (fold makes full-width ones+ * plain first).+ * - LocalHub makes a page a hub of its own: the paths and answers of a+ * service, in memory, for a browser that has joined no hub.* * No model. No dependencies. Runs in a browser as `Pyrtan` and in node. */@@ -23,6 +28,7 @@})(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict";+ const VERSION = "0.10.0";const MAX_STEPS = 500, MAX_DEPTH = 8, DEFAULT_TIMEOUT = 10.0, DEFAULT_RETRIES = 1; const MISSING = Object.freeze({ toString: () => "<missing>", missing: true }); const isMissing = (v) => v === MISSING;@@ -516,6 +522,584 @@return "ok"; }+ // ------------------------------------------------------------ language (nlp.py, intent.py)+ // How a sentence finds its tree, the same rules as the Python runtime: the+ // same tables, the same rewrites in the same order, the same regular+ // expression built from each trigger, the same verdicts and notes, so a+ // browser reads a sentence the way a hub does.+ const CONTRACTIONS = {+ "what's": "what is", "whats": "what is", "where's": "where is", "who's": "who is", "how's": "how is",+ "when's": "when is", "why's": "why is", "it's": "it is", "that's": "that is", "there's": "there is",+ "here's": "here is", "let's": "let us", "isn't": "is not", "aren't": "are not", "wasn't": "was not",+ "weren't": "were not", "don't": "do not", "doesn't": "does not", "didn't": "did not", "can't": "can not",+ "cannot": "can not", "couldn't": "could not", "won't": "will not", "wouldn't": "would not",+ "shouldn't": "should not", "haven't": "have not", "hasn't": "has not", "hadn't": "had not",+ "i'm": "i am", "i've": "i have", "i'd": "i would", "i'll": "i will", "you're": "you are",+ "you've": "you have", "you'd": "you would", "you'll": "you will", "we're": "we are", "we've": "we have",+ "they're": "they are", "they've": "they have", "gimme": "give me", "lemme": "let me", "wanna": "want to",+ "gonna": "going to", "u": "you", "pls": "please", "plz": "please", "thx": "thanks",+ };+ const LEADING_FILLERS = [+ "hey pyrtan", "hi pyrtan", "hello pyrtan", "ok pyrtan", "okay pyrtan", "pyrtan",+ "hey", "hi", "hello", "yo", "ok", "okay", "so", "um", "uh", "well", "please", "kindly",+ "can you", "could you", "would you", "will you", "can you please", "could you please", "would you please",+ "i want you to", "i need you to", "i would like you to", "i'd like you to",+ "quick question", "question",+ ];+ const TRAILING_FILLERS = ["please", "thanks", "thank you", "for me", "if you can", "if you could", "if possible", "pyrtan"];+ // Python's sorted(..., key=len, reverse=True) is stable: equal lengths keep their order.+ const byLengthDesc = (list) => list.map((f, i) => [f, i]).sort((a, b) => b[0].length - a[0].length || a[1] - b[1]).map((p) => p[0]);+ const LEADING_SORTED = byLengthDesc(LEADING_FILLERS), TRAILING_SORTED = byLengthDesc(TRAILING_FILLERS);+ const UNITS = { zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19 };+ const TENS = { twenty: 20, thirty: 30, forty: 40, fourty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 };+ const SCALES = { hundred: 100, thousand: 1000, million: 1000000, billion: 1000000000 };+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);+ const NOT_PLURALS = new Set(["news", "is", "as", "us", "his", "this", "thus", "yes", "plus", "bus", "gas", "was", "has", "its",+ "status", "bonus", "always", "perhaps", "physics", "series", "species", "means", "analysis", "basis",+ "lens", "chess", "address", "process", "access", "class", "pass", "less", "unless", "across", "ssl",+ "https", "dns", "tls", "os", "ios", "chaos", "canvas", "atlas", "virus", "campus", "census", "focus",+ "iris", "oasis", "crisis", "thesis", "diagnosis", "axis", "texas", "paris", "mars", "venus"]);+ const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];+ const MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];+ const MONTH_ABBR = {}; MONTHS.forEach((m, i) => { MONTH_ABBR[m.slice(0, 3)] = i + 1; }); MONTH_ABBR.sept = 9;+ const DURATION_UNITS = {+ s: 1, sec: 1, secs: 1, second: 1, seconds: 1, m: 60, min: 60, mins: 60, minute: 60, minutes: 60,+ h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600, d: 86400, day: 86400, days: 86400,+ w: 604800, wk: 604800, wks: 604800, week: 604800, weeks: 604800,+ };+ const HOST_RE = /^(?:https?:\/\/)?((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/\S*)?$/i;+ const URL_RE = /^https?:\/\/[^\s<>"']+$/i;+ const EMAIL_RE = /^[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$/;+ const isAlpha = (s) => s.length > 0 && /^\p{L}+$/u.test(s);+ const isUpperChar = (c) => c !== c.toLowerCase() && c === c.toUpperCase();+ const isLowerWord = (w) => w === w.toLowerCase() && w !== w.toUpperCase();+ const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");+ // Python's str() of a float: 2.0, not 2.+ const pyFloat = (n) => (Number.isInteger(n) && Math.abs(n) < 1e16 ? n.toFixed(1) : String(n));+ class ParseError extends Error {}+ class TriggerError extends Error {}++ function fold(text) {+ text = String(text).normalize("NFKC");+ text = text.replace(/’/g, "'").replace(/‘/g, "'").replace(/“/g, '"').replace(/”/g, '"');+ text = text.replace(/[‐-―−]/g, "-");+ return text.replace(/\s+/gu, " ").trim();+ }+ function trim(text) {+ text = text.replace(/^[^\p{L}\p{N}]+|[\s?!.,;:]+$/gu, "");+ text = text.replace(/\s*[,;!?]+\s*/g, " ");+ text = text.replace(/(?<=[A-Za-z])\.(?=\s|$)/g, "");+ return text.replace(/\s+/gu, " ").trim();+ }+ function stripFillers(text, keep) {+ keep = keep || new Set();+ const dropped = [];+ let changed = true;+ while (changed && text) {+ changed = false;+ let low = text.toLowerCase();+ for (const f of LEADING_SORTED) {+ if ((low === f || low.startsWith(f + " ") || low.startsWith(f + ",")) && !keep.has(f.split(" ")[0])) {+ dropped.push(text.slice(0, f.length));+ text = text.slice(f.length).replace(/^[ ,]+/, "");+ changed = true;+ break;+ }+ }+ low = text.toLowerCase();+ for (const f of TRAILING_SORTED) {+ const words = f.split(" ");+ if ((low === f || low.endsWith(" " + f) || low.endsWith("," + f)) && !keep.has(words[words.length - 1])) {+ dropped.push(text.slice(text.length - f.length));+ text = text.slice(0, text.length - f.length).replace(/[ ,]+$/, "");+ changed = true;+ break;+ }+ }+ }+ return [text, dropped];+ }+ function expandContractions(text) {+ const expanded = [];+ const out = text.replace(/[A-Za-z]+(?:'[A-Za-z]+)?/g, (word) => {+ const low = word.toLowerCase().replace(/’/g, "'");+ if (!has(CONTRACTIONS, low)) return word;+ const full = CONTRACTIONS[low];+ expanded.push(`${word}->${full}`);+ return isLowerWord(word) || !isUpperChar(word[0]) ? full : full[0].toUpperCase() + full.slice(1);+ });+ return [out, expanded];+ }+ const isNumberWord = (word) => word.toLowerCase().split("-").every((p) => has(UNITS, p) || has(TENS, p) || has(SCALES, p) || p === "dozen");+ function numberRun(words, start) {+ let i = start;+ const first = words[i].toLowerCase();+ if (first === "a" && i + 1 < words.length && ["dozen", "hundred", "thousand", "million", "billion"].includes(words[i + 1].toLowerCase())) i += 1;+ else if (!isNumberWord(first)) return start;+ let end = i;+ while (i < words.length) {+ const w = words[i].toLowerCase();+ if (isNumberWord(w)) { i += 1; end = i; }+ else if ((w === "and" || w === "a") && i + 1 < words.length && isNumberWord(words[i + 1].toLowerCase())) i += 1;+ else break;+ }+ return end;+ }+ function runValue(run) {+ const words = [];+ for (const tok of run) words.push(...tok.toLowerCase().split("-"));+ let total = 0, current = 0, seen = false;+ for (const w of words) {+ if (w === "and" || w === "a") { if (w === "a") current = 1; continue; }+ if (has(UNITS, w)) { current += UNITS[w]; seen = true; }+ else if (has(TENS, w)) { current += TENS[w]; seen = true; }+ else if (w === "dozen") { current = (current || 1) * 12; seen = true; }+ else if (w === "hundred") { current = (current || 1) * 100; seen = true; }+ else if (has(SCALES, w)) { total += (current || 1) * SCALES[w]; current = 0; seen = true; }+ else return null;+ }+ return seen ? total + current : null;+ }+ function wordsToDigits(text) {+ const words = text.split(" "), out = [], changed = [];+ let i = 0;+ while (i < words.length) {+ const end = numberRun(words, i);+ if (end > i) {+ const run = words.slice(i, end), value = runValue(run);+ if (value !== null) { out.push(String(value)); changed.push(`${run.join(" ")}->${value}`); i = end; continue; }+ }+ out.push(words[i]); i += 1;+ }+ return [out.join(" "), changed];+ }+ function clean(text, opts) {+ opts = opts || {};+ const steps = [];+ const folded = fold(text);+ if (folded !== String(text).trim()) steps.push("fold");+ text = trim(folded);+ if (opts.fillers !== false) {+ const [t, dropped] = stripFillers(text, opts.keep);+ text = t;+ if (dropped.length) { steps.push("fillers: " + dropped.map(pyrepr).join(", ")); text = trim(text); }+ }+ const [t2, expanded] = expandContractions(text);+ text = t2;+ if (expanded.length) steps.push("contractions: " + expanded.join(", "));+ if (opts.numbers !== false) {+ const [t3, changed] = wordsToDigits(text);+ text = t3;+ if (changed.length) steps.push("numbers: " + changed.join(", "));+ }+ return [text.replace(/\s+/gu, " ").trim(), steps];+ }+ function singular(word) {+ const w = word.toLowerCase();+ if (w.length < 4 || NOT_PLURALS.has(w) || w.endsWith("ss") || !isAlpha(w)) return w;+ if (w.endsWith("ies") && w.length > 4) return w.slice(0, -3) + "y";+ if (["ches", "shes", "sses", "xes", "zes"].some((e) => w.endsWith(e))) return w.slice(0, -2);+ if (w.endsWith("s")) return w.slice(0, -1);+ return w;+ }+ function plural(word) {+ const w = word.toLowerCase();+ if (!isAlpha(w) || NOT_PLURALS.has(w)) return w;+ if (w.endsWith("y") && w.length > 2 && !"aeiou".includes(w[w.length - 2])) return w.slice(0, -1) + "ies";+ if (["ch", "sh", "ss", "x", "z", "s"].some((e) => w.endsWith(e))) return w + "es";+ return w + "s";+ }+ function forms(word) {+ const w = word.toLowerCase(), out = new Set([w]);+ if (isAlpha(w) && Array.from(w).length >= 3) { const base = singular(w); out.add(base); out.add(plural(base)); }+ return out;+ }+ function distance(a, b, limit = 2) {+ a = Array.from(a.toLowerCase()); b = Array.from(b.toLowerCase());+ if (Math.abs(a.length - b.length) > limit) return limit + 1;+ let prevPrev = null, prev = Array.from({ length: b.length + 1 }, (_, j) => j);+ for (let i = 1; i <= a.length; i++) {+ const cur = [i].concat(new Array(b.length).fill(0));+ for (let j = 1; j <= b.length; j++) {+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);+ if (prevPrev !== null && i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) cur[j] = Math.min(cur[j], prevPrev[j - 2] + 1);+ }+ if (Math.min(...cur) > limit) return limit + 1;+ prevPrev = prev; prev = cur;+ }+ return prev[prev.length - 1];+ }++ // ---- values: each typed slot's fixed reader+ function parseNumber(text) {+ let [t] = wordsToDigits(text.trim());+ t = t.replace(/,/g, "");+ if (/^-?\d+$/.test(t)) return { value: parseInt(t, 10), py: String(parseInt(t, 10)) };+ if (/^-?\d+\.\d+$/.test(t)) { const v = parseFloat(t); return { value: v, py: pyFloat(v) }; }+ throw new ParseError(`${pyrepr(text)} is not a number`);+ }+ function parseDuration(text) {+ let [t] = wordsToDigits(text.trim().toLowerCase());+ t = t.replace(/\bhalf\s+an?\s+([a-z]+)/g, "0.5 $1");+ t = t.replace(/\b(an?)\s+(?=(?:hour|minute|second|day|week)s?\b)/g, "1 ");+ t = t.replace(/(\d+(?:\.\d+)?)\s*([a-z]+)\s+and\s+a\s+half\b/g, (m, n, u) => `${pyFloat(parseFloat(n) + 0.5)} ${u}`);+ t = t.replace(/\s+and\s+/g, " ");+ let total = 0, found = false, pos = 0;+ for (const m of t.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)/g)) {+ if (t.slice(pos, m.index).trim()) throw new ParseError(`${pyrepr(text)} is not a duration`);+ if (!has(DURATION_UNITS, m[2])) throw new ParseError(`${pyrepr(text)}: unknown unit ${pyrepr(m[2])}`);+ total += parseFloat(m[1]) * DURATION_UNITS[m[2]];+ found = true; pos = m.index + m[0].length;+ }+ if (!found || t.slice(pos).trim()) throw new ParseError(`${pyrepr(text)} is not a duration`);+ const v = Math.trunc(total);+ return { value: v, py: String(v) };+ }+ const pad2 = (n) => String(n).padStart(2, "0");+ function parseTime(text) {+ let [t] = wordsToDigits(text.trim().toLowerCase());+ t = t.replace(/\./g, ":").replace(/ /g, "");+ const done = (s) => ({ value: s, py: s });+ if (t === "noon" || t === "midday") return done("12:00");+ if (t === "midnight") return done("00:00");+ const m = /^(\d{1,2})(?::(\d{2}))?(?:o'?clock)?(am|pm)?$/.exec(t);+ if (!m) throw new ParseError(`${pyrepr(text)} is not a time`);+ let hour = parseInt(m[1], 10);+ const minute = parseInt(m[2] || "0", 10), half = m[3];+ if (half) {+ if (!(hour >= 1 && hour <= 12)) throw new ParseError(`${pyrepr(text)}: ${hour} with ${half}`);+ hour = (hour % 12) + (half === "pm" ? 12 : 0);+ } else if (m[2] === undefined && !m[0].endsWith("clock")) throw new ParseError(`${pyrepr(text)}: a bare number is not a time; say 15:00 or 3pm`);+ if (!(hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59)) throw new ParseError(`${pyrepr(text)} is not a time`);+ return done(`${pad2(hour)}:${pad2(minute)}`);+ }+ // Dates are plain calendar days in UTC, as the hub's clock gives them.+ const isLeap = (y) => y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);+ const monthDays = (y, m) => [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];+ const dayOf = (y, m, d) => { const out = new Date(0); out.setUTCFullYear(y, m - 1, d); return out; };+ const isoDay = (d) => `${String(d.getUTCFullYear()).padStart(4, "0")}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;+ const addDays = (d, n) => new Date(d.getTime() + n * 86400000);+ const weekdayOf = (d) => (d.getUTCDay() + 6) % 7;+ function mkDate(year, month, day, text) {+ const why = !(year >= 1 && year <= 9999) ? `year ${year} is out of range` : !(month >= 1 && month <= 12) ? "month must be in 1..12" : !(day >= 1 && day <= monthDays(year, month)) ? "day is out of range for month" : null;+ if (why) throw new ParseError(text === null ? why : `${pyrepr(text)}: ${why}`);+ return dayOf(year, month, day);+ }+ function shift(day, n, unit) {+ unit = unit.replace(/s+$/, "");+ if (unit === "day") return addDays(day, n);+ if (unit === "week") return addDays(day, 7 * n);+ const months = day.getUTCMonth() + (unit === "month" ? n : 12 * n);+ const year = day.getUTCFullYear() + Math.floor(months / 12), month = (((months % 12) + 12) % 12) + 1;+ return mkDate(year, month, Math.min(day.getUTCDate(), monthDays(year, month)), null);+ }+ function monthOf(word) {+ if (!word || /^\d+$/.test(word)) return null;+ const i = MONTHS.indexOf(word);+ if (i >= 0) return i + 1;+ const three = word.slice(0, 3);+ if (word.length >= 3 && has(MONTH_ABBR, three) && MONTHS[MONTH_ABBR[three] - 1].startsWith(word)) { const k = word.startsWith("sept") ? word.slice(0, 4) : three; return has(MONTH_ABBR, k) ? MONTH_ABBR[k] : null; }+ return null;+ }+ function weekdayIndex(word) {+ const i = WEEKDAYS.indexOf(word);+ if (i >= 0) return i;+ if (word.length >= 3) { const j = WEEKDAYS.findIndex((d) => d.slice(0, 3) === word.slice(0, 3)); if (j >= 0 && WEEKDAYS[j].startsWith(word)) return j; }+ return null;+ }+ function parseDate(text, now) {+ const n = now instanceof Date ? now : new Date();+ const today = dayOf(n.getUTCFullYear(), n.getUTCMonth() + 1, n.getUTCDate());+ const done = (d) => { const s = isoDay(d); return { value: s, py: s }; };+ let [t] = wordsToDigits(text.trim().toLowerCase());+ t = t.replace(/^(on|at|for|the|this coming)\s+/, "").trim();+ t = t.replace(/(\d)(st|nd|rd|th)\b/g, "$1");+ t = t.split(",").join(" ").split(" ").join(" ").trim();+ let m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t);+ if (m) return done(mkDate(+m[1], +m[2], +m[3], text));+ m = /^(\d{1,2})[/.](\d{1,2})(?:[/.](\d{2,4}))?$/.exec(t);+ if (m) {+ const d = +m[1], mo = +m[2];+ if (d <= 12 && mo <= 12 && d !== mo) throw new ParseError(`${pyrepr(text)} could be day/month or month/day; write it as ${today.getUTCFullYear()}-${pad2(mo)}-${pad2(d)} or '${d} ${MONTHS[mo - 1]}'`);+ let year = m[3] ? +m[3] : today.getUTCFullYear();+ if (year < 100) year += 2000;+ return done(mkDate(year, mo, d, text));+ }+ m = /^(\d{1,2})(?:\s+of)?\s+([a-z]+)(?:\s+(\d{4}))?$/.exec(t) || /^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/.exec(t);+ if (m && (monthOf(m[1]) || monthOf(m[2]))) {+ let day, month;+ if (/^\d+$/.test(m[1])) { day = +m[1]; month = monthOf(m[2]); } else { month = monthOf(m[1]); day = +m[2]; }+ if (!month) throw new ParseError(`${pyrepr(text)} is not a date`);+ return done(mkDate(m[3] ? +m[3] : today.getUTCFullYear(), month, day, text));+ }+ if (t === "today" || t === "now") return done(today);+ if (["tomorrow", "tmrw", "tmr"].includes(t)) return done(addDays(today, 1));+ if (t === "yesterday") return done(addDays(today, -1));+ if (t === "day after tomorrow" || t === "the day after tomorrow") return done(addDays(today, 2));+ if (t === "day before yesterday" || t === "the day before yesterday") return done(addDays(today, -2));+ m = /^(?:(this|next|last)\s+)?([a-z]+)$/.exec(t);+ if (m && weekdayIndex(m[2]) !== null) {+ let ahead = (((weekdayIndex(m[2]) - weekdayOf(today)) % 7) + 7) % 7;+ if (m[1] === "next") ahead = ahead || 7;+ else if (m[1] === "last") ahead = ahead ? ahead - 7 : -7;+ return done(addDays(today, ahead));+ }+ m = /^in\s+(\d+)\s+(day|days|week|weeks|month|months|year|years)$/.exec(t);+ if (m) return done(shift(today, +m[1], m[2]));+ m = /^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/.exec(t);+ if (m) return done(shift(today, -m[1], m[2]));+ m = /^(next|last)\s+(week|month|year)$/.exec(t);+ if (m) return done(shift(today, m[1] === "next" ? 1 : -1, m[2]));+ m = /^end of (?:the |this )?(month|year)$/.exec(t);+ if (m) {+ if (m[1] === "year") return done(dayOf(today.getUTCFullYear(), 12, 31));+ return done(addDays(shift(dayOf(today.getUTCFullYear(), today.getUTCMonth() + 1, 1), 1, "month"), -1));+ }+ if (["start of month", "start of the month", "beginning of month", "beginning of the month"].includes(t)) return done(dayOf(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));+ throw new ParseError(`${pyrepr(text)} is not a date this reads`);+ }+ function parseHost(text) {+ const m = HOST_RE.exec(text.trim());+ if (!m) throw new ParseError(`${pyrepr(text)} is not a host name`);+ const v = m[1].toLowerCase();+ return { value: v, py: v };+ }+ function parseUrl(text) {+ const t = text.trim();+ if (!URL_RE.test(t)) throw new ParseError(`${pyrepr(text)} is not a URL`);+ return { value: t, py: t };+ }+ function parseEmail(text) {+ const t = text.trim();+ if (!EMAIL_RE.test(t)) throw new ParseError(`${pyrepr(text)} is not an email address`);+ return { value: t.toLowerCase(), py: t.toLowerCase() };+ }+ const PARSERS = { number: parseNumber, duration: parseDuration, time: parseTime, date: parseDate, host: parseHost, url: parseUrl, email: parseEmail };+ const nlp = { fold, trim, stripFillers, expandContractions, wordsToDigits, clean, singular, plural, forms, distance, parseNumber, parseDuration, parseTime, parseDate, parseHost, parseUrl, parseEmail, ParseError };++ // ---- triggers+ const KINDS = ["int", "float", "word", "text", "number", "date", "time", "duration", "host", "url", "email"];+ const SLOT_RE = { int: "-?\\d+", float: "-?\\d+(?:\\.\\d+)?", number: "-?\\d+(?:[.,]\\d+)*", word: "\\S+", text: ".+?", date: ".+?", time: "\\S+(?:\\s*[ap]m)?", duration: ".+?", host: "\\S+", url: "\\S+", email: "\\S+", "": ".+?" };+ const TYPO_MIN_LENGTH = 5;+ function normalise(text) {+ text = String(text).trim();+ text = text.replace(/^[^\p{L}\p{N}]+|[\s?!.,;:]+$/gu, "");+ return text.replace(/\s+/gu, " ");+ }+ function language(raw, where = "language") {+ if (raw === null || raw === undefined) return { synonyms: {}, typos: false };+ if (!isObj(raw)) throw new TriggerError(`${where} must be a table`);+ const syn = raw.synonyms === undefined ? {} : raw.synonyms;+ if (!isObj(syn)) throw new TriggerError(`${where}.synonyms must be a table of word = [words]`);+ const synonyms = {};+ for (const [word, a] of Object.entries(syn)) {+ const alts = typeof a === "string" ? [a] : a;+ if (!Array.isArray(alts) || !alts.every((x) => typeof x === "string" && x.trim())) throw new TriggerError(`${where}.synonyms.${word} must be a list of words`);+ if (!/^[A-Za-z][A-Za-z'-]*$/.test(word)) throw new TriggerError(`${where}.synonyms: ${pyrepr(word)} is not a word`);+ synonyms[word.toLowerCase()] = alts.map((x) => x.trim().toLowerCase());+ }+ const typos = raw.typos === undefined ? false : raw.typos;+ if (!isBool(typos)) throw new TriggerError(`${where}.typos must be true or false`);+ for (const key of Object.keys(raw)) if (key !== "synonyms" && key !== "typos") throw new TriggerError(`${where}: unknown key ${pyrepr(key)}; one of synonyms, typos`);+ return { synonyms, typos };+ }+ const languageDict = (lang) => { const out = {}; if (Object.keys(lang.synonyms).length) out.synonyms = lang.synonyms; if (lang.typos) out.typos = true; return out; };+ function wordRegex(word, lang) {+ const spellings = forms(word), phrases = [];+ for (const syn of lang.synonyms[word.toLowerCase()] || []) {+ if (syn.includes(" ")) phrases.push(syn.split(/\s+/).map(reEscape).join("\\s+"));+ else for (const f of forms(syn)) spellings.add(f);+ }+ const sorted = [...spellings].sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0));+ return "(?:" + sorted.map(reEscape).concat(phrases).join("|") + ")";+ }+ function compilePattern(pattern, lang) {+ lang = lang || { synonyms: {}, typos: false };+ if (!pattern || !pattern.trim()) throw new TriggerError("a trigger pattern cannot be empty");+ const slots = {}, out = [], vocabulary = new Set(), ordered = [];+ let literal = 0, pos = 0, depth = 0, word = [];+ const text = Array.from(pattern.trim().toLowerCase());+ const flush = () => {+ if (!word.length) return;+ const w = word.join("");+ out.push(wordRegex(w, lang)); vocabulary.add(w); ordered.push(w);+ for (const syn of lang.synonyms[w] || []) for (const s of syn.split(/\s+/)) if (s) vocabulary.add(s);+ word = [];+ };+ while (pos < text.length) {+ const ch = text[pos];+ if (isAlpha(ch) || (word.length && "'-".includes(ch) && pos + 1 < text.length && isAlpha(text[pos + 1]))) { word.push(ch); literal++; pos++; continue; }+ flush();+ if (ch === "{") {+ const m = /^\{([A-Za-z_][A-Za-z0-9_]*)(?::([a-z]+))?\}/.exec(text.slice(pos).join(""));+ if (!m) throw new TriggerError(`bad slot in trigger ${pyrepr(pattern)} at ${pos}`);+ const name = m[1], kind = m[2] === undefined ? null : m[2];+ if (kind !== null && !KINDS.includes(kind)) throw new TriggerError(`slot {${name}:${kind}} in ${pyrepr(pattern)}: kind must be one of ${KINDS.join(", ")}`);+ if (has(slots, name)) throw new TriggerError(`slot ${pyrepr(name)} appears twice in ${pyrepr(pattern)}`);+ slots[name] = kind;+ out.push(`(?<${name}>${SLOT_RE[kind === null ? "" : kind]})`);+ pos += m[0].length;+ } else if (ch === "[") { depth++; out.push("(?:\\s*"); pos++; }+ else if (ch === "]") { if (depth === 0) throw new TriggerError(`unbalanced ] in trigger ${pyrepr(pattern)}`); depth--; out.push(")?"); pos++; }+ else if (ch === "(") { depth++; out.push("(?:"); pos++; }+ else if (ch === ")") { if (depth === 0) throw new TriggerError(`unbalanced ) in trigger ${pyrepr(pattern)}`); depth--; out.push(")"); pos++; }+ else if (ch === "|" && depth > 0) { out.push("|"); pos++; }+ else if (/^\s$/u.test(ch)) {+ while (pos < text.length && /^\s$/u.test(text[pos])) pos++;+ // Space is optional around optional groups and slots, so "[the] weather" matches "weather".+ if (out.length && ["(?:\\s*", ")?", "(?:", ")", "|"].includes(out[out.length - 1])) out.push("\\s*");+ else if (pos < text.length && "[(|".includes(text[pos])) out.push("\\s*");+ else out.push("\\s+");+ } else { out.push(reEscape(ch)); literal++; pos++; }+ }+ flush();+ if (depth !== 0) throw new TriggerError(`unbalanced [ in trigger ${pyrepr(pattern)}`);+ if (literal === 0) throw new TriggerError(`trigger ${pyrepr(pattern)} has no literal words; it would match anything`);+ let regex;+ try { regex = new RegExp("^(?:" + out.join("") + ")$", "iu"); } catch (e) { throw new TriggerError(`trigger ${pyrepr(pattern)} does not compile: ${e.message}`); }+ return { regex, slots, specificity: literal, vocabulary, edges: ordered.length ? [ordered[0], ordered[ordered.length - 1]] : ["", ""] };+ }+ function correctTypos(sentence, vocabulary) {+ if (!vocabulary.size) return [sentence, []];+ const fixed = [];+ const out = sentence.replace(/[A-Za-z]+/g, (word) => {+ const low = word.toLowerCase();+ if (low.length < TYPO_MIN_LENGTH || vocabulary.has(low) || [...vocabulary].some((v) => forms(v).has(low))) return word;+ const close = [...vocabulary].filter((v) => v.length >= TYPO_MIN_LENGTH && distance(low, v, 1) <= 1);+ if (close.length !== 1) return word;+ fixed.push([word, close[0]]);+ return close[0];+ });+ return [out, fixed];+ }+ function readSlot(kind, raw, now) {+ if (kind === "int") return { value: parseInt(raw, 10), py: String(parseInt(raw, 10)) };+ if (kind === "float") { const v = parseFloat(raw); return { value: v, py: pyFloat(v) }; }+ if (kind === null || kind === "word" || kind === "text") return { value: raw, py: raw };+ return kind === "date" ? PARSERS.date(raw, now) : PARSERS[kind](raw);+ }+ class Trigger {+ constructor(pattern, fixedInputs, lang) {+ this.pattern = pattern;+ this.fixedInputs = fixedInputs || {};+ this.language = lang && lang.synonyms ? lang : language(lang);+ // The pattern gets the rewrites a sentence gets, minus fillers and number words.+ this.cleaned = fold(pattern).split(/(\{[^}]*\})/).map((part) => (part.startsWith("{") ? part : expandContractions(part)[0])).join("");+ Object.assign(this, compilePattern(this.cleaned, this.language));+ }+ match(text, now) { const m = this.matchDetail(text, now); return m ? m.inputs : null; }+ matchDetail(text, now, cleaned = false) {+ let sentence = cleaned ? text : clean(text)[0];+ const notes = [];+ if (this.language.typos) { const [s, fixed] = correctTypos(sentence, this.vocabulary); sentence = s; for (const [a, b] of fixed) notes.push(`typo: ${a}->${b}`); }+ const m = this.regex.exec(sentence);+ if (!m) return null;+ const inputs = Object.assign({}, this.fixedInputs);+ for (const [name, kind] of Object.entries(this.slots)) {+ let raw = m.groups[name];+ if (raw === undefined) continue;+ raw = raw.trim();+ let got;+ try { got = readSlot(kind, raw, now); }+ catch (e) {+ if (!(e instanceof ParseError)) throw e;+ notes.push(`slot ${name}: ${e.message}`);+ return { tree: "", trigger: this, inputs: {}, specificity: this.specificity, notes, rejected: true };+ }+ inputs[name] = got.value;+ if (has(PARSERS, kind) && got.py !== raw) notes.push(`${name}: ${pyrepr(raw)} read as ${typeof got.value === "string" ? pyrepr(got.value) : got.py}`);+ }+ return { tree: "", trigger: this, inputs, specificity: this.specificity, notes, rejected: false };+ }+ }+ const matchDict = (m) => ({ tree: m.tree, trigger: m.trigger.pattern, specificity: m.specificity, inputs: m.inputs, notes: m.notes, rejected: m.rejected });+ function isoNow(d) {+ if (!(d instanceof Date)) return null;+ const ms = d.getUTCMilliseconds();+ return d.toISOString().replace(/\.\d{3}Z$/, (ms ? "." + String(ms).padStart(3, "0") + "000" : "") + "+00:00");+ }+ class Reading {+ constructor(text, cleaned, steps, matches, considered, now) { Object.assign(this, { text, cleaned, steps, matches, considered, now }); }+ get winner() { return this.matches.length === 1 ? this.matches[0] : null; }+ toDict() { return { text: this.text, cleaned: this.cleaned, steps: this.steps, now: this.now, matches: this.matches.map(matchDict), considered: this.considered.map(matchDict) }; }+ step() {+ const rewrite = pyrepr(this.text) + (this.cleaned !== this.text ? ` -> ${pyrepr(this.cleaned)}` : "");+ let summary;+ const w = this.winner;+ if (w) { summary = `${rewrite}; ${w.tree} via ${pyrepr(w.trigger.pattern)} (${w.specificity} literal chars)`; if (w.notes.length) summary += "; " + w.notes.join("; "); }+ else if (this.matches.length) summary = `${rewrite}; tie between ${pyrepr([...new Set(this.matches.map((m) => m.tree))].sort())}`;+ else summary = `${rewrite}; no trigger matched`;+ const observed = { cleaned: this.cleaned, steps: this.steps };+ if (w) { observed.trigger = w.trigger.pattern; observed.inputs = w.inputs; if (w.notes.length) observed.notes = w.notes; }+ return { node: "intent", kind: "intent", summary, observed };+ }+ }+ // candidates: [[treeName, Trigger], ...] in the order a registry lists them (trees by name).+ function read(text, candidates, now) {+ const keep = new Set();+ for (const [, trig] of candidates) for (const w of trig.edges) if (w) keep.add(w);+ const [cleaned, steps] = clean(text, { keep });+ const considered = [];+ for (const [tree, trig] of candidates) { const got = trig.matchDetail(cleaned, now, true); if (got) { got.tree = tree; considered.push(got); } }+ const live = considered.filter((m) => !m.rejected);+ const top = live.reduce((t, m) => Math.max(t, m.specificity), 0);+ return new Reading(text, cleaned, steps, live.filter((m) => m.specificity === top), considered, isoNow(now));+ }++ // ---- the tree that would answer a sentence nobody answered (grow.py)+ const PROPOSE_FILLER = new Set(["a", "an", "the", "what", "what's", "whats", "how", "please", "me", "my", "tell", "show", "give", "is", "are", "of", "to", "for", "in", "on", "at", "do", "does", "it", "and", "with"]);+ const NAME_MAX = 40;+ function proposalSlot(tok, inputs) {+ const bare = tok.replace(/^["'(),;:!?]+|["'(),;:!?]+$/g, "");+ const unique = (base) => { if (!has(inputs, base)) return base; let n = 2; while (has(inputs, base + n)) n++; return base + n; };+ if (/^-?\d+$/.test(bare)) { const s = unique("count"); inputs[s] = { type: "int", description: `as in: ${bare}` }; return `{${s}:int}`; }+ if (/^-?\d+\.\d+$/.test(bare)) { const s = unique("value"); inputs[s] = { type: "float", description: `as in: ${bare}` }; return `{${s}:float}`; }+ if (/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$/i.test(bare) && !bare.endsWith("...") && !bare.endsWith(".")) { const s = unique("host"); inputs[s] = { type: "str", description: `a host name, as in: ${bare}`, role: "host" }; return `{${s}:host}`; }+ if (/^[\p{L}\p{N}_.\-]+\/[\p{L}\p{N}_.\-/]+$/u.test(bare)) { const s = unique("target"); inputs[s] = { type: "str", description: `as in: ${bare}` }; return `{${s}:word}`; }+ return null;+ }+ const proposalLiteral = (tok) => tok.replace(/[{}[\]()|]/g, "").trim();+ function shellQuote(s) {+ if (/^[\p{L}\p{N}_./:@%+=,-]+$/u.test(s)) return s;+ if (!/["$`\\]/.test(s)) return `"${s}"`;+ return "'" + s.replace(/'/g, "'\"'\"'") + "'";+ }+ function proposalName(literalWords, tokens, slots) {+ const slug = (w) => w.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");+ let words = literalWords.filter((w) => !PROPOSE_FILLER.has(w.toLowerCase())).map(slug).filter(Boolean);+ if (words.length < 2 && slots.length) words = slots.map((s) => s.replace(/\d+$/, "")).concat(words);+ if (!words.length) words = tokens.map(slug).filter(Boolean);+ let name = words.join("-").slice(0, NAME_MAX).replace(/^-+|-+$/g, "");+ name = name.replace(/-{2,}/g, "-");+ if (!name || !/^[a-z0-9]/.test(name)) name = "new-tree";+ return name;+ }+ function propose(text) {+ const cleanText = normalise(text) || String(text).trim() || "new tree";+ const tokens = cleanText.split(" ");+ const pieces = [], literalWords = [];+ let inputs = {};+ for (const tok of tokens) {+ const slot = proposalSlot(tok, inputs);+ if (slot !== null) pieces.push(slot); else { pieces.push(proposalLiteral(tok)); literalWords.push(tok); }+ }+ let trigger = pieces.filter(Boolean).join(" "), words = literalWords;+ try { new Trigger(trigger); }+ catch (e) {+ if (!(e instanceof TriggerError)) throw e;+ trigger = tokens.map(proposalLiteral).filter(Boolean).join(" ") || "new tree";+ inputs = {}; words = tokens;+ }+ const name = proposalName(words, tokens, Object.keys(inputs));+ return { text: cleanText, name, trigger, inputs, scaffold: ["pyrtan", "new", name, "--from", shellQuote(cleanText)].join(" ") };+ }+ function noMatchOutcome(text, where) {+ const p = propose(text);+ return { say: `${where} answers ${pyrepr(text)}. To write the tree that would: ${p.scaffold}`, output: { unanswered: text, proposal: p } };+ }+ const intent = { Trigger, TriggerError, read, normalise, language, compilePattern, correctTypos, propose, noMatchOutcome, KINDS };+// ------------------------------------------------------------ trees const INPUT_TYPES = ["str", "int", "float", "bool", "list", "dict"]; function inferType(v) { return isBool(v) ? "bool" : isNum(v) ? (Number.isInteger(v) ? "int" : "float") : Array.isArray(v) ? "list" : isObj(v) ? "dict" : "str"; }@@ -542,8 +1126,13 @@this.name = raw.name; this.version = raw.version || 1; this.description = raw.description || ""; this.hosts = raw.hosts || []; this.start = raw.start; this.tags = raw.tags || []; this.license = raw.license || ""; this.namespace = raw.namespace || ""; for (const k of LIST_NAMES) this[k] = raw[k] || [];- this.language = raw.language || {};- this.triggers = (raw.triggers || []).map((t) => (typeof t === "string" ? { pattern: t } : t));+ try { this.language = language(raw.language); } catch (e) { if (e instanceof TriggerError) throw new TreeError(`${this.name}: ${e.message}`); throw e; }+ this.triggers = (raw.triggers || []).map((t, i) => {+ if (typeof t === "string") t = { pattern: t };+ if (!isObj(t) || !("pattern" in t)) throw new TreeError(`${this.name}: triggers[${i}] needs a 'pattern'`);+ try { return new Trigger(t.pattern, Object.assign({}, t.inputs || {}), this.language); }+ catch (e) { if (e instanceof TriggerError) throw new TreeError(`${this.name}: triggers[${i}]: ${e.message}`); throw e; }+ });this.inputs = {}; for (const [name, s] of Object.entries(raw.inputs || {})) { const spec = isObj(s) ? s : { default: s };@@ -603,7 +1192,7 @@for (const [n, s] of Object.entries(this.inputs)) { const d = {}; if (s.type) d.type = s.type;- if (s.default !== null && s.default !== undefined && s.default !== "" && s.default !== false) d.default = s.default;+ if (s.default !== null && s.default !== undefined && s.default !== "" && s.default !== false && s.default !== 0) d.default = s.default; // Python drops what == Falseif (s.required && (s.default === null || s.default === undefined)) d.required = true; if (s.description) d.description = s.description; inputs[n] = d;@@ -615,8 +1204,8 @@cap.sources = this.sources(); const watches = Array.isArray(this.raw.watches) ? this.raw.watches : []; if (watches.length) cap.watches = watches.map((w) => { const kind = ["every", "file", "queue", "serial"].find((k) => k in w) || "?"; return { kind, what: kind === "every" ? String(w.every) : kind === "file" ? String(w.file) : kind === "queue" ? `${w.queue && w.queue.topic} at ${w.queue && w.queue.url}` : String(w.serial && w.serial.device) }; });- const fixed = this.triggers.filter((t) => t.inputs && Object.keys(t.inputs).length);- return Object.assign(cap, { license: this.license, language: this.language, triggers: this.triggers.map((t) => t.pattern) }, fixed.length ? { trigger_inputs: Object.fromEntries(fixed.map((t) => [t.pattern, t.inputs])) } : {}, { inputs });+ const fixed = this.triggers.filter((t) => Object.keys(t.fixedInputs).length);+ return Object.assign(cap, { license: this.license, language: languageDict(this.language), triggers: this.triggers.map((t) => t.pattern) }, fixed.length ? { trigger_inputs: Object.fromEntries(fixed.map((t) => [t.pattern, t.fixedInputs])) } : {}, { inputs });} }@@ -638,7 +1227,9 @@const h = {}; r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; }); return new Response(r.status, h, text, r.url || url, Date.now() - started); } catch (e) {- throw new TransportError(`${method} ${url}: ${e && e.message ? e.message : e}`);+ // A browser hides why: most often the site does not let pages on other origins read it (CORS).+ const why = typeof window !== "undefined" && e instanceof TypeError ? " (the site may not let a page in a browser read it; a hub can)" : "";+ throw new TransportError(`${method} ${url}: ${e && e.message ? e.message : e}${why}`);} finally { if (timer) clearTimeout(timer); } } }@@ -731,9 +1322,42 @@} return this.run(tree, inputs || {}, { depth }); }+ candidates() {+ const out = [];+ for (const name of Object.keys(this.trees).sort()) for (const trig of this.trees[name].triggers) out.push([name, trig]);+ return out;+ }+ read(text) { return read(text, this.candidates(), this.clock()); }+ // Read a sentence and run the tree it names; the trace begins with the intent step.async runIntent(text, depth = 0) {- if (this.resolver) return fromDict(await this.resolver.runIntent(text, depth), "");- return outcome("error", "", { reason: "no-match", say: `Nothing here answers ${pyrepr(text)}.` });+ const reading = this.read(text);+ if (!reading.matches.length) {+ if (this.resolver) return fromDict(await this.resolver.runIntent(text, depth), "");+ const nm = noMatchOutcome(text, "Nothing here");+ nm.output.reading = { cleaned: reading.cleaned, steps: reading.steps, considered: reading.considered.map((m) => m.trigger.pattern) };+ return outcome("error", "", Object.assign({ reason: "no-match" }, nm));+ }+ const trees = [...new Set(reading.matches.map((m) => m.tree))].sort();+ if (trees.length > 1) return outcome("error", "", { reason: "ambiguous", say: `${pyrepr(text)} matches more than one tree equally well: ${trees.join(", ")}`, output: { candidates: trees, reading: { cleaned: reading.cleaned, steps: reading.steps } } });+ const match = reading.matches[0], tree = this.get(match.tree);+ const trace = new Trace(tree.name), st = reading.step();+ trace.add(step(st.node, st.kind, st.summary, { observed: st.observed, next: tree.start, depth }));+ return this.run(tree, match.inputs, { depth, trace });+ }+ // Continue a run paused by ask: the answer goes where the ask node said.+ async resume(snapshot, answer) {+ const tree = this.get(snapshot.tree);+ if (!tree) return outcome("error", snapshot.tree || "", { reason: "no-such-tree" });+ const node = tree.node(snapshot.node), into = String(node.get("into"));+ const inputs = Object.assign({}, snapshot.inputs), state = Object.assign({}, snapshot.state);+ if (into.startsWith("inputs.")) {+ const key = into.slice(7);+ try { inputs[key] = coerce(tree.inputs[key], answer); } catch (e) { if (e instanceof TreeError) return outcome("error", tree.name, { reason: "bad-input", say: e.message }); throw e; }+ } else put(state, into.startsWith("state.") ? into.slice(6) : into, answer);+ const trace = new Trace(tree.name);+ trace.steps = (snapshot.trace || []).map((x) => step(x.node, x.kind, x.summary, { observed: x.observed === undefined ? null : x.observed, chosen: x.chosen === undefined ? null : x.chosen, next: x.next === undefined ? null : x.next, elapsed_ms: x.elapsed_ms || 0, depth: x.depth || 0 }));+ trace.add(step(node.id, "ask", `answered: ${brief(answer)}`, { next: node.next }));+ return this.run(tree, inputs, { start: node.next, state, trace });} async run(tree, inputs, opts) { opts = opts || {};@@ -805,7 +1429,7 @@call = async () => (await this.transport.request(method, url, headers, body === undefined ? null : body, timeout)).toState(); } else { label = `${sourceName} ${what(sourceName, spec)}`;- if (!this.adapters) return outcome("error", tree.name, { reason: "source-unsupported", say: `${node.id}: a ${sourceName} node runs on a service, not on a member; this device has no ${sourceName === "browser" ? "browser of its own to drive" : sourceName === "queue" ? "socket to a broker" : "access to the box's " + (sourceName === "file" ? "files" : sourceName === "shell" ? "programs" : "devices")}.` });+ if (!this.adapters) return outcome("error", tree.name, { reason: "source-unsupported", say: `${node.id}: a ${sourceName} node runs on a service, not here; this device has no ${sourceName === "browser" ? "browser of its own to drive" : sourceName === "queue" ? "socket to a broker" : "access to the box's " + (sourceName === "file" ? "files" : sourceName === "shell" ? "programs" : "devices")}.` });call = () => this.adapters.perform(sourceName, spec, timeout, (u) => tree.hostAllowed(u)); } const attempts = retry ? 1 + Number(node.get("retries", DEFAULT_RETRIES)) : 1;@@ -912,5 +1536,99 @@return outcome(d.status || "error", d.tree || fallbackTree, { say: d.say === undefined ? null : d.say, output: d.output === undefined ? null : d.output, reason: d.reason === undefined ? null : d.reason, question: d.question === undefined ? null : d.question, snapshot: d.snapshot === undefined ? null : d.snapshot, trace, inputs: d.inputs || {} }); }- return { MISSING, Tree, Runner, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: "0.10.0" };+ // ------------------------------------------------------------ the hub in a page+ // A browser that has joined no hub is a hub of its own, for as long as the+ // page is open: it holds trees (the catalog's, as a page loads them), reads+ // sentences, runs the trees and keeps the gaps and the unanswered sentences+ // in memory. It answers the same paths with the same shapes as a service+ // (service.py), so the console talks to it exactly as it talks to a hub.+ // What it cannot do is reach what only a box can: files, programs, serial+ // lines, brokers, a browser to drive, and sites that do not let a page read+ // them. Those trees answer with the reason, and joining a hub is the cure.+ const UNANSWERED_LIMIT = 200;+ class LocalHub {+ constructor(opts) {+ opts = opts || {};+ this.name = opts.name || "this browser";+ this.version = opts.version || VERSION;+ this.clock = opts.clock || (() => new Date());+ this.wall = opts.wall || (() => Date.now() / 1000);+ this.runner = new Runner({ transport: opts.transport || new FetchTransport(), adapters: opts.adapters || null, clock: this.clock, env: opts.env });+ this.errors = {}; this.held = {};+ this.gaps = new Map(); this.unanswered = new Map();+ this.runs = 0; this.started = this.wall();+ for (const raw of opts.trees || []) this.add(raw);+ }+ // where: what to call the tree in errors (a catalog id). The first tree of a name is the one held.+ add(raw, where) {+ const name = raw && raw.name;+ if (name && this.runner.get(name) && this.held[name] !== (where || name)) { this.errors[where || name] = `a tree named ${pyrepr(name)} is already held, from ${this.held[name]}`; return null; }+ try { const t = this.runner.add(raw); this.held[t.name] = where || t.name; delete this.errors[where || t.name]; return t; }+ catch (e) { if (!(e instanceof TreeError)) throw e; this.errors[where || (raw && raw.name) || "?"] = e.message; return null; }+ }+ trees() { return Object.keys(this.runner.trees).sort().map((n) => this.runner.trees[n]); }+ health() {+ return { status: "ok", name: this.name, trees: this.trees().map((t) => t.name), errors: this.errors, runs: this.runs, gaps: this.gaps.size, unanswered: this.unanswered.size, uptime_s: Math.floor(this.wall() - this.started), watches: 0 };+ }+ capabilities() { return { name: this.name, trees: this.trees().map((t) => t.capability()) }; }+ noteGaps(gaps) {+ const now = this.wall();+ for (const g of gaps) {+ const key = JSON.stringify([g.tree, g.node, g.on, g.observed === undefined ? null : g.observed]);+ const cur = this.gaps.get(key);+ if (cur) { cur.count += 1; cur.last_seen = Math.max(cur.last_seen, now); }+ else this.gaps.set(key, { tree: g.tree, node: g.node, on: g.on, observed: g.observed === undefined ? null : g.observed, count: 1, first_seen: now, last_seen: now });+ }+ }+ noteUnanswered(text) {+ const now = this.wall(), key = normalise(text).toLowerCase() || text;+ let e = this.unanswered.get(key);+ if (!e) { e = { text: normalise(text) || text, count: 0, first: now, last: now, proposal: propose(text) }; this.unanswered.set(key, e); }+ e.count += 1; e.last = Math.max(e.last, now);+ while (this.unanswered.size > UNANSWERED_LIMIT) {+ let oldest = null;+ for (const [k, v] of this.unanswered) if (oldest === null || v.last < this.unanswered.get(oldest).last) oldest = k;+ this.unanswered.delete(oldest);+ }+ }+ finish(o, body) {+ this.runs += 1;+ if (o.trace && o.trace.gaps && o.trace.gaps.length) this.noteGaps(o.trace.gaps);+ return outcomeDict(o, body.trace === undefined ? true : Boolean(body.trace));+ }+ async intent(body) {+ const text = body.text;+ if (typeof text !== "string" || !text.trim()) return { status: "error", reason: "bad-request", say: 'Send {"text": "..."}.' };+ const o = await this.runner.runIntent(text, Math.trunc(Number(body.depth || 0)));+ if (o.reason === "no-match") this.noteUnanswered(text);+ return this.finish(o, body);+ }+ async run(body) {+ if (typeof body.tree !== "string") return { status: "error", reason: "bad-request", say: 'Send {"tree": "...", "inputs": {...}}.' };+ return this.finish(await this.runner.runTree(body.tree, body.inputs || {}, Math.trunc(Number(body.depth || 0))), body);+ }+ async resume(body) {+ const snap = body.snapshot;+ if (!isObj(snap) || !("tree" in snap)) return { status: "error", reason: "bad-request", say: 'Send {"snapshot": {...}, "answer": ...}.' };+ return this.finish(await this.runner.resume(snap, body.answer === undefined ? null : body.answer), body);+ }+ // (method, path, body) -> [status, payload], as service.py's route.+ async route(method, path, body) {+ method = String(method || "GET").toUpperCase();+ path = "/" + String(path || "").split("?")[0].replace(/^\/+/, "");+ body = isObj(body) ? body : {};+ if (method === "GET" && path === "/api") return [200, { pyrtan: "browser", name: this.name, version: this.version, auth: "none", endpoints: ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches"] }];+ if (method === "GET" && path === "/health") return [200, this.health()];+ if (method === "GET" && path === "/capabilities") return [200, this.capabilities()];+ if (method === "GET" && path === "/gaps") return [200, { gaps: [...this.gaps.values()].map((g) => Object.assign({}, g)).sort((a, b) => b.count - a.count || a.last_seen - b.last_seen) }];+ if (method === "GET" && path === "/unanswered") return [200, { unanswered: [...this.unanswered.values()].map((e) => Object.assign({}, e)).sort((a, b) => b.count - a.count || b.last - a.last) }];+ if (method === "GET" && path === "/watches") return [200, { count: 0, watches: [], watching: false }];+ if (method === "POST" && path === "/intent") return [200, await this.intent(body)];+ if (method === "POST" && path === "/run") return [200, await this.run(body)];+ if (method === "POST" && path === "/resume") return [200, await this.resume(body)];+ return [404, { status: "error", reason: "not-found", say: `No ${method} ${path} here.` }];+ }+ }++ return { MISSING, Tree, Runner, LocalHub, Reading, nlp, intent, read, propose, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: VERSION };}); diff --git a/src/pyrtan/nlp.py b/src/pyrtan/nlp.py index 9e3844a..34e33f6 100644 --- a/src/pyrtan/nlp.py +++ b/src/pyrtan/nlp.py@@ -490,10 +490,21 @@ def _weekday(word: str) -> int | None:def _date(year: int, month: int, day: int, text: str) -> _dt.date:- try:+ # Checked here rather than left to datetime, whose wording changes+ # between Python versions; pyrtan.js says the same words.+ if not 1 <= year <= 9999:+ why = f"year {year} is out of range"+ elif not 1 <= month <= 12:+ why = "month must be in 1..12"+ elif not 1 <= day <= _days_in(year, month):+ why = "day is out of range for month"+ else:return _dt.date(year, month, day)- except ValueError as e:- raise ParseError(f"{text!r}: {e}") from e+ raise ParseError(f"{text!r}: {why}")+++def _days_in(year: int, month: int) -> int:+ return [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]def _shift(day: _dt.date, n: int, unit: str) -> _dt.date:@@ -504,8 +515,7 @@ def _shift(day: _dt.date, n: int, unit: str) -> _dt.date:return day + _dt.timedelta(weeks=n) months = day.month - 1 + (n if unit == "month" else 12 * n) year, month = day.year + months // 12, months % 12 + 1- last = [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]- return _dt.date(year, month, min(day.day, last))+ return _dt.date(year, month, min(day.day, _days_in(year, month)))def parse_host(text: str) -> str: diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 8c277c9..492ef28 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -1,5 +1,6 @@"""The console: one HTML file a service or hub serves at ``/``, and the same-file hosted anywhere else (``pyrtan console --out``).+file hosted anywhere else (``pyrtan console --out``), where it is a hub of+its own until it joins one.It asks a sentence and shows the answer, the output and the trace; lists what the swarm can answer; shows the hub's peers and their ``link``; lists@@ -11,10 +12,17 @@ service, and the same whether it was served by that hub or by a statichost somewhere else. Served by a hub or service, it talks to the origin it came from. Served-from elsewhere (it finds out by asking ``api`` on its own origin), it shows-a connect box for a hub URL and a token, keeps them in the browser's local-storage, and sends the token as a bearer. The hub must then allow the-console's origin with ``--cors``. No external assets, no build step.+from elsewhere (it finds out by asking ``api`` on its own origin), the+browser is the hub: ``pyrtan.js``'s ``LocalHub`` answers the same+endpoints inside the page, with the catalog's trees (its ``trees.json``),+reading sentences and running trees here, keeping gaps and unanswered+sentences for as long as the page is open. Trees that need a box (files,+programs, devices, brokers, a browser to drive) or a site that will not+let a page read it say so. Joining a hub is explicit: its URL and token in+the connect box, kept in the browser's local storage, the token sent as a+bearer; the hub must allow the console's origin with ``--cors``.+Forgetting the hub makes the browser its own hub again. No external+assets, no build step.On a hub of 0.14 or later its Identity tab makes a key for this browser (``identity.js``, inlined: WebCrypto's Ed25519, non-extractable, kept in@@ -115,10 +123,11 @@ PAGE = r"""<!doctype html><section id="connect" hidden> <div class="card"> <div style="margin-bottom:8px" id="connect-lede">This console is not being served by a Pyrtan. Point it at one:</div>+ <div class="mute" style="font-size:14px;margin:-4px 0 8px" id="connect-local" hidden></div><form class="row" id="connect-form"> <input type="url" id="hub-url" placeholder="https://hub.example.net or http://192.168.1.20:8700" autocomplete="off"> <input type="password" id="hub-token" placeholder="token, if the hub needs one" autocomplete="off">- <button type="submit">Connect</button>+ <button type="submit" id="connect-go">Connect</button><button type="button" class="quiet" id="forget">Forget</button> </form> <div class="mute" style="font-size:14px" id="connect-help">Kept in this browser only. The hub must allow this page's origin: <code>pyrtan hub --cors <span id="origin"></span></code>. A hub on your LAN over <code>http://</code> may be refused by the browser when this page is <code>https://</code>; open the hub's own <code>/</code> instead, which is this same console.</div>@@ -131,7 +140,7 @@ PAGE = r"""<!doctype html><a href="#ask" data-tab="ask">Ask</a> <a href="#trees" data-tab="trees">Trees</a> <a href="#peers" data-tab="peers" id="peers-tab" hidden>Peers</a>- <a href="#watches" data-tab="watches">Watches</a>+ <a href="#watches" data-tab="watches" id="watches-tab">Watches</a><a href="#gaps" data-tab="gaps">Gaps</a> <a href="#unanswered" data-tab="unanswered">Unanswered</a> <a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a>@@ -156,7 +165,7 @@ PAGE = r"""<!doctype html><section id="tab-trees" hidden> <h2>What this swarm can answer</h2> <div class="card"><table id="caps"><thead><tr><th>Tree</th><th>Answers to</th><th>From</th></tr></thead><tbody></tbody></table></div>- <p class="mute" style="font-size:14px">Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.</p>+ <p class="mute" style="font-size:14px" id="trees-more">Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.</p></section> <section id="tab-peers" hidden>@@ -184,7 +193,7 @@ PAGE = r"""<!doctype html><section id="tab-unanswered" hidden> <h2>Waiting to be written</h2>- <p class="mute" style="font-size:14px;margin-top:0">Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.</p>+ <p class="mute" style="font-size:14px;margin-top:0" id="unanswered-lede">Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.</p><div class="card"><table id="unanswered"><thead><tr><th>Nobody answered</th><th>Would be</th><th>To write it</th></tr></thead><tbody></tbody></table></div> </section>@@ -289,7 +298,7 @@ GET /unansweredGET /peers (hub only) POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat) POST /chat/typing {"seat": "…", "typing": true}</pre>- <div class="mute" style="font-size:14px;margin-top:8px">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div>+ <div class="mute" style="font-size:14px;margin-top:8px" id="api-help">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div></div> </section>@@ -313,9 +322,14 @@ const store = {clear() { try { localStorage.removeItem(KEY); } catch (e) {} }, }; // base: "" when this page is served by a Pyrtan (relative URLs); otherwise the hub URL the person gave.-let base = "", token = "", remote = false, isHub = false;+// local: the hub in this page (pyrtan.js's LocalHub), while no hub is joined.+let base = "", token = "", remote = false, isHub = false, local = null, catalog = "";async function api(path, init) {+ if (local) {+ const body = init && init.body ? JSON.parse(init.body) : undefined;+ return local.route((init && init.method) || "GET", path, body).then(([status, payload]) => new Response(JSON.stringify(payload), {status, headers: {"Content-Type": "application/json"}}));+ }const headers = Object.assign({"Accept": "application/json"}, (init && init.headers) || {}); if (token) headers["Authorization"] = "Bearer " + token; const url = base ? base.replace(/\/+$/, "") + "/" + path : path;@@ -442,7 +456,8 @@ async function detect() {$("#connect").hidden = false; $("#origin").textContent = location.origin; $("#hub-url").value = base; $("#hub-token").value = token;- if (!base) return null;+ local = null; showLocal(false);+ if (!base) return startLocal();try { const a = await json("api"); if (a && a.pyrtan) { isHub = a.pyrtan === "hub"; return a; }@@ -451,7 +466,53 @@ async function detect() {return null; }+// ---------------------------------------------------------------- the hub in this page+// No hub joined: this browser is one, for as long as the page is open. It+// holds the catalog's trees and answers the same endpoints a hub does.+const params = new URLSearchParams(location.search);+const CATALOGS = [params.get("catalog"), "../catalog/", "https://pyrtan.com/catalog/"].filter(Boolean);+const TREES_KEY = "pyrtan.console.trees";+async function catalogTrees() {+ for (const c of CATALOGS) {+ try {+ const at = new URL(c.endsWith("/") ? c : c + "/", location.href);+ const r = await fetch(new URL("trees.json", at), {headers: {"Accept": "application/json"}});+ const d = r.ok ? await r.json() : null;+ if (d && Array.isArray(d.trees)) {+ catalog = at.href;+ try { localStorage.setItem(TREES_KEY, JSON.stringify({catalog, trees: d.trees})); } catch (e) {}+ return d.trees;+ }+ } catch (e) {}+ }+ // Offline, or no catalog answers: the trees this browser held last time.+ try { const kept = JSON.parse(localStorage.getItem(TREES_KEY) || "null"); if (kept && Array.isArray(kept.trees)) { catalog = kept.catalog || ""; return kept.trees; } } catch (e) {}+ return [];+}+// A tree runs here when every source it uses is http. Hosts of "*" mean any site, and most sites refuse a page, so "Try one" leaves those out.+const runsHere = (t) => (t.sources || []).every(s => s === "http");+const anySite = (t) => (t.hosts || []).includes("*");+async function startLocal() {+ isHub = false;+ local = new Pyrtan.LocalHub({name: "this browser", version: "__VERSION__"});+ const trees = await catalogTrees();+ for (const e of trees) local.add(e.tree, e.id);+ showLocal(true);+ return (await local.route("GET", "/api"))[1];+}+function showLocal(on) {+ $("#connect-local").hidden = !on;+ $("#connect-lede").textContent = on ? "This browser is a hub of its own: it holds the catalog's trees, reads your sentences and runs the trees right here." : "This console is not being served by a Pyrtan. Point it at one:";+ $("#connect-local").innerHTML = on ? "Gaps and unanswered sentences are kept only while this page is open. Trees that need a box (files, programs, devices) or a site that will not let a page read it need a hub: to join one, give its URL and token." : "";+ $("#connect-go").textContent = on ? "Join" : "Connect";+ $("#watches-tab").hidden = on;+ $("#unanswered-lede").innerHTML = on ? "Sentences nobody answered, each with the tree that would. Each command writes that tree's file; serve it from a hub and join the hub here, or publish it to the catalog and this page will hold it too." : 'Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.';+ $("#api-help").innerHTML = on ? "Answered inside this page by <code>pyrtan.js</code>: the same paths and the same answers as a hub, with no server behind them. Join a hub and they go to the hub." : 'With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.';+ $("#trees-more").innerHTML = on ? `These are the <a href="${esc(catalog || "https://pyrtan.com/catalog/")}">catalog</a>'s trees. Rows marked <em>needs a hub</em> reach what a page cannot; a service runs them.` : 'Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.';+}+async function load() {+ problem("");const a = await detect(); for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin; if (!a) { $("#who").textContent = remote ? "not connected" : "unreachable"; return; }@@ -471,26 +532,28 @@ async function load() {} member.hub = a.federation || null; if (member.hub) member.start();- $("#where").textContent = (base || location.origin) + (a.auth === "bearer" ? " · token" : "");+ $("#where").textContent = local ? "running in this browser" : (base || location.origin) + (a.auth === "bearer" ? " · token" : "");$("#hub-version").textContent = a.version ? ` · ${a.pyrtan} v${a.version}` : ""; try { const h = await json("health"); $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up` + (h.trees != null ? `, ${h.trees} trees` : "") : (h.trees ? `${h.trees.length} trees` : "")); } catch (e) { $("#who").textContent = "unreachable"; }+ for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin;if (a.auth === "bearer" && !token) { if (a.chat_public && location.hash === "#chat") showTab("chat"); // the one tab open without the token return; } let c; try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; }- problem("");+ problem(local && !(c.trees || []).length ? "No catalog answered and none was kept in this browser, so it holds no trees yet. Join a hub above, or try again online." : "", true);const body = $("#caps tbody"); const tries = $("#tries"); body.innerHTML = ""; tries.innerHTML = ""; for (const t of c.trees || []) { const tr = document.createElement("tr");- tr.innerHTML = `<td><code>${esc(t.name)}</code> <span class="mute">v${esc(t.version||1)}</span><div class="mute">${esc(t.description||"")}</div></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}${t.peer ? `<div><span class="link ${esc(t.link||"")}"></span>${esc(t.peer)}</div>` : ""}</td>`;+ const here = !local ? "" : !runsHere(t) ? `<div class="gap">needs a hub: ${esc((t.sources||[]).filter(x => x !== "http").join(", "))}</div>` : anySite(t) ? '<div class="gap">any site; most will not let a page read them</div>' : "";+ tr.innerHTML = `<td><code>${esc(t.name)}</code> <span class="mute">v${esc(t.version||1)}</span><div class="mute">${esc(t.description||"")}</div></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}${t.peer ? `<div><span class="link ${esc(t.link||"")}"></span>${esc(t.peer)}</div>` : ""}${here}</td>`;body.appendChild(tr);- const s = sample(t); if (s && !s.includes("…")) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); }+ const s = sample(t); if (s && !s.includes("…") && (!local || (runsHere(t) && !anySite(t)))) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); }} showTab((location.hash || "#ask").slice(1)); }@@ -580,7 +643,7 @@ async function ask(snapshot, answer) {const ms = Math.round(performance.now() - t0); const out = $("#out"); const card = document.createElement("div"); card.className = "card";- let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}</span></div>`;+ let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}${local ? " · ran in this browser" : ""}</span></div>`;if (r.say) html += `<div class="say">${esc(r.say)}</div>`; if (r.output && r.output.reading && r.output.reading.steps && r.output.reading.steps.length) html += `<div class="mute" style="font-size:14px">Read as <code>${esc(r.output.reading.cleaned)}</code>: ${r.output.reading.steps.map(esc).join("; ")}</div>`; if (r.reason === "no-match" && r.output && r.output.proposal) {@@ -590,6 +653,7 @@ async function ask(snapshot, answer) {if (r.status === "asked") html += `<form class="row resume"><input type="text" placeholder="${esc(r.question||"")}" autofocus><button>Answer</button></form>`; if (r.output != null) html += `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>`; if (r.trace) html += `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>`;+ if (local && (r.reason === "source-unsupported" || (r.reason === "transport" && /may not let a page/.test(r.say || "")))) html += `<div class="mute" style="font-size:14px;margin-top:8px">This one needs a hub: a page cannot reach it. Join a hub above that runs <code>${esc(r.tree||"")}</code>, and the same sentence goes there.</div>`;if (r.reason === "gap" && r.output && r.output.gap) html += `<div class="mute" style="font-size:14px;margin-top:8px">This run met a gap. It is listed under <a href="#gaps">Gaps</a>; the branch to write is in <code>${esc(r.output.gap.tree)}</code>, node <code>${esc(r.output.gap.node)}</code>.</div>`; card.innerHTML = html; out.prepend(card);@@ -878,7 +942,8 @@ $("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leavdocument.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } }); window.addEventListener("pagehide", () => chat.leave(true));-$("#forget").addEventListener("click", () => { store.clear(); base = ""; token = ""; $("#hub-url").value = ""; $("#hub-token").value = ""; $("#who").textContent = "not connected"; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); });+// Forgetting the hub makes this browser its own hub again.+$("#forget").addEventListener("click", () => { chat.leave(true); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });ident.init().finally(load); </script> </body> diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index dc40a4a..1e8c464 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -1,12 +1,19 @@"""Write tests/conformance/vectors.json: what both runtimes must say. Every example tree as JSON (the TOML's own table), and for a fixed set of-runs (a sentence read by the Python matcher, or a tree and inputs) the-outcome the Python runtime produced against the recorded responses in-tests/fixtures/examples.json, with a fixed clock. The JavaScript runtime-(src/pyrtan/js/pyrtan.js) must produce the same status, say, output,-reason, path and gaps; tests/conformance/run.cjs checks it, and-tests/test_conformance.py keeps this file from drifting.+runs (a sentence, or a tree and inputs) the outcome the Python runtime+produced against the recorded responses in tests/fixtures/examples.json,+with a fixed clock. The JavaScript runtime (src/pyrtan/js/pyrtan.js) must+produce the same status, say, output, reason, path and gaps.++Since the browser reads sentences too (it is a hub of its own until it+joins one), the vectors also hold how Python reads: every sentence's+reading (the rewrites, each trigger's verdict, the intent step), the+language rules and value parsers one by one, the tree proposed for a+sentence nobody answers, and a sequence of requests to a service and+what it answered, which pyrtan.js's LocalHub must answer the same.+tests/conformance/run.cjs checks it all, and tests/test_conformance.py+keeps this file from drifting.uv run python tests/conformance/make_vectors.py """@@ -22,6 +29,10 @@ ROOT = Path(__file__).resolve().parents[2]sys.path.insert(0, str(ROOT / "src")) from pyrtan import Registry, RecordedTransport, Runner # noqa: E402+from pyrtan import nlp # noqa: E402+from pyrtan.grow import propose # noqa: E402+from pyrtan.service import Service # noqa: E402+from pyrtan.tree import build as build_tree # noqa: E402CLOCK = _dt.datetime(2026, 10, 25, 12, 0, tzinfo=_dt.timezone.utc) CASES: list[dict] = [@@ -69,6 +80,143 @@ CASES: list[dict] = []+# Trees that exercise what the examples do not: every slot kind, synonyms+# (a word and a phrase), typos, fixed inputs, a tie, and an ask.+def _echo(name: str, triggers: list, inputs: dict, **extra) -> dict:+ return {"name": name, "version": 1, "description": f"echo for {name}", "hosts": [], "start": "reply", "triggers": triggers,+ "inputs": inputs, "nodes": {"reply": {"kind": "done", "say": "ok", "output": {k: f"{{{{ inputs.{k} }}}}" for k in inputs}}}, **extra}+++LANGUAGE_TREES: list[dict] = [+ _echo("remind", ["remind me {when:date} at {at:time}", "remind me in {wait:duration}"], {"when": {"type": "str", "default": ""}, "at": {"type": "str", "default": ""}, "wait": {"type": "int", "default": 0}}),+ _echo("count", ["count to {n:number}", "add {x:float} and {y:int}"], {"n": {"type": "float", "default": 0}, "x": {"type": "float", "default": 0.0}, "y": {"type": "int", "default": 0}}),+ _echo("ping", ["ping {host:host}", "fetch {url:url}", "mail {to:email}"], {"host": {"type": "str", "default": ""}, "url": {"type": "str", "default": ""}, "to": {"type": "str", "default": ""}}),+ _echo("books", ["(find|search) books by {author}", "top story on book club"], {"author": {"type": "str", "default": ""}, "count": {"type": "int", "default": 3}},+ language={"synonyms": {"books": ["novels", "paperback books"], "Find": "look up"}, "typos": True}),+ _echo("tie-a", ["shout {x:word}"], {"x": {"type": "str", "default": ""}}),+ _echo("tie-b", ["shout {y:word}"], {"y": {"type": "str", "default": ""}}),+ _echo("fixed", [{"pattern": "the usual", "inputs": {"what": "coffee"}}], {"what": {"type": "str", "default": "tea"}}),+ {"name": "order", "version": 1, "description": "asks, then answers", "hosts": [], "start": "size",+ "triggers": ["order a {drink:word}"], "inputs": {"drink": {"type": "str", "required": True}, "shots": {"type": "int", "default": 1}},+ "nodes": {"size": {"kind": "ask", "prompt": "What size of {{ inputs.drink }}?", "into": "state.size", "next": "shots"},+ "shots": {"kind": "ask", "prompt": "How many shots?", "into": "inputs.shots", "next": "reply"},+ "reply": {"kind": "done", "say": "A {{ state.size }} {{ inputs.drink }}, {{ inputs.shots }} shot(s).", "output": {"size": "{{ state.size }}", "shots": "{{ inputs.shots }}"}}}},+]++READINGS = [+ # the examples, read+ "What's the top story on Hacker News?", "top five stories on hacker news", "hey pyrtan, top 3 stories on hacker news please",+ "weather in Oslo", "what's the forecast for New York", "how cold is it in Oslo", "is Example.com up", "is http://Example.com:8080/x up",+ "is not a host up", "latest release of b-i-t-8/pyrtan", "how full is the disk", "disk space on /data", "the temperature in the attic",+ "headline of https://app.example/dashboard", "is the modem on /dev/ttyUSB1 up", "make me a sandwich", "", " ?! ",+ "Could you please tell me the weather in Zürich, thanks!", "TOP 3 STORIES ON HACKER NEWS", "top twenty-one stories on hacker news",+ "weather in Oslo for me", "hello", "pyrtan",+ # the language trees, read+ "remind me tomorrow at 3pm", "remind me next friday at noon", "remind me 25 dec at 9:30 pm", "remind me 3/4 at 10am",+ "remind me 31/12 at 25:00", "remind me in 2 hours", "remind me in an hour and a half", "remind me in 1h30m", "remind me in half an hour",+ "remind me in 2 parsecs", "count to two hundred and five", "count to 1,000.5", "count to 2.0", "count to a dozen", "add 1.5 and 3",+ "ping localhost", "ping 10.0.0.1:22", "ping not_a_host", "fetch https://example.com/a?b=c", "fetch ftp://x", "mail Barry@Example.COM",+ "mail nobody", "find books by Ursula Le Guin", "search novels by Le Guin", "look up paperback books by Le Guin", "finnd books by Le Guin",+ "serch books by Le Guin", "top stories on book club", "shout hello", "the usual", "order a latte", "it's the usual",+]++NLP = [+ ("clean", "Hey, can you please tell me what's up?"), ("clean", "“Curly” quotes — and dashes"), ("clean", "FULL width"),+ ("clean", "I'm sure it's a dozen"), ("clean", "one hundred and twenty three things"), ("clean", "a hundred and one"),+ ("clean", "Thanks"), ("clean", "well, um, okay pyrtan: weather."), ("clean", "St. Ives. Then Mr. Smith"),+ ("singular", "stories"), ("singular", "boxes"), ("singular", "news"), ("singular", "glass"), ("singular", "cats"), ("singular", "bus"),+ ("plural", "story"), ("plural", "box"), ("plural", "day"), ("plural", "status"),+ ("parse_number", "twenty-one"), ("parse_number", "1,234"), ("parse_number", "-3.25"), ("parse_number", "lots"),+ ("parse_duration", "90 seconds"), ("parse_duration", "2 days and 3 hours"), ("parse_duration", "a week"), ("parse_duration", "1.5 hours and a half"),+ ("parse_duration", "soon"), ("parse_duration", "3 fortnights"),+ ("parse_time", "15:30"), ("parse_time", "3:05 pm"), ("parse_time", "9 o'clock"), ("parse_time", "12am"), ("parse_time", "13pm"),+ ("parse_time", "7"), ("parse_time", "midnight"), ("parse_time", "nine thirty"),+ ("parse_date", "2026-02-29"), ("parse_date", "2028-02-29"), ("parse_date", "25 oct"), ("parse_date", "oct 25, 2027"),+ ("parse_date", "the 1st of march"), ("parse_date", "sept 3"), ("parse_date", "25/10/2026"), ("parse_date", "10/10"),+ ("parse_date", "3/4"), ("parse_date", "today"), ("parse_date", "yesterday"), ("parse_date", "day after tomorrow"),+ ("parse_date", "sunday"), ("parse_date", "this sunday"), ("parse_date", "next sunday"), ("parse_date", "last sunday"),+ ("parse_date", "last mon"), ("parse_date", "in 3 weeks"), ("parse_date", "in 4 months"), ("parse_date", "2 years ago"),+ ("parse_date", "next month"), ("parse_date", "end of month"), ("parse_date", "end of the year"), ("parse_date", "start of month"),+ ("parse_date", "someday"), ("parse_date", "30 feb"),+ ("parse_host", "Example.COM"), ("parse_host", "https://a.b.example:8443/x"), ("parse_host", "not a host"),+ ("parse_url", "http://x.example/y"), ("parse_url", "x.example"), ("parse_email", "A.B@Example.org"), ("parse_email", "a@b"),+]++PROPOSALS = [+ "make me a sandwich", "is example.com up", "top 3 stories on lobsters", "what is 2.5 times 4", "latest release of foo/bar",+ "", "the the the", "{weird} [pattern] (here)", "what's the weather on mars?", "ping 10.0.0.1 and 10.0.0.2", "café opening hours",+ 'say "hello" to $USER', "it's raining",+]++HUB = [+ ("GET", "/capabilities", None),+ ("POST", "/intent", {"text": "top 3 stories on hacker news"}),+ ("POST", "/intent", {"text": "weather in Oslo", "trace": False}),+ ("POST", "/intent", {"text": "how cold is it in Nowhereville"}),+ ("POST", "/intent", {"text": "is broken.example up"}),+ ("POST", "/intent", {"text": "is broken.example up"}),+ ("POST", "/intent", {"text": "make me a sandwich"}),+ ("POST", "/intent", {"text": "Make me a sandwich!"}),+ ("POST", "/intent", {"text": "shout hello"}),+ ("POST", "/intent", {"text": " "}),+ ("POST", "/intent", {}),+ ("POST", "/run", {"tree": "hacker-news", "inputs": {"count": 2}}),+ ("POST", "/run", {"tree": "nope"}),+ ("POST", "/run", {"tree": "weather", "inputs": {"units": "kelvin", "place": "Oslo"}}),+ ("POST", "/intent", {"text": "order a latte"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "large"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "2"}),+ ("POST", "/resume", {"snapshot": {"nope": 1}}),+ ("POST", "/intent", {"text": "order a mocha"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "small"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "many"}),+ ("POST", "/intent", {"text": "temperature in the hall"}),+ ("POST", "/intent", {"text": "what's the temperature in the hall"}),+ ("GET", "/gaps", None),+ ("GET", "/unanswered", None),+ ("GET", "/watches", None),+ ("GET", "/nowhere", None),+]+++def _nlp_case(fn: str, text: str) -> dict:+ try:+ if fn == "parse_date":+ value = nlp.parse_date(text, CLOCK)+ else:+ value = getattr(nlp, fn)(text)+ except nlp.ParseError as e:+ return {"fn": fn, "text": text, "error": str(e)}+ if fn == "clean":+ value = list(value)+ return {"fn": fn, "text": text, "value": value}+++def _hub_exchanges(reg: Registry, fixture: Path) -> list[dict]:+ """What a service with these trees answers, request by request. A+ snapshot of "@last" is the one the previous answer carried."""+ service = Service(reg, name="this browser", transport=RecordedTransport.from_file(fixture))+ service.runner.clock = lambda: CLOCK+ out, last = [], None+ for method, path, body in HUB:+ if body is not None and body.get("snapshot") == "@last":+ body = {**body, "snapshot": last}+ status, payload = service.route(method, path, json.loads(json.dumps(body)) if body is not None else None)+ if isinstance(payload, dict) and isinstance(payload.get("snapshot"), dict):+ last = payload["snapshot"]+ out.append({"method": method, "path": path, "body": body, "status": status, "expect": _stable(_untimed(json.loads(json.dumps(payload))))})+ return out+++def _untimed(d):+ """Drop when a gap or a sentence was seen: the counts and order stay."""+ for key in ("gaps", "unanswered"):+ for e in d.get(key, []) if isinstance(d, dict) else []:+ for k in ("first_seen", "last_seen", "first", "last"):+ e.pop(k, None)+ return d++def _stable(d: dict) -> dict: """Drop the wall-clock fields (when the trace started, how long each step took).""" trace = d.get("trace")@@ -90,24 +238,33 @@ def build() -> dict:reading = reg.read(case["intent"], CLOCK) match = reading.winner assert match is not None, case- outcome = runner.run_tree(match.tree, match.inputs)+ outcome = runner.run_intent(case["intent"])runs.append({"intent": case["intent"], "tree": match.tree, "inputs": match.inputs, "expect": _stable(outcome.to_dict())}) else: outcome = runner.run_tree(case["tree"], case["inputs"]) runs.append({"tree": case["tree"], "inputs": case["inputs"], "expect": _stable(outcome.to_dict())})+ both = Registry.from_paths([ROOT / "examples"])+ for raw in LANGUAGE_TREES:+ both.add(build_tree(raw))+ readings = [{"text": text, "expect": {**(r := both.read(text, CLOCK)).to_dict(), "step": r.step()}} for text in READINGS]return { "pyrtan-conformance": 1, "clock": CLOCK.isoformat(), "fixture": "tests/fixtures/examples.json", "trees": {t.name: t.raw for t in reg.trees()}, "runs": runs,+ "language_trees": LANGUAGE_TREES,+ "readings": readings,+ "nlp": [_nlp_case(fn, text) for fn, text in NLP],+ "proposals": [{"text": text, "expect": propose(text).to_dict()} for text in PROPOSALS],+ "hub": _hub_exchanges(both, fixture),} def main() -> int: out = ROOT / "tests" / "conformance" / "vectors.json" out.write_text(json.dumps(build(), indent=1, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")- print(f"wrote {out}: {len(CASES)} runs")+ print(f"wrote {out}: {len(CASES)} runs, {len(READINGS)} readings, {len(NLP)} rules, {len(PROPOSALS)} proposals, {len(HUB)} requests")return 0 diff --git a/tests/conformance/run.cjs b/tests/conformance/run.cjs index fe49d92..674442f 100644 --- a/tests/conformance/run.cjs +++ b/tests/conformance/run.cjs@@ -12,26 +12,59 @@ const clock = () => new Date(vectors.clock);function shape(d) { return { status: d.status, tree: d.tree, say: d.say === undefined ? null : d.say, reason: d.reason === undefined ? null : d.reason,- output: d.output === undefined ? null : d.output, inputs: d.inputs || {},+ output: d.output === undefined ? null : d.output, inputs: d.inputs || {}, question: d.question === undefined ? null : d.question,steps: (d.trace ? d.trace.steps : []).map((s) => ({ node: s.node, kind: s.kind, chosen: s.chosen === undefined ? null : s.chosen, next: s.next === undefined ? null : s.next, depth: s.depth || 0 })), gaps: (d.trace ? d.trace.gaps : []).map((g) => ({ node: g.node, on: g.on, observed: g.observed })),+ // The intent step is the reading, and it is compared word for word.+ intent: d.trace && d.trace.steps.length && d.trace.steps[0].kind === "intent" ? { summary: d.trace.steps[0].summary, observed: d.trace.steps[0].observed } : null,}; } const canon = (v) => JSON.stringify(v, (k, x) => (x && typeof x === "object" && !Array.isArray(x) ? Object.keys(x).sort().reduce((o, kk) => ((o[kk] = x[kk]), o), {}) : x));+let failed = 0, total = 0;+function compare(label, expect, got) {+ total++;+ const a = canon(expect), b = canon(got);+ if (a === b) { console.log(`ok ${label}`); return; }+ failed++;+ console.log(`DIFF ${label}`);+ if (expect && got && typeof expect === "object" && !Array.isArray(expect)) {+ for (const k of new Set([...Object.keys(expect), ...Object.keys(got)])) if (canon(expect[k]) !== canon(got[k])) console.log(` ${k}:\n python: ${canon(expect[k]).slice(0, 400)}\n js: ${canon(got[k]).slice(0, 400)}`);+ } else console.log(` python: ${a.slice(0, 400)}\n js: ${b.slice(0, 400)}`);+}+// A hub's answer, as compared: an outcome by its shape (with the snapshot), anything else whole.+function hubShape(d) {+ if (d && typeof d === "object" && "status" in d && "tree" in d) return Object.assign(shape(d), { snapshot: d.snapshot === undefined ? null : d.snapshot });+ if (d && Array.isArray(d.gaps)) return { gaps: d.gaps.map((g) => Object.assign({}, g, { first_seen: undefined, last_seen: undefined })) };+ if (d && Array.isArray(d.unanswered)) return { unanswered: d.unanswered.map((e) => Object.assign({}, e, { first: undefined, last: undefined })) };+ return d;+}+(async () => {- let failed = 0;for (const run of vectors.runs) { const runner = new Pyrtan.Runner({ trees: Object.values(vectors.trees), transport: new Pyrtan.RecordedTransport(fixture), clock });- const got = Pyrtan.outcomeDict(await runner.runTree(run.tree, run.inputs));- const a = canon(shape(run.expect)), b = canon(shape(got));- const label = run.intent ? `"${run.intent}"` : `${run.tree} ${JSON.stringify(run.inputs)}`;- if (a === b) { console.log(`ok ${label}`); continue; }- failed++;- console.log(`DIFF ${label}`);- const ea = shape(run.expect), eb = shape(got);- for (const k of Object.keys(ea)) if (canon(ea[k]) !== canon(eb[k])) console.log(` ${k}:\n python: ${canon(ea[k]).slice(0, 400)}\n js: ${canon(eb[k]).slice(0, 400)}`);+ const got = Pyrtan.outcomeDict(run.intent ? await runner.runIntent(run.intent) : await runner.runTree(run.tree, run.inputs));+ compare(run.intent ? `run "${run.intent}"` : `run ${run.tree} ${JSON.stringify(run.inputs)}`, shape(run.expect), shape(got));+ }+ const all = new Pyrtan.Runner({ trees: Object.values(vectors.trees).concat(vectors.language_trees), clock });+ for (const r of vectors.readings) {+ const reading = all.read(r.text);+ compare(`read "${r.text}"`, r.expect, Object.assign(reading.toDict(), { step: reading.step() }));+ }+ const fns = { clean: (t) => Pyrtan.nlp.clean(t), singular: (t) => Pyrtan.nlp.singular(t), plural: (t) => Pyrtan.nlp.plural(t), parse_number: (t) => Pyrtan.nlp.parseNumber(t).value, parse_duration: (t) => Pyrtan.nlp.parseDuration(t).value, parse_time: (t) => Pyrtan.nlp.parseTime(t).value, parse_date: (t) => Pyrtan.nlp.parseDate(t, clock()).value, parse_host: (t) => Pyrtan.nlp.parseHost(t).value, parse_url: (t) => Pyrtan.nlp.parseUrl(t).value, parse_email: (t) => Pyrtan.nlp.parseEmail(t).value };+ for (const c of vectors.nlp) {+ let got;+ try { got = { fn: c.fn, text: c.text, value: fns[c.fn](c.text) }; } catch (e) { if (!(e instanceof Pyrtan.nlp.ParseError)) throw e; got = { fn: c.fn, text: c.text, error: e.message }; }+ compare(`${c.fn} ${JSON.stringify(c.text)}`, c, got);+ }+ for (const p of vectors.proposals) compare(`propose ${JSON.stringify(p.text)}`, p.expect, Pyrtan.propose(p.text));+ // The hub in a page: the same requests, the same answers as a service.+ const transport = new Pyrtan.RecordedTransport(fixture);+ const hub = new Pyrtan.LocalHub({ trees: Object.values(vectors.trees).concat(vectors.language_trees), transport, adapters: transport, clock });+ for (const x of vectors.hub) {+ const [status, payload] = await hub.route(x.method, x.path, x.body === null ? undefined : JSON.parse(JSON.stringify(x.body)));+ compare(`hub ${x.method} ${x.path} ${x.body ? JSON.stringify(x.body).slice(0, 60) : ""}`, { status: x.status, body: hubShape(x.expect) }, { status, body: hubShape(JSON.parse(JSON.stringify(payload))) });}- console.log(`${vectors.runs.length - failed}/${vectors.runs.length} agree`);+ console.log(`${total - failed}/${total} agree`);process.exit(failed ? 1 : 0); })(); diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 184909f..14af50e 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json@@ -1,7 +1,5421 @@{ "clock": "2026-10-25T12:00:00+00:00", "fixture": "tests/fixtures/examples.json",+ "hub": [+ {+ "body": null,+ "expect": {+ "name": "this browser",+ "trees": [+ {+ "description": "echo for books",+ "hosts": [],+ "inputs": {+ "author": {+ "type": "str"+ },+ "count": {+ "default": 3,+ "type": "int"+ }+ },+ "language": {+ "synonyms": {+ "books": [+ "novels",+ "paperback books"+ ],+ "find": [+ "look up"+ ]+ },+ "typos": true+ },+ "license": "",+ "name": "books",+ "sources": [],+ "tags": [],+ "triggers": [+ "(find|search) books by {author}",+ "top story on book club"+ ],+ "version": 1+ },+ {+ "description": "echo for count",+ "hosts": [],+ "inputs": {+ "n": {+ "type": "float"+ },+ "x": {+ "type": "float"+ },+ "y": {+ "type": "int"+ }+ },+ "language": {},+ "license": "",+ "name": "count",+ "sources": [],+ "tags": [],+ "triggers": [+ "count to {n:number}",+ "add {x:float} and {y:int}"+ ],+ "version": 1+ },+ {+ "commands": [+ "df"+ ],+ "description": "How full a filesystem is, from df on the box this tree runs on.",+ "hosts": [],+ "inputs": {+ "mount": {+ "default": "/",+ "description": "A mount point, or any path on the filesystem to measure",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "disk-space",+ "namespace": "pyrtan",+ "sources": [+ "shell"+ ],+ "tags": [+ "ops",+ "box",+ "read-only"+ ],+ "triggers": [+ "[how] full is the disk",+ "disk space",+ "disk space on {mount:word}"+ ],+ "version": 2+ },+ {+ "description": "echo for fixed",+ "hosts": [],+ "inputs": {+ "what": {+ "default": "tea",+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "fixed",+ "sources": [],+ "tags": [],+ "trigger_inputs": {+ "the usual": {+ "what": "coffee"+ }+ },+ "triggers": [+ "the usual"+ ],+ "version": 1+ },+ {+ "description": "The latest release of a GitHub repository.",+ "hosts": [+ "api.github.com"+ ],+ "inputs": {+ "repo": {+ "description": "owner/name",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "github-release",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "github",+ "read-only"+ ],+ "triggers": [+ "[what's the] latest release of {repo:word}",+ "latest {repo:word} release"+ ],+ "version": 2+ },+ {+ "description": "Top stories on Hacker News, from its public API.",+ "hosts": [+ "hacker-news.firebaseio.com"+ ],+ "inputs": {+ "count": {+ "default": 1,+ "description": "How many stories to return",+ "type": "int"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "hacker-news",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "news",+ "read-only"+ ],+ "trigger_inputs": {+ "[what's the] top story on hacker news": {+ "count": 1+ }+ },+ "triggers": [+ "[what's the] top story on hacker news",+ "top {count:int} stories on hacker news"+ ],+ "version": 2+ },+ {+ "description": "One Hacker News item by id: title, url, score, author.",+ "hosts": [+ "hacker-news.firebaseio.com"+ ],+ "inputs": {+ "id": {+ "description": "The item id",+ "required": true,+ "type": "int"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "hacker-news-item",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "news",+ "read-only"+ ],+ "triggers": [+ "hacker news item {id:int}"+ ],+ "version": 2+ },+ {+ "description": "Error lines in the tail of a log file on this box.",+ "hosts": [],+ "inputs": {+ "lines": {+ "default": 200,+ "description": "How many lines from the end to look at",+ "type": "int"+ },+ "path": {+ "default": "/var/log/syslog",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "log-errors",+ "namespace": "pyrtan",+ "paths": [+ "/var/log"+ ],+ "sources": [+ "file"+ ],+ "tags": [+ "ops",+ "box",+ "read-only"+ ],+ "triggers": [+ "errors in the log",+ "errors in {path:word}"+ ],+ "version": 2+ },+ {+ "description": "Whether a modem on a serial line answers AT.",+ "devices": [+ "/dev/ttyUSB*",+ "/dev/ttyACM*"+ ],+ "hosts": [],+ "inputs": {+ "device": {+ "default": "/dev/ttyUSB0",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "modem-check",+ "namespace": "pyrtan",+ "sources": [+ "serial"+ ],+ "tags": [+ "hardware",+ "serial"+ ],+ "triggers": [+ "is the modem up",+ "is the modem on {device:word} up"+ ],+ "version": 2+ },+ {+ "description": "asks, then answers",+ "hosts": [],+ "inputs": {+ "drink": {+ "required": true,+ "type": "str"+ },+ "shots": {+ "default": 1,+ "type": "int"+ }+ },+ "language": {},+ "license": "",+ "name": "order",+ "sources": [],+ "tags": [],+ "triggers": [+ "order a {drink:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for ping",+ "hosts": [],+ "inputs": {+ "host": {+ "type": "str"+ },+ "to": {+ "type": "str"+ },+ "url": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "ping",+ "sources": [],+ "tags": [],+ "triggers": [+ "ping {host:host}",+ "fetch {url:url}",+ "mail {to:email}"+ ],+ "version": 1+ },+ {+ "description": "echo for remind",+ "hosts": [],+ "inputs": {+ "at": {+ "type": "str"+ },+ "wait": {+ "type": "int"+ },+ "when": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "remind",+ "sources": [],+ "tags": [],+ "triggers": [+ "remind me {when:date} at {at:time}",+ "remind me in {wait:duration}"+ ],+ "version": 1+ },+ {+ "description": "The main heading of a page that needs a script to render, through a real browser.",+ "hosts": [+ "*"+ ],+ "inputs": {+ "url": {+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "rendered-headline",+ "namespace": "pyrtan",+ "sources": [+ "browser"+ ],+ "tags": [+ "web",+ "browser",+ "read-only"+ ],+ "triggers": [+ "headline of {url:url}",+ "what does {url:url} say"+ ],+ "version": 2+ },+ {+ "description": "The temperature a room's sensor last published, over MQTT.",+ "hosts": [+ "broker.local"+ ],+ "inputs": {+ "room": {+ "default": "kitchen",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "room-temperature",+ "namespace": "pyrtan",+ "sources": [+ "queue"+ ],+ "tags": [+ "home",+ "mqtt"+ ],+ "topics": [+ "home/+/temperature"+ ],+ "triggers": [+ "[what's the] temperature in the {room:word}",+ "how warm is the {room:word}"+ ],+ "version": 2,+ "watches": [+ {+ "kind": "queue",+ "what": "home/kitchen/temperature at mqtt://broker.local"+ }+ ]+ },+ {+ "description": "Whether a web site answers, and how quickly.",+ "hosts": [+ "*"+ ],+ "inputs": {+ "host": {+ "description": "A host name, with no scheme",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "site-status",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "ops",+ "read-only"+ ],+ "triggers": [+ "is {host:host} up",+ "is {host:host} down",+ "[check] status of {host:host}"+ ],+ "version": 2,+ "watches": [+ {+ "kind": "every",+ "what": "15m"+ }+ ]+ },+ {+ "description": "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything.",+ "hosts": [],+ "inputs": {+ "topic": {+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "small-talk",+ "namespace": "pyrtan",+ "sources": [],+ "tags": [+ "chat",+ "read-only"+ ],+ "trigger_inputs": {+ "(bye|goodbye|see you|see ya)": {+ "topic": "bye"+ },+ "(who|what) are you": {+ "topic": "who"+ },+ "are you (a bot|human|real|a person)": {+ "topic": "who"+ },+ "how are you [doing|today]": {+ "topic": "how"+ },+ "what (can|do) you do": {+ "topic": "what"+ }+ },+ "triggers": [+ "how are you [doing|today]",+ "(who|what) are you",+ "what (can|do) you do",+ "are you (a bot|human|real|a person)",+ "(bye|goodbye|see you|see ya)"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-a",+ "hosts": [],+ "inputs": {+ "x": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "tie-a",+ "sources": [],+ "tags": [],+ "triggers": [+ "shout {x:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-b",+ "hosts": [],+ "inputs": {+ "y": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "tie-b",+ "sources": [],+ "tags": [],+ "triggers": [+ "shout {y:word}"+ ],+ "version": 1+ },+ {+ "description": "Current weather for a place, 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"+ },+ "units": {+ "default": "celsius",+ "type": "str"+ }+ },+ "language": {+ "synonyms": {+ "weather": [+ "forecast",+ "temperature"+ ]+ }+ },+ "license": "MIT",+ "name": "weather",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "weather",+ "read-only"+ ],+ "triggers": [+ "[what's the] weather in {place}",+ "[what's the] weather for {place}",+ "how [hot|cold|warm] is it in {place}"+ ],+ "version": 2+ }+ ]+ },+ "method": "GET",+ "path": "/capabilities",+ "status": 200+ },+ {+ "body": {+ "text": "top 3 stories on hacker news"+ },+ "expect": {+ "inputs": {+ "count": 3+ },+ "output": {+ "stories": [+ {+ "by": "alice",+ "id": 101,+ "score": 42,+ "title": "Pyrtan 0.1",+ "url": "https://example.org/p"+ },+ {+ "by": "x",+ "id": 102,+ "score": 7,+ "title": "Ask HN: self post",+ "url": "https://news.ycombinator.com/item?id=102"+ },+ null+ ]+ },+ "say": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch-ids",+ "node": "intent",+ "observed": {+ "cleaned": "top 3 stories on hacker news",+ "inputs": {+ "count": 3+ },+ "steps": [],+ "trigger": "top {count:int} stories on hacker news"+ },+ "summary": "'top 3 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "check",+ "node": "fetch-ids",+ "observed": {+ "ids": [+ 101,+ 102,+ 103+ ]+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/topstories.json -> 200; ids=[101, 102, 103]"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "any",+ "node": "check",+ "observed": 200,+ "summary": "state.ids_resp.status = 200"+ },+ {+ "chosen": "type 'list'",+ "depth": 0,+ "kind": "decide",+ "next": "stories",+ "node": "any",+ "observed": [+ 101,+ 102,+ 103+ ],+ "summary": "state.ids = [101, 102, 103]"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "alice",+ "score": 42,+ "title": "Pyrtan 0.1",+ "type": "story",+ "url": "https://example.org/p"+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/101.json -> 200; title=Pyrtan 0.1, url=https://example.org/p, score=42, by=alice, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "exists True",+ "depth": 1,+ "kind": "decide",+ "next": "reply",+ "node": "has-url",+ "observed": "https://example.org/p",+ "summary": "state.url = https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Pyrtan 0.1 (42 points, by alice) https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "x",+ "score": 7,+ "title": "Ask HN: self post",+ "type": "story",+ "url": null+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/102.json -> 200; title=Ask HN: self post, url=None, score=7, by=x, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "else",+ "depth": 1,+ "kind": "decide",+ "next": "self-post",+ "node": "has-url",+ "summary": "state.url = <missing>"+ },+ {+ "depth": 1,+ "kind": "set",+ "next": "reply",+ "node": "self-post",+ "observed": {+ "url": "https://news.ycombinator.com/item?id=102"+ },+ "summary": "url=https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Ask HN: self post (7 points, by x) https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "y",+ "score": null,+ "title": null,+ "type": "comment",+ "url": null+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/103.json -> 200; title=None, url=None, score=None, by=y, type=comment"+ },+ {+ "chosen": "else",+ "depth": 1,+ "kind": "decide",+ "next": "not-a-story",+ "node": "kind",+ "observed": "comment",+ "summary": "state.type = comment"+ },+ {+ "depth": 1,+ "kind": "fail",+ "next": null,+ "node": "not-a-story",+ "summary": "not-a-story: Item 103 is a comment, not a story."+ },+ {+ "depth": 0,+ "kind": "each",+ "next": "reply",+ "node": "stories",+ "summary": "3 item(s), 1 failed"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p"+ }+ ],+ "tree": "hacker-news"+ },+ "tree": "hacker-news"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "weather in Oslo",+ "trace": false+ },+ "expect": {+ "inputs": {+ "place": "Oslo",+ "units": "celsius"+ },+ "output": {+ "country": "Norway",+ "humidity": 80,+ "place": "Oslo",+ "sky": "raining",+ "temperature": 4.2,+ "unit": "°C",+ "wind_kmh": 12.3+ },+ "say": "Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.",+ "status": "done",+ "tree": "weather"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "how cold is it in Nowhereville"+ },+ "expect": {+ "inputs": {+ "place": "Nowhereville",+ "units": "celsius"+ },+ "reason": "unknown-place",+ "say": "I could not find a place called Nowhereville.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "how cold is it in Nowhereville",+ "inputs": {+ "place": "Nowhereville"+ },+ "steps": [],+ "trigger": "how [hot|cold|warm] is it in {place}"+ },+ "summary": "'how cold is it in Nowhereville'; weather via 'how [hot|cold|warm] is it in {place}' (20 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": null,+ "hit": null,+ "lat": null,+ "lon": null,+ "name": null+ },+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Nowhereville&count=1&language=en&format=json -> 200; hit=None, lat=None, lon=None, name=None, country=None"+ },+ {+ "chosen": "else",+ "depth": 0,+ "kind": "decide",+ "next": "unknown-place",+ "node": "found",+ "summary": "state.hit = <missing>"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "unknown-place",+ "summary": "unknown-place: I could not find a place called Nowhereville."+ }+ ],+ "tree": "weather"+ },+ "tree": "weather"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "is broken.example up"+ },+ "expect": {+ "inputs": {+ "host": "broken.example"+ },+ "output": {+ "host": "broken.example",+ "status": 503,+ "up": false+ },+ "reason": "server-error",+ "say": "broken.example is answering 503: up, but broken.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "probe",+ "node": "intent",+ "observed": {+ "cleaned": "is broken.example up",+ "inputs": {+ "host": "broken.example"+ },+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is broken.example up'; site-status via 'is {host:host} up' (4 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "probe",+ "summary": "GET https://broken.example/ -> 503"+ },+ {+ "chosen": "gte 500",+ "depth": 0,+ "kind": "decide",+ "next": "erroring",+ "node": "status",+ "observed": 503,+ "summary": "state.resp.status = 503"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "erroring",+ "summary": "server-error: broken.example is answering 503: up, but broken."+ }+ ],+ "tree": "site-status"+ },+ "tree": "site-status"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "is broken.example up"+ },+ "expect": {+ "inputs": {+ "host": "broken.example"+ },+ "output": {+ "host": "broken.example",+ "status": 503,+ "up": false+ },+ "reason": "server-error",+ "say": "broken.example is answering 503: up, but broken.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "probe",+ "node": "intent",+ "observed": {+ "cleaned": "is broken.example up",+ "inputs": {+ "host": "broken.example"+ },+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is broken.example up'; site-status via 'is {host:host} up' (4 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "probe",+ "summary": "GET https://broken.example/ -> 503"+ },+ {+ "chosen": "gte 500",+ "depth": 0,+ "kind": "decide",+ "next": "erroring",+ "node": "status",+ "observed": 503,+ "summary": "state.resp.status = 503"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "erroring",+ "summary": "server-error: broken.example is answering 503: up, but broken."+ }+ ],+ "tree": "site-status"+ },+ "tree": "site-status"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "make me a sandwich"+ },+ "expect": {+ "inputs": {},+ "output": {+ "proposal": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"make me a sandwich\"",+ "text": "make me a sandwich",+ "trigger": "make me a sandwich"+ },+ "reading": {+ "cleaned": "make me a sandwich",+ "considered": [],+ "steps": []+ },+ "unanswered": "make me a sandwich"+ },+ "reason": "no-match",+ "say": "Nothing here answers 'make me a sandwich'. To write the tree that would: pyrtan new make-sandwich --from \"make me a sandwich\"",+ "status": "error",+ "tree": ""+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "Make me a sandwich!"+ },+ "expect": {+ "inputs": {},+ "output": {+ "proposal": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"Make me a sandwich\"",+ "text": "Make me a sandwich",+ "trigger": "Make me a sandwich"+ },+ "reading": {+ "cleaned": "Make me a sandwich",+ "considered": [],+ "steps": []+ },+ "unanswered": "Make me a sandwich!"+ },+ "reason": "no-match",+ "say": "Nothing here answers 'Make me a sandwich!'. To write the tree that would: pyrtan new make-sandwich --from \"Make me a sandwich\"",+ "status": "error",+ "tree": ""+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "shout hello"+ },+ "expect": {+ "inputs": {},+ "output": {+ "candidates": [+ "tie-a",+ "tie-b"+ ],+ "reading": {+ "cleaned": "shout hello",+ "steps": []+ }+ },+ "reason": "ambiguous",+ "say": "'shout hello' matches more than one tree equally well: tie-a, tie-b",+ "status": "error",+ "tree": ""+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": " "+ },+ "expect": {+ "reason": "bad-request",+ "say": "Send {\"text\": \"...\"}.",+ "status": "error"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {},+ "expect": {+ "reason": "bad-request",+ "say": "Send {\"text\": \"...\"}.",+ "status": "error"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "inputs": {+ "count": 2+ },+ "tree": "hacker-news"+ },+ "expect": {+ "inputs": {+ "count": 2+ },+ "output": {+ "stories": [+ {+ "by": "alice",+ "id": 101,+ "score": 42,+ "title": "Pyrtan 0.1",+ "url": "https://example.org/p"+ },+ {+ "by": "x",+ "id": 102,+ "score": 7,+ "title": "Ask HN: self post",+ "url": "https://news.ycombinator.com/item?id=102"+ }+ ]+ },+ "say": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "observe",+ "next": "check",+ "node": "fetch-ids",+ "observed": {+ "ids": [+ 101,+ 102,+ 103+ ]+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/topstories.json -> 200; ids=[101, 102, 103]"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "any",+ "node": "check",+ "observed": 200,+ "summary": "state.ids_resp.status = 200"+ },+ {+ "chosen": "type 'list'",+ "depth": 0,+ "kind": "decide",+ "next": "stories",+ "node": "any",+ "observed": [+ 101,+ 102,+ 103+ ],+ "summary": "state.ids = [101, 102, 103]"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "alice",+ "score": 42,+ "title": "Pyrtan 0.1",+ "type": "story",+ "url": "https://example.org/p"+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/101.json -> 200; title=Pyrtan 0.1, url=https://example.org/p, score=42, by=alice, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "exists True",+ "depth": 1,+ "kind": "decide",+ "next": "reply",+ "node": "has-url",+ "observed": "https://example.org/p",+ "summary": "state.url = https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Pyrtan 0.1 (42 points, by alice) https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "x",+ "score": 7,+ "title": "Ask HN: self post",+ "type": "story",+ "url": null+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/102.json -> 200; title=Ask HN: self post, url=None, score=7, by=x, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "else",+ "depth": 1,+ "kind": "decide",+ "next": "self-post",+ "node": "has-url",+ "summary": "state.url = <missing>"+ },+ {+ "depth": 1,+ "kind": "set",+ "next": "reply",+ "node": "self-post",+ "observed": {+ "url": "https://news.ycombinator.com/item?id=102"+ },+ "summary": "url=https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Ask HN: self post (7 points, by x) https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 0,+ "kind": "each",+ "next": "reply",+ "node": "stories",+ "summary": "2 item(s)"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p"+ }+ ],+ "tree": "hacker-news"+ },+ "tree": "hacker-news"+ },+ "method": "POST",+ "path": "/run",+ "status": 200+ },+ {+ "body": {+ "tree": "nope"+ },+ "expect": {+ "inputs": {},+ "reason": "no-such-tree",+ "say": "No tree named 'nope'.",+ "status": "error",+ "tree": "nope"+ },+ "method": "POST",+ "path": "/run",+ "status": 200+ },+ {+ "body": {+ "inputs": {+ "place": "Oslo",+ "units": "kelvin"+ },+ "tree": "weather"+ },+ "expect": {+ "inputs": {},+ "reason": "bad-input",+ "say": "input 'units' must be one of ['celsius', 'fahrenheit'], got 'kelvin'",+ "status": "error",+ "trace": {+ "gaps": [],+ "steps": [],+ "tree": "weather"+ },+ "tree": "weather"+ },+ "method": "POST",+ "path": "/run",+ "status": 200+ },+ {+ "body": {+ "text": "order a latte"+ },+ "expect": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "question": "What size of latte?",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "answer": "large",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "question": "How many shots?",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "large"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "answer": "2",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "large"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {+ "drink": "latte",+ "shots": 2+ },+ "output": {+ "shots": 2,+ "size": "large"+ },+ "say": "A large latte, 2 shot(s).",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "reply",+ "node": "shots",+ "summary": "answered: 2"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "A large latte, 2 shot(s)."+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "snapshot": {+ "nope": 1+ }+ },+ "expect": {+ "reason": "bad-request",+ "say": "Send {\"snapshot\": {...}, \"answer\": ...}.",+ "status": "error"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "text": "order a mocha"+ },+ "expect": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "question": "What size of mocha?",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "answer": "small",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "question": "How many shots?",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "small"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: small"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: small"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "answer": "many",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "small"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: small"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {},+ "reason": "bad-input",+ "say": "input 'shots' must be int, got 'many'",+ "status": "error",+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "text": "temperature in the hall"+ },+ "expect": {+ "inputs": {+ "room": "hall"+ },+ "output": {+ "gap": {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ },+ "reason": "gap",+ "say": "reading: no branch for state.q.json.temperature = <missing>.",+ "status": "error",+ "trace": {+ "gaps": [+ {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ ],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "wait",+ "node": "intent",+ "observed": {+ "cleaned": "temperature in the hall",+ "inputs": {+ "room": "hall"+ },+ "steps": [],+ "trigger": "[what's the] temperature in the {room:word}"+ },+ "summary": "'temperature in the hall'; room-temperature via \"[what's the] temperature in the {room:word}\" (25 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "got",+ "node": "wait",+ "summary": "queue home/hall/temperature at mqtt://broker.local -> 1 message"+ },+ {+ "chosen": "eq True",+ "depth": 0,+ "kind": "decide",+ "next": "reading",+ "node": "got",+ "observed": true,+ "summary": "state.q.received = True"+ },+ {+ "depth": 0,+ "kind": "decide",+ "next": null,+ "node": "reading",+ "summary": "state.q.json.temperature = <missing>: no branch"+ }+ ],+ "tree": "room-temperature"+ },+ "tree": "room-temperature"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "what's the temperature in the hall"+ },+ "expect": {+ "inputs": {+ "room": "hall"+ },+ "output": {+ "gap": {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ },+ "reason": "gap",+ "say": "reading: no branch for state.q.json.temperature = <missing>.",+ "status": "error",+ "trace": {+ "gaps": [+ {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ ],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "wait",+ "node": "intent",+ "observed": {+ "cleaned": "what is the temperature in the hall",+ "inputs": {+ "room": "hall"+ },+ "steps": [+ "contractions: what's->what is"+ ],+ "trigger": "[what's the] temperature in the {room:word}"+ },+ "summary": "\"what's the temperature in the hall\" -> 'what is the temperature in the hall'; room-temperature via \"[what's the] temperature in the {room:word}\" (25 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "got",+ "node": "wait",+ "summary": "queue home/hall/temperature at mqtt://broker.local -> 1 message"+ },+ {+ "chosen": "eq True",+ "depth": 0,+ "kind": "decide",+ "next": "reading",+ "node": "got",+ "observed": true,+ "summary": "state.q.received = True"+ },+ {+ "depth": 0,+ "kind": "decide",+ "next": null,+ "node": "reading",+ "summary": "state.q.json.temperature = <missing>: no branch"+ }+ ],+ "tree": "room-temperature"+ },+ "tree": "room-temperature"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "gaps": [+ {+ "count": 2,+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ ]+ },+ "method": "GET",+ "path": "/gaps",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "unanswered": [+ {+ "count": 2,+ "proposal": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"make me a sandwich\"",+ "text": "make me a sandwich",+ "trigger": "make me a sandwich"+ },+ "text": "make me a sandwich"+ }+ ]+ },+ "method": "GET",+ "path": "/unanswered",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "count": 0,+ "watches": [],+ "watching": false+ },+ "method": "GET",+ "path": "/watches",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "reason": "not-found",+ "say": "No GET /nowhere here.",+ "status": "error"+ },+ "method": "GET",+ "path": "/nowhere",+ "status": 404+ }+ ],+ "language_trees": [+ {+ "description": "echo for remind",+ "hosts": [],+ "inputs": {+ "at": {+ "default": "",+ "type": "str"+ },+ "wait": {+ "default": 0,+ "type": "int"+ },+ "when": {+ "default": "",+ "type": "str"+ }+ },+ "name": "remind",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "at": "{{ inputs.at }}",+ "wait": "{{ inputs.wait }}",+ "when": "{{ inputs.when }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "remind me {when:date} at {at:time}",+ "remind me in {wait:duration}"+ ],+ "version": 1+ },+ {+ "description": "echo for count",+ "hosts": [],+ "inputs": {+ "n": {+ "default": 0,+ "type": "float"+ },+ "x": {+ "default": 0.0,+ "type": "float"+ },+ "y": {+ "default": 0,+ "type": "int"+ }+ },+ "name": "count",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "n": "{{ inputs.n }}",+ "x": "{{ inputs.x }}",+ "y": "{{ inputs.y }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "count to {n:number}",+ "add {x:float} and {y:int}"+ ],+ "version": 1+ },+ {+ "description": "echo for ping",+ "hosts": [],+ "inputs": {+ "host": {+ "default": "",+ "type": "str"+ },+ "to": {+ "default": "",+ "type": "str"+ },+ "url": {+ "default": "",+ "type": "str"+ }+ },+ "name": "ping",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "host": "{{ inputs.host }}",+ "to": "{{ inputs.to }}",+ "url": "{{ inputs.url }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "ping {host:host}",+ "fetch {url:url}",+ "mail {to:email}"+ ],+ "version": 1+ },+ {+ "description": "echo for books",+ "hosts": [],+ "inputs": {+ "author": {+ "default": "",+ "type": "str"+ },+ "count": {+ "default": 3,+ "type": "int"+ }+ },+ "language": {+ "synonyms": {+ "Find": "look up",+ "books": [+ "novels",+ "paperback books"+ ]+ },+ "typos": true+ },+ "name": "books",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "author": "{{ inputs.author }}",+ "count": "{{ inputs.count }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "(find|search) books by {author}",+ "top story on book club"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-a",+ "hosts": [],+ "inputs": {+ "x": {+ "default": "",+ "type": "str"+ }+ },+ "name": "tie-a",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "x": "{{ inputs.x }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "shout {x:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-b",+ "hosts": [],+ "inputs": {+ "y": {+ "default": "",+ "type": "str"+ }+ },+ "name": "tie-b",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "y": "{{ inputs.y }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "shout {y:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for fixed",+ "hosts": [],+ "inputs": {+ "what": {+ "default": "tea",+ "type": "str"+ }+ },+ "name": "fixed",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "what": "{{ inputs.what }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ {+ "inputs": {+ "what": "coffee"+ },+ "pattern": "the usual"+ }+ ],+ "version": 1+ },+ {+ "description": "asks, then answers",+ "hosts": [],+ "inputs": {+ "drink": {+ "required": true,+ "type": "str"+ },+ "shots": {+ "default": 1,+ "type": "int"+ }+ },+ "name": "order",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "shots": "{{ inputs.shots }}",+ "size": "{{ state.size }}"+ },+ "say": "A {{ state.size }} {{ inputs.drink }}, {{ inputs.shots }} shot(s)."+ },+ "shots": {+ "into": "inputs.shots",+ "kind": "ask",+ "next": "reply",+ "prompt": "How many shots?"+ },+ "size": {+ "into": "state.size",+ "kind": "ask",+ "next": "shots",+ "prompt": "What size of {{ inputs.drink }}?"+ }+ },+ "start": "size",+ "triggers": [+ "order a {drink:word}"+ ],+ "version": 1+ }+ ],+ "nlp": [+ {+ "fn": "clean",+ "text": "Hey, can you please tell me what's up?",+ "value": [+ "tell me what is up",+ [+ "fillers: 'Hey', 'can you please'",+ "contractions: what's->what is"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "“Curly” quotes — and dashes",+ "value": [+ "Curly\" quotes - and dashes",+ [+ "fold"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "FULL width",+ "value": [+ "FULL width",+ [+ "fold"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "I'm sure it's a dozen",+ "value": [+ "I am sure it is 12",+ [+ "contractions: I'm->i am, it's->it is",+ "numbers: a dozen->12"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "one hundred and twenty three things",+ "value": [+ "123 things",+ [+ "numbers: one hundred and twenty three->123"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "a hundred and one",+ "value": [+ "101",+ [+ "numbers: a hundred and one->101"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "Thanks",+ "value": [+ "",+ [+ "fillers: 'Thanks'"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "well, um, okay pyrtan: weather.",+ "value": [+ "pyrtan: weather",+ [+ "fillers: 'well', 'um', 'okay'"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "St. Ives. Then Mr. Smith",+ "value": [+ "St Ives Then Mr Smith",+ []+ ]+ },+ {+ "fn": "singular",+ "text": "stories",+ "value": "story"+ },+ {+ "fn": "singular",+ "text": "boxes",+ "value": "box"+ },+ {+ "fn": "singular",+ "text": "news",+ "value": "news"+ },+ {+ "fn": "singular",+ "text": "glass",+ "value": "glass"+ },+ {+ "fn": "singular",+ "text": "cats",+ "value": "cat"+ },+ {+ "fn": "singular",+ "text": "bus",+ "value": "bus"+ },+ {+ "fn": "plural",+ "text": "story",+ "value": "stories"+ },+ {+ "fn": "plural",+ "text": "box",+ "value": "boxes"+ },+ {+ "fn": "plural",+ "text": "day",+ "value": "days"+ },+ {+ "fn": "plural",+ "text": "status",+ "value": "status"+ },+ {+ "fn": "parse_number",+ "text": "twenty-one",+ "value": 21+ },+ {+ "fn": "parse_number",+ "text": "1,234",+ "value": 1234+ },+ {+ "fn": "parse_number",+ "text": "-3.25",+ "value": -3.25+ },+ {+ "error": "'lots' is not a number",+ "fn": "parse_number",+ "text": "lots"+ },+ {+ "fn": "parse_duration",+ "text": "90 seconds",+ "value": 90+ },+ {+ "fn": "parse_duration",+ "text": "2 days and 3 hours",+ "value": 183600+ },+ {+ "fn": "parse_duration",+ "text": "a week",+ "value": 604800+ },+ {+ "fn": "parse_duration",+ "text": "1.5 hours and a half",+ "value": 7200+ },+ {+ "error": "'soon' is not a duration",+ "fn": "parse_duration",+ "text": "soon"+ },+ {+ "error": "'3 fortnights': unknown unit 'fortnights'",+ "fn": "parse_duration",+ "text": "3 fortnights"+ },+ {+ "fn": "parse_time",+ "text": "15:30",+ "value": "15:30"+ },+ {+ "fn": "parse_time",+ "text": "3:05 pm",+ "value": "15:05"+ },+ {+ "fn": "parse_time",+ "text": "9 o'clock",+ "value": "09:00"+ },+ {+ "fn": "parse_time",+ "text": "12am",+ "value": "00:00"+ },+ {+ "error": "'13pm': 13 with pm",+ "fn": "parse_time",+ "text": "13pm"+ },+ {+ "error": "'7': a bare number is not a time; say 15:00 or 3pm",+ "fn": "parse_time",+ "text": "7"+ },+ {+ "fn": "parse_time",+ "text": "midnight",+ "value": "00:00"+ },+ {+ "error": "'nine thirty': a bare number is not a time; say 15:00 or 3pm",+ "fn": "parse_time",+ "text": "nine thirty"+ },+ {+ "error": "'2026-02-29': day is out of range for month",+ "fn": "parse_date",+ "text": "2026-02-29"+ },+ {+ "fn": "parse_date",+ "text": "2028-02-29",+ "value": "2028-02-29"+ },+ {+ "fn": "parse_date",+ "text": "25 oct",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "oct 25, 2027",+ "value": "2027-10-25"+ },+ {+ "fn": "parse_date",+ "text": "the 1st of march",+ "value": "2026-03-01"+ },+ {+ "fn": "parse_date",+ "text": "sept 3",+ "value": "2026-09-03"+ },+ {+ "fn": "parse_date",+ "text": "25/10/2026",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "10/10",+ "value": "2026-10-10"+ },+ {+ "error": "'3/4' could be day/month or month/day; write it as 2026-04-03 or '3 april'",+ "fn": "parse_date",+ "text": "3/4"+ },+ {+ "fn": "parse_date",+ "text": "today",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "yesterday",+ "value": "2026-10-24"+ },+ {+ "fn": "parse_date",+ "text": "day after tomorrow",+ "value": "2026-10-27"+ },+ {+ "fn": "parse_date",+ "text": "sunday",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "this sunday",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "next sunday",+ "value": "2026-11-01"+ },+ {+ "fn": "parse_date",+ "text": "last sunday",+ "value": "2026-10-18"+ },+ {+ "fn": "parse_date",+ "text": "last mon",+ "value": "2026-10-19"+ },+ {+ "fn": "parse_date",+ "text": "in 3 weeks",+ "value": "2026-11-15"+ },+ {+ "fn": "parse_date",+ "text": "in 4 months",+ "value": "2027-02-25"+ },+ {+ "fn": "parse_date",+ "text": "2 years ago",+ "value": "2024-10-25"+ },+ {+ "fn": "parse_date",+ "text": "next month",+ "value": "2026-11-25"+ },+ {+ "fn": "parse_date",+ "text": "end of month",+ "value": "2026-10-31"+ },+ {+ "fn": "parse_date",+ "text": "end of the year",+ "value": "2026-12-31"+ },+ {+ "fn": "parse_date",+ "text": "start of month",+ "value": "2026-10-01"+ },+ {+ "error": "'someday' is not a date this reads",+ "fn": "parse_date",+ "text": "someday"+ },+ {+ "error": "'30 feb': day is out of range for month",+ "fn": "parse_date",+ "text": "30 feb"+ },+ {+ "fn": "parse_host",+ "text": "Example.COM",+ "value": "example.com"+ },+ {+ "fn": "parse_host",+ "text": "https://a.b.example:8443/x",+ "value": "a.b.example"+ },+ {+ "error": "'not a host' is not a host name",+ "fn": "parse_host",+ "text": "not a host"+ },+ {+ "fn": "parse_url",+ "text": "http://x.example/y",+ "value": "http://x.example/y"+ },+ {+ "error": "'x.example' is not a URL",+ "fn": "parse_url",+ "text": "x.example"+ },+ {+ "fn": "parse_email",+ "text": "A.B@Example.org",+ "value": "a.b@example.org"+ },+ {+ "error": "'a@b' is not an email address",+ "fn": "parse_email",+ "text": "a@b"+ }+ ],+ "proposals": [+ {+ "expect": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"make me a sandwich\"",+ "text": "make me a sandwich",+ "trigger": "make me a sandwich"+ },+ "text": "make me a sandwich"+ },+ {+ "expect": {+ "inputs": {+ "host": {+ "description": "a host name, as in: example.com",+ "role": "host",+ "type": "str"+ }+ },+ "name": "host-up",+ "scaffold": "pyrtan new host-up --from \"is example.com up\"",+ "text": "is example.com up",+ "trigger": "is {host:host} up"+ },+ "text": "is example.com up"+ },+ {+ "expect": {+ "inputs": {+ "count": {+ "description": "as in: 3",+ "type": "int"+ }+ },+ "name": "top-stories-lobsters",+ "scaffold": "pyrtan new top-stories-lobsters --from \"top 3 stories on lobsters\"",+ "text": "top 3 stories on lobsters",+ "trigger": "top {count:int} stories on lobsters"+ },+ "text": "top 3 stories on lobsters"+ },+ {+ "expect": {+ "inputs": {+ "count": {+ "description": "as in: 4",+ "type": "int"+ },+ "value": {+ "description": "as in: 2.5",+ "type": "float"+ }+ },+ "name": "value-count-times",+ "scaffold": "pyrtan new value-count-times --from \"what is 2.5 times 4\"",+ "text": "what is 2.5 times 4",+ "trigger": "what is {value:float} times {count:int}"+ },+ "text": "what is 2.5 times 4"+ },+ {+ "expect": {+ "inputs": {+ "target": {+ "description": "as in: foo/bar",+ "type": "str"+ }+ },+ "name": "latest-release",+ "scaffold": "pyrtan new latest-release --from \"latest release of foo/bar\"",+ "text": "latest release of foo/bar",+ "trigger": "latest release of {target:word}"+ },+ "text": "latest release of foo/bar"+ },+ {+ "expect": {+ "inputs": {},+ "name": "new-tree",+ "scaffold": "pyrtan new new-tree --from \"new tree\"",+ "text": "new tree",+ "trigger": "new tree"+ },+ "text": ""+ },+ {+ "expect": {+ "inputs": {},+ "name": "the-the-the",+ "scaffold": "pyrtan new the-the-the --from \"the the the\"",+ "text": "the the the",+ "trigger": "the the the"+ },+ "text": "the the the"+ },+ {+ "expect": {+ "inputs": {},+ "name": "weird-pattern-here",+ "scaffold": "pyrtan new weird-pattern-here --from \"weird} [pattern] (here)\"",+ "text": "weird} [pattern] (here)",+ "trigger": "weird pattern here"+ },+ "text": "{weird} [pattern] (here)"+ },+ {+ "expect": {+ "inputs": {},+ "name": "weather-mars",+ "scaffold": "pyrtan new weather-mars --from \"what's the weather on mars\"",+ "text": "what's the weather on mars",+ "trigger": "what's the weather on mars"+ },+ "text": "what's the weather on mars?"+ },+ {+ "expect": {+ "inputs": {+ "host": {+ "description": "a host name, as in: 10.0.0.1",+ "role": "host",+ "type": "str"+ },+ "host2": {+ "description": "a host name, as in: 10.0.0.2",+ "role": "host",+ "type": "str"+ }+ },+ "name": "host-host-ping",+ "scaffold": "pyrtan new host-host-ping --from \"ping 10.0.0.1 and 10.0.0.2\"",+ "text": "ping 10.0.0.1 and 10.0.0.2",+ "trigger": "ping {host:host} and {host2:host}"+ },+ "text": "ping 10.0.0.1 and 10.0.0.2"+ },+ {+ "expect": {+ "inputs": {},+ "name": "caf-opening-hours",+ "scaffold": "pyrtan new caf-opening-hours --from \"café opening hours\"",+ "text": "café opening hours",+ "trigger": "café opening hours"+ },+ "text": "café opening hours"+ },+ {+ "expect": {+ "inputs": {},+ "name": "say-hello-user",+ "scaffold": "pyrtan new say-hello-user --from 'say \"hello\" to $USER'",+ "text": "say \"hello\" to $USER",+ "trigger": "say \"hello\" to $USER"+ },+ "text": "say \"hello\" to $USER"+ },+ {+ "expect": {+ "inputs": {},+ "name": "it-s-raining",+ "scaffold": "pyrtan new it-s-raining --from \"it's raining\"",+ "text": "it's raining",+ "trigger": "it's raining"+ },+ "text": "it's raining"+ }+ ],"pyrtan-conformance": 1,+ "readings": [+ {+ "expect": {+ "cleaned": "What is the top story on Hacker News",+ "considered": [+ {+ "inputs": {+ "count": 1+ },+ "notes": [],+ "rejected": false,+ "specificity": 29,+ "tree": "hacker-news",+ "trigger": "[what's the] top story on hacker news"+ }+ ],+ "matches": [+ {+ "inputs": {+ "count": 1+ },+ "notes": [],+ "rejected": false,+ "specificity": 29,+ "tree": "hacker-news",+ "trigger": "[what's the] top story on hacker news"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "What is the top story on Hacker News",+ "inputs": {+ "count": 1+ },+ "steps": [+ "contractions: What's->what is"+ ],+ "trigger": "[what's the] top story on hacker news"+ },+ "summary": "\"What's the top story on Hacker News?\" -> 'What is the top story on Hacker News'; hacker-news via \"[what's the] top story on hacker news\" (29 literal chars)"+ },+ "steps": [+ "contractions: What's->what is"+ ],+ "text": "What's the top story on Hacker News?"+ },+ "text": "What's the top story on Hacker News?"+ },+ {+ "expect": {+ "cleaned": "top 5 stories on hacker news",+ "considered": [+ {+ "inputs": {+ "count": 5+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "matches": [+ {+ "inputs": {+ "count": 5+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "top 5 stories on hacker news",+ "inputs": {+ "count": 5+ },+ "steps": [+ "numbers: five->5"+ ],+ "trigger": "top {count:int} stories on hacker news"+ },+ "summary": "'top five stories on hacker news' -> 'top 5 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"+ },+ "steps": [+ "numbers: five->5"+ ],+ "text": "top five stories on hacker news"+ },+ "text": "top five stories on hacker news"+ },+ {+ "expect": {+ "cleaned": "top 3 stories on hacker news",+ "considered": [+ {+ "inputs": {+ "count": 3+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "matches": [+ {+ "inputs": {+ "count": 3+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "top 3 stories on hacker news",+ "inputs": {+ "count": 3+ },+ "steps": [+ "fillers: 'hey pyrtan', 'please'"+ ],+ "trigger": "top {count:int} stories on hacker news"+ },+ "summary": "'hey pyrtan, top 3 stories on hacker news please' -> 'top 3 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"+ },+ "steps": [+ "fillers: 'hey pyrtan', 'please'"+ ],+ "text": "hey pyrtan, top 3 stories on hacker news please"+ },+ "text": "hey pyrtan, top 3 stories on hacker news please"+ },+ {+ "expect": {+ "cleaned": "weather in Oslo",+ "considered": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 18,+ "tree": "weather",+ "trigger": "[what's the] weather in {place}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 18,+ "tree": "weather",+ "trigger": "[what's the] weather in {place}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "weather in Oslo",+ "inputs": {+ "place": "Oslo"+ },+ "steps": [],+ "trigger": "[what's the] weather in {place}"+ },+ "summary": "'weather in Oslo'; weather via \"[what's the] weather in {place}\" (18 literal chars)"+ },+ "steps": [],+ "text": "weather in Oslo"+ },+ "text": "weather in Oslo"+ },+ {+ "expect": {+ "cleaned": "what is the forecast for New York",+ "considered": [+ {+ "inputs": {+ "place": "New York"+ },+ "notes": [],+ "rejected": false,+ "specificity": 19,+ "tree": "weather",+ "trigger": "[what's the] weather for {place}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "place": "New York"+ },+ "notes": [],+ "rejected": false,+ "specificity": 19,+ "tree": "weather",+ "trigger": "[what's the] weather for {place}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "what is the forecast for New York",+ "inputs": {+ "place": "New York"+ },+ "steps": [+ "contractions: what's->what is"+ ],+ "trigger": "[what's the] weather for {place}"+ },+ "summary": "\"what's the forecast for New York\" -> 'what is the forecast for New York'; weather via \"[what's the] weather for {place}\" (19 literal chars)"+ },+ "steps": [+ "contractions: what's->what is"+ ],+ "text": "what's the forecast for New York"+ },+ "text": "what's the forecast for New York"+ },+ {+ "expect": {+ "cleaned": "how cold is it in Oslo",+ "considered": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 20,+ "tree": "weather",+ "trigger": "how [hot|cold|warm] is it in {place}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 20,+ "tree": "weather",+ "trigger": "how [hot|cold|warm] is it in {place}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "how cold is it in Oslo",+ "inputs": {+ "place": "Oslo"+ },+ "steps": [],+ "trigger": "how [hot|cold|warm] is it in {place}"+ },+ "summary": "'how cold is it in Oslo'; weather via 'how [hot|cold|warm] is it in {place}' (20 literal chars)"+ },+ "steps": [],+ "text": "how cold is it in Oslo"+ },+ "text": "how cold is it in Oslo"+ },+ {+ "expect": {+ "cleaned": "is Example.com up",+ "considered": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'Example.com' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "matches": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'Example.com' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "is Example.com up",+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'Example.com' read as 'example.com'"+ ],+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is Example.com up'; site-status via 'is {host:host} up' (4 literal chars); host: 'Example.com' read as 'example.com'"+ },+ "steps": [],+ "text": "is Example.com up"+ },+ "text": "is Example.com up"+ },+ {+ "expect": {+ "cleaned": "is http://Example.com:8080/x up",+ "considered": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'http://Example.com:8080/x' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "matches": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'http://Example.com:8080/x' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "is http://Example.com:8080/x up",+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'http://Example.com:8080/x' read as 'example.com'"+ ],+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is http://Example.com:8080/x up'; site-status via 'is {host:host} up' (4 literal chars); host: 'http://Example.com:8080/x' read as 'example.com'"+ },+ "steps": [],+ "text": "is http://Example.com:8080/x up"+ },+ "text": "is http://Example.com:8080/x up"+ },+ {+ "expect": {+ "cleaned": "is not a host up",+ "considered": [],+ "matches": [],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "is not a host up",+ "steps": []+ },+ "summary": "'is not a host up'; no trigger matched"+ },+ "steps": [],+ "text": "is not a host up"+ },+ "text": "is not a host up"+ },+ {+ "expect": {+ "cleaned": "latest release of b-i-t-8/pyrtan",+ "considered": [+ {+ "inputs": {+ "repo": "b-i-t-8/pyrtan"+ },+ "notes": [],+ "rejected": false,+ "specificity": 24,+ "tree": "github-release",+ "trigger": "[what's the] latest release of {repo:word}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "repo": "b-i-t-8/pyrtan"+ },+ "notes": [],+ "rejected": false,+ "specificity": 24,+ "tree": "github-release",+ "trigger": "[what's the] latest release of {repo:word}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "latest release of b-i-t-8/pyrtan",+ "inputs": {+ "repo": "b-i-t-8/pyrtan"+ },+ "steps": [],+ "trigger": "[what's the] latest release of {repo:word}"+ },+ "summary": "'latest release of b-i-t-8/pyrtan'; github-release via \"[what's the] latest release of {repo:word}\" (24 literal chars)"+ },+ "steps": [],+ "text": "latest release of b-i-t-8/pyrtan"+ },+ "text": "latest release of b-i-t-8/pyrtan"+ },+ {+ "expect": {+ "cleaned": "how full is the disk",+ "considered": [+ {+ "inputs": {},+ "notes": [],+ "rejected": false,+ "specificity": 16,+ "tree": "disk-space",+ "trigger": "[how] full is the disk"+ }+ ],+ "matches": [+ {+ "inputs": {},+ "notes": [],+ "rejected": false,+ "specificity": 16,+ "tree": "disk-space",+ "trigger": "[how] full is the disk"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "how full is the disk",+ "inputs": {},+ "steps": [],+ "trigger": "[how] full is the disk"+ },+ "summary": "'how full is the disk'; disk-space via '[how] full is the disk' (16 literal chars)"+ },+ "steps": [],+ "text": "how full is the disk"+ },+ "text": "how full is the disk"+ },+ {+ "expect": {+ "cleaned": "disk space on /data",+ "considered": [+ {+ "inputs": {+ "mount": "/data"+ },+ "notes": [],+ "rejected": false,+ "specificity": 11,+ "tree": "disk-space",+ "trigger": "disk space on {mount:word}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "mount": "/data"+ },+ "notes": [],+ "rejected": false,+ "specificity": 11,+ "tree": "disk-space",+ "trigger": "disk space on {mount:word}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "disk space on /data",+ "inputs": {+ "mount": "/data"+ },+ "steps": [],+ "trigger":
The diff is cut here; the rest is in the clone.