pyrtan / sourcev0.19.0 · main

Merge pull request #23 from b-i-t-8/claude/great-heisenberg-o6ocah

b-i-t-8 · 2026-10-27 · 0a4767f2d2116b51f7e50dd2276d8c00af7c969c · parents 5529bcf 461a7d1

Make the console a hub of its own until it joins one

Changes

 README.md                         |   15 +-
 _meta/members.md                  |    9 +-
 _meta/platform.md                 |   31 +-
 site/README.md                    |    4 +-
 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 +-
 14 files changed, 7055 insertions(+), 77 deletions(-)
diff --git a/README.md b/README.md
index a57992e..746f95a 100644
--- a/README.md
+++ b/README.md
@@ -172,8 +172,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. |
`serve` and `hub` take `--token` (or `$PYRTAN_TOKEN`) to require `Authorization: Bearer` on everything but `/health` and the console, and
@@ -192,8 +192,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** the
primary 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 5ca5641..4444669 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 5edc863..62deacb 100644 --- a/_meta/platform.md +++ b/_meta/platform.md
@@ -16,7 +16,8 @@ self-hosted one.** The console, the command, the catalog client and any
future 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
@@ -145,10 +146,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 3f20c74..76a30de 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 | `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 | `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/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 f83f467..8a79f27 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -45,7 +45,7 @@ from pathlib import Path
from 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_url
from .federation import bootstrap_candidates from .grow import Proposal, propose from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER
@@ -1165,7 +1165,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
@@ -1203,7 +1206,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 == False
if (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 525ecf1..de3b7c6 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 static
host 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.
""" from __future__ import annotations
@@ -105,10 +113,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>
@@ -121,7 +130,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>
@@ -145,7 +154,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>
@@ -173,7 +182,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>
@@ -255,7 +264,7 @@ GET /unanswered
GET /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>
@@ -279,9 +288,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 = "";
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;
@@ -351,7 +365,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; }
@@ -360,7 +375,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; }
@@ -378,26 +439,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)); }
@@ -487,7 +550,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) {
@@ -497,6 +560,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);
@@ -805,7 +869,8 @@ $("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leav
document.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(); } });
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: E402
CLOCK = _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": "disk space on {mount:word}"
+ },
+ "summary": "'disk space on /data'; disk-space via 'disk space on {mount:word}' (11 literal chars)"
+ },
+ "steps": [],
+ "text": "disk space on /data"
+ },
+ "text": "disk space on /data"
+ },
+ {
+ "expect": {
+ "cleaned": "the temperature in the attic",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "the temperature in the attic",
+ "steps": []
+ },
+ "summary": "'the temperature in the attic'; no trigger matched"
+ },
+ "steps": [],
+ "text": "the temperature in the attic"
+ },
+ "text": "the temperature in the attic"
+ },
+ {
+ "expect": {
+ "cleaned": "headline of https://app.example/dashboard",
+ "considered": [
+ {
+ "inputs": {
+ "url": "https://app.example/dashboard"
+ },
+ "notes": [],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "rendered-headline",
+ "trigger": "headline of {url:url}"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "url": "https://app.example/dashboard"
+ },
+ "notes": [],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "rendered-headline",
+ "trigger": "headline of {url:url}"
+ }
+ ],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "headline of https://app.example/dashboard",
+ "inputs": {
+ "url": "https://app.example/dashboard"
+ },
+ "steps": [],
+ "trigger": "headline of {url:url}"
+ },
+ "summary": "'headline of https://app.example/dashboard'; rendered-headline via 'headline of {url:url}' (10 literal chars)"
+ },
+ "steps": [],
+ "text": "headline of https://app.example/dashboard"
+ },
+ "text": "headline of https://app.example/dashboard"
+ },
+ {
+ "expect": {
+ "cleaned": "is the modem on /dev/ttyUSB1 up",
+ "considered": [
+ {
+ "inputs": {
+ "device": "/dev/ttyUSB1"
+ },
+ "notes": [],
+ "rejected": false,
+ "specificity": 14,
+ "tree": "modem-check",
+ "trigger": "is the modem on {device:word} up"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "device": "/dev/ttyUSB1"
+ },
+ "notes": [],
+ "rejected": false,
+ "specificity": 14,
+ "tree": "modem-check",
+ "trigger": "is the modem on {device:word} up"
+ }
+ ],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "is the modem on /dev/ttyUSB1 up",
+ "inputs": {
+ "device": "/dev/ttyUSB1"
+ },
+ "steps": [],
+ "trigger": "is the modem on {device:word} up"
+ },
+ "summary": "'is the modem on /dev/ttyUSB1 up'; modem-check via 'is the modem on {device:word} up' (14 literal chars)"
+ },
+ "steps": [],
+ "text": "is the modem on /dev/ttyUSB1 up"
+ },
+ "text": "is the modem on /dev/ttyUSB1 up"
+ },
+ {
+ "expect": {
+ "cleaned": "make me a sandwich",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "make me a sandwich",
+ "steps": []
+ },
+ "summary": "'make me a sandwich'; no trigger matched"
+ },
+ "steps": [],
+ "text": "make me a sandwich"
+ },
+ "text": "make me a sandwich"
+ },
+ {
+ "expect": {
+ "cleaned": "",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "",
+ "steps": []
+ },
+ "summary": "''; no trigger matched"
+ },
+ "steps": [],
+ "text": ""
+ },
+ "text": ""
+ },
+ {
+ "expect": {
+ "cleaned": "",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "",
+ "steps": []
+ },
+ "summary": "' ?! ' -> ''; no trigger matched"
+ },
+ "steps": [],
+ "text": " ?! "
+ },
+ "text": " ?! "
+ },
+ {
+ "expect": {
+ "cleaned": "tell me the weather in Zürich",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "tell me the weather in Zürich",
+ "steps": [
+ "fillers: 'Could you please', 'thanks'"
+ ]
+ },
+ "summary": "'Could you please tell me the weather in Zürich, thanks!' -> 'tell me the weather in Zürich'; no trigger matched"
+ },
+ "steps": [
+ "fillers: 'Could you please', 'thanks'"
+ ],
+ "text": "Could you please tell me the weather in Zürich, thanks!"
+ },
+ "text": "Could you please tell me the weather in Zürich, thanks!"
+ },
+ {
+ "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": [],
+ "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)"
+ },
+ "steps": [],
+ "text": "TOP 3 STORIES ON HACKER NEWS"
+ },
+ "text": "TOP 3 STORIES ON HACKER NEWS"
+ },
+ {
+ "expect": {
+ "cleaned": "top 21 stories on hacker news",
+ "considered": [
+ {
+ "inputs": {
+ "count": 21
+ },
+ "notes": [],
+ "rejected": false,
+ "specificity": 22,
+ "tree": "hacker-news",
+ "trigger": "top {count:int} stories on hacker news"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "count": 21
+ },
+ "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 21 stories on hacker news",
+ "inputs": {
+ "count": 21
+ },
+ "steps": [
+ "numbers: twenty-one->21"
+ ],
+ "trigger": "top {count:int} stories on hacker news"
+ },
+ "summary": "'top twenty-one stories on hacker news' -> 'top 21 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"
+ },
+ "steps": [
+ "numbers: twenty-one->21"
+ ],
+ "text": "top twenty-one stories on hacker news"
+ },
+ "text": "top twenty-one stories on hacker news"
+ },
+ {
+ "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": [
+ "fillers: 'for me'"
+ ],
+ "trigger": "[what's the] weather in {place}"
+ },
+ "summary": "'weather in Oslo for me' -> 'weather in Oslo'; weather via \"[what's the] weather in {place}\" (18 literal chars)"
+ },
+ "steps": [
+ "fillers: 'for me'"
+ ],
+ "text": "weather in Oslo for me"
+ },
+ "text": "weather in Oslo for me"
+ },
+ {
+ "expect": {
+ "cleaned": "",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "",
+ "steps": [
+ "fillers: 'hello'"
+ ]
+ },
+ "summary": "'hello' -> ''; no trigger matched"
+ },
+ "steps": [
+ "fillers: 'hello'"
+ ],
+ "text": "hello"
+ },
+ "text": "hello"
+ },
+ {
+ "expect": {
+ "cleaned": "",
+ "considered": [],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "",
+ "steps": [
+ "fillers: 'pyrtan'"
+ ]
+ },
+ "summary": "'pyrtan' -> ''; no trigger matched"
+ },
+ "steps": [
+ "fillers: 'pyrtan'"
+ ],
+ "text": "pyrtan"
+ },
+ "text": "pyrtan"
+ },
+ {
+ "expect": {
+ "cleaned": "remind me tomorrow at 3pm",
+ "considered": [
+ {
+ "inputs": {
+ "at": "15:00",
+ "when": "2026-10-26"
+ },
+ "notes": [
+ "when: 'tomorrow' read as '2026-10-26'",
+ "at: '3pm' read as '15:00'"
+ ],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "at": "15:00",
+ "when": "2026-10-26"
+ },
+ "notes": [
+ "when: 'tomorrow' read as '2026-10-26'",
+ "at: '3pm' read as '15:00'"
+ ],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "remind me tomorrow at 3pm",
+ "inputs": {
+ "at": "15:00",
+ "when": "2026-10-26"
+ },
+ "notes": [
+ "when: 'tomorrow' read as '2026-10-26'",
+ "at: '3pm' read as '15:00'"
+ ],
+ "steps": [],
+ "trigger": "remind me {when:date} at {at:time}"
+ },
+ "summary": "'remind me tomorrow at 3pm'; remind via 'remind me {when:date} at {at:time}' (10 literal chars); when: 'tomorrow' read as '2026-10-26'; at: '3pm' read as '15:00'"
+ },
+ "steps": [],
+ "text": "remind me tomorrow at 3pm"
+ },
+ "text": "remind me tomorrow at 3pm"
+ },
+ {
+ "expect": {
+ "cleaned": "remind me next friday at noon",
+ "considered": [
+ {
+ "inputs": {
+ "at": "12:00",
+ "when": "2026-10-30"
+ },
+ "notes": [
+ "when: 'next friday' read as '2026-10-30'",
+ "at: 'noon' read as '12:00'"
+ ],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "at": "12:00",
+ "when": "2026-10-30"
+ },
+ "notes": [
+ "when: 'next friday' read as '2026-10-30'",
+ "at: 'noon' read as '12:00'"
+ ],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "remind me next friday at noon",
+ "inputs": {
+ "at": "12:00",
+ "when": "2026-10-30"
+ },
+ "notes": [
+ "when: 'next friday' read as '2026-10-30'",
+ "at: 'noon' read as '12:00'"
+ ],
+ "steps": [],
+ "trigger": "remind me {when:date} at {at:time}"
+ },
+ "summary": "'remind me next friday at noon'; remind via 'remind me {when:date} at {at:time}' (10 literal chars); when: 'next friday' read as '2026-10-30'; at: 'noon' read as '12:00'"
+ },
+ "steps": [],
+ "text": "remind me next friday at noon"
+ },
+ "text": "remind me next friday at noon"
+ },
+ {
+ "expect": {
+ "cleaned": "remind me 25 dec at 9:30 pm",
+ "considered": [
+ {
+ "inputs": {
+ "at": "21:30",
+ "when": "2026-12-25"
+ },
+ "notes": [
+ "when: '25 dec' read as '2026-12-25'",
+ "at: '9:30 pm' read as '21:30'"
+ ],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "matches": [
+ {
+ "inputs": {
+ "at": "21:30",
+ "when": "2026-12-25"
+ },
+ "notes": [
+ "when: '25 dec' read as '2026-12-25'",
+ "at: '9:30 pm' read as '21:30'"
+ ],
+ "rejected": false,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "remind me 25 dec at 9:30 pm",
+ "inputs": {
+ "at": "21:30",
+ "when": "2026-12-25"
+ },
+ "notes": [
+ "when: '25 dec' read as '2026-12-25'",
+ "at: '9:30 pm' read as '21:30'"
+ ],
+ "steps": [],
+ "trigger": "remind me {when:date} at {at:time}"
+ },
+ "summary": "'remind me 25 dec at 9:30 pm'; remind via 'remind me {when:date} at {at:time}' (10 literal chars); when: '25 dec' read as '2026-12-25'; at: '9:30 pm' read as '21:30'"
+ },
+ "steps": [],
+ "text": "remind me 25 dec at 9:30 pm"
+ },
+ "text": "remind me 25 dec at 9:30 pm"
+ },
+ {
+ "expect": {
+ "cleaned": "remind me 3/4 at 10am",
+ "considered": [
+ {
+ "inputs": {},
+ "notes": [
+ "slot when: '3/4' could be day/month or month/day; write it as 2026-04-03 or '3 april'"
+ ],
+ "rejected": true,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "remind me 3/4 at 10am",
+ "steps": []
+ },
+ "summary": "'remind me 3/4 at 10am'; no trigger matched"
+ },
+ "steps": [],
+ "text": "remind me 3/4 at 10am"
+ },
+ "text": "remind me 3/4 at 10am"
+ },
+ {
+ "expect": {
+ "cleaned": "remind me 31/12 at 25:00",
+ "considered": [
+ {
+ "inputs": {},
+ "notes": [
+ "when: '31/12' read as '2026-12-31'",
+ "slot at: '25:00' is not a time"
+ ],
+ "rejected": true,
+ "specificity": 10,
+ "tree": "remind",
+ "trigger": "remind me {when:date} at {at:time}"
+ }
+ ],
+ "matches": [],
+ "now": "2026-10-25T12:00:00+00:00",
+ "step": {
+ "kind": "intent",
+ "node": "intent",
+ "observed": {
+ "cleaned": "remind me 31/12 at 25:00",
+ "steps": []
+ },
+ "summary": "'remind me 31/12 at 25:00'; no trigger matched"
+ },
+ "steps": [],
+ "text": "remind me 31/12 at 25:00"
+ },
+ "text": "remind me 31/12 at 25:00"
+ },
+ {
+ "expect": {
+ "cleaned": "remind me in 2 hours",
+ "

The diff is cut here; the rest is in the clone.