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** theprimary and **tan** the secondary: click either half, right-click it, or press and hold it on a phone to pick that half's colour; tan's picker also sets light, dark or auto mode. The choice is kept in the browser. To run the swarm on a NAS or any Docker host, diff --git a/_meta/members.md b/_meta/members.md index 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 anyfuture integration speak only the public API (`/api`, `/health`, `/capabilities`, `/peers`, `/intent`, `/run`, `/resume`, `/gaps`), with a bearer token when the operator set one. Nothing hosted gets a private-endpoint.+endpoint. A browser that has joined no hub answers the same API itself+(§2.5), so the console cannot tell that apart either.The consequence for the business: what people pay for lives *around* the runtime, not in it. The runtime stays whole and AGPL; the paid product is@@ -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 Pathfrom typing import Any from . import __version__-from .catalog import Catalog, CatalogError, build_index, catalog_url+from .catalog import Catalog, CatalogError, build_bundle, build_index, catalog_urlfrom .federation import bootstrap_candidates from .grow import Proposal, propose from .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 == Falseif (s.required && (s.default === null || s.default === undefined)) d.required = true; if (s.description) d.description = s.description; inputs[n] = d;@@ -615,8 +1204,8 @@cap.sources = this.sources(); const watches = Array.isArray(this.raw.watches) ? this.raw.watches : []; if (watches.length) cap.watches = watches.map((w) => { const kind = ["every", "file", "queue", "serial"].find((k) => k in w) || "?"; return { kind, what: kind === "every" ? String(w.every) : kind === "file" ? String(w.file) : kind === "queue" ? `${w.queue && w.queue.topic} at ${w.queue && w.queue.url}` : String(w.serial && w.serial.device) }; });- const fixed = this.triggers.filter((t) => t.inputs && Object.keys(t.inputs).length);- return Object.assign(cap, { license: this.license, language: this.language, triggers: this.triggers.map((t) => t.pattern) }, fixed.length ? { trigger_inputs: Object.fromEntries(fixed.map((t) => [t.pattern, t.inputs])) } : {}, { inputs });+ const fixed = this.triggers.filter((t) => Object.keys(t.fixedInputs).length);+ return Object.assign(cap, { license: this.license, language: languageDict(this.language), triggers: this.triggers.map((t) => t.pattern) }, fixed.length ? { trigger_inputs: Object.fromEntries(fixed.map((t) => [t.pattern, t.fixedInputs])) } : {}, { inputs });} }@@ -638,7 +1227,9 @@const h = {}; r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; }); return new Response(r.status, h, text, r.url || url, Date.now() - started); } catch (e) {- throw new TransportError(`${method} ${url}: ${e && e.message ? e.message : e}`);+ // A browser hides why: most often the site does not let pages on other origins read it (CORS).+ const why = typeof window !== "undefined" && e instanceof TypeError ? " (the site may not let a page in a browser read it; a hub can)" : "";+ throw new TransportError(`${method} ${url}: ${e && e.message ? e.message : e}${why}`);} finally { if (timer) clearTimeout(timer); } } }@@ -731,9 +1322,42 @@} return this.run(tree, inputs || {}, { depth }); }+ candidates() {+ const out = [];+ for (const name of Object.keys(this.trees).sort()) for (const trig of this.trees[name].triggers) out.push([name, trig]);+ return out;+ }+ read(text) { return read(text, this.candidates(), this.clock()); }+ // Read a sentence and run the tree it names; the trace begins with the intent step.async runIntent(text, depth = 0) {- if (this.resolver) return fromDict(await this.resolver.runIntent(text, depth), "");- return outcome("error", "", { reason: "no-match", say: `Nothing here answers ${pyrepr(text)}.` });+ const reading = this.read(text);+ if (!reading.matches.length) {+ if (this.resolver) return fromDict(await this.resolver.runIntent(text, depth), "");+ const nm = noMatchOutcome(text, "Nothing here");+ nm.output.reading = { cleaned: reading.cleaned, steps: reading.steps, considered: reading.considered.map((m) => m.trigger.pattern) };+ return outcome("error", "", Object.assign({ reason: "no-match" }, nm));+ }+ const trees = [...new Set(reading.matches.map((m) => m.tree))].sort();+ if (trees.length > 1) return outcome("error", "", { reason: "ambiguous", say: `${pyrepr(text)} matches more than one tree equally well: ${trees.join(", ")}`, output: { candidates: trees, reading: { cleaned: reading.cleaned, steps: reading.steps } } });+ const match = reading.matches[0], tree = this.get(match.tree);+ const trace = new Trace(tree.name), st = reading.step();+ trace.add(step(st.node, st.kind, st.summary, { observed: st.observed, next: tree.start, depth }));+ return this.run(tree, match.inputs, { depth, trace });+ }+ // Continue a run paused by ask: the answer goes where the ask node said.+ async resume(snapshot, answer) {+ const tree = this.get(snapshot.tree);+ if (!tree) return outcome("error", snapshot.tree || "", { reason: "no-such-tree" });+ const node = tree.node(snapshot.node), into = String(node.get("into"));+ const inputs = Object.assign({}, snapshot.inputs), state = Object.assign({}, snapshot.state);+ if (into.startsWith("inputs.")) {+ const key = into.slice(7);+ try { inputs[key] = coerce(tree.inputs[key], answer); } catch (e) { if (e instanceof TreeError) return outcome("error", tree.name, { reason: "bad-input", say: e.message }); throw e; }+ } else put(state, into.startsWith("state.") ? into.slice(6) : into, answer);+ const trace = new Trace(tree.name);+ trace.steps = (snapshot.trace || []).map((x) => step(x.node, x.kind, x.summary, { observed: x.observed === undefined ? null : x.observed, chosen: x.chosen === undefined ? null : x.chosen, next: x.next === undefined ? null : x.next, elapsed_ms: x.elapsed_ms || 0, depth: x.depth || 0 }));+ trace.add(step(node.id, "ask", `answered: ${brief(answer)}`, { next: node.next }));+ return this.run(tree, inputs, { start: node.next, state, trace });} async run(tree, inputs, opts) { opts = opts || {};@@ -805,7 +1429,7 @@call = async () => (await this.transport.request(method, url, headers, body === undefined ? null : body, timeout)).toState(); } else { label = `${sourceName} ${what(sourceName, spec)}`;- if (!this.adapters) return outcome("error", tree.name, { reason: "source-unsupported", say: `${node.id}: a ${sourceName} node runs on a service, not on a member; this device has no ${sourceName === "browser" ? "browser of its own to drive" : sourceName === "queue" ? "socket to a broker" : "access to the box's " + (sourceName === "file" ? "files" : sourceName === "shell" ? "programs" : "devices")}.` });+ if (!this.adapters) return outcome("error", tree.name, { reason: "source-unsupported", say: `${node.id}: a ${sourceName} node runs on a service, not here; this device has no ${sourceName === "browser" ? "browser of its own to drive" : sourceName === "queue" ? "socket to a broker" : "access to the box's " + (sourceName === "file" ? "files" : sourceName === "shell" ? "programs" : "devices")}.` });call = () => this.adapters.perform(sourceName, spec, timeout, (u) => tree.hostAllowed(u)); } const attempts = retry ? 1 + Number(node.get("retries", DEFAULT_RETRIES)) : 1;@@ -912,5 +1536,99 @@return outcome(d.status || "error", d.tree || fallbackTree, { say: d.say === undefined ? null : d.say, output: d.output === undefined ? null : d.output, reason: d.reason === undefined ? null : d.reason, question: d.question === undefined ? null : d.question, snapshot: d.snapshot === undefined ? null : d.snapshot, trace, inputs: d.inputs || {} }); }- return { MISSING, Tree, Runner, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: "0.10.0" };+ // ------------------------------------------------------------ the hub in a page+ // A browser that has joined no hub is a hub of its own, for as long as the+ // page is open: it holds trees (the catalog's, as a page loads them), reads+ // sentences, runs the trees and keeps the gaps and the unanswered sentences+ // in memory. It answers the same paths with the same shapes as a service+ // (service.py), so the console talks to it exactly as it talks to a hub.+ // What it cannot do is reach what only a box can: files, programs, serial+ // lines, brokers, a browser to drive, and sites that do not let a page read+ // them. Those trees answer with the reason, and joining a hub is the cure.+ const UNANSWERED_LIMIT = 200;+ class LocalHub {+ constructor(opts) {+ opts = opts || {};+ this.name = opts.name || "this browser";+ this.version = opts.version || VERSION;+ this.clock = opts.clock || (() => new Date());+ this.wall = opts.wall || (() => Date.now() / 1000);+ this.runner = new Runner({ transport: opts.transport || new FetchTransport(), adapters: opts.adapters || null, clock: this.clock, env: opts.env });+ this.errors = {}; this.held = {};+ this.gaps = new Map(); this.unanswered = new Map();+ this.runs = 0; this.started = this.wall();+ for (const raw of opts.trees || []) this.add(raw);+ }+ // where: what to call the tree in errors (a catalog id). The first tree of a name is the one held.+ add(raw, where) {+ const name = raw && raw.name;+ if (name && this.runner.get(name) && this.held[name] !== (where || name)) { this.errors[where || name] = `a tree named ${pyrepr(name)} is already held, from ${this.held[name]}`; return null; }+ try { const t = this.runner.add(raw); this.held[t.name] = where || t.name; delete this.errors[where || t.name]; return t; }+ catch (e) { if (!(e instanceof TreeError)) throw e; this.errors[where || (raw && raw.name) || "?"] = e.message; return null; }+ }+ trees() { return Object.keys(this.runner.trees).sort().map((n) => this.runner.trees[n]); }+ health() {+ return { status: "ok", name: this.name, trees: this.trees().map((t) => t.name), errors: this.errors, runs: this.runs, gaps: this.gaps.size, unanswered: this.unanswered.size, uptime_s: Math.floor(this.wall() - this.started), watches: 0 };+ }+ capabilities() { return { name: this.name, trees: this.trees().map((t) => t.capability()) }; }+ noteGaps(gaps) {+ const now = this.wall();+ for (const g of gaps) {+ const key = JSON.stringify([g.tree, g.node, g.on, g.observed === undefined ? null : g.observed]);+ const cur = this.gaps.get(key);+ if (cur) { cur.count += 1; cur.last_seen = Math.max(cur.last_seen, now); }+ else this.gaps.set(key, { tree: g.tree, node: g.node, on: g.on, observed: g.observed === undefined ? null : g.observed, count: 1, first_seen: now, last_seen: now });+ }+ }+ noteUnanswered(text) {+ const now = this.wall(), key = normalise(text).toLowerCase() || text;+ let e = this.unanswered.get(key);+ if (!e) { e = { text: normalise(text) || text, count: 0, first: now, last: now, proposal: propose(text) }; this.unanswered.set(key, e); }+ e.count += 1; e.last = Math.max(e.last, now);+ while (this.unanswered.size > UNANSWERED_LIMIT) {+ let oldest = null;+ for (const [k, v] of this.unanswered) if (oldest === null || v.last < this.unanswered.get(oldest).last) oldest = k;+ this.unanswered.delete(oldest);+ }+ }+ finish(o, body) {+ this.runs += 1;+ if (o.trace && o.trace.gaps && o.trace.gaps.length) this.noteGaps(o.trace.gaps);+ return outcomeDict(o, body.trace === undefined ? true : Boolean(body.trace));+ }+ async intent(body) {+ const text = body.text;+ if (typeof text !== "string" || !text.trim()) return { status: "error", reason: "bad-request", say: 'Send {"text": "..."}.' };+ const o = await this.runner.runIntent(text, Math.trunc(Number(body.depth || 0)));+ if (o.reason === "no-match") this.noteUnanswered(text);+ return this.finish(o, body);+ }+ async run(body) {+ if (typeof body.tree !== "string") return { status: "error", reason: "bad-request", say: 'Send {"tree": "...", "inputs": {...}}.' };+ return this.finish(await this.runner.runTree(body.tree, body.inputs || {}, Math.trunc(Number(body.depth || 0))), body);+ }+ async resume(body) {+ const snap = body.snapshot;+ if (!isObj(snap) || !("tree" in snap)) return { status: "error", reason: "bad-request", say: 'Send {"snapshot": {...}, "answer": ...}.' };+ return this.finish(await this.runner.resume(snap, body.answer === undefined ? null : body.answer), body);+ }+ // (method, path, body) -> [status, payload], as service.py's route.+ async route(method, path, body) {+ method = String(method || "GET").toUpperCase();+ path = "/" + String(path || "").split("?")[0].replace(/^\/+/, "");+ body = isObj(body) ? body : {};+ if (method === "GET" && path === "/api") return [200, { pyrtan: "browser", name: this.name, version: this.version, auth: "none", endpoints: ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches"] }];+ if (method === "GET" && path === "/health") return [200, this.health()];+ if (method === "GET" && path === "/capabilities") return [200, this.capabilities()];+ if (method === "GET" && path === "/gaps") return [200, { gaps: [...this.gaps.values()].map((g) => Object.assign({}, g)).sort((a, b) => b.count - a.count || a.last_seen - b.last_seen) }];+ if (method === "GET" && path === "/unanswered") return [200, { unanswered: [...this.unanswered.values()].map((e) => Object.assign({}, e)).sort((a, b) => b.count - a.count || b.last - a.last) }];+ if (method === "GET" && path === "/watches") return [200, { count: 0, watches: [], watching: false }];+ if (method === "POST" && path === "/intent") return [200, await this.intent(body)];+ if (method === "POST" && path === "/run") return [200, await this.run(body)];+ if (method === "POST" && path === "/resume") return [200, await this.resume(body)];+ return [404, { status: "error", reason: "not-found", say: `No ${method} ${path} here.` }];+ }+ }++ return { MISSING, Tree, Runner, LocalHub, Reading, nlp, intent, read, propose, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: VERSION };}); diff --git a/src/pyrtan/nlp.py b/src/pyrtan/nlp.py index 9e3844a..34e33f6 100644 --- a/src/pyrtan/nlp.py +++ b/src/pyrtan/nlp.py@@ -490,10 +490,21 @@ def _weekday(word: str) -> int | None:def _date(year: int, month: int, day: int, text: str) -> _dt.date:- try:+ # Checked here rather than left to datetime, whose wording changes+ # between Python versions; pyrtan.js says the same words.+ if not 1 <= year <= 9999:+ why = f"year {year} is out of range"+ elif not 1 <= month <= 12:+ why = "month must be in 1..12"+ elif not 1 <= day <= _days_in(year, month):+ why = "day is out of range for month"+ else:return _dt.date(year, month, day)- except ValueError as e:- raise ParseError(f"{text!r}: {e}") from e+ raise ParseError(f"{text!r}: {why}")+++def _days_in(year: int, month: int) -> int:+ return [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]def _shift(day: _dt.date, n: int, unit: str) -> _dt.date:@@ -504,8 +515,7 @@ def _shift(day: _dt.date, n: int, unit: str) -> _dt.date:return day + _dt.timedelta(weeks=n) months = day.month - 1 + (n if unit == "month" else 12 * n) year, month = day.year + months // 12, months % 12 + 1- last = [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]- return _dt.date(year, month, min(day.day, last))+ return _dt.date(year, month, min(day.day, _days_in(year, month)))def parse_host(text: str) -> str: diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 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 statichost somewhere else. Served by a hub or service, it talks to the origin it came from. Served-from elsewhere (it finds out by asking ``api`` on its own origin), it shows-a connect box for a hub URL and a token, keeps them in the browser's local-storage, and sends the token as a bearer. The hub must then allow the-console's origin with ``--cors``. No external assets, no build step.+from elsewhere (it finds out by asking ``api`` on its own origin), the+browser is the hub: ``pyrtan.js``'s ``LocalHub`` answers the same+endpoints inside the page, with the catalog's trees (its ``trees.json``),+reading sentences and running trees here, keeping gaps and unanswered+sentences for as long as the page is open. Trees that need a box (files,+programs, devices, brokers, a browser to drive) or a site that will not+let a page read it say so. Joining a hub is explicit: its URL and token in+the connect box, kept in the browser's local storage, the token sent as a+bearer; the hub must allow the console's origin with ``--cors``.+Forgetting the hub makes the browser its own hub again. No external+assets, no build step.""" 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 /unansweredGET /peers (hub only) POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat) POST /chat/typing {"seat": "…", "typing": true}</pre>- <div class="mute" style="font-size:14px;margin-top:8px">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div>+ <div class="mute" style="font-size:14px;margin-top:8px" id="api-help">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div></div> </section>@@ -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.leavdocument.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } }); window.addEventListener("pagehide", () => chat.leave(true));-$("#forget").addEventListener("click", () => { store.clear(); base = ""; token = ""; $("#hub-url").value = ""; $("#hub-token").value = ""; $("#who").textContent = "not connected"; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); });+// Forgetting the hub makes this browser its own hub again.+$("#forget").addEventListener("click", () => { chat.leave(true); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });load(); </script> </body> diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index dc40a4a..1e8c464 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -1,12 +1,19 @@"""Write tests/conformance/vectors.json: what both runtimes must say. Every example tree as JSON (the TOML's own table), and for a fixed set of-runs (a sentence read by the Python matcher, or a tree and inputs) the-outcome the Python runtime produced against the recorded responses in-tests/fixtures/examples.json, with a fixed clock. The JavaScript runtime-(src/pyrtan/js/pyrtan.js) must produce the same status, say, output,-reason, path and gaps; tests/conformance/run.cjs checks it, and-tests/test_conformance.py keeps this file from drifting.+runs (a sentence, or a tree and inputs) the outcome the Python runtime+produced against the recorded responses in tests/fixtures/examples.json,+with a fixed clock. The JavaScript runtime (src/pyrtan/js/pyrtan.js) must+produce the same status, say, output, reason, path and gaps.++Since the browser reads sentences too (it is a hub of its own until it+joins one), the vectors also hold how Python reads: every sentence's+reading (the rewrites, each trigger's verdict, the intent step), the+language rules and value parsers one by one, the tree proposed for a+sentence nobody answers, and a sequence of requests to a service and+what it answered, which pyrtan.js's LocalHub must answer the same.+tests/conformance/run.cjs checks it all, and tests/test_conformance.py+keeps this file from drifting.uv run python tests/conformance/make_vectors.py """@@ -22,6 +29,10 @@ ROOT = Path(__file__).resolve().parents[2]sys.path.insert(0, str(ROOT / "src")) from pyrtan import Registry, RecordedTransport, Runner # noqa: E402+from pyrtan import nlp # noqa: E402+from pyrtan.grow import propose # noqa: E402+from pyrtan.service import Service # noqa: E402+from pyrtan.tree import build as build_tree # noqa: E402CLOCK = _dt.datetime(2026, 10, 25, 12, 0, tzinfo=_dt.timezone.utc) CASES: list[dict] = [@@ -69,6 +80,143 @@ CASES: list[dict] = []+# Trees that exercise what the examples do not: every slot kind, synonyms+# (a word and a phrase), typos, fixed inputs, a tie, and an ask.+def _echo(name: str, triggers: list, inputs: dict, **extra) -> dict:+ return {"name": name, "version": 1, "description": f"echo for {name}", "hosts": [], "start": "reply", "triggers": triggers,+ "inputs": inputs, "nodes": {"reply": {"kind": "done", "say": "ok", "output": {k: f"{{{{ inputs.{k} }}}}" for k in inputs}}}, **extra}+++LANGUAGE_TREES: list[dict] = [+ _echo("remind", ["remind me {when:date} at {at:time}", "remind me in {wait:duration}"], {"when": {"type": "str", "default": ""}, "at": {"type": "str", "default": ""}, "wait": {"type": "int", "default": 0}}),+ _echo("count", ["count to {n:number}", "add {x:float} and {y:int}"], {"n": {"type": "float", "default": 0}, "x": {"type": "float", "default": 0.0}, "y": {"type": "int", "default": 0}}),+ _echo("ping", ["ping {host:host}", "fetch {url:url}", "mail {to:email}"], {"host": {"type": "str", "default": ""}, "url": {"type": "str", "default": ""}, "to": {"type": "str", "default": ""}}),+ _echo("books", ["(find|search) books by {author}", "top story on book club"], {"author": {"type": "str", "default": ""}, "count": {"type": "int", "default": 3}},+ language={"synonyms": {"books": ["novels", "paperback books"], "Find": "look up"}, "typos": True}),+ _echo("tie-a", ["shout {x:word}"], {"x": {"type": "str", "default": ""}}),+ _echo("tie-b", ["shout {y:word}"], {"y": {"type": "str", "default": ""}}),+ _echo("fixed", [{"pattern": "the usual", "inputs": {"what": "coffee"}}], {"what": {"type": "str", "default": "tea"}}),+ {"name": "order", "version": 1, "description": "asks, then answers", "hosts": [], "start": "size",+ "triggers": ["order a {drink:word}"], "inputs": {"drink": {"type": "str", "required": True}, "shots": {"type": "int", "default": 1}},+ "nodes": {"size": {"kind": "ask", "prompt": "What size of {{ inputs.drink }}?", "into": "state.size", "next": "shots"},+ "shots": {"kind": "ask", "prompt": "How many shots?", "into": "inputs.shots", "next": "reply"},+ "reply": {"kind": "done", "say": "A {{ state.size }} {{ inputs.drink }}, {{ inputs.shots }} shot(s).", "output": {"size": "{{ state.size }}", "shots": "{{ inputs.shots }}"}}}},+]++READINGS = [+ # the examples, read+ "What's the top story on Hacker News?", "top five stories on hacker news", "hey pyrtan, top 3 stories on hacker news please",+ "weather in Oslo", "what's the forecast for New York", "how cold is it in Oslo", "is Example.com up", "is http://Example.com:8080/x up",+ "is not a host up", "latest release of b-i-t-8/pyrtan", "how full is the disk", "disk space on /data", "the temperature in the attic",+ "headline of https://app.example/dashboard", "is the modem on /dev/ttyUSB1 up", "make me a sandwich", "", " ?! ",+ "Could you please tell me the weather in Zürich, thanks!", "TOP 3 STORIES ON HACKER NEWS", "top twenty-one stories on hacker news",+ "weather in Oslo for me", "hello", "pyrtan",+ # the language trees, read+ "remind me tomorrow at 3pm", "remind me next friday at noon", "remind me 25 dec at 9:30 pm", "remind me 3/4 at 10am",+ "remind me 31/12 at 25:00", "remind me in 2 hours", "remind me in an hour and a half", "remind me in 1h30m", "remind me in half an hour",+ "remind me in 2 parsecs", "count to two hundred and five", "count to 1,000.5", "count to 2.0", "count to a dozen", "add 1.5 and 3",+ "ping localhost", "ping 10.0.0.1:22", "ping not_a_host", "fetch https://example.com/a?b=c", "fetch ftp://x", "mail Barry@Example.COM",+ "mail nobody", "find books by Ursula Le Guin", "search novels by Le Guin", "look up paperback books by Le Guin", "finnd books by Le Guin",+ "serch books by Le Guin", "top stories on book club", "shout hello", "the usual", "order a latte", "it's the usual",+]++NLP = [+ ("clean", "Hey, can you please tell me what's up?"), ("clean", "“Curly” quotes — and dashes"), ("clean", "FULL width"),+ ("clean", "I'm sure it's a dozen"), ("clean", "one hundred and twenty three things"), ("clean", "a hundred and one"),+ ("clean", "Thanks"), ("clean", "well, um, okay pyrtan: weather."), ("clean", "St. Ives. Then Mr. Smith"),+ ("singular", "stories"), ("singular", "boxes"), ("singular", "news"), ("singular", "glass"), ("singular", "cats"), ("singular", "bus"),+ ("plural", "story"), ("plural", "box"), ("plural", "day"), ("plural", "status"),+ ("parse_number", "twenty-one"), ("parse_number", "1,234"), ("parse_number", "-3.25"), ("parse_number", "lots"),+ ("parse_duration", "90 seconds"), ("parse_duration", "2 days and 3 hours"), ("parse_duration", "a week"), ("parse_duration", "1.5 hours and a half"),+ ("parse_duration", "soon"), ("parse_duration", "3 fortnights"),+ ("parse_time", "15:30"), ("parse_time", "3:05 pm"), ("parse_time", "9 o'clock"), ("parse_time", "12am"), ("parse_time", "13pm"),+ ("parse_time", "7"), ("parse_time", "midnight"), ("parse_time", "nine thirty"),+ ("parse_date", "2026-02-29"), ("parse_date", "2028-02-29"), ("parse_date", "25 oct"), ("parse_date", "oct 25, 2027"),+ ("parse_date", "the 1st of march"), ("parse_date", "sept 3"), ("parse_date", "25/10/2026"), ("parse_date", "10/10"),+ ("parse_date", "3/4"), ("parse_date", "today"), ("parse_date", "yesterday"), ("parse_date", "day after tomorrow"),+ ("parse_date", "sunday"), ("parse_date", "this sunday"), ("parse_date", "next sunday"), ("parse_date", "last sunday"),+ ("parse_date", "last mon"), ("parse_date", "in 3 weeks"), ("parse_date", "in 4 months"), ("parse_date", "2 years ago"),+ ("parse_date", "next month"), ("parse_date", "end of month"), ("parse_date", "end of the year"), ("parse_date", "start of month"),+ ("parse_date", "someday"), ("parse_date", "30 feb"),+ ("parse_host", "Example.COM"), ("parse_host", "https://a.b.example:8443/x"), ("parse_host", "not a host"),+ ("parse_url", "http://x.example/y"), ("parse_url", "x.example"), ("parse_email", "A.B@Example.org"), ("parse_email", "a@b"),+]++PROPOSALS = [+ "make me a sandwich", "is example.com up", "top 3 stories on lobsters", "what is 2.5 times 4", "latest release of foo/bar",+ "", "the the the", "{weird} [pattern] (here)", "what's the weather on mars?", "ping 10.0.0.1 and 10.0.0.2", "café opening hours",+ 'say "hello" to $USER', "it's raining",+]++HUB = [+ ("GET", "/capabilities", None),+ ("POST", "/intent", {"text": "top 3 stories on hacker news"}),+ ("POST", "/intent", {"text": "weather in Oslo", "trace": False}),+ ("POST", "/intent", {"text": "how cold is it in Nowhereville"}),+ ("POST", "/intent", {"text": "is broken.example up"}),+ ("POST", "/intent", {"text": "is broken.example up"}),+ ("POST", "/intent", {"text": "make me a sandwich"}),+ ("POST", "/intent", {"text": "Make me a sandwich!"}),+ ("POST", "/intent", {"text": "shout hello"}),+ ("POST", "/intent", {"text": " "}),+ ("POST", "/intent", {}),+ ("POST", "/run", {"tree": "hacker-news", "inputs": {"count": 2}}),+ ("POST", "/run", {"tree": "nope"}),+ ("POST", "/run", {"tree": "weather", "inputs": {"units": "kelvin", "place": "Oslo"}}),+ ("POST", "/intent", {"text": "order a latte"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "large"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "2"}),+ ("POST", "/resume", {"snapshot": {"nope": 1}}),+ ("POST", "/intent", {"text": "order a mocha"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "small"}),+ ("POST", "/resume", {"snapshot": "@last", "answer": "many"}),+ ("POST", "/intent", {"text": "temperature in the hall"}),+ ("POST", "/intent", {"text": "what's the temperature in the hall"}),+ ("GET", "/gaps", None),+ ("GET", "/unanswered", None),+ ("GET", "/watches", None),+ ("GET", "/nowhere", None),+]+++def _nlp_case(fn: str, text: str) -> dict:+ try:+ if fn == "parse_date":+ value = nlp.parse_date(text, CLOCK)+ else:+ value = getattr(nlp, fn)(text)+ except nlp.ParseError as e:+ return {"fn": fn, "text": text, "error": str(e)}+ if fn == "clean":+ value = list(value)+ return {"fn": fn, "text": text, "value": value}+++def _hub_exchanges(reg: Registry, fixture: Path) -> list[dict]:+ """What a service with these trees answers, request by request. A+ snapshot of "@last" is the one the previous answer carried."""+ service = Service(reg, name="this browser", transport=RecordedTransport.from_file(fixture))+ service.runner.clock = lambda: CLOCK+ out, last = [], None+ for method, path, body in HUB:+ if body is not None and body.get("snapshot") == "@last":+ body = {**body, "snapshot": last}+ status, payload = service.route(method, path, json.loads(json.dumps(body)) if body is not None else None)+ if isinstance(payload, dict) and isinstance(payload.get("snapshot"), dict):+ last = payload["snapshot"]+ out.append({"method": method, "path": path, "body": body, "status": status, "expect": _stable(_untimed(json.loads(json.dumps(payload))))})+ return out+++def _untimed(d):+ """Drop when a gap or a sentence was seen: the counts and order stay."""+ for key in ("gaps", "unanswered"):+ for e in d.get(key, []) if isinstance(d, dict) else []:+ for k in ("first_seen", "last_seen", "first", "last"):+ e.pop(k, None)+ return d++def _stable(d: dict) -> dict: """Drop the wall-clock fields (when the trace started, how long each step took).""" trace = d.get("trace")@@ -90,24 +238,33 @@ def build() -> dict:reading = reg.read(case["intent"], CLOCK) match = reading.winner assert match is not None, case- outcome = runner.run_tree(match.tree, match.inputs)+ outcome = runner.run_intent(case["intent"])runs.append({"intent": case["intent"], "tree": match.tree, "inputs": match.inputs, "expect": _stable(outcome.to_dict())}) else: outcome = runner.run_tree(case["tree"], case["inputs"]) runs.append({"tree": case["tree"], "inputs": case["inputs"], "expect": _stable(outcome.to_dict())})+ both = Registry.from_paths([ROOT / "examples"])+ for raw in LANGUAGE_TREES:+ both.add(build_tree(raw))+ readings = [{"text": text, "expect": {**(r := both.read(text, CLOCK)).to_dict(), "step": r.step()}} for text in READINGS]return { "pyrtan-conformance": 1, "clock": CLOCK.isoformat(), "fixture": "tests/fixtures/examples.json", "trees": {t.name: t.raw for t in reg.trees()}, "runs": runs,+ "language_trees": LANGUAGE_TREES,+ "readings": readings,+ "nlp": [_nlp_case(fn, text) for fn, text in NLP],+ "proposals": [{"text": text, "expect": propose(text).to_dict()} for text in PROPOSALS],+ "hub": _hub_exchanges(both, fixture),} def main() -> int: out = ROOT / "tests" / "conformance" / "vectors.json" out.write_text(json.dumps(build(), indent=1, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")- print(f"wrote {out}: {len(CASES)} runs")+ print(f"wrote {out}: {len(CASES)} runs, {len(READINGS)} readings, {len(NLP)} rules, {len(PROPOSALS)} proposals, {len(HUB)} requests")return 0 diff --git a/tests/conformance/run.cjs b/tests/conformance/run.cjs index fe49d92..674442f 100644 --- a/tests/conformance/run.cjs +++ b/tests/conformance/run.cjs@@ -12,26 +12,59 @@ const clock = () => new Date(vectors.clock);function shape(d) { return { status: d.status, tree: d.tree, say: d.say === undefined ? null : d.say, reason: d.reason === undefined ? null : d.reason,- output: d.output === undefined ? null : d.output, inputs: d.inputs || {},+ output: d.output === undefined ? null : d.output, inputs: d.inputs || {}, question: d.question === undefined ? null : d.question,steps: (d.trace ? d.trace.steps : []).map((s) => ({ node: s.node, kind: s.kind, chosen: s.chosen === undefined ? null : s.chosen, next: s.next === undefined ? null : s.next, depth: s.depth || 0 })), gaps: (d.trace ? d.trace.gaps : []).map((g) => ({ node: g.node, on: g.on, observed: g.observed })),+ // The intent step is the reading, and it is compared word for word.+ intent: d.trace && d.trace.steps.length && d.trace.steps[0].kind === "intent" ? { summary: d.trace.steps[0].summary, observed: d.trace.steps[0].observed } : null,}; } const canon = (v) => JSON.stringify(v, (k, x) => (x && typeof x === "object" && !Array.isArray(x) ? Object.keys(x).sort().reduce((o, kk) => ((o[kk] = x[kk]), o), {}) : x));+let failed = 0, total = 0;+function compare(label, expect, got) {+ total++;+ const a = canon(expect), b = canon(got);+ if (a === b) { console.log(`ok ${label}`); return; }+ failed++;+ console.log(`DIFF ${label}`);+ if (expect && got && typeof expect === "object" && !Array.isArray(expect)) {+ for (const k of new Set([...Object.keys(expect), ...Object.keys(got)])) if (canon(expect[k]) !== canon(got[k])) console.log(` ${k}:\n python: ${canon(expect[k]).slice(0, 400)}\n js: ${canon(got[k]).slice(0, 400)}`);+ } else console.log(` python: ${a.slice(0, 400)}\n js: ${b.slice(0, 400)}`);+}+// A hub's answer, as compared: an outcome by its shape (with the snapshot), anything else whole.+function hubShape(d) {+ if (d && typeof d === "object" && "status" in d && "tree" in d) return Object.assign(shape(d), { snapshot: d.snapshot === undefined ? null : d.snapshot });+ if (d && Array.isArray(d.gaps)) return { gaps: d.gaps.map((g) => Object.assign({}, g, { first_seen: undefined, last_seen: undefined })) };+ if (d && Array.isArray(d.unanswered)) return { unanswered: d.unanswered.map((e) => Object.assign({}, e, { first: undefined, last: undefined })) };+ return d;+}+(async () => {- let failed = 0;for (const run of vectors.runs) { const runner = new Pyrtan.Runner({ trees: Object.values(vectors.trees), transport: new Pyrtan.RecordedTransport(fixture), clock });- const got = Pyrtan.outcomeDict(await runner.runTree(run.tree, run.inputs));- const a = canon(shape(run.expect)), b = canon(shape(got));- const label = run.intent ? `"${run.intent}"` : `${run.tree} ${JSON.stringify(run.inputs)}`;- if (a === b) { console.log(`ok ${label}`); continue; }- failed++;- console.log(`DIFF ${label}`);- const ea = shape(run.expect), eb = shape(got);- for (const k of Object.keys(ea)) if (canon(ea[k]) !== canon(eb[k])) console.log(` ${k}:\n python: ${canon(ea[k]).slice(0, 400)}\n js: ${canon(eb[k]).slice(0, 400)}`);+ const got = Pyrtan.outcomeDict(run.intent ? await runner.runIntent(run.intent) : await runner.runTree(run.tree, run.inputs));+ compare(run.intent ? `run "${run.intent}"` : `run ${run.tree} ${JSON.stringify(run.inputs)}`, shape(run.expect), shape(got));+ }+ const all = new Pyrtan.Runner({ trees: Object.values(vectors.trees).concat(vectors.language_trees), clock });+ for (const r of vectors.readings) {+ const reading = all.read(r.text);+ compare(`read "${r.text}"`, r.expect, Object.assign(reading.toDict(), { step: reading.step() }));+ }+ const fns = { clean: (t) => Pyrtan.nlp.clean(t), singular: (t) => Pyrtan.nlp.singular(t), plural: (t) => Pyrtan.nlp.plural(t), parse_number: (t) => Pyrtan.nlp.parseNumber(t).value, parse_duration: (t) => Pyrtan.nlp.parseDuration(t).value, parse_time: (t) => Pyrtan.nlp.parseTime(t).value, parse_date: (t) => Pyrtan.nlp.parseDate(t, clock()).value, parse_host: (t) => Pyrtan.nlp.parseHost(t).value, parse_url: (t) => Pyrtan.nlp.parseUrl(t).value, parse_email: (t) => Pyrtan.nlp.parseEmail(t).value };+ for (const c of vectors.nlp) {+ let got;+ try { got = { fn: c.fn, text: c.text, value: fns[c.fn](c.text) }; } catch (e) { if (!(e instanceof Pyrtan.nlp.ParseError)) throw e; got = { fn: c.fn, text: c.text, error: e.message }; }+ compare(`${c.fn} ${JSON.stringify(c.text)}`, c, got);+ }+ for (const p of vectors.proposals) compare(`propose ${JSON.stringify(p.text)}`, p.expect, Pyrtan.propose(p.text));+ // The hub in a page: the same requests, the same answers as a service.+ const transport = new Pyrtan.RecordedTransport(fixture);+ const hub = new Pyrtan.LocalHub({ trees: Object.values(vectors.trees).concat(vectors.language_trees), transport, adapters: transport, clock });+ for (const x of vectors.hub) {+ const [status, payload] = await hub.route(x.method, x.path, x.body === null ? undefined : JSON.parse(JSON.stringify(x.body)));+ compare(`hub ${x.method} ${x.path} ${x.body ? JSON.stringify(x.body).slice(0, 60) : ""}`, { status: x.status, body: hubShape(x.expect) }, { status, body: hubShape(JSON.parse(JSON.stringify(payload))) });}- console.log(`${vectors.runs.length - failed}/${vectors.runs.length} agree`);+ console.log(`${total - failed}/${total} agree`);process.exit(failed ? 1 : 0); })(); diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 184909f..14af50e 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json@@ -1,7 +1,5421 @@{ "clock": "2026-10-25T12:00:00+00:00", "fixture": "tests/fixtures/examples.json",+ "hub": [+ {+ "body": null,+ "expect": {+ "name": "this browser",+ "trees": [+ {+ "description": "echo for books",+ "hosts": [],+ "inputs": {+ "author": {+ "type": "str"+ },+ "count": {+ "default": 3,+ "type": "int"+ }+ },+ "language": {+ "synonyms": {+ "books": [+ "novels",+ "paperback books"+ ],+ "find": [+ "look up"+ ]+ },+ "typos": true+ },+ "license": "",+ "name": "books",+ "sources": [],+ "tags": [],+ "triggers": [+ "(find|search) books by {author}",+ "top story on book club"+ ],+ "version": 1+ },+ {+ "description": "echo for count",+ "hosts": [],+ "inputs": {+ "n": {+ "type": "float"+ },+ "x": {+ "type": "float"+ },+ "y": {+ "type": "int"+ }+ },+ "language": {},+ "license": "",+ "name": "count",+ "sources": [],+ "tags": [],+ "triggers": [+ "count to {n:number}",+ "add {x:float} and {y:int}"+ ],+ "version": 1+ },+ {+ "commands": [+ "df"+ ],+ "description": "How full a filesystem is, from df on the box this tree runs on.",+ "hosts": [],+ "inputs": {+ "mount": {+ "default": "/",+ "description": "A mount point, or any path on the filesystem to measure",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "disk-space",+ "namespace": "pyrtan",+ "sources": [+ "shell"+ ],+ "tags": [+ "ops",+ "box",+ "read-only"+ ],+ "triggers": [+ "[how] full is the disk",+ "disk space",+ "disk space on {mount:word}"+ ],+ "version": 2+ },+ {+ "description": "echo for fixed",+ "hosts": [],+ "inputs": {+ "what": {+ "default": "tea",+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "fixed",+ "sources": [],+ "tags": [],+ "trigger_inputs": {+ "the usual": {+ "what": "coffee"+ }+ },+ "triggers": [+ "the usual"+ ],+ "version": 1+ },+ {+ "description": "The latest release of a GitHub repository.",+ "hosts": [+ "api.github.com"+ ],+ "inputs": {+ "repo": {+ "description": "owner/name",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "github-release",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "github",+ "read-only"+ ],+ "triggers": [+ "[what's the] latest release of {repo:word}",+ "latest {repo:word} release"+ ],+ "version": 2+ },+ {+ "description": "Top stories on Hacker News, from its public API.",+ "hosts": [+ "hacker-news.firebaseio.com"+ ],+ "inputs": {+ "count": {+ "default": 1,+ "description": "How many stories to return",+ "type": "int"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "hacker-news",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "news",+ "read-only"+ ],+ "trigger_inputs": {+ "[what's the] top story on hacker news": {+ "count": 1+ }+ },+ "triggers": [+ "[what's the] top story on hacker news",+ "top {count:int} stories on hacker news"+ ],+ "version": 2+ },+ {+ "description": "One Hacker News item by id: title, url, score, author.",+ "hosts": [+ "hacker-news.firebaseio.com"+ ],+ "inputs": {+ "id": {+ "description": "The item id",+ "required": true,+ "type": "int"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "hacker-news-item",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "news",+ "read-only"+ ],+ "triggers": [+ "hacker news item {id:int}"+ ],+ "version": 2+ },+ {+ "description": "Error lines in the tail of a log file on this box.",+ "hosts": [],+ "inputs": {+ "lines": {+ "default": 200,+ "description": "How many lines from the end to look at",+ "type": "int"+ },+ "path": {+ "default": "/var/log/syslog",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "log-errors",+ "namespace": "pyrtan",+ "paths": [+ "/var/log"+ ],+ "sources": [+ "file"+ ],+ "tags": [+ "ops",+ "box",+ "read-only"+ ],+ "triggers": [+ "errors in the log",+ "errors in {path:word}"+ ],+ "version": 2+ },+ {+ "description": "Whether a modem on a serial line answers AT.",+ "devices": [+ "/dev/ttyUSB*",+ "/dev/ttyACM*"+ ],+ "hosts": [],+ "inputs": {+ "device": {+ "default": "/dev/ttyUSB0",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "modem-check",+ "namespace": "pyrtan",+ "sources": [+ "serial"+ ],+ "tags": [+ "hardware",+ "serial"+ ],+ "triggers": [+ "is the modem up",+ "is the modem on {device:word} up"+ ],+ "version": 2+ },+ {+ "description": "asks, then answers",+ "hosts": [],+ "inputs": {+ "drink": {+ "required": true,+ "type": "str"+ },+ "shots": {+ "default": 1,+ "type": "int"+ }+ },+ "language": {},+ "license": "",+ "name": "order",+ "sources": [],+ "tags": [],+ "triggers": [+ "order a {drink:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for ping",+ "hosts": [],+ "inputs": {+ "host": {+ "type": "str"+ },+ "to": {+ "type": "str"+ },+ "url": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "ping",+ "sources": [],+ "tags": [],+ "triggers": [+ "ping {host:host}",+ "fetch {url:url}",+ "mail {to:email}"+ ],+ "version": 1+ },+ {+ "description": "echo for remind",+ "hosts": [],+ "inputs": {+ "at": {+ "type": "str"+ },+ "wait": {+ "type": "int"+ },+ "when": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "remind",+ "sources": [],+ "tags": [],+ "triggers": [+ "remind me {when:date} at {at:time}",+ "remind me in {wait:duration}"+ ],+ "version": 1+ },+ {+ "description": "The main heading of a page that needs a script to render, through a real browser.",+ "hosts": [+ "*"+ ],+ "inputs": {+ "url": {+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "rendered-headline",+ "namespace": "pyrtan",+ "sources": [+ "browser"+ ],+ "tags": [+ "web",+ "browser",+ "read-only"+ ],+ "triggers": [+ "headline of {url:url}",+ "what does {url:url} say"+ ],+ "version": 2+ },+ {+ "description": "The temperature a room's sensor last published, over MQTT.",+ "hosts": [+ "broker.local"+ ],+ "inputs": {+ "room": {+ "default": "kitchen",+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "room-temperature",+ "namespace": "pyrtan",+ "sources": [+ "queue"+ ],+ "tags": [+ "home",+ "mqtt"+ ],+ "topics": [+ "home/+/temperature"+ ],+ "triggers": [+ "[what's the] temperature in the {room:word}",+ "how warm is the {room:word}"+ ],+ "version": 2,+ "watches": [+ {+ "kind": "queue",+ "what": "home/kitchen/temperature at mqtt://broker.local"+ }+ ]+ },+ {+ "description": "Whether a web site answers, and how quickly.",+ "hosts": [+ "*"+ ],+ "inputs": {+ "host": {+ "description": "A host name, with no scheme",+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "site-status",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "ops",+ "read-only"+ ],+ "triggers": [+ "is {host:host} up",+ "is {host:host} down",+ "[check] status of {host:host}"+ ],+ "version": 2,+ "watches": [+ {+ "kind": "every",+ "what": "15m"+ }+ ]+ },+ {+ "description": "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything.",+ "hosts": [],+ "inputs": {+ "topic": {+ "required": true,+ "type": "str"+ }+ },+ "language": {},+ "license": "MIT",+ "name": "small-talk",+ "namespace": "pyrtan",+ "sources": [],+ "tags": [+ "chat",+ "read-only"+ ],+ "trigger_inputs": {+ "(bye|goodbye|see you|see ya)": {+ "topic": "bye"+ },+ "(who|what) are you": {+ "topic": "who"+ },+ "are you (a bot|human|real|a person)": {+ "topic": "who"+ },+ "how are you [doing|today]": {+ "topic": "how"+ },+ "what (can|do) you do": {+ "topic": "what"+ }+ },+ "triggers": [+ "how are you [doing|today]",+ "(who|what) are you",+ "what (can|do) you do",+ "are you (a bot|human|real|a person)",+ "(bye|goodbye|see you|see ya)"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-a",+ "hosts": [],+ "inputs": {+ "x": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "tie-a",+ "sources": [],+ "tags": [],+ "triggers": [+ "shout {x:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-b",+ "hosts": [],+ "inputs": {+ "y": {+ "type": "str"+ }+ },+ "language": {},+ "license": "",+ "name": "tie-b",+ "sources": [],+ "tags": [],+ "triggers": [+ "shout {y:word}"+ ],+ "version": 1+ },+ {+ "description": "Current weather for a place, from Open-Meteo.",+ "hosts": [+ "geocoding-api.open-meteo.com",+ "api.open-meteo.com"+ ],+ "inputs": {+ "place": {+ "description": "A town or city name",+ "required": true,+ "type": "str"+ },+ "units": {+ "default": "celsius",+ "type": "str"+ }+ },+ "language": {+ "synonyms": {+ "weather": [+ "forecast",+ "temperature"+ ]+ }+ },+ "license": "MIT",+ "name": "weather",+ "namespace": "pyrtan",+ "sources": [+ "http"+ ],+ "tags": [+ "weather",+ "read-only"+ ],+ "triggers": [+ "[what's the] weather in {place}",+ "[what's the] weather for {place}",+ "how [hot|cold|warm] is it in {place}"+ ],+ "version": 2+ }+ ]+ },+ "method": "GET",+ "path": "/capabilities",+ "status": 200+ },+ {+ "body": {+ "text": "top 3 stories on hacker news"+ },+ "expect": {+ "inputs": {+ "count": 3+ },+ "output": {+ "stories": [+ {+ "by": "alice",+ "id": 101,+ "score": 42,+ "title": "Pyrtan 0.1",+ "url": "https://example.org/p"+ },+ {+ "by": "x",+ "id": 102,+ "score": 7,+ "title": "Ask HN: self post",+ "url": "https://news.ycombinator.com/item?id=102"+ },+ null+ ]+ },+ "say": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "fetch-ids",+ "node": "intent",+ "observed": {+ "cleaned": "top 3 stories on hacker news",+ "inputs": {+ "count": 3+ },+ "steps": [],+ "trigger": "top {count:int} stories on hacker news"+ },+ "summary": "'top 3 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "check",+ "node": "fetch-ids",+ "observed": {+ "ids": [+ 101,+ 102,+ 103+ ]+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/topstories.json -> 200; ids=[101, 102, 103]"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "any",+ "node": "check",+ "observed": 200,+ "summary": "state.ids_resp.status = 200"+ },+ {+ "chosen": "type 'list'",+ "depth": 0,+ "kind": "decide",+ "next": "stories",+ "node": "any",+ "observed": [+ 101,+ 102,+ 103+ ],+ "summary": "state.ids = [101, 102, 103]"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "alice",+ "score": 42,+ "title": "Pyrtan 0.1",+ "type": "story",+ "url": "https://example.org/p"+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/101.json -> 200; title=Pyrtan 0.1, url=https://example.org/p, score=42, by=alice, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "exists True",+ "depth": 1,+ "kind": "decide",+ "next": "reply",+ "node": "has-url",+ "observed": "https://example.org/p",+ "summary": "state.url = https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Pyrtan 0.1 (42 points, by alice) https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "x",+ "score": 7,+ "title": "Ask HN: self post",+ "type": "story",+ "url": null+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/102.json -> 200; title=Ask HN: self post, url=None, score=7, by=x, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "else",+ "depth": 1,+ "kind": "decide",+ "next": "self-post",+ "node": "has-url",+ "summary": "state.url = <missing>"+ },+ {+ "depth": 1,+ "kind": "set",+ "next": "reply",+ "node": "self-post",+ "observed": {+ "url": "https://news.ycombinator.com/item?id=102"+ },+ "summary": "url=https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Ask HN: self post (7 points, by x) https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "y",+ "score": null,+ "title": null,+ "type": "comment",+ "url": null+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/103.json -> 200; title=None, url=None, score=None, by=y, type=comment"+ },+ {+ "chosen": "else",+ "depth": 1,+ "kind": "decide",+ "next": "not-a-story",+ "node": "kind",+ "observed": "comment",+ "summary": "state.type = comment"+ },+ {+ "depth": 1,+ "kind": "fail",+ "next": null,+ "node": "not-a-story",+ "summary": "not-a-story: Item 103 is a comment, not a story."+ },+ {+ "depth": 0,+ "kind": "each",+ "next": "reply",+ "node": "stories",+ "summary": "3 item(s), 1 failed"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p"+ }+ ],+ "tree": "hacker-news"+ },+ "tree": "hacker-news"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "weather in Oslo",+ "trace": false+ },+ "expect": {+ "inputs": {+ "place": "Oslo",+ "units": "celsius"+ },+ "output": {+ "country": "Norway",+ "humidity": 80,+ "place": "Oslo",+ "sky": "raining",+ "temperature": 4.2,+ "unit": "°C",+ "wind_kmh": 12.3+ },+ "say": "Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.",+ "status": "done",+ "tree": "weather"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "how cold is it in Nowhereville"+ },+ "expect": {+ "inputs": {+ "place": "Nowhereville",+ "units": "celsius"+ },+ "reason": "unknown-place",+ "say": "I could not find a place called Nowhereville.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "geocode",+ "node": "intent",+ "observed": {+ "cleaned": "how cold is it in Nowhereville",+ "inputs": {+ "place": "Nowhereville"+ },+ "steps": [],+ "trigger": "how [hot|cold|warm] is it in {place}"+ },+ "summary": "'how cold is it in Nowhereville'; weather via 'how [hot|cold|warm] is it in {place}' (20 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "found",+ "node": "geocode",+ "observed": {+ "country": null,+ "hit": null,+ "lat": null,+ "lon": null,+ "name": null+ },+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Nowhereville&count=1&language=en&format=json -> 200; hit=None, lat=None, lon=None, name=None, country=None"+ },+ {+ "chosen": "else",+ "depth": 0,+ "kind": "decide",+ "next": "unknown-place",+ "node": "found",+ "summary": "state.hit = <missing>"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "unknown-place",+ "summary": "unknown-place: I could not find a place called Nowhereville."+ }+ ],+ "tree": "weather"+ },+ "tree": "weather"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "is broken.example up"+ },+ "expect": {+ "inputs": {+ "host": "broken.example"+ },+ "output": {+ "host": "broken.example",+ "status": 503,+ "up": false+ },+ "reason": "server-error",+ "say": "broken.example is answering 503: up, but broken.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "probe",+ "node": "intent",+ "observed": {+ "cleaned": "is broken.example up",+ "inputs": {+ "host": "broken.example"+ },+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is broken.example up'; site-status via 'is {host:host} up' (4 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "probe",+ "summary": "GET https://broken.example/ -> 503"+ },+ {+ "chosen": "gte 500",+ "depth": 0,+ "kind": "decide",+ "next": "erroring",+ "node": "status",+ "observed": 503,+ "summary": "state.resp.status = 503"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "erroring",+ "summary": "server-error: broken.example is answering 503: up, but broken."+ }+ ],+ "tree": "site-status"+ },+ "tree": "site-status"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "is broken.example up"+ },+ "expect": {+ "inputs": {+ "host": "broken.example"+ },+ "output": {+ "host": "broken.example",+ "status": 503,+ "up": false+ },+ "reason": "server-error",+ "say": "broken.example is answering 503: up, but broken.",+ "status": "failed",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "probe",+ "node": "intent",+ "observed": {+ "cleaned": "is broken.example up",+ "inputs": {+ "host": "broken.example"+ },+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is broken.example up'; site-status via 'is {host:host} up' (4 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "status",+ "node": "probe",+ "summary": "GET https://broken.example/ -> 503"+ },+ {+ "chosen": "gte 500",+ "depth": 0,+ "kind": "decide",+ "next": "erroring",+ "node": "status",+ "observed": 503,+ "summary": "state.resp.status = 503"+ },+ {+ "depth": 0,+ "kind": "fail",+ "next": null,+ "node": "erroring",+ "summary": "server-error: broken.example is answering 503: up, but broken."+ }+ ],+ "tree": "site-status"+ },+ "tree": "site-status"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "make me a sandwich"+ },+ "expect": {+ "inputs": {},+ "output": {+ "proposal": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"make me a sandwich\"",+ "text": "make me a sandwich",+ "trigger": "make me a sandwich"+ },+ "reading": {+ "cleaned": "make me a sandwich",+ "considered": [],+ "steps": []+ },+ "unanswered": "make me a sandwich"+ },+ "reason": "no-match",+ "say": "Nothing here answers 'make me a sandwich'. To write the tree that would: pyrtan new make-sandwich --from \"make me a sandwich\"",+ "status": "error",+ "tree": ""+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "Make me a sandwich!"+ },+ "expect": {+ "inputs": {},+ "output": {+ "proposal": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"Make me a sandwich\"",+ "text": "Make me a sandwich",+ "trigger": "Make me a sandwich"+ },+ "reading": {+ "cleaned": "Make me a sandwich",+ "considered": [],+ "steps": []+ },+ "unanswered": "Make me a sandwich!"+ },+ "reason": "no-match",+ "say": "Nothing here answers 'Make me a sandwich!'. To write the tree that would: pyrtan new make-sandwich --from \"Make me a sandwich\"",+ "status": "error",+ "tree": ""+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "shout hello"+ },+ "expect": {+ "inputs": {},+ "output": {+ "candidates": [+ "tie-a",+ "tie-b"+ ],+ "reading": {+ "cleaned": "shout hello",+ "steps": []+ }+ },+ "reason": "ambiguous",+ "say": "'shout hello' matches more than one tree equally well: tie-a, tie-b",+ "status": "error",+ "tree": ""+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": " "+ },+ "expect": {+ "reason": "bad-request",+ "say": "Send {\"text\": \"...\"}.",+ "status": "error"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {},+ "expect": {+ "reason": "bad-request",+ "say": "Send {\"text\": \"...\"}.",+ "status": "error"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "inputs": {+ "count": 2+ },+ "tree": "hacker-news"+ },+ "expect": {+ "inputs": {+ "count": 2+ },+ "output": {+ "stories": [+ {+ "by": "alice",+ "id": 101,+ "score": 42,+ "title": "Pyrtan 0.1",+ "url": "https://example.org/p"+ },+ {+ "by": "x",+ "id": 102,+ "score": 7,+ "title": "Ask HN: self post",+ "url": "https://news.ycombinator.com/item?id=102"+ }+ ]+ },+ "say": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "observe",+ "next": "check",+ "node": "fetch-ids",+ "observed": {+ "ids": [+ 101,+ 102,+ 103+ ]+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/topstories.json -> 200; ids=[101, 102, 103]"+ },+ {+ "chosen": "eq 200",+ "depth": 0,+ "kind": "decide",+ "next": "any",+ "node": "check",+ "observed": 200,+ "summary": "state.ids_resp.status = 200"+ },+ {+ "chosen": "type 'list'",+ "depth": 0,+ "kind": "decide",+ "next": "stories",+ "node": "any",+ "observed": [+ 101,+ 102,+ 103+ ],+ "summary": "state.ids = [101, 102, 103]"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "alice",+ "score": 42,+ "title": "Pyrtan 0.1",+ "type": "story",+ "url": "https://example.org/p"+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/101.json -> 200; title=Pyrtan 0.1, url=https://example.org/p, score=42, by=alice, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "exists True",+ "depth": 1,+ "kind": "decide",+ "next": "reply",+ "node": "has-url",+ "observed": "https://example.org/p",+ "summary": "state.url = https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Pyrtan 0.1 (42 points, by alice) https://example.org/p"+ },+ {+ "depth": 1,+ "kind": "observe",+ "next": "kind",+ "node": "fetch",+ "observed": {+ "by": "x",+ "score": 7,+ "title": "Ask HN: self post",+ "type": "story",+ "url": null+ },+ "summary": "GET https://hacker-news.firebaseio.com/v0/item/102.json -> 200; title=Ask HN: self post, url=None, score=7, by=x, type=story"+ },+ {+ "chosen": "eq 'story'",+ "depth": 1,+ "kind": "decide",+ "next": "has-url",+ "node": "kind",+ "observed": "story",+ "summary": "state.type = story"+ },+ {+ "chosen": "else",+ "depth": 1,+ "kind": "decide",+ "next": "self-post",+ "node": "has-url",+ "summary": "state.url = <missing>"+ },+ {+ "depth": 1,+ "kind": "set",+ "next": "reply",+ "node": "self-post",+ "observed": {+ "url": "https://news.ycombinator.com/item?id=102"+ },+ "summary": "url=https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 1,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Ask HN: self post (7 points, by x) https://news.ycombinator.com/item?id=102"+ },+ {+ "depth": 0,+ "kind": "each",+ "next": "reply",+ "node": "stories",+ "summary": "2 item(s)"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "Top of Hacker News: Pyrtan 0.1 — https://example.org/p"+ }+ ],+ "tree": "hacker-news"+ },+ "tree": "hacker-news"+ },+ "method": "POST",+ "path": "/run",+ "status": 200+ },+ {+ "body": {+ "tree": "nope"+ },+ "expect": {+ "inputs": {},+ "reason": "no-such-tree",+ "say": "No tree named 'nope'.",+ "status": "error",+ "tree": "nope"+ },+ "method": "POST",+ "path": "/run",+ "status": 200+ },+ {+ "body": {+ "inputs": {+ "place": "Oslo",+ "units": "kelvin"+ },+ "tree": "weather"+ },+ "expect": {+ "inputs": {},+ "reason": "bad-input",+ "say": "input 'units' must be one of ['celsius', 'fahrenheit'], got 'kelvin'",+ "status": "error",+ "trace": {+ "gaps": [],+ "steps": [],+ "tree": "weather"+ },+ "tree": "weather"+ },+ "method": "POST",+ "path": "/run",+ "status": 200+ },+ {+ "body": {+ "text": "order a latte"+ },+ "expect": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "question": "What size of latte?",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "answer": "large",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "question": "How many shots?",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "large"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "answer": "2",+ "snapshot": {+ "inputs": {+ "drink": "latte",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "large"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {+ "drink": "latte",+ "shots": 2+ },+ "output": {+ "shots": 2,+ "size": "large"+ },+ "say": "A large latte, 2 shot(s).",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a latte",+ "inputs": {+ "drink": "latte"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a latte'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of latte?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: large"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "reply",+ "node": "shots",+ "summary": "answered: 2"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "reply",+ "summary": "A large latte, 2 shot(s)."+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "snapshot": {+ "nope": 1+ }+ },+ "expect": {+ "reason": "bad-request",+ "say": "Send {\"snapshot\": {...}, \"answer\": ...}.",+ "status": "error"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "text": "order a mocha"+ },+ "expect": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "question": "What size of mocha?",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "answer": "small",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "size",+ "state": {},+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "question": "How many shots?",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "small"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: small"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "status": "asked",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: small"+ },+ {+ "depth": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ },+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "answer": "many",+ "snapshot": {+ "inputs": {+ "drink": "mocha",+ "shots": 1+ },+ "node": "shots",+ "state": {+ "size": "small"+ },+ "trace": [+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "intent",+ "next": "size",+ "node": "intent",+ "observed": {+ "cleaned": "order a mocha",+ "inputs": {+ "drink": "mocha"+ },+ "steps": [],+ "trigger": "order a {drink:word}"+ },+ "summary": "'order a mocha'; order via 'order a {drink:word}' (6 literal chars)"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "size",+ "summary": "asked: What size of mocha?"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": "shots",+ "node": "size",+ "summary": "answered: small"+ },+ {+ "depth": 0,+ "elapsed_ms": 0,+ "kind": "ask",+ "next": null,+ "node": "shots",+ "summary": "asked: How many shots?"+ }+ ],+ "tree": "order"+ }+ },+ "expect": {+ "inputs": {},+ "reason": "bad-input",+ "say": "input 'shots' must be int, got 'many'",+ "status": "error",+ "tree": "order"+ },+ "method": "POST",+ "path": "/resume",+ "status": 200+ },+ {+ "body": {+ "text": "temperature in the hall"+ },+ "expect": {+ "inputs": {+ "room": "hall"+ },+ "output": {+ "gap": {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ },+ "reason": "gap",+ "say": "reading: no branch for state.q.json.temperature = <missing>.",+ "status": "error",+ "trace": {+ "gaps": [+ {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ ],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "wait",+ "node": "intent",+ "observed": {+ "cleaned": "temperature in the hall",+ "inputs": {+ "room": "hall"+ },+ "steps": [],+ "trigger": "[what's the] temperature in the {room:word}"+ },+ "summary": "'temperature in the hall'; room-temperature via \"[what's the] temperature in the {room:word}\" (25 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "got",+ "node": "wait",+ "summary": "queue home/hall/temperature at mqtt://broker.local -> 1 message"+ },+ {+ "chosen": "eq True",+ "depth": 0,+ "kind": "decide",+ "next": "reading",+ "node": "got",+ "observed": true,+ "summary": "state.q.received = True"+ },+ {+ "depth": 0,+ "kind": "decide",+ "next": null,+ "node": "reading",+ "summary": "state.q.json.temperature = <missing>: no branch"+ }+ ],+ "tree": "room-temperature"+ },+ "tree": "room-temperature"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": {+ "text": "what's the temperature in the hall"+ },+ "expect": {+ "inputs": {+ "room": "hall"+ },+ "output": {+ "gap": {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ },+ "reason": "gap",+ "say": "reading: no branch for state.q.json.temperature = <missing>.",+ "status": "error",+ "trace": {+ "gaps": [+ {+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ ],+ "steps": [+ {+ "depth": 0,+ "kind": "intent",+ "next": "wait",+ "node": "intent",+ "observed": {+ "cleaned": "what is the temperature in the hall",+ "inputs": {+ "room": "hall"+ },+ "steps": [+ "contractions: what's->what is"+ ],+ "trigger": "[what's the] temperature in the {room:word}"+ },+ "summary": "\"what's the temperature in the hall\" -> 'what is the temperature in the hall'; room-temperature via \"[what's the] temperature in the {room:word}\" (25 literal chars)"+ },+ {+ "depth": 0,+ "kind": "observe",+ "next": "got",+ "node": "wait",+ "summary": "queue home/hall/temperature at mqtt://broker.local -> 1 message"+ },+ {+ "chosen": "eq True",+ "depth": 0,+ "kind": "decide",+ "next": "reading",+ "node": "got",+ "observed": true,+ "summary": "state.q.received = True"+ },+ {+ "depth": 0,+ "kind": "decide",+ "next": null,+ "node": "reading",+ "summary": "state.q.json.temperature = <missing>: no branch"+ }+ ],+ "tree": "room-temperature"+ },+ "tree": "room-temperature"+ },+ "method": "POST",+ "path": "/intent",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "gaps": [+ {+ "count": 2,+ "node": "reading",+ "observed": null,+ "on": "state.q.json.temperature",+ "tree": "room-temperature"+ }+ ]+ },+ "method": "GET",+ "path": "/gaps",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "unanswered": [+ {+ "count": 2,+ "proposal": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"make me a sandwich\"",+ "text": "make me a sandwich",+ "trigger": "make me a sandwich"+ },+ "text": "make me a sandwich"+ }+ ]+ },+ "method": "GET",+ "path": "/unanswered",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "count": 0,+ "watches": [],+ "watching": false+ },+ "method": "GET",+ "path": "/watches",+ "status": 200+ },+ {+ "body": null,+ "expect": {+ "reason": "not-found",+ "say": "No GET /nowhere here.",+ "status": "error"+ },+ "method": "GET",+ "path": "/nowhere",+ "status": 404+ }+ ],+ "language_trees": [+ {+ "description": "echo for remind",+ "hosts": [],+ "inputs": {+ "at": {+ "default": "",+ "type": "str"+ },+ "wait": {+ "default": 0,+ "type": "int"+ },+ "when": {+ "default": "",+ "type": "str"+ }+ },+ "name": "remind",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "at": "{{ inputs.at }}",+ "wait": "{{ inputs.wait }}",+ "when": "{{ inputs.when }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "remind me {when:date} at {at:time}",+ "remind me in {wait:duration}"+ ],+ "version": 1+ },+ {+ "description": "echo for count",+ "hosts": [],+ "inputs": {+ "n": {+ "default": 0,+ "type": "float"+ },+ "x": {+ "default": 0.0,+ "type": "float"+ },+ "y": {+ "default": 0,+ "type": "int"+ }+ },+ "name": "count",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "n": "{{ inputs.n }}",+ "x": "{{ inputs.x }}",+ "y": "{{ inputs.y }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "count to {n:number}",+ "add {x:float} and {y:int}"+ ],+ "version": 1+ },+ {+ "description": "echo for ping",+ "hosts": [],+ "inputs": {+ "host": {+ "default": "",+ "type": "str"+ },+ "to": {+ "default": "",+ "type": "str"+ },+ "url": {+ "default": "",+ "type": "str"+ }+ },+ "name": "ping",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "host": "{{ inputs.host }}",+ "to": "{{ inputs.to }}",+ "url": "{{ inputs.url }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "ping {host:host}",+ "fetch {url:url}",+ "mail {to:email}"+ ],+ "version": 1+ },+ {+ "description": "echo for books",+ "hosts": [],+ "inputs": {+ "author": {+ "default": "",+ "type": "str"+ },+ "count": {+ "default": 3,+ "type": "int"+ }+ },+ "language": {+ "synonyms": {+ "Find": "look up",+ "books": [+ "novels",+ "paperback books"+ ]+ },+ "typos": true+ },+ "name": "books",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "author": "{{ inputs.author }}",+ "count": "{{ inputs.count }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "(find|search) books by {author}",+ "top story on book club"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-a",+ "hosts": [],+ "inputs": {+ "x": {+ "default": "",+ "type": "str"+ }+ },+ "name": "tie-a",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "x": "{{ inputs.x }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "shout {x:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for tie-b",+ "hosts": [],+ "inputs": {+ "y": {+ "default": "",+ "type": "str"+ }+ },+ "name": "tie-b",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "y": "{{ inputs.y }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ "shout {y:word}"+ ],+ "version": 1+ },+ {+ "description": "echo for fixed",+ "hosts": [],+ "inputs": {+ "what": {+ "default": "tea",+ "type": "str"+ }+ },+ "name": "fixed",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "what": "{{ inputs.what }}"+ },+ "say": "ok"+ }+ },+ "start": "reply",+ "triggers": [+ {+ "inputs": {+ "what": "coffee"+ },+ "pattern": "the usual"+ }+ ],+ "version": 1+ },+ {+ "description": "asks, then answers",+ "hosts": [],+ "inputs": {+ "drink": {+ "required": true,+ "type": "str"+ },+ "shots": {+ "default": 1,+ "type": "int"+ }+ },+ "name": "order",+ "nodes": {+ "reply": {+ "kind": "done",+ "output": {+ "shots": "{{ inputs.shots }}",+ "size": "{{ state.size }}"+ },+ "say": "A {{ state.size }} {{ inputs.drink }}, {{ inputs.shots }} shot(s)."+ },+ "shots": {+ "into": "inputs.shots",+ "kind": "ask",+ "next": "reply",+ "prompt": "How many shots?"+ },+ "size": {+ "into": "state.size",+ "kind": "ask",+ "next": "shots",+ "prompt": "What size of {{ inputs.drink }}?"+ }+ },+ "start": "size",+ "triggers": [+ "order a {drink:word}"+ ],+ "version": 1+ }+ ],+ "nlp": [+ {+ "fn": "clean",+ "text": "Hey, can you please tell me what's up?",+ "value": [+ "tell me what is up",+ [+ "fillers: 'Hey', 'can you please'",+ "contractions: what's->what is"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "“Curly” quotes — and dashes",+ "value": [+ "Curly\" quotes - and dashes",+ [+ "fold"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "FULL width",+ "value": [+ "FULL width",+ [+ "fold"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "I'm sure it's a dozen",+ "value": [+ "I am sure it is 12",+ [+ "contractions: I'm->i am, it's->it is",+ "numbers: a dozen->12"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "one hundred and twenty three things",+ "value": [+ "123 things",+ [+ "numbers: one hundred and twenty three->123"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "a hundred and one",+ "value": [+ "101",+ [+ "numbers: a hundred and one->101"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "Thanks",+ "value": [+ "",+ [+ "fillers: 'Thanks'"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "well, um, okay pyrtan: weather.",+ "value": [+ "pyrtan: weather",+ [+ "fillers: 'well', 'um', 'okay'"+ ]+ ]+ },+ {+ "fn": "clean",+ "text": "St. Ives. Then Mr. Smith",+ "value": [+ "St Ives Then Mr Smith",+ []+ ]+ },+ {+ "fn": "singular",+ "text": "stories",+ "value": "story"+ },+ {+ "fn": "singular",+ "text": "boxes",+ "value": "box"+ },+ {+ "fn": "singular",+ "text": "news",+ "value": "news"+ },+ {+ "fn": "singular",+ "text": "glass",+ "value": "glass"+ },+ {+ "fn": "singular",+ "text": "cats",+ "value": "cat"+ },+ {+ "fn": "singular",+ "text": "bus",+ "value": "bus"+ },+ {+ "fn": "plural",+ "text": "story",+ "value": "stories"+ },+ {+ "fn": "plural",+ "text": "box",+ "value": "boxes"+ },+ {+ "fn": "plural",+ "text": "day",+ "value": "days"+ },+ {+ "fn": "plural",+ "text": "status",+ "value": "status"+ },+ {+ "fn": "parse_number",+ "text": "twenty-one",+ "value": 21+ },+ {+ "fn": "parse_number",+ "text": "1,234",+ "value": 1234+ },+ {+ "fn": "parse_number",+ "text": "-3.25",+ "value": -3.25+ },+ {+ "error": "'lots' is not a number",+ "fn": "parse_number",+ "text": "lots"+ },+ {+ "fn": "parse_duration",+ "text": "90 seconds",+ "value": 90+ },+ {+ "fn": "parse_duration",+ "text": "2 days and 3 hours",+ "value": 183600+ },+ {+ "fn": "parse_duration",+ "text": "a week",+ "value": 604800+ },+ {+ "fn": "parse_duration",+ "text": "1.5 hours and a half",+ "value": 7200+ },+ {+ "error": "'soon' is not a duration",+ "fn": "parse_duration",+ "text": "soon"+ },+ {+ "error": "'3 fortnights': unknown unit 'fortnights'",+ "fn": "parse_duration",+ "text": "3 fortnights"+ },+ {+ "fn": "parse_time",+ "text": "15:30",+ "value": "15:30"+ },+ {+ "fn": "parse_time",+ "text": "3:05 pm",+ "value": "15:05"+ },+ {+ "fn": "parse_time",+ "text": "9 o'clock",+ "value": "09:00"+ },+ {+ "fn": "parse_time",+ "text": "12am",+ "value": "00:00"+ },+ {+ "error": "'13pm': 13 with pm",+ "fn": "parse_time",+ "text": "13pm"+ },+ {+ "error": "'7': a bare number is not a time; say 15:00 or 3pm",+ "fn": "parse_time",+ "text": "7"+ },+ {+ "fn": "parse_time",+ "text": "midnight",+ "value": "00:00"+ },+ {+ "error": "'nine thirty': a bare number is not a time; say 15:00 or 3pm",+ "fn": "parse_time",+ "text": "nine thirty"+ },+ {+ "error": "'2026-02-29': day is out of range for month",+ "fn": "parse_date",+ "text": "2026-02-29"+ },+ {+ "fn": "parse_date",+ "text": "2028-02-29",+ "value": "2028-02-29"+ },+ {+ "fn": "parse_date",+ "text": "25 oct",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "oct 25, 2027",+ "value": "2027-10-25"+ },+ {+ "fn": "parse_date",+ "text": "the 1st of march",+ "value": "2026-03-01"+ },+ {+ "fn": "parse_date",+ "text": "sept 3",+ "value": "2026-09-03"+ },+ {+ "fn": "parse_date",+ "text": "25/10/2026",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "10/10",+ "value": "2026-10-10"+ },+ {+ "error": "'3/4' could be day/month or month/day; write it as 2026-04-03 or '3 april'",+ "fn": "parse_date",+ "text": "3/4"+ },+ {+ "fn": "parse_date",+ "text": "today",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "yesterday",+ "value": "2026-10-24"+ },+ {+ "fn": "parse_date",+ "text": "day after tomorrow",+ "value": "2026-10-27"+ },+ {+ "fn": "parse_date",+ "text": "sunday",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "this sunday",+ "value": "2026-10-25"+ },+ {+ "fn": "parse_date",+ "text": "next sunday",+ "value": "2026-11-01"+ },+ {+ "fn": "parse_date",+ "text": "last sunday",+ "value": "2026-10-18"+ },+ {+ "fn": "parse_date",+ "text": "last mon",+ "value": "2026-10-19"+ },+ {+ "fn": "parse_date",+ "text": "in 3 weeks",+ "value": "2026-11-15"+ },+ {+ "fn": "parse_date",+ "text": "in 4 months",+ "value": "2027-02-25"+ },+ {+ "fn": "parse_date",+ "text": "2 years ago",+ "value": "2024-10-25"+ },+ {+ "fn": "parse_date",+ "text": "next month",+ "value": "2026-11-25"+ },+ {+ "fn": "parse_date",+ "text": "end of month",+ "value": "2026-10-31"+ },+ {+ "fn": "parse_date",+ "text": "end of the year",+ "value": "2026-12-31"+ },+ {+ "fn": "parse_date",+ "text": "start of month",+ "value": "2026-10-01"+ },+ {+ "error": "'someday' is not a date this reads",+ "fn": "parse_date",+ "text": "someday"+ },+ {+ "error": "'30 feb': day is out of range for month",+ "fn": "parse_date",+ "text": "30 feb"+ },+ {+ "fn": "parse_host",+ "text": "Example.COM",+ "value": "example.com"+ },+ {+ "fn": "parse_host",+ "text": "https://a.b.example:8443/x",+ "value": "a.b.example"+ },+ {+ "error": "'not a host' is not a host name",+ "fn": "parse_host",+ "text": "not a host"+ },+ {+ "fn": "parse_url",+ "text": "http://x.example/y",+ "value": "http://x.example/y"+ },+ {+ "error": "'x.example' is not a URL",+ "fn": "parse_url",+ "text": "x.example"+ },+ {+ "fn": "parse_email",+ "text": "A.B@Example.org",+ "value": "a.b@example.org"+ },+ {+ "error": "'a@b' is not an email address",+ "fn": "parse_email",+ "text": "a@b"+ }+ ],+ "proposals": [+ {+ "expect": {+ "inputs": {},+ "name": "make-sandwich",+ "scaffold": "pyrtan new make-sandwich --from \"make me a sandwich\"",+ "text": "make me a sandwich",+ "trigger": "make me a sandwich"+ },+ "text": "make me a sandwich"+ },+ {+ "expect": {+ "inputs": {+ "host": {+ "description": "a host name, as in: example.com",+ "role": "host",+ "type": "str"+ }+ },+ "name": "host-up",+ "scaffold": "pyrtan new host-up --from \"is example.com up\"",+ "text": "is example.com up",+ "trigger": "is {host:host} up"+ },+ "text": "is example.com up"+ },+ {+ "expect": {+ "inputs": {+ "count": {+ "description": "as in: 3",+ "type": "int"+ }+ },+ "name": "top-stories-lobsters",+ "scaffold": "pyrtan new top-stories-lobsters --from \"top 3 stories on lobsters\"",+ "text": "top 3 stories on lobsters",+ "trigger": "top {count:int} stories on lobsters"+ },+ "text": "top 3 stories on lobsters"+ },+ {+ "expect": {+ "inputs": {+ "count": {+ "description": "as in: 4",+ "type": "int"+ },+ "value": {+ "description": "as in: 2.5",+ "type": "float"+ }+ },+ "name": "value-count-times",+ "scaffold": "pyrtan new value-count-times --from \"what is 2.5 times 4\"",+ "text": "what is 2.5 times 4",+ "trigger": "what is {value:float} times {count:int}"+ },+ "text": "what is 2.5 times 4"+ },+ {+ "expect": {+ "inputs": {+ "target": {+ "description": "as in: foo/bar",+ "type": "str"+ }+ },+ "name": "latest-release",+ "scaffold": "pyrtan new latest-release --from \"latest release of foo/bar\"",+ "text": "latest release of foo/bar",+ "trigger": "latest release of {target:word}"+ },+ "text": "latest release of foo/bar"+ },+ {+ "expect": {+ "inputs": {},+ "name": "new-tree",+ "scaffold": "pyrtan new new-tree --from \"new tree\"",+ "text": "new tree",+ "trigger": "new tree"+ },+ "text": ""+ },+ {+ "expect": {+ "inputs": {},+ "name": "the-the-the",+ "scaffold": "pyrtan new the-the-the --from \"the the the\"",+ "text": "the the the",+ "trigger": "the the the"+ },+ "text": "the the the"+ },+ {+ "expect": {+ "inputs": {},+ "name": "weird-pattern-here",+ "scaffold": "pyrtan new weird-pattern-here --from \"weird} [pattern] (here)\"",+ "text": "weird} [pattern] (here)",+ "trigger": "weird pattern here"+ },+ "text": "{weird} [pattern] (here)"+ },+ {+ "expect": {+ "inputs": {},+ "name": "weather-mars",+ "scaffold": "pyrtan new weather-mars --from \"what's the weather on mars\"",+ "text": "what's the weather on mars",+ "trigger": "what's the weather on mars"+ },+ "text": "what's the weather on mars?"+ },+ {+ "expect": {+ "inputs": {+ "host": {+ "description": "a host name, as in: 10.0.0.1",+ "role": "host",+ "type": "str"+ },+ "host2": {+ "description": "a host name, as in: 10.0.0.2",+ "role": "host",+ "type": "str"+ }+ },+ "name": "host-host-ping",+ "scaffold": "pyrtan new host-host-ping --from \"ping 10.0.0.1 and 10.0.0.2\"",+ "text": "ping 10.0.0.1 and 10.0.0.2",+ "trigger": "ping {host:host} and {host2:host}"+ },+ "text": "ping 10.0.0.1 and 10.0.0.2"+ },+ {+ "expect": {+ "inputs": {},+ "name": "caf-opening-hours",+ "scaffold": "pyrtan new caf-opening-hours --from \"café opening hours\"",+ "text": "café opening hours",+ "trigger": "café opening hours"+ },+ "text": "café opening hours"+ },+ {+ "expect": {+ "inputs": {},+ "name": "say-hello-user",+ "scaffold": "pyrtan new say-hello-user --from 'say \"hello\" to $USER'",+ "text": "say \"hello\" to $USER",+ "trigger": "say \"hello\" to $USER"+ },+ "text": "say \"hello\" to $USER"+ },+ {+ "expect": {+ "inputs": {},+ "name": "it-s-raining",+ "scaffold": "pyrtan new it-s-raining --from \"it's raining\"",+ "text": "it's raining",+ "trigger": "it's raining"+ },+ "text": "it's raining"+ }+ ],"pyrtan-conformance": 1,+ "readings": [+ {+ "expect": {+ "cleaned": "What is the top story on Hacker News",+ "considered": [+ {+ "inputs": {+ "count": 1+ },+ "notes": [],+ "rejected": false,+ "specificity": 29,+ "tree": "hacker-news",+ "trigger": "[what's the] top story on hacker news"+ }+ ],+ "matches": [+ {+ "inputs": {+ "count": 1+ },+ "notes": [],+ "rejected": false,+ "specificity": 29,+ "tree": "hacker-news",+ "trigger": "[what's the] top story on hacker news"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "What is the top story on Hacker News",+ "inputs": {+ "count": 1+ },+ "steps": [+ "contractions: What's->what is"+ ],+ "trigger": "[what's the] top story on hacker news"+ },+ "summary": "\"What's the top story on Hacker News?\" -> 'What is the top story on Hacker News'; hacker-news via \"[what's the] top story on hacker news\" (29 literal chars)"+ },+ "steps": [+ "contractions: What's->what is"+ ],+ "text": "What's the top story on Hacker News?"+ },+ "text": "What's the top story on Hacker News?"+ },+ {+ "expect": {+ "cleaned": "top 5 stories on hacker news",+ "considered": [+ {+ "inputs": {+ "count": 5+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "matches": [+ {+ "inputs": {+ "count": 5+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "top 5 stories on hacker news",+ "inputs": {+ "count": 5+ },+ "steps": [+ "numbers: five->5"+ ],+ "trigger": "top {count:int} stories on hacker news"+ },+ "summary": "'top five stories on hacker news' -> 'top 5 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"+ },+ "steps": [+ "numbers: five->5"+ ],+ "text": "top five stories on hacker news"+ },+ "text": "top five stories on hacker news"+ },+ {+ "expect": {+ "cleaned": "top 3 stories on hacker news",+ "considered": [+ {+ "inputs": {+ "count": 3+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "matches": [+ {+ "inputs": {+ "count": 3+ },+ "notes": [],+ "rejected": false,+ "specificity": 22,+ "tree": "hacker-news",+ "trigger": "top {count:int} stories on hacker news"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "top 3 stories on hacker news",+ "inputs": {+ "count": 3+ },+ "steps": [+ "fillers: 'hey pyrtan', 'please'"+ ],+ "trigger": "top {count:int} stories on hacker news"+ },+ "summary": "'hey pyrtan, top 3 stories on hacker news please' -> 'top 3 stories on hacker news'; hacker-news via 'top {count:int} stories on hacker news' (22 literal chars)"+ },+ "steps": [+ "fillers: 'hey pyrtan', 'please'"+ ],+ "text": "hey pyrtan, top 3 stories on hacker news please"+ },+ "text": "hey pyrtan, top 3 stories on hacker news please"+ },+ {+ "expect": {+ "cleaned": "weather in Oslo",+ "considered": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 18,+ "tree": "weather",+ "trigger": "[what's the] weather in {place}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 18,+ "tree": "weather",+ "trigger": "[what's the] weather in {place}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "weather in Oslo",+ "inputs": {+ "place": "Oslo"+ },+ "steps": [],+ "trigger": "[what's the] weather in {place}"+ },+ "summary": "'weather in Oslo'; weather via \"[what's the] weather in {place}\" (18 literal chars)"+ },+ "steps": [],+ "text": "weather in Oslo"+ },+ "text": "weather in Oslo"+ },+ {+ "expect": {+ "cleaned": "what is the forecast for New York",+ "considered": [+ {+ "inputs": {+ "place": "New York"+ },+ "notes": [],+ "rejected": false,+ "specificity": 19,+ "tree": "weather",+ "trigger": "[what's the] weather for {place}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "place": "New York"+ },+ "notes": [],+ "rejected": false,+ "specificity": 19,+ "tree": "weather",+ "trigger": "[what's the] weather for {place}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "what is the forecast for New York",+ "inputs": {+ "place": "New York"+ },+ "steps": [+ "contractions: what's->what is"+ ],+ "trigger": "[what's the] weather for {place}"+ },+ "summary": "\"what's the forecast for New York\" -> 'what is the forecast for New York'; weather via \"[what's the] weather for {place}\" (19 literal chars)"+ },+ "steps": [+ "contractions: what's->what is"+ ],+ "text": "what's the forecast for New York"+ },+ "text": "what's the forecast for New York"+ },+ {+ "expect": {+ "cleaned": "how cold is it in Oslo",+ "considered": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 20,+ "tree": "weather",+ "trigger": "how [hot|cold|warm] is it in {place}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "place": "Oslo"+ },+ "notes": [],+ "rejected": false,+ "specificity": 20,+ "tree": "weather",+ "trigger": "how [hot|cold|warm] is it in {place}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "how cold is it in Oslo",+ "inputs": {+ "place": "Oslo"+ },+ "steps": [],+ "trigger": "how [hot|cold|warm] is it in {place}"+ },+ "summary": "'how cold is it in Oslo'; weather via 'how [hot|cold|warm] is it in {place}' (20 literal chars)"+ },+ "steps": [],+ "text": "how cold is it in Oslo"+ },+ "text": "how cold is it in Oslo"+ },+ {+ "expect": {+ "cleaned": "is Example.com up",+ "considered": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'Example.com' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "matches": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'Example.com' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "is Example.com up",+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'Example.com' read as 'example.com'"+ ],+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is Example.com up'; site-status via 'is {host:host} up' (4 literal chars); host: 'Example.com' read as 'example.com'"+ },+ "steps": [],+ "text": "is Example.com up"+ },+ "text": "is Example.com up"+ },+ {+ "expect": {+ "cleaned": "is http://Example.com:8080/x up",+ "considered": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'http://Example.com:8080/x' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "matches": [+ {+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'http://Example.com:8080/x' read as 'example.com'"+ ],+ "rejected": false,+ "specificity": 4,+ "tree": "site-status",+ "trigger": "is {host:host} up"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "is http://Example.com:8080/x up",+ "inputs": {+ "host": "example.com"+ },+ "notes": [+ "host: 'http://Example.com:8080/x' read as 'example.com'"+ ],+ "steps": [],+ "trigger": "is {host:host} up"+ },+ "summary": "'is http://Example.com:8080/x up'; site-status via 'is {host:host} up' (4 literal chars); host: 'http://Example.com:8080/x' read as 'example.com'"+ },+ "steps": [],+ "text": "is http://Example.com:8080/x up"+ },+ "text": "is http://Example.com:8080/x up"+ },+ {+ "expect": {+ "cleaned": "is not a host up",+ "considered": [],+ "matches": [],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "is not a host up",+ "steps": []+ },+ "summary": "'is not a host up'; no trigger matched"+ },+ "steps": [],+ "text": "is not a host up"+ },+ "text": "is not a host up"+ },+ {+ "expect": {+ "cleaned": "latest release of b-i-t-8/pyrtan",+ "considered": [+ {+ "inputs": {+ "repo": "b-i-t-8/pyrtan"+ },+ "notes": [],+ "rejected": false,+ "specificity": 24,+ "tree": "github-release",+ "trigger": "[what's the] latest release of {repo:word}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "repo": "b-i-t-8/pyrtan"+ },+ "notes": [],+ "rejected": false,+ "specificity": 24,+ "tree": "github-release",+ "trigger": "[what's the] latest release of {repo:word}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "latest release of b-i-t-8/pyrtan",+ "inputs": {+ "repo": "b-i-t-8/pyrtan"+ },+ "steps": [],+ "trigger": "[what's the] latest release of {repo:word}"+ },+ "summary": "'latest release of b-i-t-8/pyrtan'; github-release via \"[what's the] latest release of {repo:word}\" (24 literal chars)"+ },+ "steps": [],+ "text": "latest release of b-i-t-8/pyrtan"+ },+ "text": "latest release of b-i-t-8/pyrtan"+ },+ {+ "expect": {+ "cleaned": "how full is the disk",+ "considered": [+ {+ "inputs": {},+ "notes": [],+ "rejected": false,+ "specificity": 16,+ "tree": "disk-space",+ "trigger": "[how] full is the disk"+ }+ ],+ "matches": [+ {+ "inputs": {},+ "notes": [],+ "rejected": false,+ "specificity": 16,+ "tree": "disk-space",+ "trigger": "[how] full is the disk"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "how full is the disk",+ "inputs": {},+ "steps": [],+ "trigger": "[how] full is the disk"+ },+ "summary": "'how full is the disk'; disk-space via '[how] full is the disk' (16 literal chars)"+ },+ "steps": [],+ "text": "how full is the disk"+ },+ "text": "how full is the disk"+ },+ {+ "expect": {+ "cleaned": "disk space on /data",+ "considered": [+ {+ "inputs": {+ "mount": "/data"+ },+ "notes": [],+ "rejected": false,+ "specificity": 11,+ "tree": "disk-space",+ "trigger": "disk space on {mount:word}"+ }+ ],+ "matches": [+ {+ "inputs": {+ "mount": "/data"+ },+ "notes": [],+ "rejected": false,+ "specificity": 11,+ "tree": "disk-space",+ "trigger": "disk space on {mount:word}"+ }+ ],+ "now": "2026-10-25T12:00:00+00:00",+ "step": {+ "kind": "intent",+ "node": "intent",+ "observed": {+ "cleaned": "disk space on /data",+ "inputs": {+ "mount": "/data"+ },+ "steps": [],+ "trigger": "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.